1019 lines
42 KiB
Objective-C
1019 lines
42 KiB
Objective-C
/*
|
|
* el_appkit.m — AppKit backend for the el native widget system.
|
|
*
|
|
* This file implements the macOS AppKit widget layer that el_seed.c calls
|
|
* through to when EL_TARGET_MACOS is defined.
|
|
*
|
|
* Architecture:
|
|
* el program (el code)
|
|
* → __widget_* C builtins in el_seed.c
|
|
* → el_appkit_* C-callable functions declared here
|
|
* → NSView/NSWindow/NSStackView/NSButton/etc. via ObjC
|
|
*
|
|
* Widget handles: every widget (window, view, control) is assigned an int64_t
|
|
* slot index into _el_widgets[]. The el program holds these as opaque Int values.
|
|
* Slot 0 is never valid (reserved as null handle = -1 convention).
|
|
*
|
|
* Threading: All AppKit calls MUST run on the main thread. el_appkit_dispatch_main
|
|
* bounces work to the main thread synchronously if called from a background thread.
|
|
* el_appkit_run_loop MUST be called from the main thread; it never returns.
|
|
*
|
|
* Callback mechanism: when a widget fires an event (button click, text change),
|
|
* the bridge looks up the registered callback function name, then calls
|
|
* dlsym(RTLD_DEFAULT, fn_name)(widget_handle, event_data_string)
|
|
* This resolves the El function symbol at runtime, matching the __thread_create
|
|
* pattern already established in el_seed.c.
|
|
*
|
|
* Compile (MRC — no ARC; manual retain/release needed for struct-embedded id):
|
|
* clang -std=gnu11 -ObjC -fno-objc-arc -framework Cocoa -c el_appkit.m -o el_appkit.o
|
|
* Then link el_appkit.o alongside el_seed.c.
|
|
*
|
|
* Do NOT use -fobjc-arc: the widget table stores id in a plain C struct. ARC
|
|
* forbids explicit retain/release on struct-embedded object pointers and cannot
|
|
* insert automatic retain/release through C struct boundaries. MRC is the
|
|
* correct choice for this kind of C/ObjC bridge.
|
|
*
|
|
* Link flags required:
|
|
* -framework Cocoa
|
|
*/
|
|
|
|
#import <Cocoa/Cocoa.h>
|
|
#include <stdint.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <stdio.h>
|
|
#include <dlfcn.h>
|
|
|
|
/* ── Widget table ─────────────────────────────────────────────────────────── */
|
|
|
|
#define EL_APPKIT_MAX_WIDGETS 4096
|
|
|
|
typedef enum {
|
|
EL_WIDGET_FREE = 0,
|
|
EL_WIDGET_WINDOW = 1,
|
|
EL_WIDGET_VSTACK = 2,
|
|
EL_WIDGET_HSTACK = 3,
|
|
EL_WIDGET_ZSTACK = 4,
|
|
EL_WIDGET_SCROLL = 5,
|
|
EL_WIDGET_LABEL = 6,
|
|
EL_WIDGET_BUTTON = 7,
|
|
EL_WIDGET_TEXTFIELD = 8,
|
|
EL_WIDGET_TEXTAREA = 9,
|
|
EL_WIDGET_IMAGE = 10,
|
|
EL_WIDGET_DIVIDER = 11,
|
|
EL_WIDGET_SPACER = 12,
|
|
} ElWidgetKind;
|
|
|
|
typedef struct {
|
|
ElWidgetKind kind;
|
|
id obj; /* NSWindow* or NSView* subclass, retained */
|
|
char* cb_click; /* El function name for click/submit events */
|
|
char* cb_change; /* El function name for value-change events */
|
|
char* user_data; /* custom data string passed to click callbacks */
|
|
} ElWidget;
|
|
|
|
static ElWidget _el_widgets[EL_APPKIT_MAX_WIDGETS];
|
|
static int _el_widget_count = 0;
|
|
|
|
/* Allocate a slot and return its index. Returns -1 if full. */
|
|
static int64_t el_widget_alloc(ElWidgetKind kind, id obj) {
|
|
for (int i = 1; i < EL_APPKIT_MAX_WIDGETS; i++) {
|
|
if (_el_widgets[i].kind == EL_WIDGET_FREE) {
|
|
_el_widgets[i].kind = kind;
|
|
_el_widgets[i].obj = [obj retain];
|
|
_el_widgets[i].cb_click = NULL;
|
|
_el_widgets[i].cb_change = NULL;
|
|
_el_widgets[i].user_data = NULL;
|
|
if (i > _el_widget_count) _el_widget_count = i;
|
|
return (int64_t)i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
static ElWidget* el_widget_get(int64_t handle) {
|
|
if (handle <= 0 || handle >= EL_APPKIT_MAX_WIDGETS) return NULL;
|
|
if (_el_widgets[handle].kind == EL_WIDGET_FREE) return NULL;
|
|
return &_el_widgets[handle];
|
|
}
|
|
|
|
static void el_widget_free(int64_t handle) {
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
[w->obj release];
|
|
w->obj = nil;
|
|
w->kind = EL_WIDGET_FREE;
|
|
free(w->cb_click); w->cb_click = NULL;
|
|
free(w->cb_change); w->cb_change = NULL;
|
|
free(w->user_data); w->user_data = NULL;
|
|
}
|
|
|
|
/* ── Dispatch helpers ─────────────────────────────────────────────────────── */
|
|
|
|
/* Run a block on the main thread, waiting for completion. Safe to call from
|
|
* any thread including the main thread. */
|
|
static void el_appkit_sync_main(void (^block)(void)) {
|
|
if ([NSThread isMainThread]) {
|
|
block();
|
|
} else {
|
|
dispatch_sync(dispatch_get_main_queue(), block);
|
|
}
|
|
}
|
|
|
|
/* ── El callback invocation ──────────────────────────────────────────────── */
|
|
/*
|
|
* Invoke an El callback by symbol name. The El function must have the
|
|
* signature: fn handler(handle: Int, data: String) -> Void
|
|
* which compiles to: void fn_name(el_val_t handle, el_val_t data)
|
|
*/
|
|
typedef void (*ElCb2)(int64_t handle, int64_t data);
|
|
|
|
static void el_appkit_invoke_cb(const char* fn_name, int64_t handle, const char* data) {
|
|
if (!fn_name || !*fn_name) return;
|
|
void* sym = dlsym(RTLD_DEFAULT, fn_name);
|
|
if (!sym) return;
|
|
ElCb2 fn = (ElCb2)sym;
|
|
fn(handle, (int64_t)(uintptr_t)(data ? data : ""));
|
|
}
|
|
|
|
/* ── ObjC delegate for button clicks ─────────────────────────────────────── */
|
|
|
|
@interface ElButtonTarget : NSObject
|
|
@property int64_t widgetHandle;
|
|
@end
|
|
|
|
@implementation ElButtonTarget
|
|
- (void)buttonClicked:(id)sender {
|
|
ElWidget* w = el_widget_get(self.widgetHandle);
|
|
if (w && w->cb_click) {
|
|
el_appkit_invoke_cb(w->cb_click, self.widgetHandle, w->user_data ? w->user_data : "");
|
|
}
|
|
}
|
|
@end
|
|
|
|
/* ── ObjC delegate for text field changes / enter key ───────────────────── */
|
|
|
|
@interface ElTextFieldDelegate : NSObject <NSTextFieldDelegate>
|
|
@property int64_t widgetHandle;
|
|
@end
|
|
|
|
@implementation ElTextFieldDelegate
|
|
- (void)controlTextDidChange:(NSNotification*)notif {
|
|
ElWidget* w = el_widget_get(self.widgetHandle);
|
|
if (!w) return;
|
|
NSTextField* tf = (NSTextField*)notif.object;
|
|
const char* val = [[tf stringValue] UTF8String];
|
|
if (w->cb_change) {
|
|
el_appkit_invoke_cb(w->cb_change, self.widgetHandle, val ? val : "");
|
|
}
|
|
}
|
|
- (void)controlTextDidEndEditing:(NSNotification*)notif {
|
|
/* Enter key (or tab) submits. The userInfo value is an NSNumber, not NSEvent. */
|
|
NSNumber* movement = [[notif userInfo] objectForKey:@"NSTextMovement"];
|
|
if (movement && [movement intValue] == NSTextMovementReturn) {
|
|
ElWidget* w = el_widget_get(self.widgetHandle);
|
|
if (!w) return;
|
|
NSTextField* tf = (NSTextField*)notif.object;
|
|
const char* val = [[tf stringValue] UTF8String];
|
|
if (w->cb_click) { /* cb_click = on_submit for text fields */
|
|
el_appkit_invoke_cb(w->cb_click, self.widgetHandle, val ? val : "");
|
|
}
|
|
}
|
|
}
|
|
@end
|
|
|
|
/* We hold strong references to our ObjC delegate objects in a side table so
|
|
* ARC/MRC don't collect them. Simple parallel array keyed by widget slot. */
|
|
static id _el_delegates[EL_APPKIT_MAX_WIDGETS];
|
|
|
|
/* ── Flipped wrapper for NSScrollView document views ─────────────────────────
|
|
* NSScrollView's clip view uses the document view's coordinate system. When
|
|
* the document view is non-flipped (origin at bottom-left), content stacks
|
|
* from the bottom. This subclass overrides isFlipped so content starts at the
|
|
* visual top-left, matching expected list/feed behaviour. */
|
|
@interface ElFlippedView : NSView
|
|
@end
|
|
@implementation ElFlippedView
|
|
- (BOOL)isFlipped { return YES; }
|
|
@end
|
|
|
|
/* ── ElResizableContentView ───────────────────────────────────────────────────
|
|
* Used as NSWindow's content view instead of a plain NSView.
|
|
* Overrides setFrameSize: to propagate the new size directly to the single
|
|
* root child without going through the AL constraint graph.
|
|
*
|
|
* Why: with TAMIC:NO on the content view and 4-edge AL constraints to the
|
|
* child, NSWindow calls [contentView fittingSize] to compute its minimum
|
|
* resize floor. fittingSize walks the constraint graph and returns the
|
|
* largest minimum it finds — locking horizontal resize to the initial width.
|
|
* By removing the AL constraints and propagating via setFrameSize: instead,
|
|
* NSWindow has no constraint-based minimum and respects setContentMinSize: only. */
|
|
@interface ElResizableContentView : NSView
|
|
@end
|
|
@implementation ElResizableContentView
|
|
- (NSSize)fittingSize { return NSMakeSize(1.0, 1.0); }
|
|
- (NSSize)intrinsicContentSize { return NSMakeSize(NSViewNoIntrinsicMetric, NSViewNoIntrinsicMetric); }
|
|
@end
|
|
|
|
/* ElWindow subclass — overrides minSize so the AL constraint graph cannot
|
|
* inflate the resize floor beyond the manifest-specified minimum.
|
|
* NSWindow normally sets minSize from the larger of the explicit setMinSize:
|
|
* call and the AL-computed minimum content size; the override replaces that
|
|
* with the explicit minimum only. */
|
|
@interface ElWindow : NSWindow
|
|
@property (nonatomic) NSSize elMinFrameSize;
|
|
@property (nonatomic) NSSize elInitialContentSize; /* designed width/height from manifest */
|
|
@end
|
|
@implementation ElWindow
|
|
- (NSSize)minSize { return _elMinFrameSize; }
|
|
@end
|
|
|
|
/* ── NSApplication setup ──────────────────────────────────────────────────── */
|
|
|
|
@interface ElAppDelegate : NSObject <NSApplicationDelegate>
|
|
@end
|
|
|
|
@implementation ElAppDelegate
|
|
- (void)applicationDidFinishLaunching:(NSNotification*)notif {
|
|
/* Nothing needed — windows are created programmatically before run loop. */
|
|
}
|
|
- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication*)app {
|
|
return YES;
|
|
}
|
|
@end
|
|
|
|
static ElAppDelegate* _el_app_delegate = nil;
|
|
|
|
/* ── Public C API (called from el_seed.c) ─────────────────────────────────── */
|
|
|
|
/*
|
|
* el_appkit_init — initialize NSApplication. Must be called once on the main
|
|
* thread before any other el_appkit_* function. Idempotent.
|
|
*/
|
|
void el_appkit_init(void) {
|
|
static int done = 0;
|
|
if (done) return;
|
|
done = 1;
|
|
[NSApplication sharedApplication];
|
|
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
|
|
_el_app_delegate = [[ElAppDelegate alloc] init];
|
|
[NSApp setDelegate:_el_app_delegate];
|
|
}
|
|
|
|
/*
|
|
* el_appkit_window_create — create an NSWindow and return its handle.
|
|
* title, width, height, min_width, min_height come from manifest.el app{} block.
|
|
*/
|
|
int64_t el_appkit_window_create(const char* title, int width, int height,
|
|
int min_width, int min_height) {
|
|
__block int64_t handle = -1;
|
|
el_appkit_sync_main(^{
|
|
NSRect frame = NSMakeRect(0, 0, (CGFloat)width, (CGFloat)height);
|
|
NSWindowStyleMask style =
|
|
NSWindowStyleMaskTitled |
|
|
NSWindowStyleMaskClosable |
|
|
NSWindowStyleMaskMiniaturizable |
|
|
NSWindowStyleMaskResizable;
|
|
ElWindow* win = [[ElWindow alloc]
|
|
initWithContentRect:frame
|
|
styleMask:style
|
|
backing:NSBackingStoreBuffered
|
|
defer:NO];
|
|
[win setTitle:[NSString stringWithUTF8String:title ? title : ""]];
|
|
/* Store the manifest minimum as our explicit frame floor. */
|
|
NSRect minContentRect = NSMakeRect(0, 0, (CGFloat)min_width, (CGFloat)min_height);
|
|
NSSize minFrame = [win frameRectForContentRect:minContentRect].size;
|
|
win.elMinFrameSize = minFrame;
|
|
[win setContentMinSize:NSMakeSize((CGFloat)min_width, (CGFloat)min_height)];
|
|
win.elInitialContentSize = NSMakeSize((CGFloat)width, (CGFloat)height);
|
|
[win center];
|
|
|
|
ElResizableContentView* root = [[ElResizableContentView alloc] initWithFrame:frame];
|
|
[win setContentView:root];
|
|
|
|
handle = el_widget_alloc(EL_WIDGET_WINDOW, win);
|
|
[win release];
|
|
[root release];
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
/*
|
|
* el_appkit_window_show — make the window visible and bring it to front.
|
|
*/
|
|
void el_appkit_window_show(int64_t handle) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w || w->kind != EL_WIDGET_WINDOW) return;
|
|
NSWindow* win = (NSWindow*)w->obj;
|
|
/* AL may have shrunk the window during the build phase.
|
|
* Restore the designed content size before showing — the window
|
|
* is not yet visible so the user never sees the shrunken state.
|
|
* After this, AL runs within the correct frame and required
|
|
* 4-edge constraints keep the child filling on every resize. */
|
|
if ([win isKindOfClass:[ElWindow class]]) {
|
|
ElWindow* elWin = (ElWindow*)win;
|
|
NSSize cs = elWin.elInitialContentSize;
|
|
if (cs.width > 0) {
|
|
[win setContentSize:cs];
|
|
[win center];
|
|
}
|
|
}
|
|
[win makeKeyAndOrderFront:nil];
|
|
[NSApp activateIgnoringOtherApps:YES];
|
|
});
|
|
}
|
|
|
|
/*
|
|
* el_appkit_window_set_title — update window title at runtime.
|
|
*/
|
|
void el_appkit_window_set_title(int64_t handle, const char* title) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w || w->kind != EL_WIDGET_WINDOW) return;
|
|
NSWindow* win = (NSWindow*)w->obj;
|
|
[win setTitle:[NSString stringWithUTF8String:title ? title : ""]];
|
|
});
|
|
}
|
|
|
|
/*
|
|
* el_appkit_run_loop — start the NSApplication run loop. Never returns.
|
|
* Must be called from the main thread as the last step of main().
|
|
*/
|
|
void el_appkit_run_loop(void) {
|
|
[NSApp run];
|
|
}
|
|
|
|
/* ── Layout container factories ───────────────────────────────────────────── */
|
|
|
|
static int64_t make_stack(NSUserInterfaceLayoutOrientation orient,
|
|
CGFloat spacing) {
|
|
__block int64_t handle = -1;
|
|
el_appkit_sync_main(^{
|
|
NSStackView* sv = [[NSStackView alloc] initWithFrame:NSZeroRect];
|
|
[sv setOrientation:orient];
|
|
/* Alignment must match the stack's cross-axis to avoid NSStackView
|
|
* silently flipping orientation. For a vertical stack, align children
|
|
* to the leading edge (left); for a horizontal stack, align to top.
|
|
* Using NSLayoutAttributeLeading on a horizontal stack flips it vertical. */
|
|
NSLayoutAttribute align = (orient == NSUserInterfaceLayoutOrientationVertical)
|
|
? NSLayoutAttributeLeading : NSLayoutAttributeTop;
|
|
[sv setAlignment:align];
|
|
[sv setSpacing:spacing];
|
|
[sv setEdgeInsets:NSEdgeInsetsZero];
|
|
[sv setDistribution:NSStackViewDistributionFill];
|
|
[sv setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
ElWidgetKind kind = (orient == NSUserInterfaceLayoutOrientationVertical)
|
|
? EL_WIDGET_VSTACK : EL_WIDGET_HSTACK;
|
|
handle = el_widget_alloc(kind, sv);
|
|
[sv release];
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
int64_t el_appkit_vstack_create(int spacing) {
|
|
return make_stack(NSUserInterfaceLayoutOrientationVertical, (CGFloat)spacing);
|
|
}
|
|
|
|
int64_t el_appkit_hstack_create(int spacing) {
|
|
return make_stack(NSUserInterfaceLayoutOrientationHorizontal, (CGFloat)spacing);
|
|
}
|
|
|
|
int64_t el_appkit_zstack_create(void) {
|
|
/* ZStack = NSView with manual child positioning (no stack manager). */
|
|
__block int64_t handle = -1;
|
|
el_appkit_sync_main(^{
|
|
NSView* v = [[NSView alloc] initWithFrame:NSZeroRect];
|
|
[v setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
handle = el_widget_alloc(EL_WIDGET_ZSTACK, v);
|
|
[v release];
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
int64_t el_appkit_scroll_create(void) {
|
|
__block int64_t handle = -1;
|
|
el_appkit_sync_main(^{
|
|
NSScrollView* sv = [[NSScrollView alloc] initWithFrame:NSZeroRect];
|
|
[sv setHasVerticalScroller:YES];
|
|
[sv setHasHorizontalScroller:NO];
|
|
[sv setAutohidesScrollers:YES];
|
|
[sv setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
handle = el_widget_alloc(EL_WIDGET_SCROLL, sv);
|
|
[sv release];
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
/* ── Widget factories ─────────────────────────────────────────────────────── */
|
|
|
|
int64_t el_appkit_label_create(const char* text) {
|
|
__block int64_t handle = -1;
|
|
el_appkit_sync_main(^{
|
|
NSTextField* lbl = [NSTextField labelWithString:
|
|
[NSString stringWithUTF8String:text ? text : ""]];
|
|
[lbl setEditable:NO];
|
|
[lbl setBordered:NO];
|
|
[lbl setDrawsBackground:NO];
|
|
[lbl setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
handle = el_widget_alloc(EL_WIDGET_LABEL, lbl);
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
int64_t el_appkit_button_create(const char* label) {
|
|
__block int64_t handle = -1;
|
|
el_appkit_sync_main(^{
|
|
NSButton* btn = [[NSButton alloc] initWithFrame:NSZeroRect];
|
|
[btn setButtonType:NSButtonTypeMomentaryLight];
|
|
[btn setBezelStyle:NSBezelStyleShadowlessSquare];
|
|
[btn setTitle:[NSString stringWithUTF8String:label ? label : ""]];
|
|
[btn setAlignment:NSTextAlignmentLeft];
|
|
[btn setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
|
|
/* Create action target. */
|
|
ElButtonTarget* target = [[ElButtonTarget alloc] init];
|
|
/* handle assigned below after alloc — set after */
|
|
[btn setTarget:target];
|
|
[btn setAction:@selector(buttonClicked:)];
|
|
|
|
int64_t h = el_widget_alloc(EL_WIDGET_BUTTON, btn);
|
|
target.widgetHandle = h;
|
|
_el_delegates[h] = target; /* retain */
|
|
handle = h;
|
|
[btn release];
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
int64_t el_appkit_text_field_create(const char* placeholder) {
|
|
__block int64_t handle = -1;
|
|
el_appkit_sync_main(^{
|
|
NSTextField* tf = [[NSTextField alloc] initWithFrame:NSZeroRect];
|
|
[tf setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
if (placeholder && *placeholder) {
|
|
[tf setPlaceholderString:[NSString stringWithUTF8String:placeholder]];
|
|
}
|
|
|
|
ElTextFieldDelegate* del = [[ElTextFieldDelegate alloc] init];
|
|
[tf setDelegate:del];
|
|
|
|
int64_t h = el_widget_alloc(EL_WIDGET_TEXTFIELD, tf);
|
|
del.widgetHandle = h;
|
|
_el_delegates[h] = del;
|
|
handle = h;
|
|
[tf release];
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
int64_t el_appkit_text_area_create(const char* placeholder) {
|
|
__block int64_t handle = -1;
|
|
el_appkit_sync_main(^{
|
|
NSScrollView* scroll = [[NSScrollView alloc] initWithFrame:NSZeroRect];
|
|
[scroll setHasVerticalScroller:YES];
|
|
[scroll setAutohidesScrollers:YES];
|
|
[scroll setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
|
|
NSTextView* tv = [[NSTextView alloc] initWithFrame:NSZeroRect];
|
|
[tv setMinSize:NSMakeSize(0, 60)];
|
|
[tv setMaxSize:NSMakeSize(FLT_MAX, FLT_MAX)];
|
|
[tv setVerticallyResizable:YES];
|
|
[tv setHorizontallyResizable:NO];
|
|
[tv setAutoresizingMask:NSViewWidthSizable];
|
|
if (placeholder && *placeholder) {
|
|
/* NSTextView has no native placeholder; we set a hint via
|
|
* a custom accessibility description as a minimal approach. */
|
|
[tv setAccessibilityPlaceholderValue:
|
|
[NSString stringWithUTF8String:placeholder]];
|
|
}
|
|
[[tv textContainer] setWidthTracksTextView:YES];
|
|
[scroll setDocumentView:tv];
|
|
|
|
int64_t h = el_widget_alloc(EL_WIDGET_TEXTAREA, scroll);
|
|
handle = h;
|
|
[scroll release];
|
|
[tv release];
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
int64_t el_appkit_image_create(const char* path_or_name) {
|
|
__block int64_t handle = -1;
|
|
el_appkit_sync_main(^{
|
|
NSImage* img = nil;
|
|
if (path_or_name && *path_or_name) {
|
|
/* Try as filesystem path first, then as named system image. */
|
|
img = [[NSImage alloc] initWithContentsOfFile:
|
|
[NSString stringWithUTF8String:path_or_name]];
|
|
if (!img) {
|
|
img = [NSImage imageNamed:[NSString stringWithUTF8String:path_or_name]];
|
|
if (img) [img retain];
|
|
}
|
|
}
|
|
NSImageView* iv = [[NSImageView alloc] initWithFrame:NSZeroRect];
|
|
[iv setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
if (img) {
|
|
[iv setImage:img];
|
|
[img release];
|
|
}
|
|
handle = el_widget_alloc(EL_WIDGET_IMAGE, iv);
|
|
[iv release];
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
/* ── Widget property setters ─────────────────────────────────────────────── */
|
|
|
|
void el_appkit_widget_set_text(int64_t handle, const char* text) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
NSString* s = [NSString stringWithUTF8String:text ? text : ""];
|
|
switch (w->kind) {
|
|
case EL_WIDGET_LABEL:
|
|
case EL_WIDGET_TEXTFIELD:
|
|
[(NSTextField*)w->obj setStringValue:s];
|
|
break;
|
|
case EL_WIDGET_BUTTON:
|
|
[(NSButton*)w->obj setTitle:s];
|
|
break;
|
|
case EL_WIDGET_TEXTAREA: {
|
|
NSScrollView* sv = (NSScrollView*)w->obj;
|
|
NSTextView* tv = (NSTextView*)[sv documentView];
|
|
[tv setString:s];
|
|
break;
|
|
}
|
|
case EL_WIDGET_WINDOW:
|
|
[(NSWindow*)w->obj setTitle:s];
|
|
break;
|
|
default: break;
|
|
}
|
|
});
|
|
}
|
|
|
|
const char* el_appkit_widget_get_text(int64_t handle) {
|
|
__block const char* result = "";
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
NSString* s = nil;
|
|
switch (w->kind) {
|
|
case EL_WIDGET_LABEL:
|
|
case EL_WIDGET_TEXTFIELD:
|
|
s = [(NSTextField*)w->obj stringValue];
|
|
break;
|
|
case EL_WIDGET_BUTTON:
|
|
s = [(NSButton*)w->obj title];
|
|
break;
|
|
case EL_WIDGET_TEXTAREA: {
|
|
NSScrollView* sv = (NSScrollView*)w->obj;
|
|
NSTextView* tv = (NSTextView*)[sv documentView];
|
|
s = [tv string];
|
|
break;
|
|
}
|
|
default: break;
|
|
}
|
|
if (s) result = strdup([s UTF8String]);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
void el_appkit_widget_set_color(int64_t handle, float r, float g, float b, float a) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
NSColor* c = [NSColor colorWithSRGBRed:(CGFloat)r
|
|
green:(CGFloat)g
|
|
blue:(CGFloat)b
|
|
alpha:(CGFloat)a];
|
|
switch (w->kind) {
|
|
case EL_WIDGET_LABEL:
|
|
case EL_WIDGET_TEXTFIELD:
|
|
[(NSTextField*)w->obj setTextColor:c];
|
|
break;
|
|
case EL_WIDGET_BUTTON: {
|
|
/* Update foreground color on attributed title, preserving font. */
|
|
NSButton* btn = (NSButton*)w->obj;
|
|
NSAttributedString* existing = [btn attributedTitle];
|
|
NSMutableAttributedString* as;
|
|
if (existing && [existing length] > 0) {
|
|
as = [[NSMutableAttributedString alloc] initWithAttributedString:existing];
|
|
} else {
|
|
NSString* t = [btn title];
|
|
if (!t) t = @"";
|
|
as = [[NSMutableAttributedString alloc] initWithString:t];
|
|
}
|
|
[as addAttribute:NSForegroundColorAttributeName value:c
|
|
range:NSMakeRange(0, [as length])];
|
|
[btn setAttributedTitle:as];
|
|
[as release];
|
|
break;
|
|
}
|
|
case EL_WIDGET_VSTACK:
|
|
case EL_WIDGET_HSTACK:
|
|
case EL_WIDGET_ZSTACK: {
|
|
/* Set background via wantsLayer + layer background. */
|
|
NSView* v = (NSView*)w->obj;
|
|
[v setWantsLayer:YES];
|
|
[v layer].backgroundColor = [c CGColor];
|
|
break;
|
|
}
|
|
default: break;
|
|
}
|
|
});
|
|
}
|
|
|
|
void el_appkit_widget_set_bg_color(int64_t handle, float r, float g, float b, float a) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
NSColor* c = [NSColor colorWithSRGBRed:(CGFloat)r
|
|
green:(CGFloat)g
|
|
blue:(CGFloat)b
|
|
alpha:(CGFloat)a];
|
|
if (w->kind == EL_WIDGET_WINDOW) {
|
|
NSWindow* win = (NSWindow*)w->obj;
|
|
[win setBackgroundColor:c];
|
|
/* Also paint the contentView layer so it shows through NSStackView. */
|
|
NSView* cv = [win contentView];
|
|
[cv setWantsLayer:YES];
|
|
[cv layer].backgroundColor = [c CGColor];
|
|
return;
|
|
}
|
|
if (w->kind == EL_WIDGET_BUTTON) {
|
|
/* Disable native bezel so CALayer background is visible. */
|
|
NSButton* btn = (NSButton*)w->obj;
|
|
[btn setBordered:NO];
|
|
[btn setWantsLayer:YES];
|
|
[btn layer].backgroundColor = [c CGColor];
|
|
return;
|
|
}
|
|
NSView* v = (NSView*)w->obj;
|
|
if (w->kind == EL_WIDGET_SCROLL) {
|
|
/* NSScrollView needs setBackgroundColor, not just layer bg. */
|
|
NSScrollView* sv = (NSScrollView*)v;
|
|
[sv setBackgroundColor:c];
|
|
[sv setDrawsBackground:YES];
|
|
}
|
|
[v setWantsLayer:YES];
|
|
[v layer].backgroundColor = [c CGColor];
|
|
});
|
|
}
|
|
|
|
void el_appkit_widget_set_font(int64_t handle, const char* family, int size, int bold) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
NSFont* font;
|
|
if (bold) {
|
|
font = [NSFont boldSystemFontOfSize:(CGFloat)size];
|
|
} else if (family && *family && strcmp(family, "system") != 0) {
|
|
font = [NSFont fontWithName:[NSString stringWithUTF8String:family]
|
|
size:(CGFloat)size];
|
|
if (!font) font = [NSFont systemFontOfSize:(CGFloat)size];
|
|
} else {
|
|
font = [NSFont systemFontOfSize:(CGFloat)size];
|
|
}
|
|
switch (w->kind) {
|
|
case EL_WIDGET_LABEL:
|
|
case EL_WIDGET_TEXTFIELD:
|
|
[(NSTextField*)w->obj setFont:font];
|
|
break;
|
|
case EL_WIDGET_BUTTON:
|
|
/* NSButton: set font via attributed title, preserving existing foreground color. */
|
|
{
|
|
NSButton* fbtn = (NSButton*)w->obj;
|
|
NSAttributedString* fexisting = [fbtn attributedTitle];
|
|
NSMutableAttributedString* as;
|
|
if (fexisting && [fexisting length] > 0) {
|
|
as = [[NSMutableAttributedString alloc] initWithAttributedString:fexisting];
|
|
} else {
|
|
NSString* ft = [fbtn title];
|
|
if (!ft) ft = @"";
|
|
as = [[NSMutableAttributedString alloc] initWithString:ft];
|
|
}
|
|
[as addAttribute:NSFontAttributeName value:font
|
|
range:NSMakeRange(0, [as length])];
|
|
[fbtn setAttributedTitle:as];
|
|
[as release];
|
|
}
|
|
break;
|
|
case EL_WIDGET_TEXTAREA: {
|
|
NSScrollView* sv = (NSScrollView*)w->obj;
|
|
NSTextView* tv = (NSTextView*)[sv documentView];
|
|
[tv setFont:font];
|
|
break;
|
|
}
|
|
default: break;
|
|
}
|
|
});
|
|
}
|
|
|
|
void el_appkit_widget_set_padding(int64_t handle, int top, int right, int bottom, int left) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
/* Padding on stack views maps to edgeInsets. */
|
|
if (w->kind == EL_WIDGET_VSTACK || w->kind == EL_WIDGET_HSTACK) {
|
|
NSStackView* sv = (NSStackView*)w->obj;
|
|
[sv setEdgeInsets:NSEdgeInsetsMake((CGFloat)top, (CGFloat)left,
|
|
(CGFloat)bottom, (CGFloat)right)];
|
|
}
|
|
/* For individual widgets we set a custom layout margin via constraints.
|
|
* For now, NSTextView respects textContainerInset. */
|
|
if (w->kind == EL_WIDGET_TEXTAREA) {
|
|
NSScrollView* sv = (NSScrollView*)w->obj;
|
|
NSTextView* tv = (NSTextView*)[sv documentView];
|
|
[tv setTextContainerInset:NSMakeSize((CGFloat)(left + right) / 2,
|
|
(CGFloat)(top + bottom) / 2)];
|
|
}
|
|
});
|
|
}
|
|
|
|
void el_appkit_widget_set_width(int64_t handle, int width) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
NSView* v = (NSView*)w->obj;
|
|
if ([v isKindOfClass:[NSWindow class]]) return;
|
|
/* Add a fixed-width constraint. */
|
|
NSLayoutConstraint* c = [NSLayoutConstraint
|
|
constraintWithItem:v
|
|
attribute:NSLayoutAttributeWidth
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:nil
|
|
attribute:NSLayoutAttributeNotAnAttribute
|
|
multiplier:1.0
|
|
constant:(CGFloat)width];
|
|
c.priority = NSLayoutPriorityDefaultHigh;
|
|
[v addConstraint:c];
|
|
});
|
|
}
|
|
|
|
void el_appkit_widget_set_height(int64_t handle, int height) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
NSView* v = (NSView*)w->obj;
|
|
if ([v isKindOfClass:[NSWindow class]]) return;
|
|
NSLayoutConstraint* c = [NSLayoutConstraint
|
|
constraintWithItem:v
|
|
attribute:NSLayoutAttributeHeight
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:nil
|
|
attribute:NSLayoutAttributeNotAnAttribute
|
|
multiplier:1.0
|
|
constant:(CGFloat)height];
|
|
c.priority = NSLayoutPriorityDefaultHigh;
|
|
[v addConstraint:c];
|
|
});
|
|
}
|
|
|
|
void el_appkit_widget_set_flex(int64_t handle, int flex) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
NSView* v = (NSView*)w->obj;
|
|
if ([v isKindOfClass:[NSWindow class]]) return;
|
|
if (flex > 0) {
|
|
/* flex:1 → low hugging (wants to expand) + low compression resistance
|
|
* (willing to be compressed). This makes NSStackViewDistributionFill
|
|
* choose this view to grow/shrink to fill available space. */
|
|
[v setContentHuggingPriority:NSLayoutPriorityDefaultLow
|
|
forOrientation:NSLayoutConstraintOrientationHorizontal];
|
|
[v setContentHuggingPriority:NSLayoutPriorityDefaultLow
|
|
forOrientation:NSLayoutConstraintOrientationVertical];
|
|
[v setContentCompressionResistancePriority:NSLayoutPriorityDefaultLow
|
|
forOrientation:NSLayoutConstraintOrientationHorizontal];
|
|
[v setContentCompressionResistancePriority:NSLayoutPriorityDefaultLow
|
|
forOrientation:NSLayoutConstraintOrientationVertical];
|
|
} else {
|
|
/* flex:0 → high hugging (stays at natural size) + high compression
|
|
* resistance (resists shrinking). */
|
|
[v setContentHuggingPriority:NSLayoutPriorityDefaultHigh
|
|
forOrientation:NSLayoutConstraintOrientationHorizontal];
|
|
[v setContentHuggingPriority:NSLayoutPriorityDefaultHigh
|
|
forOrientation:NSLayoutConstraintOrientationVertical];
|
|
[v setContentCompressionResistancePriority:NSLayoutPriorityDefaultHigh
|
|
forOrientation:NSLayoutConstraintOrientationHorizontal];
|
|
[v setContentCompressionResistancePriority:NSLayoutPriorityDefaultHigh
|
|
forOrientation:NSLayoutConstraintOrientationVertical];
|
|
}
|
|
});
|
|
}
|
|
|
|
void el_appkit_widget_set_corner_radius(int64_t handle, int radius) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
NSView* v = (NSView*)w->obj;
|
|
if ([v isKindOfClass:[NSWindow class]]) return;
|
|
[v setWantsLayer:YES];
|
|
[v layer].cornerRadius = (CGFloat)radius;
|
|
[v layer].masksToBounds = YES;
|
|
});
|
|
}
|
|
|
|
void el_appkit_widget_set_disabled(int64_t handle, int disabled) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
if (w->kind == EL_WIDGET_BUTTON) {
|
|
[(NSButton*)w->obj setEnabled:!disabled];
|
|
} else if (w->kind == EL_WIDGET_TEXTFIELD) {
|
|
[(NSTextField*)w->obj setEnabled:!disabled];
|
|
}
|
|
});
|
|
}
|
|
|
|
void el_appkit_widget_set_hidden(int64_t handle, int hidden) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
NSView* v = (NSView*)w->obj;
|
|
if (![v isKindOfClass:[NSWindow class]]) {
|
|
[v setHidden:(BOOL)hidden];
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ── Child management ─────────────────────────────────────────────────────── */
|
|
|
|
/*
|
|
* el_appkit_widget_add_child — attach child widget to parent.
|
|
*
|
|
* - Window: child is added to contentView (NSStackView).
|
|
* - VStack/HStack: child is added as an arranged subview.
|
|
* - ZStack/generic view: child is added as a plain subview.
|
|
* - ScrollView: child becomes the documentView (only first child honoured).
|
|
*/
|
|
void el_appkit_widget_add_child(int64_t parent, int64_t child) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* pw = el_widget_get(parent);
|
|
ElWidget* cw = el_widget_get(child);
|
|
if (!pw || !cw) return;
|
|
|
|
NSView* childView = (NSView*)cw->obj;
|
|
if ([childView isKindOfClass:[NSWindow class]]) return; /* can't add window as child */
|
|
|
|
switch (pw->kind) {
|
|
case EL_WIDGET_WINDOW: {
|
|
NSWindow* win = (NSWindow*)pw->obj;
|
|
NSView* cv = [win contentView];
|
|
/* Standard required 4-edge constraints so the child always
|
|
* fills the content view during user-initiated resize.
|
|
* AL may shrink the window during the build phase (before show),
|
|
* but el_appkit_window_show calls setContentSize: to restore
|
|
* the designed size before making the window visible. */
|
|
[childView setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
[cv addSubview:childView];
|
|
[NSLayoutConstraint activateConstraints:@[
|
|
[childView.topAnchor constraintEqualToAnchor:cv.topAnchor],
|
|
[childView.bottomAnchor constraintEqualToAnchor:cv.bottomAnchor],
|
|
[childView.leadingAnchor constraintEqualToAnchor:cv.leadingAnchor],
|
|
[childView.trailingAnchor constraintEqualToAnchor:cv.trailingAnchor],
|
|
]];
|
|
break;
|
|
}
|
|
case EL_WIDGET_VSTACK: {
|
|
NSStackView* sv = (NSStackView*)pw->obj;
|
|
[sv addArrangedSubview:childView];
|
|
/* Pin child leading+trailing to fill cross-axis (width) of the VStack.
|
|
* Priority 999 avoids intrinsic-size feedback through NSWindow. */
|
|
[childView setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
NSLayoutConstraint* vl = [NSLayoutConstraint
|
|
constraintWithItem:childView attribute:NSLayoutAttributeLeading
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:sv attribute:NSLayoutAttributeLeading
|
|
multiplier:1.0 constant:0.0];
|
|
NSLayoutConstraint* vr = [NSLayoutConstraint
|
|
constraintWithItem:childView attribute:NSLayoutAttributeTrailing
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:sv attribute:NSLayoutAttributeTrailing
|
|
multiplier:1.0 constant:0.0];
|
|
vl.priority = 999;
|
|
vr.priority = 999;
|
|
[sv addConstraints:@[vl, vr]];
|
|
break;
|
|
}
|
|
case EL_WIDGET_HSTACK: {
|
|
NSStackView* sv = (NSStackView*)pw->obj;
|
|
[sv addArrangedSubview:childView];
|
|
/* Pin child top+bottom to fill cross-axis (height) of the HStack.
|
|
* Priority 499 (well below required 1000) lets the window frame
|
|
* win over child intrinsic size, avoiding NSWindow auto-resize. */
|
|
[childView setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
NSLayoutConstraint* ht = [NSLayoutConstraint
|
|
constraintWithItem:childView attribute:NSLayoutAttributeTop
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:sv attribute:NSLayoutAttributeTop
|
|
multiplier:1.0 constant:0.0];
|
|
NSLayoutConstraint* hb = [NSLayoutConstraint
|
|
constraintWithItem:childView attribute:NSLayoutAttributeBottom
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:sv attribute:NSLayoutAttributeBottom
|
|
multiplier:1.0 constant:0.0];
|
|
ht.priority = 499;
|
|
hb.priority = 499;
|
|
[sv addConstraints:@[ht, hb]];
|
|
break;
|
|
}
|
|
case EL_WIDGET_SCROLL: {
|
|
NSScrollView* sv = (NSScrollView*)pw->obj;
|
|
/* Wrap the child in a flipped view so content starts at the
|
|
* visual top-left. Without flipping, NSScrollView places the
|
|
* document origin at the bottom-left and lists appear inverted. */
|
|
ElFlippedView* flipper = [[ElFlippedView alloc] initWithFrame:NSZeroRect];
|
|
[flipper setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
[childView setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
[flipper addSubview:childView];
|
|
/* Pin child to all 4 edges of the flipper. */
|
|
NSArray* hc = [NSLayoutConstraint constraintsWithVisualFormat:@"H:|[c]|"
|
|
options:0 metrics:nil views:@{@"c": childView}];
|
|
NSArray* vc = [NSLayoutConstraint constraintsWithVisualFormat:@"V:|[c]|"
|
|
options:0 metrics:nil views:@{@"c": childView}];
|
|
[flipper addConstraints:hc];
|
|
[flipper addConstraints:vc];
|
|
[sv setDocumentView:flipper];
|
|
/* Constrain flipper width to the scroll view so items fill the
|
|
* full panel width (vertical-only scrolling). */
|
|
NSLayoutConstraint* wc = [NSLayoutConstraint
|
|
constraintWithItem:flipper attribute:NSLayoutAttributeWidth
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:sv attribute:NSLayoutAttributeWidth
|
|
multiplier:1.0 constant:0.0];
|
|
[sv addConstraint:wc];
|
|
[flipper release];
|
|
break;
|
|
}
|
|
case EL_WIDGET_ZSTACK:
|
|
default: {
|
|
NSView* pv = (NSView*)pw->obj;
|
|
[pv addSubview:childView];
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/*
|
|
* el_appkit_widget_remove_child — remove a child widget from its parent.
|
|
*/
|
|
void el_appkit_widget_remove_child(int64_t parent, int64_t child) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* cw = el_widget_get(child);
|
|
if (!cw) return;
|
|
NSView* v = (NSView*)cw->obj;
|
|
if (![v isKindOfClass:[NSWindow class]]) {
|
|
[v removeFromSuperview];
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ── Event registration ───────────────────────────────────────────────────── */
|
|
|
|
void el_appkit_widget_set_data(int64_t handle, const char* data_str) {
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
free(w->user_data);
|
|
w->user_data = data_str && *data_str ? strdup(data_str) : NULL;
|
|
}
|
|
|
|
void el_appkit_widget_on_click(int64_t handle, const char* fn_name) {
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
free(w->cb_click);
|
|
w->cb_click = fn_name && *fn_name ? strdup(fn_name) : NULL;
|
|
}
|
|
|
|
void el_appkit_widget_on_change(int64_t handle, const char* fn_name) {
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
free(w->cb_change);
|
|
w->cb_change = fn_name && *fn_name ? strdup(fn_name) : NULL;
|
|
}
|
|
|
|
void el_appkit_widget_on_submit(int64_t handle, const char* fn_name) {
|
|
/* For text fields, submit = pressing Enter → stored in cb_click slot. */
|
|
el_appkit_widget_on_click(handle, fn_name);
|
|
}
|
|
|
|
/* ── Widget destroy ───────────────────────────────────────────────────────── */
|
|
|
|
void el_appkit_widget_destroy(int64_t handle) {
|
|
el_appkit_sync_main(^{
|
|
ElWidget* w = el_widget_get(handle);
|
|
if (!w) return;
|
|
NSView* v = (NSView*)w->obj;
|
|
if (![v isKindOfClass:[NSWindow class]]) {
|
|
[v removeFromSuperview];
|
|
} else {
|
|
[(NSWindow*)w->obj close];
|
|
}
|
|
[_el_delegates[handle] release];
|
|
_el_delegates[handle] = nil;
|
|
el_widget_free(handle);
|
|
});
|
|
}
|