diff --git a/Package.swift b/Package.swift index ccfdcb5..c51d5e7 100644 --- a/Package.swift +++ b/Package.swift @@ -64,6 +64,15 @@ targets += [ pkgConfig: "gtk4", providers: [.apt(["libgtk-4-dev"])] ), + // GtkSourceView 5 — the code-editor widget behind SwiftOpenUI's `CodeEditor` + // (syntax highlighting / gutter). Its own system-library module so the + // gtksourceview include path + link stay off the gtk4-only CGTK module. + .systemLibrary( + name: "CGtkSource", + path: "Sources/Backend/GTK4/CGtkSource", + pkgConfig: "gtksourceview-5", + providers: [.apt(["libgtksourceview-5-dev"])] + ), .target( name: "CGTKBridge", dependencies: ["CGTK"], @@ -71,7 +80,7 @@ targets += [ ), .target( name: "BackendGTK4", - dependencies: ["SwiftOpenUI", "CGTK", "CGTKBridge", "SwiftOpenUISymbols"], + dependencies: ["SwiftOpenUI", "CGTK", "CGtkSource", "CGTKBridge", "SwiftOpenUISymbols"], path: "Sources/Backend/GTK4/Rendering", linkerSettings: [ // FontConfig is used by the process-local font registration diff --git a/Sources/Backend/GTK4/CGTK/shim.h b/Sources/Backend/GTK4/CGTK/shim.h index c2d47d6..055b980 100644 --- a/Sources/Backend/GTK4/CGTK/shim.h +++ b/Sources/Backend/GTK4/CGTK/shim.h @@ -1191,3 +1191,90 @@ gtk_swift_get_active_window(void) { if (!app || !GTK_IS_APPLICATION(app)) return NULL; return gtk_application_get_active_window(GTK_APPLICATION(app)); } + +/// Return the process default GApplication cast to GtkApplication, or NULL if +/// there is no default application or it is not a GtkApplication. Used to +/// parent an imperatively-opened standalone window (via +/// GTK4Backend.openStandaloneWindow) to the GtkApplication started by +/// GTK4Backend.run(_:), so a window can be opened at runtime from outside the +/// App/Scene tree. +static inline GtkApplication * +gtk_swift_get_default_gtk_application(void) { + GApplication *app = g_application_get_default(); + if (!app || !GTK_IS_APPLICATION(app)) return NULL; + return GTK_APPLICATION(app); +} + +// --- Native file dialog (GtkFileDialog, GTK 4.10+) --- +// +// Async open/save wrappers for the live-coding scratchpad's Open/Save buttons. +// The chosen path (or NULL on cancel/error) is delivered to a Swift callback +// via an opaque user pointer; the dialog is parented to the active window (see +// gtk_swift_get_active_window) so GTK 4.14 doesn't emit realization criticals. +typedef void (*LyrebirdFileDialogCB)(const char *path, void *user); + +typedef struct { + LyrebirdFileDialogCB cb; + void *user; +} LyrebirdFileDialogCtx; + +static void +lyrebird_fd_open_done(GObject *src, GAsyncResult *res, gpointer data) { + LyrebirdFileDialogCtx *ctx = (LyrebirdFileDialogCtx *)data; + GFile *file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(src), res, NULL); + if (file) { + char *p = g_file_get_path(file); + ctx->cb(p, ctx->user); + g_free(p); + g_object_unref(file); + } else { + ctx->cb(NULL, ctx->user); + } + g_free(ctx); +} + +static void +lyrebird_fd_save_done(GObject *src, GAsyncResult *res, gpointer data) { + LyrebirdFileDialogCtx *ctx = (LyrebirdFileDialogCtx *)data; + GFile *file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(src), res, NULL); + if (file) { + char *p = g_file_get_path(file); + ctx->cb(p, ctx->user); + g_free(p); + g_object_unref(file); + } else { + ctx->cb(NULL, ctx->user); + } + g_free(ctx); +} + +/// Present a native Open dialog. `cb(path, user)` is invoked on the GTK main +/// thread with the chosen path, or NULL if cancelled. +static inline void +gtk_swift_present_open_dialog(const char *title, void *user, LyrebirdFileDialogCB cb) { + GtkFileDialog *dlg = gtk_file_dialog_new(); + if (title) gtk_file_dialog_set_title(dlg, title); + LyrebirdFileDialogCtx *ctx = g_new0(LyrebirdFileDialogCtx, 1); + ctx->cb = cb; + ctx->user = user; + gtk_file_dialog_open(dlg, gtk_swift_get_active_window(), NULL, + lyrebird_fd_open_done, ctx); + g_object_unref(dlg); +} + +/// Present a native Save dialog, pre-filling `suggested_name` (may be NULL). +/// `cb(path, user)` is invoked on the GTK main thread with the chosen path, or +/// NULL if cancelled. +static inline void +gtk_swift_present_save_dialog(const char *title, const char *suggested_name, + void *user, LyrebirdFileDialogCB cb) { + GtkFileDialog *dlg = gtk_file_dialog_new(); + if (title) gtk_file_dialog_set_title(dlg, title); + if (suggested_name) gtk_file_dialog_set_initial_name(dlg, suggested_name); + LyrebirdFileDialogCtx *ctx = g_new0(LyrebirdFileDialogCtx, 1); + ctx->cb = cb; + ctx->user = user; + gtk_file_dialog_save(dlg, gtk_swift_get_active_window(), NULL, + lyrebird_fd_save_done, ctx); + g_object_unref(dlg); +} diff --git a/Sources/Backend/GTK4/CGtkSource/module.modulemap b/Sources/Backend/GTK4/CGtkSource/module.modulemap new file mode 100644 index 0000000..1d4b7d6 --- /dev/null +++ b/Sources/Backend/GTK4/CGtkSource/module.modulemap @@ -0,0 +1,5 @@ +module CGtkSource [system] { + header "shim.h" + link "gtksourceview-5" + export * +} diff --git a/Sources/Backend/GTK4/CGtkSource/shim.h b/Sources/Backend/GTK4/CGtkSource/shim.h new file mode 100644 index 0000000..1a27ec8 --- /dev/null +++ b/Sources/Backend/GTK4/CGtkSource/shim.h @@ -0,0 +1,156 @@ +// CGtkSource — a thin, OpaquePointer-only shim over GtkSourceView 5, kept in its +// own system-library module (pkgConfig "gtksourceview-5") so the gtksourceview +// include path + link don't have to be forced onto the gtk4-only CGTK module. +// +// Every function takes/returns `void*` (an opaque GtkWidget* / GtkTextBuffer*), +// so the Swift caller treats the handles as `OpaquePointer`/`gpointer` and never +// imports GtkSourceView's C types alongside CGTK's — avoiding cross-module +// GtkWidget/GtkTextBuffer redefinitions. Signal wiring stays on the CGTK side +// (the buffer is just a gpointer to g_signal_connect_data). +#ifndef LYREBIRD_CGTKSOURCE_SHIM_H +#define LYREBIRD_CGTKSOURCE_SHIM_H + +#include + +// Create a code editor view configured for Swift: syntax highlighting, a +// line-number gutter, a monospace font, 4-space soft tabs, and auto-indent. +// Returns the GtkSourceView as an opaque GtkWidget*. +static inline void * +gtk_swift_source_view_new(void) { + GtkSourceView *view = GTK_SOURCE_VIEW(gtk_source_view_new()); + gtk_source_view_set_show_line_numbers(view, TRUE); + gtk_source_view_set_auto_indent(view, TRUE); + gtk_source_view_set_highlight_current_line(view, TRUE); + gtk_source_view_set_tab_width(view, 4); + gtk_source_view_set_insert_spaces_instead_of_tabs(view, TRUE); + + // Monospace is a GtkTextView property in GtkSourceView 5 (there is no + // gtk_source_view_set_monospace). + GtkTextView *tv = GTK_TEXT_VIEW(view); + gtk_text_view_set_monospace(tv, TRUE); + + GtkSourceBuffer *buf = + GTK_SOURCE_BUFFER(gtk_text_view_get_buffer(tv)); + GtkSourceLanguageManager *lm = gtk_source_language_manager_get_default(); + GtkSourceLanguage *lang = + gtk_source_language_manager_get_language(lm, "swift"); + if (lang) { + gtk_source_buffer_set_language(buf, lang); + gtk_source_buffer_set_highlight_syntax(buf, TRUE); + } + return (void *)view; +} + +// The view's GtkSourceBuffer as an opaque GtkTextBuffer* (it is a subclass, so +// the base gtk_text_buffer_* API applies). +static inline void * +gtk_swift_source_view_get_buffer(void *view) { + return (void *)gtk_text_view_get_buffer(GTK_TEXT_VIEW(view)); +} + +static inline void +gtk_swift_source_buffer_set_text(void *buffer, const char *text, int length) { + gtk_text_buffer_set_text((GtkTextBuffer *)buffer, text, length); +} + +// Full buffer contents. Caller must g_free the returned string. +static inline char * +gtk_swift_source_buffer_get_text(void *buffer) { + GtkTextIter start, end; + gtk_text_buffer_get_bounds((GtkTextBuffer *)buffer, &start, &end); + return gtk_text_buffer_get_text((GtkTextBuffer *)buffer, &start, &end, FALSE); +} + +// The currently selected text, or an empty string when nothing is selected. +// Caller must g_free the returned string. +static inline char * +gtk_swift_source_buffer_get_selected_text(void *buffer) { + GtkTextBuffer *b = (GtkTextBuffer *)buffer; + GtkTextIter start, end; + if (!gtk_text_buffer_get_selection_bounds(b, &start, &end)) { + return g_strdup(""); + } + return gtk_text_buffer_get_text(b, &start, &end, FALSE); +} + +// Cursor position as 0-based (line, column-in-characters). +static inline void +gtk_swift_source_buffer_get_cursor_line_col(void *buffer, int *line, int *col) { + GtkTextBuffer *b = (GtkTextBuffer *)buffer; + GtkTextMark *insert = gtk_text_buffer_get_insert(b); + GtkTextIter it; + gtk_text_buffer_get_iter_at_mark(b, &it, insert); + *line = gtk_text_iter_get_line(&it); + *col = gtk_text_iter_get_line_offset(&it); +} + +// Insert `text` at the cursor (replacing any selection). +static inline void +gtk_swift_source_buffer_insert_at_cursor(void *buffer, const char *text) { + GtkTextBuffer *b = (GtkTextBuffer *)buffer; + gtk_text_buffer_begin_user_action(b); + if (gtk_text_buffer_get_has_selection(b)) { + gtk_text_buffer_delete_selection(b, FALSE, TRUE); + } + gtk_text_buffer_insert_at_cursor(b, text, -1); + gtk_text_buffer_end_user_action(b); +} + +// Replace the identifier word immediately before the cursor with `text`, then +// leave the caret after the inserted text. Used when applying a completion so +// the already-typed prefix (`Osc` when picking `OscSin`) is not duplicated. When +// there is no identifier prefix before the caret (e.g. just after a `.`), this +// inserts without deleting anything. Any active selection is replaced first. +static inline void +gtk_swift_source_buffer_replace_prefix_at_cursor(void *buffer, const char *text) { + GtkTextBuffer *b = (GtkTextBuffer *)buffer; + gtk_text_buffer_begin_user_action(b); + if (gtk_text_buffer_get_has_selection(b)) { + gtk_text_buffer_delete_selection(b, FALSE, TRUE); + } + GtkTextMark *insert = gtk_text_buffer_get_insert(b); + GtkTextIter end; + gtk_text_buffer_get_iter_at_mark(b, &end, insert); + GtkTextIter start = end; + // Walk left over identifier characters (letters, digits, underscore). + while (gtk_text_iter_backward_char(&start)) { + gunichar ch = gtk_text_iter_get_char(&start); + if (!(g_unichar_isalnum(ch) || ch == '_')) { + gtk_text_iter_forward_char(&start); + break; + } + } + gtk_text_buffer_delete(b, &start, &end); + gtk_text_buffer_insert_at_cursor(b, text, -1); + gtk_text_buffer_end_user_action(b); +} + +// The caret's rectangle in widget coordinates (for anchoring a popover). +static inline void +gtk_swift_source_view_get_cursor_rect(void *view, int *x, int *y, int *w, int *h) { + GtkTextView *tv = GTK_TEXT_VIEW(view); + GtkTextBuffer *b = gtk_text_view_get_buffer(tv); + GtkTextMark *insert = gtk_text_buffer_get_insert(b); + GtkTextIter it; + gtk_text_buffer_get_iter_at_mark(b, &it, insert); + GdkRectangle loc; + gtk_text_view_get_iter_location(tv, &it, &loc); + int bx = 0, by = 0; + gtk_text_view_buffer_to_window_coords(tv, GTK_TEXT_WINDOW_WIDGET, + loc.x, loc.y, &bx, &by); + *x = bx; *y = by; *w = loc.width; *h = loc.height; +} + +// Apply a named style scheme (e.g. "Adwaita-dark", "classic") when present. +static inline void +gtk_swift_source_buffer_set_style_scheme(void *buffer, const char *scheme_id) { + GtkSourceStyleSchemeManager *sm = + gtk_source_style_scheme_manager_get_default(); + GtkSourceStyleScheme *scheme = + gtk_source_style_scheme_manager_get_scheme(sm, scheme_id); + if (scheme) { + gtk_source_buffer_set_style_scheme((GtkSourceBuffer *)buffer, scheme); + } +} + +#endif /* LYREBIRD_CGTKSOURCE_SHIM_H */ diff --git a/Sources/Backend/GTK4/Rendering/CodeEditorCompletionGTK.swift b/Sources/Backend/GTK4/Rendering/CodeEditorCompletionGTK.swift new file mode 100644 index 0000000..55d5fa0 --- /dev/null +++ b/Sources/Backend/GTK4/Rendering/CodeEditorCompletionGTK.swift @@ -0,0 +1,207 @@ +import CGTK +import CGtkSource +import CGTKBridge +import SwiftOpenUI +import Foundation + +// Ctrl+Space completion for the GtkSourceView `CodeEditor`. A key controller on +// the view triggers the host's async `completionProvider`; the result is applied +// on the main thread. (This step inserts the first candidate; a picker popover is +// layered on next.) +// +// `@unchecked Sendable`: main-thread-confined — the raw GTK pointers are only +// touched inside `@MainActor` methods; the Task hops back to the main actor before +// applying anything. +final class CodeEditorCompletionController: @unchecked Sendable { + let viewRaw: UnsafeMutableRawPointer + let bufferRaw: UnsafeMutableRawPointer + let provider: @Sendable (String, Int, Int) async -> [CodeCompletionItem] + + private var items: [CodeCompletionItem] = [] + private var popover: UnsafeMutablePointer? + /// The identifier word to the left of the caret at the moment Ctrl+Space was + /// pressed. Used to narrow the (large, unfiltered) sourcekit-lsp candidate set + /// client-side — sourcekit returns every in-scope symbol and expects the + /// editor to filter by what was typed. + private var prefix: String = "" + + init( + viewRaw: UnsafeMutableRawPointer, + bufferRaw: UnsafeMutableRawPointer, + provider: @escaping @Sendable (String, Int, Int) async -> [CodeCompletionItem] + ) { + self.viewRaw = viewRaw + self.bufferRaw = bufferRaw + self.provider = provider + } + + /// Gather buffer text + caret (on the GTK main thread), ask the provider off + /// the main thread, then apply the result back on the main actor. + @MainActor + func trigger() { + guard let cStr: UnsafeMutablePointer = gtk_swift_source_buffer_get_text(bufferRaw) else { return } + let text: String = String(cString: cStr) + g_free(UnsafeMutableRawPointer(cStr)) + var line: Int32 = 0 + var col: Int32 = 0 + gtk_swift_source_buffer_get_cursor_line_col(bufferRaw, &line, &col) + let l: Int = Int(line) + let c: Int = Int(col) + prefix = Self.identifierPrefix(text: text, line: l, column: c) + Task { [self] in + let result: [CodeCompletionItem] = await provider(text, l, c) + await MainActor.run { self.present(result) } + } + } + + /// The identifier word (letters / digits / `_`) ending at the caret on + /// `line` / `column` (both 0-based). Empty when the char before the caret is + /// not an identifier char (e.g. just after `.` or a space). + private static func identifierPrefix(text: String, line: Int, column: Int) -> String { + let lines: [Substring] = text.split(separator: "\n", omittingEmptySubsequences: false) + guard line >= 0, line < lines.count else { return "" } + let chars: [Character] = Array(lines[line]) + let end: Int = min(max(column, 0), chars.count) + var start: Int = end + while start > 0 { + let ch: Character = chars[start - 1] + if ch.isLetter || ch.isNumber || ch == "_" { start -= 1 } else { break } + } + return String(chars[start.. = viewRaw.assumingMemoryBound(to: GtkWidget.self) + + guard let popover = gtk_popover_new() else { return } + + guard let listBox = gtk_list_box_new() else { return } + let listBoxOp: OpaquePointer = OpaquePointer(listBox) + gtk_list_box_set_selection_mode(listBoxOp, GTK_SELECTION_SINGLE) + for candidate: CodeCompletionItem in filtered { + let label: UnsafeMutablePointer = candidate.label.withCString { (c: UnsafePointer) in + gtk_label_new(c) + } + gtk_widget_set_halign(label, GTK_ALIGN_START) + let row = gtk_list_box_row_new()! + gtk_list_box_row_set_child( + UnsafeMutableRawPointer(row).assumingMemoryBound(to: GtkListBoxRow.self), + label + ) + gtk_list_box_append(listBoxOp, row) + } + + let scrolled = gtk_scrolled_window_new()! + let scrolledOp: OpaquePointer = OpaquePointer(scrolled) + gtk_scrolled_window_set_policy(scrolledOp, GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC) + gtk_scrolled_window_set_child(scrolledOp, listBox) + gtk_widget_set_size_request(scrolled, 280, min(220, 26 * Int32(filtered.count))) + + gtk_swift_popover_set_child(popover, scrolled) + gtk_widget_set_parent(popover, view) + + var rx: Int32 = 0, ry: Int32 = 0, rw: Int32 = 0, rh: Int32 = 0 + gtk_swift_source_view_get_cursor_rect(viewRaw, &rx, &ry, &rw, &rh) + gtk_swift_popover_set_pointing_to(popover, rx, ry, rw, rh) + + // Insert on row activation. + let activateBox: UnsafeMutableRawPointer = Unmanaged.passRetained(IntClosureBox { [weak self] (index: Int) in + self?.insert(at: index) + }).toOpaque() + g_signal_connect_data( + gpointer(listBox), + "row-activated", + unsafeBitCast({ (_: gpointer?, rowPtr: gpointer?, userData: gpointer?) in + guard let userData, let rowPtr else { return } + let row = rowPtr.assumingMemoryBound(to: GtkListBoxRow.self) + let index: Int = Int(gtk_list_box_row_get_index(row)) + Unmanaged.fromOpaque(userData).takeUnretainedValue().closure(index) + } as @convention(c) (gpointer?, gpointer?, gpointer?) -> Void, to: GCallback.self), + activateBox, + { (data: gpointer?, _: UnsafeMutablePointer?) in + if let data { Unmanaged.fromOpaque(data).release() } + }, + GConnectFlags(rawValue: 0) + ) + + gtk_swift_popover_popup(popover) + self.popover = popover + } + + @MainActor + private func insert(at index: Int) { + guard index >= 0, index < items.count else { dismiss(); return } + // Replace the already-typed prefix (rather than append) so picking + // `OscSin` after typing `Osc` yields `OscSin`, not `OscOscSin`. + items[index].insertText.withCString { (c: UnsafePointer) in + gtk_swift_source_buffer_replace_prefix_at_cursor(bufferRaw, c) + } + dismiss() + } + + /// Narrow the raw sourcekit-lsp candidate set to those matching the typed + /// `prefix` (case-insensitive), preferring a `label`/`insertText` that starts + /// with the prefix, then a contains-match, each ordered alphabetically. An + /// empty prefix (member access after `.`) passes the set through unchanged — + /// sourcekit has already scoped it. + private static func filter(_ candidates: [CodeCompletionItem], prefix: String) -> [CodeCompletionItem] { + guard !prefix.isEmpty else { return candidates } + let lp: String = prefix.lowercased() + var starts: [CodeCompletionItem] = [] + var contains: [CodeCompletionItem] = [] + for item: CodeCompletionItem in candidates { + let label: String = item.label.lowercased() + let insert: String = item.insertText.lowercased() + if label.hasPrefix(lp) || insert.hasPrefix(lp) { + starts.append(item) + } else if label.contains(lp) { + contains.append(item) + } + } + starts.sort { $0.label.lowercased() < $1.label.lowercased() } + contains.sort { $0.label.lowercased() < $1.label.lowercased() } + return starts + contains + } + + @MainActor + private func dismiss() { + if let pop: UnsafeMutablePointer = popover { + gtk_swift_popover_popdown(pop) + gtk_widget_unparent(pop) + popover = nil + } + } +} + +/// Add a Ctrl+Space key controller to `view` (a GtkSourceView) that drives +/// `controller.trigger()`. +func gtkAttachCodeEditorCompletion(view: UnsafeMutableRawPointer, controller: CodeEditorCompletionController) { + let box: UnsafeMutableRawPointer = Unmanaged.passRetained(controller).toOpaque() + guard let keyController = gtk_event_controller_key_new() else { return } + g_signal_connect_data( + gpointer(keyController), + "key-pressed", + unsafeBitCast({ (_: OpaquePointer?, keyval: guint, _: guint, state: guint, userData: gpointer?) -> gboolean in + guard let userData else { return 0 } + // Ctrl+Space: GDK_KEY_space = 0x20, GDK_CONTROL_MASK = 1<<2 = 4. + let isCtrl: Bool = (state & 4) != 0 + guard isCtrl, keyval == 0x20 else { return 0 } + let ctrl = Unmanaged.fromOpaque(userData).takeUnretainedValue() + MainActor.assumeIsolated { ctrl.trigger() } + return 1 // consumed + } as @convention(c) (OpaquePointer?, guint, guint, guint, gpointer?) -> gboolean, to: GCallback.self), + box, + { (userData: gpointer?, _: UnsafeMutablePointer?) in + if let userData { Unmanaged.fromOpaque(userData).release() } + }, + GConnectFlags(rawValue: 0) + ) + gtk_widget_add_controller(view.assumingMemoryBound(to: GtkWidget.self), keyController) +} diff --git a/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift new file mode 100644 index 0000000..78003d7 --- /dev/null +++ b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift @@ -0,0 +1,120 @@ +import CGTK +import CGtkSource +import CGTKBridge +import SwiftOpenUI + +// GTK4 rendering for `CodeEditor`: a GtkSourceView (Swift highlighting + +// line-number gutter, from the CGtkSource shim) inside a scrolled window, with a +// two-way text binding wired through the buffer's "changed" signal — the same +// shape as the plain `TextEditor` render, but the widget is created via the +// OpaquePointer-only CGtkSource shim so gtksourceview's C types never cross into +// this file. +// A code editor holds live, user-owned state (the buffer text, caret, and +// selection) that must survive host rebuilds. Describe it as an opaque leaf with +// a CONSTANT signature so the narrow path always REUSES the existing +// GtkSourceView instead of recreating it: an unrelated `@Observable` change (the +// scope's poll timer, the run flag) that rebuilds the host no longer flickers the +// editor or snaps the caret to the end on every keystroke. The trade-off — an +// external, programmatic change to the bound text is not pushed back into the +// widget — is exactly right for a scratch editor the user is the sole author of. +extension CodeEditor: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { AnyHashable("SwiftOpenUI.CodeEditor") } +} + +extension CodeEditor: GTKRenderable { + public func gtkCreateWidget() -> OpaquePointer { + let viewRaw: UnsafeMutableRawPointer = gtk_swift_source_view_new() + let view: UnsafeMutablePointer = viewRaw.assumingMemoryBound(to: GtkWidget.self) + let bufferRaw: UnsafeMutableRawPointer = gtk_swift_source_view_get_buffer(viewRaw) + + // Best-effort dark scheme; ignored when the scheme id isn't installed. + "Adwaita-dark".withCString { (id: UnsafePointer) in + gtk_swift_source_buffer_set_style_scheme(bufferRaw, id) + } + + let current: String = text.wrappedValue + if !current.isEmpty { + current.withCString { (c: UnsafePointer) in + gtk_swift_source_buffer_set_text(bufferRaw, c, Int32(current.utf8.count)) + } + } + + let binding: Binding = text + let box: UnsafeMutableRawPointer = Unmanaged.passRetained(StringClosureBox { (newText: String) in + if newText != binding.wrappedValue { + binding.wrappedValue = newText + } + }).toOpaque() + g_signal_connect_data( + gpointer(bufferRaw), + "changed", + unsafeBitCast({ (bufferPtr: gpointer?, userData: gpointer?) in + guard let userData, let bufferPtr else { return } + let box = Unmanaged.fromOpaque(userData).takeUnretainedValue() + guard let cStr: UnsafeMutablePointer = gtk_swift_source_buffer_get_text(bufferPtr) else { return } + let result: String = String(cString: cStr) + g_free(UnsafeMutableRawPointer(cStr)) + box.closure(result) + } as @convention(c) (gpointer?, gpointer?) -> Void, to: GCallback.self), + box, + { (userData: gpointer?, _: UnsafeMutablePointer?) in + if let userData { Unmanaged.fromOpaque(userData).release() } + }, + GConnectFlags(rawValue: 0) + ) + + // Mirror the selection out (for line-level evaluation). "mark-set" fires + // whenever the caret or selection bound moves. + if let selectionBinding: Binding = selection { + let selBox: UnsafeMutableRawPointer = Unmanaged.passRetained(StringClosureBox { (sel: String) in + if sel != selectionBinding.wrappedValue { + selectionBinding.wrappedValue = sel + } + }).toOpaque() + g_signal_connect_data( + gpointer(bufferRaw), + "mark-set", + unsafeBitCast({ (bufferPtr: gpointer?, _: gpointer?, _: gpointer?, userData: gpointer?) in + guard let userData, let bufferPtr else { return } + let box = Unmanaged.fromOpaque(userData).takeUnretainedValue() + guard let cStr: UnsafeMutablePointer = gtk_swift_source_buffer_get_selected_text(bufferPtr) else { return } + let result: String = String(cString: cStr) + g_free(UnsafeMutableRawPointer(cStr)) + box.closure(result) + } as @convention(c) (gpointer?, gpointer?, gpointer?, gpointer?) -> Void, to: GCallback.self), + selBox, + { (userData: gpointer?, _: UnsafeMutablePointer?) in + if let userData { Unmanaged.fromOpaque(userData).release() } + }, + GConnectFlags(rawValue: 0) + ) + } + + // Imperative text replacement (file loads): push straight into the + // buffer. The buffer's "changed" signal above then flows the new text + // back out through the `text` binding, so the host's state stays in sync. + if let controller: CodeEditorController = controller { + controller._applyText = { (newText: String) in + newText.withCString { (c: UnsafePointer) in + gtk_swift_source_buffer_set_text(bufferRaw, c, Int32(newText.utf8.count)) + } + } + } + + // Ctrl+Space completion, when a provider was supplied. + if let provider = completionProvider { + let completion: CodeEditorCompletionController = CodeEditorCompletionController( + viewRaw: viewRaw, bufferRaw: bufferRaw, provider: provider + ) + gtkAttachCodeEditorCompletion(view: viewRaw, controller: completion) + } + + let scrolled = gtk_scrolled_window_new()! + gtk_scrolled_window_set_policy(OpaquePointer(scrolled), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC) + gtk_scrolled_window_set_child(OpaquePointer(scrolled), view) + gtk_widget_set_vexpand(scrolled, 1) + gtk_widget_set_hexpand(scrolled, 1) + + return opaqueFromWidget(scrolled) + } +} diff --git a/Sources/Backend/GTK4/Rendering/GTK4Backend.swift b/Sources/Backend/GTK4/Rendering/GTK4Backend.swift index c85e223..5a1457e 100644 --- a/Sources/Backend/GTK4/Rendering/GTK4Backend.swift +++ b/Sources/Backend/GTK4/Rendering/GTK4Backend.swift @@ -162,6 +162,7 @@ extension WindowGroup: GTKWindowRenderable { gtkSetupMenuBarIfNeeded(winPtr: winWidget, contentWidget: contentWidget, windowID: Int(bitPattern: winPtr)) gtkAttachKeyboardShortcutController(to: winWidget) gtkAttachWindowActivationHandler(to: winWidget) + gtkAttachPointerTracking(to: winWidget) gtk_window_present(winPtr) } } @@ -673,6 +674,7 @@ extension Window: GTKWindowRenderable { gtkSetupMenuBarIfNeeded(winPtr: winWidget, contentWidget: contentWidget, windowID: Int(bitPattern: winPtr)) gtkAttachKeyboardShortcutController(to: winWidget) gtkAttachWindowActivationHandler(to: winWidget) + gtkAttachPointerTracking(to: winWidget) gtk_window_present(winPtr) // Track the live window so repeated openWindow(id:) refocuses diff --git a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift index c434590..2bd529f 100644 --- a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift +++ b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift @@ -24,6 +24,15 @@ public enum GTK4DescriptorKind: Equatable { case searchable case font case text + case textField + /// An opaque native widget whose state the narrow path does not model (Toggle, + /// Stepper, Picker, a filled Shape, …). It carries an `AnyHashable` state + /// signature so the diff can tell whether it changed: an unchanged signature + /// `.reuse`s (passing the narrow gate — it is NOT an empty `.composite`), a + /// changed one plans an `.update`/`.none` the gate rejects, forcing a full + /// rebuild. This keeps such widgets from poisoning a host's narrow path while + /// staying correct on a real state change. + case opaqueLeaf case color case frame case foregroundColor @@ -84,6 +93,23 @@ public struct GTK4TextDescriptor: Equatable { public let content: String } +/// A `TextField`'s current text + placeholder. Making the text visible in the +/// descriptor tree means a changed value plans a `.textFieldValue` update the +/// narrow path applies in place (`gtk_swift_editable_set_text` on the hosted +/// GtkEntry) instead of a silent-reuse that never updates, or an empty +/// `.composite` that poisons the whole host's narrow path. +public struct GTK4TextFieldDescriptor: Equatable { + public let text: String + public let placeholder: String +} + +/// State signature of an opaque native widget (see `.opaqueLeaf`). Two describe +/// as equal iff their signatures are equal, so an unchanged widget reuses and a +/// changed one forces a rebuild. `AnyHashable` is `Equatable`, so this is too. +public struct GTK4OpaqueLeafDescriptor: Equatable { + public let signature: AnyHashable +} + public struct GTK4ColorDescriptor: Equatable { public let red: Double public let green: Double @@ -226,6 +252,8 @@ public enum GTK4DescriptorProps: Equatable { case rotation(GTK4RotationDescriptor) case scale(GTK4ScaleDescriptor) case text(GTK4TextDescriptor) + case textField(GTK4TextFieldDescriptor) + case opaqueLeaf(GTK4OpaqueLeafDescriptor) case color(GTK4ColorDescriptor) case frame(GTK4FrameDescriptor) case foregroundColor(GTK4ColorDescriptor) @@ -382,6 +410,7 @@ public enum GTK4DescriptorUpdateIntent: Equatable { case sliderConfiguration case sliderValue case textContent + case textFieldValue case vStackLayout case zStackLayout case widgetPropertyUpdate @@ -490,6 +519,29 @@ public protocol GTKDescribable { func gtkDescribeNode() -> GTK4DescriptorNode } +/// A transparent single-`content` wrapper view (a styling / gesture / lifecycle +/// modifier) whose only visible structure is the view it wraps. Conforming lets +/// `gtkDescribeView` describe the wrapped content rather than falling through to +/// an EMPTY `.composite` — which the narrow-mutation gate rejects +/// (`gtkCanApplyTextColorHostMutation`), poisoning the whole host's narrow path +/// and dropping the wrapped node from the descriptor tree entirely. With this, +/// a `Text` / `TextField` / slider wrapped in `.onChange` / `.monospacedDigit` / +/// `.focused` / … stays narrow-applicable, so its value change updates in place +/// instead of forcing a full-window rebuild. +public protocol GTKContentWrapper { + var gtkWrappedContent: any View { get } +} + +/// An opaque native-widget view (Toggle, Stepper, Picker, a filled Shape, …) that +/// the narrow path cannot update in place. Conforming makes it describe as a +/// `.opaqueLeaf` carrying `gtkStateSignature` instead of an empty `.composite`, so +/// it no longer poisons the host's narrow path — an unchanged signature reuses; a +/// changed one forces a full rebuild. The signature MUST include every bound value +/// that affects the widget's appearance, or a programmatic change goes stale. +public protocol GTKOpaqueLeaf { + var gtkStateSignature: AnyHashable { get } +} + private final class GTK4CanvasPayloadCollector { var payloads: [GTK4CanvasPayload] = [] } @@ -524,6 +576,27 @@ public func gtkDescribeView(_ view: V) -> GTK4DescriptorNode { if let describable = view as? GTKDescribable { return describable.gtkDescribeNode() } + // Transparent single-content wrapper (styling / gesture / lifecycle modifier): + // describe the wrapped content so it stays in the descriptor tree and remains + // narrow-applicable, instead of collapsing to an empty `.composite` that the + // narrow gate rejects (which would poison the whole host's narrow path). + if let wrapper = view as? GTKContentWrapper { + return GTK4DescriptorNode( + kind: .composite, + typeName: String(describing: type(of: view)), + children: [gtkDescribeAnyView(wrapper.gtkWrappedContent)] + ) + } + // Opaque native leaf widget: describe as `.opaqueLeaf` carrying its state + // signature (not an empty `.composite`), so it doesn't poison the host's + // narrow path but still forces a rebuild when its state changes. + if let leaf = view as? GTKOpaqueLeaf { + return GTK4DescriptorNode( + kind: .opaqueLeaf, + typeName: String(describing: type(of: view)), + props: .opaqueLeaf(GTK4OpaqueLeafDescriptor(signature: leaf.gtkStateSignature)) + ) + } if let multi = view as? MultiChildView { return GTK4DescriptorNode( kind: .composite, @@ -689,6 +762,16 @@ private func gtkUpdateIntent(old: GTK4DescriptorNode, return oldSlider.range == newSlider.range && oldSlider.step == newSlider.step ? .sliderValue : .sliderConfiguration case .text: return .textContent + case .opaqueLeaf: return .none // any state-signature change → full rebuild + case .textField: + guard case let .textField(oldTF) = old.props, + case let .textField(newTF) = new.props else { + return .none + } + // Only a text change rides the narrow path; a placeholder change is rare + // and left to a full rebuild (returns .none → reuse, no narrow update). + return oldTF.text != newTF.text && oldTF.placeholder == newTF.placeholder + ? .textFieldValue : .none case .vStack: return .vStackLayout case .zStack: return .zStackLayout case .animated: return .animatedTiming @@ -799,6 +882,7 @@ public func gtkCanApplyTextColorHostMutation(plan: GTK4DescriptorPlan) -> Bool { guard plan.updateIntent == .textContent || plan.updateIntent == .colorFill || plan.updateIntent == .canvasContent || plan.updateIntent == .sliderValue + || plan.updateIntent == .textFieldValue || plan.updateIntent == .paddingLayout else { // .widgetPropertyUpdate is deliberately NOT here: `.widgetProperty` // applies in-place to the content's (often already-hosted) widget @@ -836,6 +920,8 @@ private func gtkUpdateHook(action: GTK4ExecutorAction, return gtkCanvasContentHook(action: action, performMutation: performMutation) case .sliderValue: return gtkSliderValueHook(action: action, performMutation: performMutation) + case .textFieldValue: + return gtkTextFieldValueHook(action: action, performMutation: performMutation) case .paddingLayout: return gtkPaddingLayoutHook(action: action, performMutation: performMutation) case .animatedTiming, .backgroundColor, .borderStyle, .fontStyle, .frameLayout, .foregroundColor, @@ -911,6 +997,21 @@ private func gtkSliderValueHook(action: GTK4ExecutorAction, mutationSucceeded: mutationSucceeded) } +private func gtkTextFieldValueHook(action: GTK4ExecutorAction, + performMutation: Bool) -> GTK4HookResult { + var mutationSucceeded = true + if performMutation, + case let .textField(tfDesc) = action.currentDescriptor.props, + let slotID = action.resultingNode.nativeSlotID ?? action.previousNode?.nativeSlotID { + mutationSucceeded = gtkSetTextFieldValue(slotID: slotID, text: tfDesc.text) + } else if performMutation { + mutationSucceeded = false + } + return gtkUpdatedHookResult(action: action, intent: .textFieldValue, + performMutation: performMutation, + mutationSucceeded: mutationSucceeded) +} + private func gtkPaddingLayoutHook(action: GTK4ExecutorAction, performMutation: Bool) -> GTK4HookResult { var mutationSucceeded = true @@ -1018,6 +1119,7 @@ public func gtkColorDescriptor(_ color: Color) -> GTK4ColorDescriptor { /// Kinds of hosted native widgets that support in-place mutation. public enum GTK4HostedNodeKind: String { case text + case textField case color case canvas case slider @@ -1035,6 +1137,8 @@ public func gtkMarkHostedNodeKind(_ widget: UnsafeMutablePointer, switch kind { case .text: g_object_set_data(gobject, gtkHostedKindKey, UnsafeMutableRawPointer(mutating: gtkHostedKindTextPtr)) + case .textField: + g_object_set_data(gobject, gtkHostedKindKey, UnsafeMutableRawPointer(mutating: gtkHostedKindTextFieldPtr)) case .color: g_object_set_data(gobject, gtkHostedKindKey, UnsafeMutableRawPointer(mutating: gtkHostedKindColorPtr)) case .canvas: @@ -1053,6 +1157,7 @@ public func gtkHostedNodeKind(of widget: UnsafeMutablePointer) -> GTK let gobject = UnsafeMutableRawPointer(widget).assumingMemoryBound(to: GObject.self) guard let raw = g_object_get_data(gobject, gtkHostedKindKey) else { return .unknown } if raw == UnsafeMutableRawPointer(mutating: gtkHostedKindTextPtr) { return .text } + if raw == UnsafeMutableRawPointer(mutating: gtkHostedKindTextFieldPtr) { return .textField } if raw == UnsafeMutableRawPointer(mutating: gtkHostedKindColorPtr) { return .color } if raw == UnsafeMutableRawPointer(mutating: gtkHostedKindCanvasPtr) { return .canvas } if raw == UnsafeMutableRawPointer(mutating: gtkHostedKindSliderPtr) { return .slider } @@ -1073,6 +1178,12 @@ private let gtkHostedKindColorPtr: UnsafePointer = { return UnsafePointer(p) }() +private let gtkHostedKindTextFieldPtr: UnsafePointer = { + let p = UnsafeMutablePointer.allocate(capacity: 1) + p.pointee = 6 + return UnsafePointer(p) +}() + private let gtkHostedKindCanvasPtr: UnsafePointer = { let p = UnsafeMutablePointer.allocate(capacity: 1) p.pointee = 5 @@ -1095,6 +1206,7 @@ private let gtkHostedKindPaddingPtr: UnsafePointer = { public func gtkHostedKindForDescriptor(_ kind: GTK4DescriptorKind) -> GTK4HostedNodeKind? { switch kind { case .text: return .text + case .textField: return .textField case .color: return .color case .canvas: return .canvas case .slider: return .slider @@ -1163,7 +1275,7 @@ private func gtkCollectSupportedHostedWidgets( into result: inout [UnsafeMutablePointer] ) { let kind = gtkHostedNodeKind(of: widget) - if kind == .text || kind == .color || kind == .canvas || kind == .slider || kind == .padding { + if kind == .text || kind == .textField || kind == .color || kind == .canvas || kind == .slider || kind == .padding { result.append(widget) } var child = gtk_widget_get_first_child(widget) @@ -1198,6 +1310,7 @@ public func gtkAllSlotsValid(action: GTK4ExecutorAction) -> Bool { if action.updateIntent == .textContent || action.updateIntent == .colorFill || action.updateIntent == .canvasContent || action.updateIntent == .sliderValue + || action.updateIntent == .textFieldValue || action.updateIntent == .paddingLayout { guard let slotID = action.resultingNode.nativeSlotID ?? action.previousNode?.nativeSlotID, let widget = gtkWidgetFromSlotID(slotID), @@ -1267,6 +1380,26 @@ public func gtkSetSliderValue(slotID: Int, value: Double) -> Bool { return true } +/// Set the text of a hosted GtkEntry (TextField) in place. +/// +/// Skips while the widget is focused so a programmatic set can't clobber the +/// caret/selection of a user who is typing — the binding still holds the value, +/// and the field reconciles on the next full rebuild after blur. Skips when the +/// text already matches (also avoids a redundant `notify::text` → binding +/// round-trip). Returns `true` in the skip cases too: the narrow path has +/// nothing to do, and must not fall back to a full rebuild that would recreate +/// the entry mid-interaction. +public func gtkSetTextFieldValue(slotID: Int, text: String) -> Bool { + guard let widget = gtkWidgetFromSlotID(slotID) else { return false } + guard gtk_swift_is_widget(widget) != 0 else { return false } + if gtk_widget_is_focus(widget) != 0 { return true } + if let cStr = gtk_editable_get_text(OpaquePointer(widget)) { + if String(cString: cStr) == text { return true } + } + gtk_swift_editable_set_text(widget, text) + return true +} + private let gtkCanvasDrawBoxKey = "gtk-swift-canvas-draw-box" public func gtkSetCanvasContent(slotID: Int, payload: GTK4CanvasPayload) -> Bool { diff --git a/Sources/Backend/GTK4/Rendering/GTK4FileDialog.swift b/Sources/Backend/GTK4/Rendering/GTK4FileDialog.swift new file mode 100644 index 0000000..efaa31d --- /dev/null +++ b/Sources/Backend/GTK4/Rendering/GTK4FileDialog.swift @@ -0,0 +1,68 @@ +import CGTK +import Foundation + +/// Native Open / Save file dialogs (`GtkFileDialog`), presented imperatively +/// from a button action — the same imperative style as +/// ``GTK4Backend/openStandaloneWindow(title:width:height:onClose:content:)`` and +/// `GTK4PointerTracking`. +/// +/// Both calls are non-blocking: the dialog runs on the GTK main loop and the +/// completion fires later on that same thread with the chosen path, or `nil` +/// when the user cancels. The dialog is parented to the active window by the +/// shim. Call from the UI (GTK main) thread. +public enum GTK4FileDialog { + + /// Present an Open dialog. `completion(path)` runs on the GTK main thread + /// with the chosen path, or `nil` if cancelled. + public static func open(title: String? = nil, _ completion: @escaping (String?) -> Void) { + present(save: false, title: title, suggestedName: nil, completion: completion) + } + + /// Present a Save dialog pre-filled with `suggestedName`. `completion(path)` + /// runs on the GTK main thread with the chosen path, or `nil` if cancelled. + public static func save(title: String? = nil, suggestedName: String, _ completion: @escaping (String?) -> Void) { + present(save: true, title: title, suggestedName: suggestedName, completion: completion) + } + + /// Retains the escaping completion across the C async boundary. + private final class Box { + let completion: (String?) -> Void + init(_ completion: @escaping (String?) -> Void) { self.completion = completion } + } + + // Captures nothing, so it is a valid @convention(c) function pointer. Unpacks + // the retained box, converts the C path (NULL → nil), and invokes it once. + private static let trampoline: @convention(c) (UnsafePointer?, UnsafeMutableRawPointer?) -> Void = { + (path: UnsafePointer?, user: UnsafeMutableRawPointer?) in + guard let user: UnsafeMutableRawPointer = user else { return } + let box: Box = Unmanaged.fromOpaque(user).takeRetainedValue() + let result: String? = path.map { (c: UnsafePointer) in String(cString: c) } + box.completion(result) + } + + private static func present( + save: Bool, + title: String?, + suggestedName: String?, + completion: @escaping (String?) -> Void + ) { + let user: UnsafeMutableRawPointer = Unmanaged.passRetained(Box(completion)).toOpaque() + + func withTitle(_ body: (UnsafePointer?) -> T) -> T { + if let title: String = title { return title.withCString(body) } + return body(nil) + } + + if save { + withTitle { (titlePtr: UnsafePointer?) in + (suggestedName ?? "untitled.swift").withCString { (namePtr: UnsafePointer) in + gtk_swift_present_save_dialog(titlePtr, namePtr, user, trampoline) + } + } + } else { + withTitle { (titlePtr: UnsafePointer?) in + gtk_swift_present_open_dialog(titlePtr, user, trampoline) + } + } + } +} diff --git a/Sources/Backend/GTK4/Rendering/GTK4PointerTracking.swift b/Sources/Backend/GTK4/Rendering/GTK4PointerTracking.swift new file mode 100644 index 0000000..6752dbd --- /dev/null +++ b/Sources/Backend/GTK4/Rendering/GTK4PointerTracking.swift @@ -0,0 +1,93 @@ +import CGTK +import CGTKBridge +import Foundation + +/// Process-wide sink for pointer motion/button events from the windows +/// `GTK4Backend` opens. A host (e.g. Lyrebird's Linux `MouseTracker`) installs +/// handlers here; the backend attaches a `GtkEventControllerMotion` and a +/// `GtkGestureClick` to each top-level window and forwards normalized updates. +/// +/// This is the GTK4 counterpart to a global pointer monitor (AppKit's +/// `NSEvent.addGlobalMonitorForEvents`), scoped window-local: it reports while +/// the pointer is over one of the app's windows. Coordinates are normalized to +/// the window: `x` 0…1 left→right, `y` 0…1 bottom→top (y flipped from GTK's +/// top-left origin so it matches screen-up conventions). `pressed` is the +/// primary-button state. +/// +/// Handlers run on the GTK main thread. +public enum GTK4PointerTracking { + + /// Normalized (x, y) move handler. Set via ``install(onMove:onButton:)``. + nonisolated(unsafe) static var onMove: ((Double, Double) -> Void)? + + /// Primary-button state handler (`true` = pressed). + nonisolated(unsafe) static var onButton: ((Bool) -> Void)? + + /// Install the pointer sink. Existing windows created before this call are + /// not retrofitted, but the app's main `WindowGroup` window attaches its + /// controllers at creation and forwards to whatever is installed here, so + /// installing before or after `run(_:)` both work. + /// + /// - Parameters: + /// - onMove: Called with normalized `(x, y)` on pointer motion. + /// - onButton: Called with the primary-button state on press/release. + public static func install( + onMove: @escaping (Double, Double) -> Void, + onButton: @escaping (Bool) -> Void + ) { + self.onMove = onMove + self.onButton = onButton + } +} + +/// Attach motion + click controllers to `widget` (a top-level window) that +/// forward to ``GTK4PointerTracking``. Called by the window-creating paths. +func gtkAttachPointerTracking(to widget: UnsafeMutablePointer) { + // Motion: coordinates are in the controlled widget's space; normalize by its + // current allocation. The widget pointer rides as user_data so the handler + // can read the live size. + guard let motion = gtk_event_controller_motion_new() else { return } + g_signal_connect_data( + gpointer(motion), "motion", + unsafeBitCast({ (_: OpaquePointer?, x: Double, y: Double, ud: gpointer?) in + guard let ud else { return } + let w: UnsafeMutablePointer = ud.assumingMemoryBound(to: GtkWidget.self) + let width: Double = Double(gtk_widget_get_width(w)) + let height: Double = Double(gtk_widget_get_height(w)) + guard width > 0, height > 0 else { return } + let nx: Double = min(max(x / width, 0), 1) + // Flip Y so 1 = top (GTK's origin is top-left). + let ny: Double = min(max(1 - y / height, 0), 1) + GTK4PointerTracking.onMove?(nx, ny) + } as @convention(c) (OpaquePointer?, Double, Double, gpointer?) -> Void, + to: GCallback.self), + gpointer(widget), nil, + GConnectFlags(rawValue: 0) + ) + gtk_widget_add_controller(widget, motion) + + // Primary-button state. A window-level GtkGestureClick in the default + // (bubble) phase sees presses not consumed by an interactive child — enough + // for a baseline Mouse.Click; motion (x/y) is the fully-covered case. + guard let click = gtk_gesture_click_new() else { return } + gtk_swift_gesture_single_set_button(click, 1) // primary button + g_signal_connect_data( + gpointer(click), "pressed", + unsafeBitCast({ (_: gpointer?, _: gint, _: Double, _: Double, _: gpointer?) in + GTK4PointerTracking.onButton?(true) + } as @convention(c) (gpointer?, gint, Double, Double, gpointer?) -> Void, + to: GCallback.self), + nil, nil, + GConnectFlags(rawValue: 0) + ) + g_signal_connect_data( + gpointer(click), "released", + unsafeBitCast({ (_: gpointer?, _: gint, _: Double, _: Double, _: gpointer?) in + GTK4PointerTracking.onButton?(false) + } as @convention(c) (gpointer?, gint, Double, Double, gpointer?) -> Void, + to: GCallback.self), + nil, nil, + GConnectFlags(rawValue: 0) + ) + gtk_swift_add_gesture(widget, click) +} diff --git a/Sources/Backend/GTK4/Rendering/GTK4StandaloneWindow.swift b/Sources/Backend/GTK4/Rendering/GTK4StandaloneWindow.swift new file mode 100644 index 0000000..f230ffc --- /dev/null +++ b/Sources/Backend/GTK4/Rendering/GTK4StandaloneWindow.swift @@ -0,0 +1,113 @@ +import CGTK +import CGTKBridge +import SwiftOpenUI +import Foundation + +/// Handle to a standalone GTK window opened imperatively via +/// ``GTK4Backend/openStandaloneWindow(title:width:height:onClose:content:)``. +/// +/// The caller retains the handle to keep control of the window's lifetime. The +/// window is owned by GTK; ``close()`` destroys it, and a user close (WM close +/// button) is reported through the `onClose` callback and flips ``isOpen`` to +/// `false`. This is the GTK4 analogue of the AppKit `NSWindow` + delegate that +/// backs `LyrebirdWindow` on macOS. +public final class GTK4StandaloneWindowHandle { + + /// Live GTK window pointer, or `nil` once the window has been closed + /// (either via ``close()`` or by the user). Cleared by the destroy signal, + /// so it is a single source of truth for "is this window still open". + fileprivate var winPtr: UnsafeMutablePointer? + + fileprivate init(winPtr: UnsafeMutablePointer) { + self.winPtr = winPtr + } + + /// Whether the window is still open. + public var isOpen: Bool { winPtr != nil } + + /// Bring the window to the front (no-op once closed). + public func present() { + guard let win: UnsafeMutablePointer = winPtr else { return } + gtk_window_present(win) + } + + /// Close and destroy the window. The destroy signal clears ``winPtr`` and + /// fires the `onClose` callback, so calling this more than once is safe. + public func close() { + guard let win: UnsafeMutablePointer = winPtr else { return } + gtk_window_destroy(win) + } +} + +extension GTK4Backend { + + /// Open a standalone top-level GTK window hosting `content`, parented to the + /// running `GtkApplication` started by ``run(_:)``. + /// + /// This is the imperative counterpart to the declarative `Window` /scene + /// path: it opens a window at runtime from outside the App/Scene tree, which + /// is what a host like `LyrebirdWindow`'s "Open Window" buttons need. + /// + /// - Important: A `GtkApplication` must already be running (i.e. you are + /// inside a ``run(_:)`` main loop). If there is no default application, + /// this returns `nil`. + /// + /// - Parameters: + /// - title: Window title-bar text. + /// - width: Initial window width in points. + /// - height: Initial window height in points. + /// - onClose: Invoked (once) when the window is closed, whether by + /// ``GTK4StandaloneWindowHandle/close()`` or by the user. Runs on the + /// GTK main thread. + /// - content: The SwiftOpenUI view to host. + /// - Returns: A handle for controlling/closing the window, or `nil` if no + /// `GtkApplication` is running. + public static func openStandaloneWindow( + title: String, + width: Int, + height: Int, + onClose: (() -> Void)? = nil, + @ViewBuilder content: () -> Content + ) -> GTK4StandaloneWindowHandle? { + guard let appPtr: UnsafeMutablePointer = gtk_swift_get_default_gtk_application() else { + return nil + } + + guard let rawWindow = gtk_application_window_new(appPtr) else { return nil } + let winPtr: UnsafeMutablePointer = windowPointer(rawWindow) + gtk_window_set_title(winPtr, title) + gtk_window_set_default_size(winPtr, gint(width), gint(height)) + + let contentWidget: UnsafeMutablePointer = widgetFromOpaque(gtkRenderView(content())) + gtkConfigureRootContentToFillWindow(contentWidget) + gtk_window_set_child(winPtr, contentWidget) + gtkAttachPointerTracking(to: widgetPointer(winPtr)) + + let handle: GTK4StandaloneWindowHandle = GTK4StandaloneWindowHandle(winPtr: winPtr) + + // Single teardown path: the destroy signal clears the handle and fires + // onClose exactly once, covering both close() and the WM close button. + // The handle is captured weakly so dropping the caller's reference does + // not keep it alive; onClose is captured strongly so it still fires. + let box: ClosureBox = ClosureBox { [weak handle] in + handle?.winPtr = nil + onClose?() + } + let userData: UnsafeMutableRawPointer = Unmanaged.passRetained(box).toOpaque() + g_signal_connect_data( + gpointer(winPtr), "destroy", + unsafeBitCast({ (_: gpointer?, ud: gpointer?) in + guard let ud else { return } + Unmanaged.fromOpaque(ud).takeUnretainedValue().closure() + } as @convention(c) (gpointer?, gpointer?) -> Void, to: GCallback.self), + userData, + { (data: gpointer?, _: UnsafeMutablePointer?) in + if let data { Unmanaged.fromOpaque(data).release() } + }, + GConnectFlags(rawValue: 0) + ) + + gtk_window_present(winPtr) + return handle + } +} diff --git a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift index 1555055..56d2fce 100644 --- a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift +++ b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift @@ -225,10 +225,22 @@ extension Divider: GTKRenderable, GTKDescribable { } } -extension TextField: GTKRenderable { +extension TextField: GTKRenderable, GTKDescribable { + public func gtkDescribeNode() -> GTK4DescriptorNode { + // A real descriptor node (not an empty `.composite`, which would poison + // the host's narrow-mutation path) so a text change can be applied in + // place via `.textFieldValue` — letting the field track a live value + // (e.g. bound to a slider being dragged) instead of only at mouse-up. + GTK4DescriptorNode( + kind: .textField, typeName: "TextField", + props: .textField(GTK4TextFieldDescriptor( + text: text.wrappedValue, placeholder: title))) + } + public func gtkCreateWidget() -> OpaquePointer { let entry = gtk_entry_new()! gtk_widget_set_hexpand(entry, 1) + gtkMarkHostedNodeKind(entry, kind: .textField) let entryPtr = UnsafeMutableRawPointer(entry).assumingMemoryBound(to: GtkEntry.self) let bufferPtr = gtk_entry_get_buffer(entryPtr) gtk_entry_buffer_set_text(bufferPtr, text.wrappedValue, -1) @@ -436,7 +448,22 @@ extension Button: GTKRenderable, GTKDescribable { // dedicated kind prevents the narrow-mutation guard from rejecting // the entire tree when a Button appears alongside mutable nodes // (Canvas, Text, Slider, etc.). - GTK4DescriptorNode(kind: .button, typeName: "Button") + // + // A `Text` label renders as the GtkButton's OWN native label (see + // gtkCreateWidget: `gtk_button_new_with_label`) — no separate hosted + // widget — so describe a childless leaf. A CUSTOM label view is rendered + // as child widgets (`gtkRenderView(label)`), whose hosted leaves + // (Text/Canvas/…) are collected during slot capture; they must appear in + // the descriptor too, or the descriptor/widget leaf counts mismatch and + // `gtkCaptureSupportedNativeSlots` bails (assigning no slots → later + // narrow updates see nil slots). + if label is Text { + return GTK4DescriptorNode(kind: .button, typeName: "Button") + } + return GTK4DescriptorNode( + kind: .button, typeName: "Button", + children: [gtkDescribeView(label)] + ) } public func gtkCreateWidget() -> OpaquePointer { @@ -2893,6 +2920,25 @@ private class GTKDragState { var startX: Double = 0 var startY: Double = 0 var dragStarted = false + /// Owning view host, captured at widget creation, used to bracket the drag + /// in an interactive-update deferral so a mid-drag rebuild cannot recreate + /// (and detach) the gesture's widget. Weak — the host outlives the gesture. + weak var host: GTKViewHost? +} + +/// Queue a redraw of `widget` and every descendant. +/// +/// GTK4 caches each widget's render node and reuses a child's node when only an +/// ancestor is invalidated, so `gtk_widget_queue_draw` on a container does not +/// re-run a nested `GtkDrawingArea`'s draw func. Walking the subtree marks every +/// widget dirty, which is what a live Canvas redraw during a drag needs. +func gtkQueueDrawSubtree(_ widget: UnsafeMutablePointer) { + gtk_widget_queue_draw(widget) + var child = gtk_widget_get_first_child(widget) + while let c = child { + gtkQueueDrawSubtree(c) + child = gtk_widget_get_next_sibling(c) + } } extension DragGestureView: GTKRenderable, GTKDescribable { @@ -2914,6 +2960,51 @@ extension DragGestureView: GTKRenderable, GTKDescribable { let gesture = gtk_gesture_drag_new()! let dragState = GTKDragState() + dragState.host = GTKViewHost.getCurrentRebuilding() + + // Bracket the whole drag sequence in the host's interactive-update + // deferral. A state-mutating onChanged schedules a rebuild that, when + // sibling controls are bound to the same model, is not narrow-applicable + // and recreates this gesture's widget mid-drag — detaching GtkGestureDrag + // so the sequence ends after one tick. Deferring rebuilds until drag-end + // keeps the widget (and the in-flight gesture) alive; the live redraw + // still happens via gtkQueueDrawSubtree in drag-update, and the single + // deferred rebuild on drag-end reconciles final state and re-registers + // observation. GtkGestureDrag emits drag-begin and drag-end exactly once + // per sequence, so these begin/end calls pair. Connected before the user + // handlers; GTK invokes multiple handlers for a signal in order. + let bracketState = dragState + _ = bracketState // host no longer needed for the bracket; kept for the trace + let bracketBeginBox = Unmanaged.passRetained(DoubleDoubleClosureBox { _, _ in + GTKViewHost.beginGlobalInteraction() + }).toOpaque() + g_signal_connect_data( + gpointer(gesture), + "drag-begin", + unsafeBitCast({ (_: gpointer?, x: gdouble, y: gdouble, userData: gpointer?) in + Unmanaged.fromOpaque(userData!).takeUnretainedValue().closure(x, y) + } as @convention(c) (gpointer?, gdouble, gdouble, gpointer?) -> Void, to: GCallback.self), + bracketBeginBox, + { (userData: gpointer?, _: UnsafeMutablePointer?) in + Unmanaged.fromOpaque(userData!).release() + }, + GConnectFlags(rawValue: 0) + ) + let bracketEndBox = Unmanaged.passRetained(DoubleDoubleClosureBox { _, _ in + GTKViewHost.endGlobalInteraction() + }).toOpaque() + g_signal_connect_data( + gpointer(gesture), + "drag-end", + unsafeBitCast({ (_: gpointer?, offsetX: gdouble, offsetY: gdouble, userData: gpointer?) in + Unmanaged.fromOpaque(userData!).takeUnretainedValue().closure(offsetX, offsetY) + } as @convention(c) (gpointer?, gdouble, gdouble, gpointer?) -> Void, to: GCallback.self), + bracketEndBox, + { (userData: gpointer?, _: UnsafeMutablePointer?) in + Unmanaged.fromOpaque(userData!).release() + }, + GConnectFlags(rawValue: 0) + ) if let onChanged = onChanged { let boundOnChanged = bindActionToCurrentEnvironment(onChanged) @@ -2931,6 +3022,27 @@ extension DragGestureView: GTKRenderable, GTKDescribable { translation: (width: offsetX, height: offsetY) ) boundOnChanged(value) + // Live redraw during the drag. The rebuild that a state-mutating + // onChanged schedules runs at default idle priority, which the + // stream of pointer-motion events starves, so a Canvas otherwise + // only repaints on release. The drawing-area draw func re-invokes + // the stored draw closure, which reads the bound value at paint + // time, so forcing a redraw here repaints the new value + // immediately — no rebuild required. + // + // The gesture's widget is typically a container (e.g. a Canvas + // wrapped by `.frame`); GTK4 reuses a child's cached render node + // when only an ancestor is invalidated, so a bare + // `queue_draw(widget)` would not re-run a nested GtkDrawingArea's + // draw func. Walk the subtree so the Canvas itself is marked + // dirty and actually repaints. + gtkQueueDrawSubtree(widget) + // Repaint every control bound to the value being dragged (linked + // knob/XY sharing a parameter), not just the dragged one, so they + // track live. Their rebuilds are deferred during the drag, so this + // drives the redraw directly; the Canvas closures read the value + // at paint time. + GTKViewHost.redrawDeferredInteractionHosts() }).toOpaque() // drag-begin: record start position @@ -7444,3 +7556,307 @@ extension ViewThatFits: GTKRenderable { return opaqueFromWidget(stack) } } + +// MARK: - Transparent content-wrapper describe conformances +// +// These styling / gesture / lifecycle / environment modifiers wrap a single +// `content` view and render it directly. Conforming to GTKContentWrapper makes +// gtkDescribeView describe the wrapped content instead of collapsing to an empty +// `.composite` (which the narrow-mutation gate rejects, poisoning the host). + +extension OnChangeView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension OnChangeTwoArgView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension OnSubmitView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension OnAppearView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension OnDisappearView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension FocusedView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension FocusedEqualsView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension FocusedValueView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension MonospacedDigitView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension MultilineTextAlignmentView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension TextFieldStyleModifier: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ButtonStyleModifier: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ToggleStyleModifier: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension LabelsHiddenView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension LineLimitView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension TruncationModeView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension LineSpacingView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension BoldView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ItalicView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension FontWeightView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension UnderlineView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension StrikethroughView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension TextCaseView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension CornerRadiusView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ClippedView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ClipShapeView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ShadowView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension BlurView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension AspectRatioView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension PositionView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension LayoutPriorityView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension FixedSizeView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension HelpView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension IdView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension TagView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension KeyboardShortcutView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension HiddenView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ContextMenuView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension LongPressGestureView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension OnExitCommandView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension EnvironmentModifierView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension EnvironmentObjectModifierView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension EnvironmentObservableModifierView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} + +// OverlayView has two view children (content + overlay); describe both so a +// change in either stays narrow-applicable. +extension OverlayView: GTKDescribable { + public func gtkDescribeNode() -> GTK4DescriptorNode { + GTK4DescriptorNode( + kind: .composite, typeName: "OverlayView", + children: [gtkDescribeAnyView(content), gtkDescribeAnyView(overlay)] + ) + } +} + +// MARK: - Opaque-leaf state-signature conformances +// +// Native widgets the narrow path can't update in place. Describing them as +// `.opaqueLeaf(signature)` (not an empty `.composite`) stops them poisoning a +// host's narrow path; the signature captures the bound state that affects +// appearance, so an unchanged widget reuses and a changed one forces a rebuild. +// Each signature must include EVERY value that changes the widget's look. + +extension Toggle: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(label), AnyHashable(isOn.wrappedValue)]) + } +} + +extension Stepper: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(label), AnyHashable(value.wrappedValue), + AnyHashable(range.lowerBound), AnyHashable(range.upperBound), + AnyHashable(step)]) + } +} + +extension Picker: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(label), AnyHashable(selected), AnyHashable(options)]) + } +} + +extension FilledShape: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(color.red), AnyHashable(color.green), + AnyHashable(color.blue), AnyHashable(color.alpha)]) + } +} + +extension StrokedShape: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(color.red), AnyHashable(color.green), + AnyHashable(color.blue), AnyHashable(color.alpha)]) + } +} + +// Static shapes / gradients / empty — a constant signature (they don't change; +// a structural swap is caught by the descriptor typeName, not the signature). +extension Circle: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("Circle") } } +extension Rectangle: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("Rectangle") } } +extension Ellipse: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("Ellipse") } } +extension Capsule: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("Capsule") } } +extension RoundedRectangle: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable(cornerRadius) } } +extension LinearGradient: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("LinearGradient") } } +extension RadialGradient: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("RadialGradient") } } +extension EmptyView: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("EmptyView") } } + +// MARK: - Conditional / optional transparent describe +// +// `if/else` in a ViewBuilder produces `_ConditionalView`; a bare `if` (incl. +// `if let`) produces `Optional`. Both are GTKRenderable-only, so they +// described as empty `.composite`s that poison the host's narrow path. Describe +// the ACTIVE branch transparently instead. A branch/optional FLIP changes the +// described child's type — caught as a structural change (rebuild) — but a stable +// condition (as during a drag) reuses, keeping the host narrow-applicable. + +extension _ConditionalView: GTKContentWrapper { + public var gtkWrappedContent: any View { + switch self { + case .trueContent(let view): return view + case .falseContent(let view): return view + } + } +} + +extension Optional: GTKContentWrapper where Wrapped: View { + public var gtkWrappedContent: any View { + switch self { + case .some(let view): return view + case .none: return EmptyView() + } + } +} + +// MARK: - Broader narrow-path coverage (Patch F, batch 2) +// +// More views that described as empty `.composite`s and poisoned a host's narrow +// path. Same principle as batch 1: describe what gtkCreateWidget renders inline, +// so descriptor and widget leaves stay balanced for slot capture. + +// Type-erased single view. +extension AnyView: GTKContentWrapper { + public var gtkWrappedContent: any View { wrapped } +} + +// Single-content wrappers whose `content` is the inline base view (the modal / +// drop / grid-cell chrome is auxiliary and rendered elsewhere). +extension DropDestinationView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension GridCellSpanView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension FullScreenCoverView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension PopoverView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension SheetModifierView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension ItemSheetModifierView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension AlertModifierView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension ConfirmationDialogView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } + +// Container wrappers that render `content` inline as their body. +extension List: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension Grid: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension DisclosureGroup: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension Section: GTKContentWrapper { public var gtkWrappedContent: any View { content } } + +// Lazy stacks/grids render one child per data item — expose them as children so +// each item's leaves participate in the narrow path (like ForEach). +extension LazyVStack: MultiChildView { + public var children: [any View] { items.map { contentBuilder($0) as any View } } +} +extension LazyHStack: MultiChildView { + public var children: [any View] { items.map { contentBuilder($0) as any View } } +} +extension LazyVGrid: MultiChildView { + public var children: [any View] { items.map { contentBuilder($0) as any View } } +} +extension LazyHGrid: MultiChildView { + public var children: [any View] { items.map { contentBuilder($0) as any View } } +} + +// Opaque native leaf widgets (native labels/entries — no marked inner widgets). +extension SecureField: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(placeholder), AnyHashable(text.wrappedValue)]) + } +} +extension TextEditor: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { AnyHashable(text.wrappedValue) } +} +extension DatePicker: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(title), AnyHashable(selection?.wrappedValue)]) + } +} +extension ProgressView: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(value), AnyHashable(total), AnyHashable(title)]) + } +} +extension Link: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(title), AnyHashable(destination)]) + } +} +extension Label: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(title), AnyHashable(systemImage), AnyHashable(imagePath)]) + } +} +extension Image: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { AnyHashable(String(describing: source)) } +} diff --git a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift index 2a7f15c..69ee1fe 100644 --- a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift +++ b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift @@ -125,9 +125,17 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { let currentAnimation = getCurrentAnimation() defer { lock.unlock() } guard isContainerAlive else { return } - // Defer rebuild while interactive (e.g. slider drag) - if interactiveUpdateDepth > 0 { + // Defer rebuild while interactive: either THIS host is mid-interaction + // (interactiveUpdateDepth, e.g. a native slider drag) or ANY host is + // (globalInteractionDepth). A gesture drag freezes EVERY host's rebuilds, + // because a sibling/ancestor host that observes the same model would + // otherwise rebuild mid-drag and recreate — detaching — the dragged + // gesture's widget. Deferred hosts are collected and flushed on end. + if interactiveUpdateDepth > 0 || GTKViewHost.globalInteractionDepth > 0 { rebuildDeferredDuringInteraction = true + if GTKViewHost.globalInteractionDepth > 0 { + GTKViewHost.deferredDuringGlobalInteraction[ObjectIdentifier(self)] = self + } return } if let currentAnimation { @@ -143,6 +151,60 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { }, retained.toOpaque()) } + // MARK: - Global interaction deferral + + /// While > 0, EVERY host defers its rebuilds (see `scheduleRebuild`). A + /// gesture drag brackets itself with `begin/endGlobalInteraction` so that no + /// host — not the dragged control's, nor a sibling/ancestor that observes the + /// same model — rebuilds mid-drag and recreates (and thereby detaches) the + /// dragged gesture's widget. Accessed only on the GTK main thread, so no lock. + static var globalInteractionDepth: Int = 0 + + /// Hosts that deferred a rebuild during the current global interaction, + /// flushed exactly once when the interaction ends. Strong refs are fine — + /// entries live only for the duration of a drag. + static var deferredDuringGlobalInteraction: [ObjectIdentifier: GTKViewHost] = [:] + + /// Enter a global interaction (drag begin). Balanced by `endGlobalInteraction`. + static func beginGlobalInteraction() { + globalInteractionDepth += 1 + } + + /// Leave a global interaction (drag end). When the last one ends, flush every + /// host that deferred a rebuild during it, so the whole UI reconciles once. + static func endGlobalInteraction() { + guard globalInteractionDepth > 0 else { return } + globalInteractionDepth -= 1 + guard globalInteractionDepth == 0 else { return } + let hosts = deferredDuringGlobalInteraction + deferredDuringGlobalInteraction = [:] + for host in hosts.values { + host.scheduleRebuild() + } + } + + /// Repaint every host that has deferred a rebuild during the current global + /// interaction, without rebuilding. Called on each drag-update so controls + /// bound to the value being dragged (a linked knob and XY pad, say) track it + /// live: their Canvas draw closures read the shared value at paint time, so a + /// queue_draw reflects the new value even though the value-change observation + /// is one-shot and its re-registering rebuild is deferred until drag-end. + static func redrawDeferredInteractionHosts() { + // Snapshot the values first: a host's narrow-mutation describe pass can + // re-register observation whose onChange re-inserts into the dict, so we + // must not iterate the live dictionary while mutating it. + let hosts = Array(deferredDuringGlobalInteraction.values) + for host in hosts where host.isContainerAlive { + // Push narrow (text/color/canvas) in-place updates so NATIVE widgets + // bound to the dragged value — a TextField's GtkEntry, say — track + // live too, not just Canvas hosts. The narrow path never recreates a + // widget, so it can't detach the in-flight gesture; structural + // changes stay deferred to drag-end. + host.applyDeferredNarrowMutationDuringInteraction() + gtkQueueDrawSubtree(host.container) + } + } + public func beginInteractiveUpdate() { lock.lock() defer { lock.unlock() } @@ -221,6 +283,102 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { return result } + /// Attempt an in-place text/color/canvas mutation for the current body, + /// preserving the widget tree (and any in-flight gesture) instead of a full + /// teardown-rebuild. Returns `true` if the plan was narrow-applicable and + /// applied — the caller should then skip the full rebuild — or `false` if a + /// structural rebuild is still required. + /// + /// Applied for both @State- and @Observable-driven changes. For an + /// @Observable change the describe pass runs under `withObservationTracking`, + /// so re-reading the observed properties re-registers the one-shot + /// subscription — the same re-registration the full rebuild gets from + /// `buildBodyWithTracking`, without the teardown. + /// + /// Must be called with `lock` NOT held (it runs the describe/plan/apply pass + /// lock-free, matching the original inline placement after `lock.unlock()`). + func tryNarrowMutation(fromObservation: Bool) -> Bool { + guard let describeBody = describeBody, + let oldRetained = lastRetainedDescriptor, + let oldExecutor = retainedExecutor else { + return false + } + + let previousEnv = getCurrentEnvironment() + installRebuildEnvironment() + let described: (descriptor: GTK4DescriptorNode, canvasPayloads: [GTK4CanvasPayload]) + if fromObservation { + #if canImport(Observation) + if #available(macOS 14.0, iOS 17.0, *) { + var captured: (descriptor: GTK4DescriptorNode, canvasPayloads: [GTK4CanvasPayload])! + withObservationTracking { + captured = gtkDescribeCapturingCanvasPayloads(describeBody) + } onChange: { [weak self] in + guard let self else { return } + self.lock.lock() + self.observationDidFire = true + self.lock.unlock() + self.scheduleRebuild() + } + described = captured + } else { + described = gtkDescribeCapturingCanvasPayloads(describeBody) + } + #else + described = gtkDescribeCapturingCanvasPayloads(describeBody) + #endif + } else { + described = gtkDescribeCapturingCanvasPayloads(describeBody) + } + setCurrentEnvironment(previousEnv) + + let newIdentified = gtkIdentifyDescriptorTree(described.descriptor) + let canvasPayloads = gtkCanvasPayloadsByIdentity( + descriptorRoot: newIdentified, + payloads: described.canvasPayloads + ) + let plan = gtkPlanDescriptorTree(old: oldRetained, new: newIdentified) + + if gtkCanApplyTextColorHostMutation(plan: plan) { + let action = gtkExecuteDescriptorPlan( + old: oldExecutor, + plan: plan, + canvasPayloadsByIdentity: canvasPayloads + ) + + // Verify all slots are still valid before mutating + let allSlotsValid = gtkAllSlotsValid(action: action) + if allSlotsValid { + let result = gtkApplyHookMutation(action: action) + if gtkHookMutationSucceeded(result) { + // Success — update retained state, skip full rebuild + lastRetainedDescriptor = gtkRetainDescriptorTree(newIdentified) + retainedExecutor = action.resultingNode + return true + } + } + } + return false + } + + /// Apply a narrow in-place mutation for a host whose rebuild is deferred by an + /// active drag, WITHOUT falling back to a full rebuild. Lets native widgets + /// bound to the value being dragged (e.g. a `TextField`'s `GtkEntry`) track + /// live during the drag, the same way Canvas hosts track via redraw — the + /// narrow path never recreates widgets, so it cannot detach the gesture. Any + /// structural change stays deferred to drag-end (where the full rebuild runs, + /// picking it up because a failed narrow attempt leaves the retained + /// descriptor untouched). Called from `redrawDeferredInteractionHosts` per + /// drag-update. `observationDidFire` is intentionally NOT cleared here — the + /// deferred drag-end rebuild still needs it to re-register observation. + func applyDeferredNarrowMutationDuringInteraction() { + lock.lock() + guard isContainerAlive else { lock.unlock(); return } + let fromObservation = observationDidFire + lock.unlock() + _ = tryNarrowMutation(fromObservation: fromObservation) + } + func rebuild() { lock.lock() scheduled = false @@ -236,48 +394,12 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { observationDidFire = false lock.unlock() - // --- Narrow mutation path: try text/color in-place update --- - // Skipped when withObservationTracking's onChange fired — the narrow - // path returns without re-running body under withObservationTracking, - // which would leave @Observable subscriptions dead after the first - // change. Fall through to the full rebuild so observation re-registers. - if !fromObservation, - let describeBody = describeBody, - let oldRetained = lastRetainedDescriptor, - let oldExecutor = retainedExecutor { - - let previousEnv = getCurrentEnvironment() - installRebuildEnvironment() - let described = gtkDescribeCapturingCanvasPayloads(describeBody) - setCurrentEnvironment(previousEnv) - - let newIdentified = gtkIdentifyDescriptorTree(described.descriptor) - let canvasPayloads = gtkCanvasPayloadsByIdentity( - descriptorRoot: newIdentified, - payloads: described.canvasPayloads - ) - let plan = gtkPlanDescriptorTree(old: oldRetained, new: newIdentified) - - if gtkCanApplyTextColorHostMutation(plan: plan) { - let action = gtkExecuteDescriptorPlan( - old: oldExecutor, - plan: plan, - canvasPayloadsByIdentity: canvasPayloads - ) - - // Verify all slots are still valid before mutating - let allSlotsValid = gtkAllSlotsValid(action: action) - if allSlotsValid { - let result = gtkApplyHookMutation(action: action) - if gtkHookMutationSucceeded(result) { - // Success — update retained state, skip full rebuild - lastRetainedDescriptor = gtkRetainDescriptorTree(newIdentified) - retainedExecutor = action.resultingNode - return - } - } - } - // Fall through to full rebuild + // Narrow mutation path: try an in-place text/color/canvas update that + // preserves the widget tree (and any in-flight gesture) instead of a + // teardown-rebuild. If it fully applies, skip the full rebuild; otherwise + // fall through, which re-registers observation as before. + if tryNarrowMutation(fromObservation: fromObservation) { + return } // Phase 7: skip body evaluation if no storage was mutated since last render. diff --git a/Sources/SwiftOpenUI/Views/CodeEditor.swift b/Sources/SwiftOpenUI/Views/CodeEditor.swift new file mode 100644 index 0000000..309c60c --- /dev/null +++ b/Sources/SwiftOpenUI/Views/CodeEditor.swift @@ -0,0 +1,84 @@ +/// An imperative handle to a live `CodeEditor` widget, for pushing text into it +/// from outside the SwiftUI-style data flow. +/// +/// The GTK `CodeEditor` is an opaque, reused widget (so typing never flickers or +/// resets the caret), which means a plain change to the `text` binding is *not* +/// mirrored back into the widget. That is the right default for a document the +/// user is editing, but a host that loads a file needs to replace the whole +/// buffer. Passing a controller gives it that ability: ``setText(_:)`` writes +/// the buffer directly, and the widget's own change signal then flows the new +/// text back out through the `text` binding. +/// +/// `@unchecked Sendable`: the backend stores a UI-thread-only apply closure into +/// it on widget creation; ``setText(_:)`` must be called on the UI (GTK main) +/// thread, exactly like the rest of the Linux UI. +public final class CodeEditorController: @unchecked Sendable { + /// Set by the backend when the editor widget is created. Pushes `text` into + /// the live buffer on the UI thread. `nil` until the widget exists. + public var _applyText: ((String) -> Void)? + + public init() {} + + /// Replace the editor's entire contents with `text`. No-op before the widget + /// is created. Call on the UI (GTK main) thread. + public func setText(_ text: String) { + _applyText?(text) + } +} + +/// One completion candidate offered in the editor's Ctrl+Space popover. +public struct CodeCompletionItem: Sendable { + /// Text shown in the popover row. + public let label: String + /// Text inserted at the caret when the row is chosen. + public let insertText: String + + public init(label: String, insertText: String) { + self.label = label + self.insertText = insertText + } +} + +/// A multi-line source-code editor with syntax highlighting and a line-number +/// gutter. On the GTK4 backend this is a `GtkSourceView` configured for Swift; +/// other backends may fall back to a plain text editor. +public struct CodeEditor: View { + public typealias Body = Never + + public let text: Binding + + /// Optional one-way mirror of the editor's current selection (empty when + /// nothing is selected). Updated as the caret/selection moves — lets a host + /// evaluate just the selected lines. + public let selection: Binding? + + /// Optional Ctrl+Space completion source. Given the full buffer text and the + /// 0-based caret (line, column), it returns candidates asynchronously; the + /// editor shows them in a popover at the caret and inserts the chosen one. + public let completionProvider: (@Sendable (String, Int, Int) async -> [CodeCompletionItem])? + + /// Optional imperative handle for pushing text into the live widget (e.g. + /// loading a file). See ``CodeEditorController``. + public let controller: CodeEditorController? + + /// Create a code editor bound to `text`. The initial language is Swift. + /// + /// - Parameters: + /// - text: Two-way binding to the full document. + /// - selection: Optional binding updated with the current selection. + /// - controller: Optional handle for imperative text replacement (loads). + /// - completionProvider: Optional Ctrl+Space completion source. + public init( + text: Binding, + selection: Binding? = nil, + controller: CodeEditorController? = nil, + completionProvider: (@Sendable (String, Int, Int) async -> [CodeCompletionItem])? = nil + ) { + self.text = text + self.selection = selection + self.controller = controller + self.completionProvider = completionProvider + } + + public var body: Never { fatalError("CodeEditor is a primitive view") } +} diff --git a/Sources/SwiftOpenUI/Views/DatePicker.swift b/Sources/SwiftOpenUI/Views/DatePicker.swift index 57f2306..3b0d20d 100644 --- a/Sources/SwiftOpenUI/Views/DatePicker.swift +++ b/Sources/SwiftOpenUI/Views/DatePicker.swift @@ -1,7 +1,11 @@ import Foundation /// A simple date value type for DatePicker (no Foundation dependency). -public struct DateComponents: Equatable { +/// +/// `Hashable` (synthesized from the three `Int` fields) so it can back a +/// `GTKOpaqueLeaf` state signature — unlike Foundation's `DateComponents`, +/// which is not `Hashable` on swift-corelibs-foundation. +public struct DateComponents: Hashable { public var year: Int public var month: Int public var day: Int