From a7f15868680198fdffdfa58a2d1d95e6663bf208 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 26 Sep 2026 11:31:27 -0700 Subject: [PATCH 1/8] Add LUI split: Bonsplit-style tabbed split panes on all platforms - src/lui_split.ml(i): split-view/split-branch/split-pane/split-tab extension schemas, decoders, and a shared Model (state + reducer + renderer) implementing Bonsplit controller semantics: tab select/ close/move, edge-drop splits (0.25 share), pane pruning, directional focus navigation, ratio updates - Native renderers per backend, each owning gesture-time visuals (drag preview, drop indicators, live divider) and reporting semantic events: SwiftUI (LUISplit.swift), Qt/QML (5 QML files + registerSplitExtensions), Flutter (lui_flutter_split.dart), WinUI (LUISplitExtensions.cs), Web (lui-split.js/css + standalone demo) - Gallery: split components registered via generated extension schemas; new demo section; generator learns qml/winui hosts and emits typed decoder annotations for identically-shaped event records - Tests: fingerprint sync across all six host sources, extension ops, event decoders, model reduction --- examples/gallery/extension_schemas.json | 363 ++++++- examples/gallery/extension_schemas.ml | 441 ++++++++ examples/gallery/extension_schemas.mli | 51 + examples/gallery/model.ml | 30 + examples/gallery/view.ml | 33 +- .../Sources/LUIAppleBackend/LUISplit.swift | 807 ++++++++++++++ platform/flutter/lib/lui_flutter_backend.dart | 1 + .../flutter/lib/lui_flutter_extension.dart | 19 + platform/flutter/lib/lui_flutter_split.dart | 810 +++++++++++++++ platform/qt/lib/CMakeLists.txt | 7 + platform/qt/lib/lui_split_extensions.cpp | 137 +++ platform/qt/lib/lui_split_extensions.h | 19 + platform/qt/qml/LuiSplitBranch.qml | 135 +++ platform/qt/qml/LuiSplitPane.qml | 226 ++++ platform/qt/qml/LuiSplitTab.qml | 23 + platform/qt/qml/LuiSplitTabChip.qml | 130 +++ platform/qt/qml/LuiSplitView.qml | 27 + platform/web/demo/lui-split.html | 79 ++ platform/web/src/lui-split.css | 224 ++++ platform/web/src/lui-split.js | 535 ++++++++++ .../winui/LUI.WinUI/LUISplitExtensions.cs | 953 +++++++++++++++++ src/lui_split.ml | 982 ++++++++++++++++++ src/lui_split.mli | 280 +++++ test/test_lui.ml | 313 ++++++ tooling/generate_extension_api.mjs | 14 +- 25 files changed, 6615 insertions(+), 24 deletions(-) create mode 100644 platform/apple/Sources/LUIAppleBackend/LUISplit.swift create mode 100644 platform/flutter/lib/lui_flutter_split.dart create mode 100644 platform/qt/lib/lui_split_extensions.cpp create mode 100644 platform/qt/lib/lui_split_extensions.h create mode 100644 platform/qt/qml/LuiSplitBranch.qml create mode 100644 platform/qt/qml/LuiSplitPane.qml create mode 100644 platform/qt/qml/LuiSplitTab.qml create mode 100644 platform/qt/qml/LuiSplitTabChip.qml create mode 100644 platform/qt/qml/LuiSplitView.qml create mode 100644 platform/web/demo/lui-split.html create mode 100644 platform/web/src/lui-split.css create mode 100644 platform/web/src/lui-split.js create mode 100644 platform/winui/LUI.WinUI/LUISplitExtensions.cs create mode 100644 src/lui_split.ml create mode 100644 src/lui_split.mli diff --git a/examples/gallery/extension_schemas.json b/examples/gallery/extension_schemas.json index 78966497..cad4d5f5 100644 --- a/examples/gallery/extension_schemas.json +++ b/examples/gallery/extension_schemas.json @@ -1,18 +1,98 @@ { "profiles": { "apple": [ - {"os": "macos", "host": "swiftui"}, - {"os": "ios", "host": "swiftui"} + { + "os": "macos", + "host": "swiftui" + }, + { + "os": "ios", + "host": "swiftui" + } ], "all": [ - {"os": "android", "host": "flutter"}, - {"os": "ios", "host": "flutter"}, - {"os": "ios", "host": "swiftui"}, - {"os": "linux", "host": "flutter"}, - {"os": "macos", "host": "flutter"}, - {"os": "macos", "host": "swiftui"}, - {"os": "web", "host": "web"}, - {"os": "windows", "host": "flutter"} + { + "os": "android", + "host": "flutter" + }, + { + "os": "ios", + "host": "flutter" + }, + { + "os": "ios", + "host": "swiftui" + }, + { + "os": "linux", + "host": "flutter" + }, + { + "os": "macos", + "host": "flutter" + }, + { + "os": "macos", + "host": "swiftui" + }, + { + "os": "web", + "host": "web" + }, + { + "os": "windows", + "host": "flutter" + } + ], + "splits": [ + { + "os": "macos", + "host": "swiftui" + }, + { + "os": "ios", + "host": "swiftui" + }, + { + "os": "linux", + "host": "qml" + }, + { + "os": "macos", + "host": "qml" + }, + { + "os": "windows", + "host": "qml" + }, + { + "os": "android", + "host": "flutter" + }, + { + "os": "ios", + "host": "flutter" + }, + { + "os": "linux", + "host": "flutter" + }, + { + "os": "macos", + "host": "flutter" + }, + { + "os": "windows", + "host": "flutter" + }, + { + "os": "windows", + "host": "winui" + }, + { + "os": "web", + "host": "web" + } ] }, "components": [ @@ -20,12 +100,30 @@ "identifier": "apple-map", "profiles": "apple", "standardChildren": false, - "children": ["apple-map-marker"], + "children": [ + "apple-map-marker" + ], "properties": [ - {"name": "latitude", "kind": "float", "required": true}, - {"name": "longitude", "kind": "float", "required": true}, - {"name": "latitude-delta", "kind": "float", "required": true}, - {"name": "longitude-delta", "kind": "float", "required": true} + { + "name": "latitude", + "kind": "float", + "required": true + }, + { + "name": "longitude", + "kind": "float", + "required": true + }, + { + "name": "latitude-delta", + "kind": "float", + "required": true + }, + { + "name": "longitude-delta", + "kind": "float", + "required": true + } ], "events": [] }, @@ -35,14 +133,239 @@ "standardChildren": false, "children": [], "properties": [ - {"name": "title", "kind": "string", "required": true}, - {"name": "latitude", "kind": "float", "required": true}, - {"name": "longitude", "kind": "float", "required": true} + { + "name": "title", + "kind": "string", + "required": true + }, + { + "name": "latitude", + "kind": "float", + "required": true + }, + { + "name": "longitude", + "kind": "float", + "required": true + } + ], + "events": [] + }, + { + "identifier": "split-view", + "profiles": "splits", + "standardChildren": false, + "children": [ + "split-branch", + "split-pane" + ], + "properties": [ + { + "name": "divider-thickness", + "kind": "float" + }, + { + "name": "animation", + "kind": "bool" + }, + { + "name": "accessibility-identifier", + "kind": "string" + } + ], + "events": [] + }, + { + "identifier": "split-branch", + "profiles": "splits", + "standardChildren": false, + "children": [ + "split-branch", + "split-pane" + ], + "properties": [ + { + "name": "orientation", + "kind": "string", + "required": true + }, + { + "name": "ratio", + "kind": "float", + "required": true + } + ], + "events": [ + { + "name": "ratio-changed", + "fields": [ + { + "name": "ratio", + "kind": "float", + "required": true + } + ] + } + ] + }, + { + "identifier": "split-pane", + "profiles": "splits", + "standardChildren": false, + "children": [ + "split-tab" + ], + "properties": [ + { + "name": "pane-id", + "kind": "string", + "required": true + }, + { + "name": "selected", + "kind": "string" + }, + { + "name": "focused", + "kind": "bool" + }, + { + "name": "accessibility-identifier", + "kind": "string" + } + ], + "events": [ + { + "name": "tab-selected", + "fields": [ + { + "name": "tab", + "kind": "string", + "required": true + } + ] + }, + { + "name": "tab-closed", + "fields": [ + { + "name": "tab", + "kind": "string", + "required": true + } + ] + }, + { + "name": "tab-moved", + "fields": [ + { + "name": "tab", + "kind": "string", + "required": true + }, + { + "name": "index", + "kind": "int", + "required": true + }, + { + "name": "from-pane", + "kind": "string", + "required": true + } + ] + }, + { + "name": "pane-focused", + "fields": [] + }, + { + "name": "navigate", + "fields": [ + { + "name": "direction", + "kind": "string", + "required": true + } + ] + }, + { + "name": "split-requested", + "fields": [ + { + "name": "orientation", + "kind": "string", + "required": true + } + ] + }, + { + "name": "split-drop", + "fields": [ + { + "name": "tab", + "kind": "string", + "required": true + }, + { + "name": "from-pane", + "kind": "string", + "required": true + }, + { + "name": "edge", + "kind": "string", + "required": true + } + ] + }, + { + "name": "pane-closed", + "fields": [] + } + ] + }, + { + "identifier": "split-tab", + "profiles": "splits", + "standardChildren": true, + "children": [], + "properties": [ + { + "name": "tab-id", + "kind": "string", + "required": true + }, + { + "name": "title", + "kind": "string", + "required": true + }, + { + "name": "icon", + "kind": "string" + }, + { + "name": "dirty", + "kind": "bool" + }, + { + "name": "closable", + "kind": "bool" + }, + { + "name": "accessibility-identifier", + "kind": "string" + } ], "events": [] } ], "tweaks": [ - {"identifier": "gallery-accent", "profiles": "all", "properties": []} + { + "identifier": "gallery-accent", + "profiles": "all", + "properties": [] + } ] -} +} \ No newline at end of file diff --git a/examples/gallery/extension_schemas.ml b/examples/gallery/extension_schemas.ml index 9a227b94..3c2c1245 100644 --- a/examples/gallery/extension_schemas.ml +++ b/examples/gallery/extension_schemas.ml @@ -2,9 +2,163 @@ open Lui_protocol +type split_branch_ratio_changed = { + event_node : int; + ratio : float; +} +type split_pane_tab_selected = { + event_node : int; + tab : string; +} +type split_pane_tab_closed = { + event_node : int; + tab : string; +} +type split_pane_tab_moved = { + event_node : int; + tab : string; + index : int; + from_pane : string; +} + +type split_pane_pane_focused = { + event_node : int; + +} + +type split_pane_navigate = { + event_node : int; + direction : string; +} + +type split_pane_split_requested = { + event_node : int; + orientation : string; +} + +type split_pane_split_drop = { + event_node : int; + tab : string; + from_pane : string; + edge : string; +} + +type split_pane_pane_closed = { + event_node : int; + +} + +let decode_split_branch_ratio_changed = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "ratio-changed" + && String.equal identifier "split-branch" -> + (match (String_map.find_opt "ratio" values) with + | (Some (FloatValue ratio)) -> + Some ({ + event_node = node; + ratio = ratio; + } : split_branch_ratio_changed) + | _ -> None) + | _ -> None + +let decode_split_pane_tab_selected = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "tab-selected" + && String.equal identifier "split-pane" -> + (match (String_map.find_opt "tab" values) with + | (Some (StringValue tab)) -> + Some ({ + event_node = node; + tab = tab; + } : split_pane_tab_selected) + | _ -> None) + | _ -> None + +let decode_split_pane_tab_closed = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "tab-closed" + && String.equal identifier "split-pane" -> + (match (String_map.find_opt "tab" values) with + | (Some (StringValue tab)) -> + Some ({ + event_node = node; + tab = tab; + } : split_pane_tab_closed) + | _ -> None) + | _ -> None + +let decode_split_pane_tab_moved = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "tab-moved" + && String.equal identifier "split-pane" -> + (match (String_map.find_opt "tab" values, String_map.find_opt "index" values, String_map.find_opt "from-pane" values) with + | (Some (StringValue tab), Some (IntValue index), Some (StringValue from_pane)) -> + Some ({ + event_node = node; + tab = tab; + index = index; + from_pane = from_pane; + } : split_pane_tab_moved) + | _ -> None) + | _ -> None + +let decode_split_pane_pane_focused = function + | ExtensionEvent (node, identifier, event_name, _values) + when String.equal event_name "pane-focused" + && String.equal identifier "split-pane" -> + Some ({ event_node = node } : split_pane_pane_focused) + | _ -> None + +let decode_split_pane_navigate = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "navigate" + && String.equal identifier "split-pane" -> + (match (String_map.find_opt "direction" values) with + | (Some (StringValue direction)) -> + Some ({ + event_node = node; + direction = direction; + } : split_pane_navigate) + | _ -> None) + | _ -> None + +let decode_split_pane_split_requested = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "split-requested" + && String.equal identifier "split-pane" -> + (match (String_map.find_opt "orientation" values) with + | (Some (StringValue orientation)) -> + Some ({ + event_node = node; + orientation = orientation; + } : split_pane_split_requested) + | _ -> None) + | _ -> None + +let decode_split_pane_split_drop = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "split-drop" + && String.equal identifier "split-pane" -> + (match (String_map.find_opt "tab" values, String_map.find_opt "from-pane" values, String_map.find_opt "edge" values) with + | (Some (StringValue tab), Some (StringValue from_pane), Some (StringValue edge)) -> + Some ({ + event_node = node; + tab = tab; + from_pane = from_pane; + edge = edge; + } : split_pane_split_drop) + | _ -> None) + | _ -> None + +let decode_split_pane_pane_closed = function + | ExtensionEvent (node, identifier, event_name, _values) + when String.equal event_name "pane-closed" + && String.equal identifier "split-pane" -> + Some ({ event_node = node } : split_pane_pane_closed) + | _ -> None let apple_map_schema = Lui_extension.component "apple-map" [ { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = SwiftUIHost }; { Lui_protocol.profile_os = IOS; Lui_protocol.profile_host = SwiftUIHost } ] @@ -20,6 +174,34 @@ let apple_map_marker_schema = [ Lui_extension.property "title" Lui_extension.StringScalar true None; Lui_extension.property "latitude" Lui_extension.FloatScalar true None; Lui_extension.property "longitude" Lui_extension.FloatScalar true None ] [ ] +let split_view_schema = + Lui_extension.component "split-view" [ { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = SwiftUIHost }; { Lui_protocol.profile_os = IOS; Lui_protocol.profile_host = SwiftUIHost }; { Lui_protocol.profile_os = LinuxOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = AndroidOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = IOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = LinuxOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = WinUIHost }; { Lui_protocol.profile_os = WebOS; Lui_protocol.profile_host = WebHost } ] + false + [ "split-branch"; "split-pane" ] + [ Lui_extension.property "divider-thickness" Lui_extension.FloatScalar false None; Lui_extension.property "animation" Lui_extension.BoolScalar false None; Lui_extension.property "accessibility-identifier" Lui_extension.StringScalar false None ] + [ ] + +let split_branch_schema = + Lui_extension.component "split-branch" [ { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = SwiftUIHost }; { Lui_protocol.profile_os = IOS; Lui_protocol.profile_host = SwiftUIHost }; { Lui_protocol.profile_os = LinuxOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = AndroidOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = IOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = LinuxOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = WinUIHost }; { Lui_protocol.profile_os = WebOS; Lui_protocol.profile_host = WebHost } ] + false + [ "split-branch"; "split-pane" ] + [ Lui_extension.property "orientation" Lui_extension.StringScalar true None; Lui_extension.property "ratio" Lui_extension.FloatScalar true None ] + [ Lui_extension.event "ratio-changed" [ Lui_extension.event_field "ratio" Lui_extension.FloatScalar true ] ] + +let split_pane_schema = + Lui_extension.component "split-pane" [ { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = SwiftUIHost }; { Lui_protocol.profile_os = IOS; Lui_protocol.profile_host = SwiftUIHost }; { Lui_protocol.profile_os = LinuxOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = AndroidOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = IOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = LinuxOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = WinUIHost }; { Lui_protocol.profile_os = WebOS; Lui_protocol.profile_host = WebHost } ] + false + [ "split-tab" ] + [ Lui_extension.property "pane-id" Lui_extension.StringScalar true None; Lui_extension.property "selected" Lui_extension.StringScalar false None; Lui_extension.property "focused" Lui_extension.BoolScalar false None; Lui_extension.property "accessibility-identifier" Lui_extension.StringScalar false None ] + [ Lui_extension.event "tab-selected" [ Lui_extension.event_field "tab" Lui_extension.StringScalar true ]; Lui_extension.event "tab-closed" [ Lui_extension.event_field "tab" Lui_extension.StringScalar true ]; Lui_extension.event "tab-moved" [ Lui_extension.event_field "tab" Lui_extension.StringScalar true; Lui_extension.event_field "index" Lui_extension.IntScalar true; Lui_extension.event_field "from-pane" Lui_extension.StringScalar true ]; Lui_extension.event "pane-focused" [ ]; Lui_extension.event "navigate" [ Lui_extension.event_field "direction" Lui_extension.StringScalar true ]; Lui_extension.event "split-requested" [ Lui_extension.event_field "orientation" Lui_extension.StringScalar true ]; Lui_extension.event "split-drop" [ Lui_extension.event_field "tab" Lui_extension.StringScalar true; Lui_extension.event_field "from-pane" Lui_extension.StringScalar true; Lui_extension.event_field "edge" Lui_extension.StringScalar true ]; Lui_extension.event "pane-closed" [ ] ] + +let split_tab_schema = + Lui_extension.component "split-tab" [ { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = SwiftUIHost }; { Lui_protocol.profile_os = IOS; Lui_protocol.profile_host = SwiftUIHost }; { Lui_protocol.profile_os = LinuxOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = QMLHost }; { Lui_protocol.profile_os = AndroidOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = IOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = LinuxOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = WinUIHost }; { Lui_protocol.profile_os = WebOS; Lui_protocol.profile_host = WebHost } ] + true + [ ] + [ Lui_extension.property "tab-id" Lui_extension.StringScalar true None; Lui_extension.property "title" Lui_extension.StringScalar true None; Lui_extension.property "icon" Lui_extension.StringScalar false None; Lui_extension.property "dirty" Lui_extension.BoolScalar false None; Lui_extension.property "closable" Lui_extension.BoolScalar false None; Lui_extension.property "accessibility-identifier" Lui_extension.StringScalar false None ] + [ ] + let gallery_accent_schema = Lui_extension.tweak "gallery-accent" [ { Lui_protocol.profile_os = AndroidOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = IOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = IOS; Lui_protocol.profile_host = SwiftUIHost }; { Lui_protocol.profile_os = LinuxOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = FlutterHost }; { Lui_protocol.profile_os = MacOS; Lui_protocol.profile_host = SwiftUIHost }; { Lui_protocol.profile_os = WebOS; Lui_protocol.profile_host = WebHost }; { Lui_protocol.profile_os = WindowsOS; Lui_protocol.profile_host = FlutterHost } ] [ ] @@ -28,6 +210,10 @@ let registry () = let registry = Lui_extension.registry () in Lui_extension.register_component registry apple_map_schema; Lui_extension.register_component registry apple_map_marker_schema; + Lui_extension.register_component registry split_view_schema; + Lui_extension.register_component registry split_branch_schema; + Lui_extension.register_component registry split_pane_schema; + Lui_extension.register_component registry split_tab_schema; Lui_extension.register_tweak registry gallery_accent_schema; Lui_extension.freeze registry; registry @@ -123,3 +309,258 @@ let apple_map_marker ?key ~title ~latitude ~longitude ?title_signal ?latitude_si | None -> ()); node + +let split_view ?key ?divider_thickness ?animation ?accessibility_identifier ?divider_thickness_signal ?animation_signal ?accessibility_identifier_signal (children : Lui_elements.t list) : Lui_elements.t = + fun context parent -> + let node = Lui_ui.extension context "split-view" in + Option.iter (Lui_ui.key context node) key; + Option.iter + (fun value -> + Lui_ui.extension_property context node "divider-thickness" + (FloatValue value)) + divider_thickness; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "divider-thickness" + (Signal.map (fun value -> FloatValue value) signal)) + divider_thickness_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "animation" + (BoolValue value)) + animation; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "animation" + (Signal.map (fun value -> BoolValue value) signal)) + animation_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "accessibility-identifier" + (StringValue value)) + accessibility_identifier; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "accessibility-identifier" + (Signal.map (fun value -> StringValue value) signal)) + accessibility_identifier_signal; + + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + Lui_elements.mount_children context node children; + node + +let split_branch ?key ~orientation ~ratio ?orientation_signal ?ratio_signal ?on_ratio_changed (children : Lui_elements.t list) : Lui_elements.t = + fun context parent -> + let node = Lui_ui.extension context "split-branch" in + Option.iter (Lui_ui.key context node) key; + Option.iter + (fun value -> + Lui_ui.extension_property context node "orientation" + (StringValue value)) + (Some orientation); + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "orientation" + (Signal.map (fun value -> StringValue value) signal)) + orientation_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "ratio" + (FloatValue value)) + (Some ratio); + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "ratio" + (Signal.map (fun value -> FloatValue value) signal)) + ratio_signal; + Option.iter + (fun handler -> + Lui_ui.on_event context node (fun raw -> + match decode_split_branch_ratio_changed raw with + | Some event -> handler event + | None -> ())) + on_ratio_changed; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + Lui_elements.mount_children context node children; + node + +let split_pane ?key ~pane_id ?selected ?focused ?accessibility_identifier ?pane_id_signal ?selected_signal ?focused_signal ?accessibility_identifier_signal ?on_tab_selected ?on_tab_closed ?on_tab_moved ?on_pane_focused ?on_navigate ?on_split_requested ?on_split_drop ?on_pane_closed (children : Lui_elements.t list) : Lui_elements.t = + fun context parent -> + let node = Lui_ui.extension context "split-pane" in + Option.iter (Lui_ui.key context node) key; + Option.iter + (fun value -> + Lui_ui.extension_property context node "pane-id" + (StringValue value)) + (Some pane_id); + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "pane-id" + (Signal.map (fun value -> StringValue value) signal)) + pane_id_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "selected" + (StringValue value)) + selected; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "selected" + (Signal.map (fun value -> StringValue value) signal)) + selected_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "focused" + (BoolValue value)) + focused; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "focused" + (Signal.map (fun value -> BoolValue value) signal)) + focused_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "accessibility-identifier" + (StringValue value)) + accessibility_identifier; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "accessibility-identifier" + (Signal.map (fun value -> StringValue value) signal)) + accessibility_identifier_signal; + Option.iter + (fun handler -> + Lui_ui.on_event context node (fun raw -> + match decode_split_pane_tab_selected raw with + | Some event -> handler event + | None -> ())) + on_tab_selected; + Option.iter + (fun handler -> + Lui_ui.on_event context node (fun raw -> + match decode_split_pane_tab_closed raw with + | Some event -> handler event + | None -> ())) + on_tab_closed; + Option.iter + (fun handler -> + Lui_ui.on_event context node (fun raw -> + match decode_split_pane_tab_moved raw with + | Some event -> handler event + | None -> ())) + on_tab_moved; + Option.iter + (fun handler -> + Lui_ui.on_event context node (fun raw -> + match decode_split_pane_pane_focused raw with + | Some event -> handler event + | None -> ())) + on_pane_focused; + Option.iter + (fun handler -> + Lui_ui.on_event context node (fun raw -> + match decode_split_pane_navigate raw with + | Some event -> handler event + | None -> ())) + on_navigate; + Option.iter + (fun handler -> + Lui_ui.on_event context node (fun raw -> + match decode_split_pane_split_requested raw with + | Some event -> handler event + | None -> ())) + on_split_requested; + Option.iter + (fun handler -> + Lui_ui.on_event context node (fun raw -> + match decode_split_pane_split_drop raw with + | Some event -> handler event + | None -> ())) + on_split_drop; + Option.iter + (fun handler -> + Lui_ui.on_event context node (fun raw -> + match decode_split_pane_pane_closed raw with + | Some event -> handler event + | None -> ())) + on_pane_closed; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + Lui_elements.mount_children context node children; + node + +let split_tab ?key ~tab_id ~title ?icon ?dirty ?closable ?accessibility_identifier ?tab_id_signal ?title_signal ?icon_signal ?dirty_signal ?closable_signal ?accessibility_identifier_signal (children : Lui_elements.t list) : Lui_elements.t = + fun context parent -> + let node = Lui_ui.extension context "split-tab" in + Option.iter (Lui_ui.key context node) key; + Option.iter + (fun value -> + Lui_ui.extension_property context node "tab-id" + (StringValue value)) + (Some tab_id); + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "tab-id" + (Signal.map (fun value -> StringValue value) signal)) + tab_id_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "title" + (StringValue value)) + (Some title); + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "title" + (Signal.map (fun value -> StringValue value) signal)) + title_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "icon" + (StringValue value)) + icon; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "icon" + (Signal.map (fun value -> StringValue value) signal)) + icon_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "dirty" + (BoolValue value)) + dirty; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "dirty" + (Signal.map (fun value -> BoolValue value) signal)) + dirty_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "closable" + (BoolValue value)) + closable; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "closable" + (Signal.map (fun value -> BoolValue value) signal)) + closable_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "accessibility-identifier" + (StringValue value)) + accessibility_identifier; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "accessibility-identifier" + (Signal.map (fun value -> StringValue value) signal)) + accessibility_identifier_signal; + + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + Lui_elements.mount_children context node children; + node diff --git a/examples/gallery/extension_schemas.mli b/examples/gallery/extension_schemas.mli index 23acd2bb..e4881118 100644 --- a/examples/gallery/extension_schemas.mli +++ b/examples/gallery/extension_schemas.mli @@ -1,8 +1,59 @@ (* Generated by tooling/generate_extension_api.mjs. Do not edit by hand. *) +type split_branch_ratio_changed = { + event_node : int; + ratio : float; +} +type split_pane_tab_selected = { + event_node : int; + tab : string; +} + +type split_pane_tab_closed = { + event_node : int; + tab : string; +} + +type split_pane_tab_moved = { + event_node : int; + tab : string; + index : int; + from_pane : string; +} + +type split_pane_pane_focused = { + event_node : int; + +} + +type split_pane_navigate = { + event_node : int; + direction : string; +} + +type split_pane_split_requested = { + event_node : int; + orientation : string; +} + +type split_pane_split_drop = { + event_node : int; + tab : string; + from_pane : string; + edge : string; +} + +type split_pane_pane_closed = { + event_node : int; + +} val apple_map : ?key:string -> latitude:float -> longitude:float -> latitude_delta:float -> longitude_delta:float -> ?latitude_signal:float Signal.signal -> ?longitude_signal:float Signal.signal -> ?latitude_delta_signal:float Signal.signal -> ?longitude_delta_signal:float Signal.signal -> Lui_elements.t list -> Lui_elements.t val apple_map_marker : ?key:string -> title:string -> latitude:float -> longitude:float -> ?title_signal:string Signal.signal -> ?latitude_signal:float Signal.signal -> ?longitude_signal:float Signal.signal -> unit -> Lui_elements.t +val split_view : ?key:string -> ?divider_thickness:float -> ?animation:bool -> ?accessibility_identifier:string -> ?divider_thickness_signal:float Signal.signal -> ?animation_signal:bool Signal.signal -> ?accessibility_identifier_signal:string Signal.signal -> Lui_elements.t list -> Lui_elements.t +val split_branch : ?key:string -> orientation:string -> ratio:float -> ?orientation_signal:string Signal.signal -> ?ratio_signal:float Signal.signal -> ?on_ratio_changed:(split_branch_ratio_changed -> unit) -> Lui_elements.t list -> Lui_elements.t +val split_pane : ?key:string -> pane_id:string -> ?selected:string -> ?focused:bool -> ?accessibility_identifier:string -> ?pane_id_signal:string Signal.signal -> ?selected_signal:string Signal.signal -> ?focused_signal:bool Signal.signal -> ?accessibility_identifier_signal:string Signal.signal -> ?on_tab_selected:(split_pane_tab_selected -> unit) -> ?on_tab_closed:(split_pane_tab_closed -> unit) -> ?on_tab_moved:(split_pane_tab_moved -> unit) -> ?on_pane_focused:(split_pane_pane_focused -> unit) -> ?on_navigate:(split_pane_navigate -> unit) -> ?on_split_requested:(split_pane_split_requested -> unit) -> ?on_split_drop:(split_pane_split_drop -> unit) -> ?on_pane_closed:(split_pane_pane_closed -> unit) -> Lui_elements.t list -> Lui_elements.t +val split_tab : ?key:string -> tab_id:string -> title:string -> ?icon:string -> ?dirty:bool -> ?closable:bool -> ?accessibility_identifier:string -> ?tab_id_signal:string Signal.signal -> ?title_signal:string Signal.signal -> ?icon_signal:string Signal.signal -> ?dirty_signal:bool Signal.signal -> ?closable_signal:bool Signal.signal -> ?accessibility_identifier_signal:string Signal.signal -> Lui_elements.t list -> Lui_elements.t val registry : unit -> Lui_extension.extension_registry diff --git a/examples/gallery/model.ml b/examples/gallery/model.ml index 4dbd7994..9a18a277 100644 --- a/examples/gallery/model.ml +++ b/examples/gallery/model.ml @@ -27,8 +27,34 @@ type t = { combine_dialog_open : bool; combine_sheet_open : bool; combine_menu_open : bool; + split_panes : Lui_split.Model.t; } +let gallery_split_state = + let open Lui_split.Model in + create ~focused:"editor" + (Split + { + split_id = "gallery-root"; + split_orientation = `horizontal; + split_ratio = 0.62; + split_first = + Leaf + (pane ~pane_id:"editor" ~selected:"welcome" + [ + tab ~tab_id:"welcome" ~title:"Welcome" (); + tab ~tab_id:"notes" ~title:"Notes.md" ~dirty:true (); + tab ~tab_id:"repl" ~title:"REPL" ~closable:false (); + ]); + split_second = + Leaf + (pane ~pane_id:"sidebar" ~selected:"outline" + [ + tab ~tab_id:"outline" ~title:"Outline" (); + tab ~tab_id:"backlinks" ~title:"Backlinks" (); + ]); + }) + type action = | ToggleDisabled | SetFieldValue of string @@ -63,6 +89,7 @@ type action = | CloseCombineSheet | OpenCombineMenu | CloseCombineMenu + | SplitPanes of Lui_split.Model.action let initial = { @@ -91,6 +118,7 @@ let initial = combine_dialog_open = false; combine_sheet_open = false; combine_menu_open = false; + split_panes = gallery_split_state; } let update model action = @@ -151,6 +179,8 @@ let update model action = | CloseCombineSheet -> { model with combine_sheet_open = false } | OpenCombineMenu -> { model with combine_menu_open = true } | CloseCombineMenu -> { model with combine_menu_open = false } + | SplitPanes action -> + { model with split_panes = Lui_split.Model.update model.split_panes action } let progress_label model = Printf.sprintf "Progress fraction: %.1f" model.progress diff --git a/examples/gallery/view.ml b/examples/gallery/view.ml index 60a16120..3b8b842d 100644 --- a/examples/gallery/view.ml +++ b/examples/gallery/view.ml @@ -1336,6 +1336,31 @@ let native_extension_section : t = context (Some page_id)); page_id +let split_panes_section model_source send : t = + let pane_body (tab : Lui_split.Model.tab) = + column ~gap:8 ~padding:16 + [ text ~value:tab.Lui_split.Model.tab_title [] + ; paragraph ~value:"Tab bodies stay mounted per tab; selection, splits, + edge drops and shortcuts dispatch through the shared model." [] + ] + in + section "Split Panes (extension)" + [ paragraph + ~value:"Bonsplit-style tabbed panes: LUISplit.register renders them + natively with drag-to-reorder, edge-drop splits and keyboard + navigation." + [] + ; dyn + ~equal:(fun (a : Model.t) (b : Model.t) -> + a.Model.split_panes == b.Model.split_panes) + (fun (m : Model.t) -> + Lui_split.Model.render + ~build:(fun tab -> [ pane_body tab ]) + ~dispatch:(fun action -> ignore (send (Model.SplitPanes action))) + m.Model.split_panes) + model_source + ] + let tweak_paragraph : t = fun context parent -> let node = Lui_ui.platform_tweak context "gallery-accent" in @@ -1564,7 +1589,13 @@ let view context model_source send : t = in let sections = match Lui_ui.platform context with - | IOS | MacOS -> sections @ [ native_extension_section; tweak_paragraph ] + | IOS | MacOS -> + sections + @ [ + native_extension_section; + tweak_paragraph; + split_panes_section model_source send; + ] | _ -> sections in column sections diff --git a/platform/apple/Sources/LUIAppleBackend/LUISplit.swift b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift new file mode 100644 index 00000000..7ddd9537 --- /dev/null +++ b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift @@ -0,0 +1,807 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// Bonsplit-style tabbed split panes, driven by the `split-view`, +/// `split-branch`, `split-pane`, and `split-tab` extension nodes. +/// +/// The OCaml app owns the tree; these views own gesture-time visuals (drag +/// preview, drop-zone highlight, divider feedback) so interactions run at +/// full display rate, and only committed actions cross the bridge. +@MainActor +public enum LUISplit { + static let viewFingerprint = + "lui-extension-v1|10:split-view|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:17:divider-thickness:float:optional:none,24:accessibility-identifier:string:optional:none,9:animation:bool:optional:none|events:" + static let branchFingerprint = + "lui-extension-v1|12:split-branch|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:11:orientation:string:required:none,5:ratio:float:required:none|events:13:ratio-changed[5:ratio:float:required]" + static let paneFingerprint = + "lui-extension-v1|10:split-pane|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:9:split-tab|properties:24:accessibility-identifier:string:optional:none,7:focused:bool:optional:none,7:pane-id:string:required:none,8:selected:string:optional:none|events:10:split-drop[3:tab:string:required,4:edge:string:required,9:from-pane:string:required],10:tab-closed[3:tab:string:required],11:pane-closed[],12:pane-focused[],12:tab-selected[3:tab:string:required],15:split-requested[11:orientation:string:required],8:navigate[9:direction:string:required],9:tab-moved[3:tab:string:required,5:index:int:required,9:from-pane:string:required]" + static let tabFingerprint = + "lui-extension-v1|9:split-tab|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:1|children:|properties:24:accessibility-identifier:string:optional:none,4:icon:string:optional:none,5:dirty:bool:optional:none,5:title:string:required:none,6:tab-id:string:required:none,8:closable:bool:optional:none|events:" + + private static let accessibilityIdentifier = LUIExtensionProperty( + name: "accessibility-identifier", kind: .string) + + /// Registers all four split components. Call this alongside the app's + /// other extension registrations, before the registry freezes. + public static func register(in registry: LUIAppleExtensionRegistry) throws { + try registry.register( + LUIAppleExtension( + identifier: "split-view", + fingerprint: viewFingerprint, + childIdentifiers: ["split-branch", "split-pane"], + properties: [ + LUIExtensionProperty(name: "divider-thickness", kind: .double), + LUIExtensionProperty(name: "animation", kind: .bool), + accessibilityIdentifier, + ], + viewFactory: { AnyView(LUISplitViewNode(context: $0)) } + )) + try registry.register( + LUIAppleExtension( + identifier: "split-branch", + fingerprint: branchFingerprint, + childIdentifiers: ["split-branch", "split-pane"], + properties: [ + LUIExtensionProperty( + name: "orientation", kind: .string, isRequired: true), + LUIExtensionProperty(name: "ratio", kind: .double, isRequired: true), + ], + events: [ + LUIExtensionEvent( + name: "ratio-changed", + fields: [ + LUIExtensionEventField( + name: "ratio", kind: .double, isRequired: true) + ]) + ], + viewFactory: { AnyView(LUISplitBranchNode(context: $0)) } + )) + try registry.register( + LUIAppleExtension( + identifier: "split-pane", + fingerprint: paneFingerprint, + childIdentifiers: ["split-tab"], + properties: [ + LUIExtensionProperty(name: "pane-id", kind: .string, isRequired: true), + LUIExtensionProperty(name: "selected", kind: .string), + LUIExtensionProperty(name: "focused", kind: .bool), + accessibilityIdentifier, + ], + events: [ + LUIExtensionEvent( + name: "tab-selected", + fields: [ + LUIExtensionEventField(name: "tab", kind: .string, isRequired: true) + ]), + LUIExtensionEvent( + name: "tab-closed", + fields: [ + LUIExtensionEventField(name: "tab", kind: .string, isRequired: true) + ]), + LUIExtensionEvent( + name: "tab-moved", + fields: [ + LUIExtensionEventField(name: "tab", kind: .string, isRequired: true), + LUIExtensionEventField(name: "index", kind: .int, isRequired: true), + LUIExtensionEventField( + name: "from-pane", kind: .string, isRequired: true), + ]), + LUIExtensionEvent(name: "pane-focused"), + LUIExtensionEvent( + name: "navigate", + fields: [ + LUIExtensionEventField( + name: "direction", kind: .string, isRequired: true) + ]), + LUIExtensionEvent( + name: "split-requested", + fields: [ + LUIExtensionEventField( + name: "orientation", kind: .string, isRequired: true) + ]), + LUIExtensionEvent( + name: "split-drop", + fields: [ + LUIExtensionEventField(name: "tab", kind: .string, isRequired: true), + LUIExtensionEventField( + name: "from-pane", kind: .string, isRequired: true), + LUIExtensionEventField(name: "edge", kind: .string, isRequired: true), + ]), + LUIExtensionEvent(name: "pane-closed"), + ], + viewFactory: { AnyView(LUISplitPaneNode(context: $0)) } + )) + try registry.register( + LUIAppleExtension( + identifier: "split-tab", + fingerprint: tabFingerprint, + acceptsStandardChildren: true, + properties: [ + LUIExtensionProperty(name: "tab-id", kind: .string, isRequired: true), + LUIExtensionProperty(name: "title", kind: .string, isRequired: true), + LUIExtensionProperty(name: "icon", kind: .string), + LUIExtensionProperty(name: "dirty", kind: .bool), + LUIExtensionProperty(name: "closable", kind: .bool), + accessibilityIdentifier, + ], + // Tab nodes are data carriers; the pane renders their content. + viewFactory: { AnyView(LUISplitTabNode(context: $0)) } + )) + } +} + +// MARK: - Shared environment + +/// Visual settings the `split-view` root broadcasts to every descendant. +private struct LUISplitSettings: Equatable, Sendable { + var dividerThickness: CGFloat = 9 + var animationEnabled = true + + static let fallback = LUISplitSettings() +} + +private struct LUISplitSettingsKey: EnvironmentKey { + static let defaultValue = LUISplitSettings.fallback +} + +private extension EnvironmentValues { + var splitSettings: LUISplitSettings { + get { self[LUISplitSettingsKey.self] } + set { self[LUISplitSettingsKey.self] = newValue } + } +} + +private extension LUIAppleExtensionViewContext { + func string(_ name: String) -> String? { + guard case .string(let value)? = property(name) else { return nil } + return value + } + func bool(_ name: String, default fallback: Bool = false) -> Bool { + guard case .bool(let value)? = property(name) else { return fallback } + return value + } + func double(_ name: String, default fallback: Double = 0) -> Double { + guard case .double(let value)? = property(name) else { return fallback } + return value + } + func childString(node: Int, _ name: String) -> String? { + guard case .string(let value)? = childProperty(node: node, name) else { return nil } + return value + } + func childBool(node: Int, _ name: String, default fallback: Bool = false) -> Bool { + guard case .bool(let value)? = childProperty(node: node, name) else { return fallback } + return value + } + func tryEmit(_ name: String, values: [String: LUIExtensionValue] = [:]) { + guard isUserInteractionEnabled else { return } + try? emit(name: name, values: values) + } +} + +private var luiSplitSeparatorColor: Color { + #if os(macOS) + Color(nsColor: .separatorColor) + #else + Color(uiColor: .separator) + #endif +} + +private var luiSplitTabStripColor: Color { + #if os(macOS) + Color(nsColor: .windowBackgroundColor) + #else + Color(uiColor: .secondarySystemBackground) + #endif +} + +private var luiSplitSelectedTabColor: Color { + #if os(macOS) + Color(nsColor: .controlBackgroundColor) + #else + Color(uiColor: .systemBackground) + #endif +} + +// MARK: - split-view + +private struct LUISplitViewNode: View { + let context: LUIAppleExtensionViewContext + + var body: some View { + let settings = LUISplitSettings( + dividerThickness: max(CGFloat(context.double("divider-thickness", default: 9)), 1), + animationEnabled: context.bool("animation", default: true) + ) + context.content + .environment(\.splitSettings, settings) + .accessibilityIdentifier(context.string("accessibility-identifier") ?? "") + } +} + +// MARK: - split-branch + +private struct LUISplitBranchNode: View { + let context: LUIAppleExtensionViewContext + @Environment(\.splitSettings) private var settings + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var fractionState: LUISplitFractionState + @State private var dragStartFraction: Double? + + init(context: LUIAppleExtensionViewContext) { + self.context = context + _fractionState = State( + initialValue: LUISplitFractionState( + sourceFraction: context.double("ratio", default: 0.5))) + } + + private var isHorizontal: Bool { + context.string("orientation") != "vertical" + } + + private var animation: Animation? { + guard settings.animationEnabled, !reduceMotion, dragStartFraction == nil else { + return nil + } + return .spring(duration: 0.25, bounce: 0.1) + } + + var body: some View { + GeometryReader { geometry in + let childIDs = context.childIDs + let firstID = childIDs.first + let secondID = childIDs.dropFirst().first + let axis = isHorizontal ? geometry.size.width : geometry.size.height + let gap = settings.dividerThickness + let available = max(axis - gap, 0) + let fraction = LUISplitGeometry.effectiveFraction( + value: fractionState.fraction, + available: Double(available), + firstMinimum: 0, + secondMinimum: 0 + ) + let firstLength = available * CGFloat(fraction) + + ZStack(alignment: .topLeading) { + Group { + if isHorizontal { + HStack(spacing: gap) { + child(firstID, length: firstLength) + child(secondID, length: max(available - firstLength, 0)) + } + } else { + VStack(spacing: gap) { + child(firstID, length: firstLength) + child(secondID, length: max(available - firstLength, 0)) + } + } + } + .animation(animation, value: firstLength) + + divider( + offset: firstLength, + geometry: geometry.size, + available: available + ) + } + } + .onAppear { reconcile(sourceRatio: context.double("ratio", default: 0.5)) } + .onChange(of: context.double("ratio", default: 0.5)) { _, value in + reconcile(sourceRatio: value) + } + } + + @ViewBuilder + private func child(_ id: Int?, length: CGFloat) -> some View { + Group { + if let id { + context.content(for: id) + } else { + Color.clear + } + } + .frame( + width: isHorizontal ? length : nil, + height: isHorizontal ? nil : length + ) + } + + private func divider(offset: CGFloat, geometry: CGSize, available: CGFloat) -> some View { + let gap = settings.dividerThickness + let visible = min(gap, 1) + return ZStack { + Color.clear + Rectangle() + .fill(luiSplitSeparatorColor) + .frame( + width: isHorizontal ? visible : nil, + height: isHorizontal ? nil : visible + ) + } + .frame( + width: isHorizontal ? gap : geometry.width, + height: isHorizontal ? geometry.height : gap + ) + .contentShape(Rectangle()) + .offset(x: isHorizontal ? offset : 0, y: isHorizontal ? 0 : offset) + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + let start = dragStartFraction ?? fractionState.fraction + dragStartFraction = start + let delta = isHorizontal ? value.translation.width : value.translation.height + fractionState.applyUserFraction(start + Double(delta / max(available, 1))) + } + .onEnded { _ in + dragStartFraction = nil + context.tryEmit( + "ratio-changed", values: ["ratio": .double(fractionState.fraction)]) + } + ) + .focusable() + .onKeyPress(.leftArrow) { adjustDivider(by: -0.05) } + .onKeyPress(.rightArrow) { adjustDivider(by: 0.05) } + .onKeyPress(.upArrow) { adjustDivider(by: -0.05) } + .onKeyPress(.downArrow) { adjustDivider(by: 0.05) } + .accessibilityElement() + .accessibilityLabel("Split divider") + .accessibilityValue("\(Int((fractionState.fraction * 100).rounded()))%") + .accessibilityAdjustableAction { direction in + _ = adjustDivider(by: direction == .increment ? 0.05 : -0.05) + } + } + + private func adjustDivider(by delta: Double) -> KeyPress.Result { + fractionState.applyUserFraction(fractionState.fraction + delta) + context.tryEmit("ratio-changed", values: ["ratio": .double(fractionState.fraction)]) + return .handled + } + + private func reconcile(sourceRatio: Double) { + fractionState.reconcile(sourceFraction: sourceRatio) + } +} + +// MARK: - split-pane + +/// Where a pending drop would land on this pane. +private enum LUISplitDropZone: Equatable { + case center + case edge(String) // "left" | "right" | "top" | "bottom" +} + +/// Splits a drag payload of the form "\t". +private func luiSplitUnpackDragPayload(_ raw: String) -> (pane: String, tab: String) { + if let sep = raw.firstIndex(of: "\t") { + return (String(raw[raw.startIndex.. some View { + let isSelected = tab.tabID == selectedTabID + return HStack(spacing: 4) { + if tab.dirty { + Circle().fill(Color.secondary).frame(width: 5, height: 5) + } + Text(tab.title) + .font(.system(size: 12)) + .lineLimit(1) + .foregroundStyle(isSelected ? .primary : .secondary) + if tab.closable { + Button { + context.tryEmit("tab-closed", values: ["tab": .string(tab.tabID)]) + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .frame(width: 14, height: 14) + .contentShape(Rectangle()) + .accessibilityLabel("Close \(tab.title)") + } + } + .padding(.horizontal, 10) + .frame(height: 30) + .background(isSelected ? luiSplitSelectedTabColor : .clear) + .contentShape(Rectangle()) + .id(tab.tabID) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) + .accessibilityIdentifier(tab.accessibilityIdentifier ?? "") + .onTapGesture { + hasKeyFocus = true + context.tryEmit("tab-selected", values: ["tab": .string(tab.tabID)]) + context.tryEmit("pane-focused") + } + .onDrag { + // Payload packs the source pane so the drop target can report an + // accurate `from-pane` without consulting the app. + NSItemProvider(object: "\(paneID)\t\(tab.tabID)" as NSString) + } preview: { + Text(tab.title) + .font(.system(size: 12)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6)) + } + .onDrop( + of: [UTType.text], + delegate: LUISplitTabDropDelegate( + index: index, + context: context, + tabCount: tabs.count, + tabDropIndex: $tabDropIndex + ) + ) + .overlay(alignment: .leading) { + if tabDropIndex == index { dropIndicator.offset(x: -1) } + } + } + + // MARK: content + drop zones + + private var paneContent: some View { + ZStack { + // Keep every tab mounted so scroll/focus/editor state survives + // switching; only the selected one is visible. + ForEach(tabs) { tab in + context.content(for: tab.node) + .opacity(tab.tabID == selectedTabID ? 1 : 0) + .allowsHitTesting(tab.tabID == selectedTabID) + .accessibilityHidden(tab.tabID != selectedTabID) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .layoutPriority(1) + } + + @ViewBuilder + private var dropZoneOverlay: some View { + if let dropZone { + ZStack { + switch dropZone { + case .center: + Color.accentColor.opacity(0.10) + case .edge(let edge): + GeometryReader { geo in + let horizontal = edge == "left" || edge == "right" + let primary = horizontal ? geo.size.width : geo.size.height + let extent = max(primary * 0.35, 48) + Color.accentColor.opacity(0.18) + .frame( + width: horizontal ? extent : geo.size.width, + height: horizontal ? geo.size.height : extent + ) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: Alignment( + horizontal: edge == "right" ? .trailing + : edge == "left" ? .leading : .center, + vertical: edge == "bottom" ? .bottom + : edge == "top" ? .top : .center + )) + } + } + } + .allowsHitTesting(false) + .transition(.opacity) + } + } +} + +/// Pane-level keyboard commands. Semantic intents cross the bridge as +/// extension events so the app decides what each does. +private struct LUISplitPaneKeys: ViewModifier { + let context: LUIAppleExtensionViewContext + let selectedTab: String + + func body(content: Content) -> some View { + // Intermediate lets keep each type-check small enough for the + // expression solver; modifier filtering happens inside each closure + // (onKeyPress has no modifiers parameter). + let navigation = content + .onKeyPress(.leftArrow, phases: .down) { press in + navigate(press, modifiers: [.command, .option], direction: "left") + } + .onKeyPress(.rightArrow, phases: .down) { press in + navigate(press, modifiers: [.command, .option], direction: "right") + } + .onKeyPress(.upArrow, phases: .down) { press in + navigate(press, modifiers: [.command, .option], direction: "up") + } + .onKeyPress(.downArrow, phases: .down) { press in + navigate(press, modifiers: [.command, .option], direction: "down") + } + let splits = navigation + .onKeyPress(KeyEquivalent("d"), phases: .down) { press in + guard press.modifiers.contains([.command, .option]) else { return .ignored } + return split(press.modifiers.contains(.shift) ? "vertical" : "horizontal") + } + .onKeyPress(KeyEquivalent("\\"), phases: .down) { press in + guard press.modifiers.contains(.command) else { return .ignored } + return split(press.modifiers.contains(.shift) ? "vertical" : "horizontal") + } + return splits + .onKeyPress(KeyEquivalent("w"), phases: .down) { press in + guard press.modifiers.contains(.command) else { return .ignored } + if press.modifiers.contains(.shift) { + context.tryEmit("pane-closed") + return .handled + } + return closeSelectedTab() + } + } + + private func navigate( + _ press: KeyPress, modifiers: EventModifiers, direction: String + ) -> KeyPress.Result { + guard press.modifiers == modifiers else { return .ignored } + context.tryEmit("navigate", values: ["direction": .string(direction)]) + return .handled + } + + private func split(_ orientation: String) -> KeyPress.Result { + context.tryEmit("split-requested", values: ["orientation": .string(orientation)]) + return .handled + } + + private func closeSelectedTab() -> KeyPress.Result { + guard !selectedTab.isEmpty, context.isUserInteractionEnabled else { return .ignored } + context.tryEmit("tab-closed", values: ["tab": .string(selectedTab)]) + return .handled + } +} + +// MARK: - Drop delegates + +/// Drop on a pane body: center merges the tab into this pane, an edge asks +/// the app to split this pane. +private struct LUISplitPaneDropDelegate: DropDelegate { + let paneID: String + let context: LUIAppleExtensionViewContext + let paneSize: CGSize + @Binding var zone: LUISplitDropZone? + @Binding var tabDropIndex: Int? + + func dropUpdated(info: DropInfo) -> DropProposal? { + let size = paneSize + guard size.width > 0, size.height > 0 else { + return DropProposal(operation: .move) + } + // Edge zone = outer 25%, clamped to [48, 160]pt, matching Bonsplit. + let edgeX = min(max(size.width * 0.25, 48), 160) + let edgeY = min(max(size.height * 0.25, 48), 160) + let location = info.location + let newZone: LUISplitDropZone + if location.x < edgeX { newZone = .edge("left") } else if location.x > size.width - edgeX + { newZone = .edge("right") } else if location.y < edgeY { newZone = .edge("top") } + else if location.y > size.height - edgeY { newZone = .edge("bottom") } + else { newZone = .center } + if newZone != zone { zone = newZone } + return DropProposal(operation: .move) + } + + func dropExited(info: DropInfo) { + zone = nil + tabDropIndex = nil + } + + func performDrop(info: DropInfo) -> Bool { + defer { + zone = nil + tabDropIndex = nil + } + guard let zone, + let provider = info.itemProviders(for: [UTType.text]).first + else { return false } + provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in + let tabID = + (item as? String) + ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } + guard let raw = tabID else { return } + let payload = luiSplitUnpackDragPayload(raw) + Task { @MainActor in + switch zone { + case .center: + context.tryEmit( + "tab-moved", + values: [ + "tab": .string(payload.tab), + "index": .int(Int.max), // append + "from-pane": .string(payload.pane), + ]) + case .edge(let edge): + context.tryEmit( + "split-drop", + values: [ + "tab": .string(payload.tab), + "from-pane": .string(payload.pane), + "edge": .string(edge), + ]) + } + } + } + return true + } +} + +/// Drop on a specific tab: inserts the dragged tab at this index. +private struct LUISplitTabDropDelegate: DropDelegate { + let index: Int + let context: LUIAppleExtensionViewContext + let tabCount: Int + @Binding var tabDropIndex: Int? + + private var resolvedIndex: Int { tabDropIndex ?? index } + + func dropUpdated(info: DropInfo) -> DropProposal? { + // Top half of the 30pt tab strip inserts before, bottom half after. + let before = info.location.y < 15 + tabDropIndex = min(before ? index : index + 1, tabCount) + return DropProposal(operation: .move) + } + + func dropExited(info: DropInfo) { + if tabDropIndex == index || tabDropIndex == index + 1 { tabDropIndex = nil } + } + + func performDrop(info: DropInfo) -> Bool { + let target = resolvedIndex + defer { tabDropIndex = nil } + guard let provider = info.itemProviders(for: [UTType.text]).first else { return false } + provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in + let tabID = + (item as? String) + ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } + guard let raw = tabID else { return } + let payload = luiSplitUnpackDragPayload(raw) + Task { @MainActor in + context.tryEmit( + "tab-moved", + values: [ + "tab": .string(payload.tab), + "index": .int(target), + "from-pane": .string(payload.pane), + ]) + } + } + return true + } +} + +// MARK: - split-tab + +/// Tab nodes only exist so the pane can render their content; a bare +/// `split-tab` outside a pane renders its own children stacked. +private struct LUISplitTabNode: View { + let context: LUIAppleExtensionViewContext + + var body: some View { + VStack(spacing: 0) { + ForEach(context.childIDs, id: \.self) { childID in + context.content(for: childID) + } + } + } +} diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 85862bd8..64367f8c 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -11,6 +11,7 @@ import 'package:flutter/services.dart'; part 'lui_wire_schema.g.dart'; part 'lui_flutter_extension.dart'; +part 'lui_flutter_split.dart'; sealed class LUIEvent { const LUIEvent(); diff --git a/platform/flutter/lib/lui_flutter_extension.dart b/platform/flutter/lib/lui_flutter_extension.dart index 4a8acee4..b4560c0d 100644 --- a/platform/flutter/lib/lui_flutter_extension.dart +++ b/platform/flutter/lib/lui_flutter_extension.dart @@ -249,6 +249,25 @@ final class LUIFlutterExtensionContext { : const SizedBox.shrink(); } + /// Renders one declared child of this extension node (e.g. a `split-pane` + /// showing a specific `split-tab`). Unknown ids render nothing. + Widget contentFor(int childID) => childIDs.contains(childID) + ? _backend.widget(node: childID) + : const SizedBox.shrink(); + + /// Reads a property from the nearest ancestor extension node with the + /// given identifier (e.g. `split-view` settings from a nested branch). + Object? ancestorProperty(String identifier, String name) { + var parent = _backend._requireExtensionState(nodeID).parent; + while (parent != null) { + final state = _backend._extensionStates[parent]; + if (state == null) return null; + if (state.identifier == identifier) return state.properties[name]; + parent = state.parent; + } + return null; + } + Object? childProperty(int childID, String name) => _backend._requireExtensionState(childID).properties[name]; diff --git a/platform/flutter/lib/lui_flutter_split.dart b/platform/flutter/lib/lui_flutter_split.dart new file mode 100644 index 00000000..e0eae119 --- /dev/null +++ b/platform/flutter/lib/lui_flutter_split.dart @@ -0,0 +1,810 @@ +part of 'lui_flutter_backend.dart'; + +/// Bonsplit-style tabbed split panes for the Flutter backend. +/// +/// The OCaml app owns the tree (see src/lui_split.ml); these widgets own +/// gesture-time visuals — drag previews, drop-zone highlight, divider +/// feedback — so interactions run at display rate, and only committed +/// actions cross the bridge. +final class LUIFlutterSplit { + static const _viewFingerprint = + 'lui-extension-v1|10:split-view|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:17:divider-thickness:float:optional:none,24:accessibility-identifier:string:optional:none,9:animation:bool:optional:none|events:'; + static const _branchFingerprint = + 'lui-extension-v1|12:split-branch|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:11:orientation:string:required:none,5:ratio:float:required:none|events:13:ratio-changed[5:ratio:float:required]'; + static const _paneFingerprint = + 'lui-extension-v1|10:split-pane|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:9:split-tab|properties:24:accessibility-identifier:string:optional:none,7:focused:bool:optional:none,7:pane-id:string:required:none,8:selected:string:optional:none|events:10:split-drop[3:tab:string:required,4:edge:string:required,9:from-pane:string:required],10:tab-closed[3:tab:string:required],11:pane-closed[],12:pane-focused[],12:tab-selected[3:tab:string:required],15:split-requested[11:orientation:string:required],8:navigate[9:direction:string:required],9:tab-moved[3:tab:string:required,5:index:int:required,9:from-pane:string:required]'; + static const _tabFingerprint = + 'lui-extension-v1|9:split-tab|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:1|children:|properties:24:accessibility-identifier:string:optional:none,4:icon:string:optional:none,5:dirty:bool:optional:none,5:title:string:required:none,6:tab-id:string:required:none,8:closable:bool:optional:none|events:'; + + static const _accessibilityIdentifier = LUIExtensionProperty( + name: 'accessibility-identifier', + kind: LUIExtensionValueKind.string, + ); + + /// Registers all four split components. + static void register(LUIFlutterExtensionRegistry registry) { + registry + ..register( + LUIFlutterExtension( + identifier: 'split-view', + fingerprint: _viewFingerprint, + childIdentifiers: const ['split-branch', 'split-pane'], + properties: const [ + LUIExtensionProperty( + name: 'divider-thickness', + kind: LUIExtensionValueKind.doubleValue, + ), + LUIExtensionProperty( + name: 'animation', + kind: LUIExtensionValueKind.boolean, + ), + _accessibilityIdentifier, + ], + builder: _SplitView.new, + ), + ) + ..register( + LUIFlutterExtension( + identifier: 'split-branch', + fingerprint: _branchFingerprint, + childIdentifiers: const ['split-branch', 'split-pane'], + properties: const [ + LUIExtensionProperty( + name: 'orientation', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + LUIExtensionProperty( + name: 'ratio', + kind: LUIExtensionValueKind.doubleValue, + isRequired: true, + ), + ], + events: const [ + LUIExtensionEventSchema( + name: 'ratio-changed', + fields: [ + LUIExtensionEventField( + name: 'ratio', + kind: LUIExtensionValueKind.doubleValue, + isRequired: true, + ), + ], + ), + ], + builder: _SplitBranch.new, + ), + ) + ..register( + LUIFlutterExtension( + identifier: 'split-pane', + fingerprint: _paneFingerprint, + childIdentifiers: const ['split-tab'], + properties: const [ + LUIExtensionProperty( + name: 'pane-id', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + LUIExtensionProperty( + name: 'selected', + kind: LUIExtensionValueKind.string, + ), + LUIExtensionProperty( + name: 'focused', + kind: LUIExtensionValueKind.boolean, + ), + _accessibilityIdentifier, + ], + events: const [ + LUIExtensionEventSchema( + name: 'tab-selected', + fields: [ + LUIExtensionEventField( + name: 'tab', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + ], + ), + LUIExtensionEventSchema( + name: 'tab-closed', + fields: [ + LUIExtensionEventField( + name: 'tab', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + ], + ), + LUIExtensionEventSchema( + name: 'tab-moved', + fields: [ + LUIExtensionEventField( + name: 'tab', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + LUIExtensionEventField( + name: 'index', + kind: LUIExtensionValueKind.integer, + isRequired: true, + ), + LUIExtensionEventField( + name: 'from-pane', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + ], + ), + LUIExtensionEventSchema(name: 'pane-focused'), + LUIExtensionEventSchema( + name: 'navigate', + fields: [ + LUIExtensionEventField( + name: 'direction', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + ], + ), + LUIExtensionEventSchema( + name: 'split-requested', + fields: [ + LUIExtensionEventField( + name: 'orientation', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + ], + ), + LUIExtensionEventSchema( + name: 'split-drop', + fields: [ + LUIExtensionEventField( + name: 'tab', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + LUIExtensionEventField( + name: 'from-pane', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + LUIExtensionEventField( + name: 'edge', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + ], + ), + LUIExtensionEventSchema(name: 'pane-closed'), + ], + builder: _SplitPane.new, + ), + ) + ..register( + LUIFlutterExtension( + identifier: 'split-tab', + fingerprint: _tabFingerprint, + acceptsStandardChildren: true, + properties: const [ + LUIExtensionProperty( + name: 'tab-id', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + LUIExtensionProperty( + name: 'title', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + LUIExtensionProperty( + name: 'icon', + kind: LUIExtensionValueKind.string, + ), + LUIExtensionProperty( + name: 'dirty', + kind: LUIExtensionValueKind.boolean, + ), + LUIExtensionProperty( + name: 'closable', + kind: LUIExtensionValueKind.boolean, + ), + _accessibilityIdentifier, + ], + builder: _SplitTab.new, + ), + ); + } +} + +// MARK: - shared helpers + +typedef _TabDrag = ({String tab, String pane}); + +extension on LUIFlutterExtensionContext { + String? stringOf(String name) => property(name) as String?; + bool boolOf(String name, {bool fallback = false}) => + property(name) as bool? ?? fallback; + double doubleOf(String name, {double fallback = 0}) => + (property(name) as num?)?.toDouble() ?? fallback; + String? childString(int childID, String name) => + childProperty(childID, name) as String?; + bool childBool(int childID, String name, {bool fallback = false}) => + childProperty(childID, name) as bool? ?? fallback; +} + +/// Visual settings broadcast by `split-view` through the extension tree. +final class _SplitSettings extends InheritedWidget { + const _SplitSettings({ + required this.dividerThickness, + required this.animationEnabled, + required super.child, + }); + + final double dividerThickness; + final bool animationEnabled; + + static _SplitSettings of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_SplitSettings>() ?? + const _SplitSettings( + dividerThickness: 9, + animationEnabled: true, + child: SizedBox.shrink(), + ); + + @override + bool updateShouldNotify(_SplitSettings old) => + dividerThickness != old.dividerThickness || + animationEnabled != old.animationEnabled; +} + +// MARK: - split-view + +final class _SplitView extends StatelessWidget { + const _SplitView(this.ext); + + final LUIFlutterExtensionContext ext; + + @override + Widget build(BuildContext context) => _SplitSettings( + dividerThickness: ext.doubleOf('divider-thickness', fallback: 9).clamp(1, 1e9), + animationEnabled: ext.boolOf('animation', fallback: true), + child: ext.content, + ); +} + +// MARK: - split-branch + +final class _SplitBranch extends StatefulWidget { + const _SplitBranch(this.ext); + + final LUIFlutterExtensionContext ext; + + @override + State<_SplitBranch> createState() => _SplitBranchState(); +} + +final class _SplitBranchState extends State<_SplitBranch> { + late double _ratio = _sourceRatio; + double? _dragStart; + bool _dragging = false; + + double get _sourceRatio => + (widget.ext.doubleOf('ratio', fallback: 0.5)).clamp(0.0, 1.0); + + bool get _horizontal => + widget.ext.stringOf('orientation') != 'vertical'; + + @override + void didUpdateWidget(_SplitBranch old) { + super.didUpdateWidget(old); + if (!_dragging && (_sourceRatio - _ratio).abs() > 0.000001) { + _ratio = _sourceRatio; + } + } + + void _emit() => widget.ext.emit( + name: 'ratio-changed', + values: {'ratio': _ratio}, + ); + + void _nudge(double delta) { + setState(() => _ratio = (_ratio + delta).clamp(0.0, 1.0)); + _emit(); + } + + @override + Widget build(BuildContext context) { + final settings = _SplitSettings.of(context); + final childIDs = widget.ext.childIDs; + final first = childIDs.isNotEmpty ? childIDs[0] : null; + final second = childIDs.length > 1 ? childIDs[1] : null; + + return LayoutBuilder( + builder: (context, constraints) { + final axis = _horizontal ? constraints.maxWidth : constraints.maxHeight; + final gap = settings.dividerThickness; + final available = (axis - gap).clamp(0.0, double.infinity); + final firstLength = available * _ratio; + final secondLength = available - firstLength; + + final firstChild = SizedBox( + width: _horizontal ? firstLength : null, + height: _horizontal ? null : firstLength, + child: first != null ? widget.ext.contentFor(first) : null, + ); + final secondChild = SizedBox( + width: _horizontal ? secondLength : null, + height: _horizontal ? null : secondLength, + child: second != null ? widget.ext.contentFor(second) : null, + ); + + return Stack( + children: [ + Positioned.fill( + child: _horizontal + ? Row(children: [firstChild, SizedBox(width: gap), secondChild]) + : Column( + children: [firstChild, SizedBox(height: gap), secondChild], + ), + ), + Positioned( + left: _horizontal ? firstLength : 0, + top: _horizontal ? 0 : firstLength, + width: _horizontal ? gap : constraints.maxWidth, + height: _horizontal ? constraints.maxHeight : gap, + child: _Divider( + horizontal: _horizontal, + animate: settings.animationEnabled && !_dragging, + onDragStart: () { + _dragging = true; + _dragStart = _ratio; + }, + onDrag: (delta) { + setState( + () => _ratio = ((_dragStart ?? _ratio) + delta / available) + .clamp(0.0, 1.0), + ); + }, + onDragEnd: () { + _dragging = false; + _dragStart = null; + _emit(); + }, + onNudge: _nudge, + ), + ), + ], + ); + }, + ); + } +} + +final class _Divider extends StatelessWidget { + const _Divider({ + required this.horizontal, + required this.animate, + required this.onDragStart, + required this.onDrag, + required this.onDragEnd, + required this.onNudge, + }); + + final bool horizontal; + final bool animate; + final VoidCallback onDragStart; + final ValueChanged onDrag; + final VoidCallback onDragEnd; + final ValueChanged onNudge; + + @override + Widget build(BuildContext context) => CallbackShortcuts( + bindings: { + SingleActivator( + horizontal ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowUp, + ): () => onNudge(-0.05), + SingleActivator( + horizontal ? LogicalKeyboardKey.arrowRight : LogicalKeyboardKey.arrowDown, + ): () => onNudge(0.05), + }, + child: Focus( + child: MouseRegion( + cursor: horizontal + ? SystemMouseCursors.resizeColumn + : SystemMouseCursors.resizeRow, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => onDragStart(), + onPanUpdate: (details) => + onDrag(horizontal ? details.delta.dx : details.delta.dy), + onPanEnd: (_) => onDragEnd(), + onPanCancel: onDragEnd, + child: Center( + child: Container( + width: horizontal ? 1 : null, + height: horizontal ? null : 1, + color: Theme.of(context).dividerColor, + ), + ), + ), + ), + ), + ); +} + +// MARK: - split-pane + +final class _SplitPane extends StatefulWidget { + const _SplitPane(this.ext); + + final LUIFlutterExtensionContext ext; + + @override + State<_SplitPane> createState() => _SplitPaneState(); +} + +final class _SplitPaneState extends State<_SplitPane> { + String? _dropZone; // null | 'center' | 'left' | 'right' | 'top' | 'bottom' + int? _tabDropIndex; + final _focusNode = FocusNode(); + final _contentKey = GlobalKey(); + final _chipKeys = {}; + + GlobalKey _chipKey(int tabNode) => + _chipKeys.putIfAbsent(tabNode, GlobalKey.new); + + String get _paneId => widget.ext.stringOf('pane-id') ?? ''; + bool get _focused => widget.ext.boolOf('focused'); + + List get _tabs => widget.ext.childIDs; + + String _tabId(int tabNode) => + widget.ext.childString(tabNode, 'tab-id') ?? ''; + + String get _selectedId { + final selected = widget.ext.stringOf('selected'); + if (selected != null && selected.isNotEmpty) return selected; + return _tabs.isNotEmpty ? _tabId(_tabs.first) : ''; + } + + void _emit(String name, [Map values = const {}]) => + widget.ext.emit(name: name, values: values); + + void _acceptDrag(_TabDrag drag, String zone) { + setState(() => _dropZone = null); + if (zone == 'center') { + _emit('tab-moved', { + 'tab': drag.tab, + 'index': _tabs.length, + 'from-pane': drag.pane, + }); + } else { + _emit('split-drop', { + 'tab': drag.tab, + 'from-pane': drag.pane, + 'edge': zone, + }); + } + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final shortcuts = { + for (final entry in { + 'left': LogicalKeyboardKey.arrowLeft, + 'right': LogicalKeyboardKey.arrowRight, + 'up': LogicalKeyboardKey.arrowUp, + 'down': LogicalKeyboardKey.arrowDown, + }.entries) + SingleActivator(entry.value, control: true, alt: true): () => + _emit('navigate', {'direction': entry.key}), + for (final entry in { + 'left': LogicalKeyboardKey.arrowLeft, + 'right': LogicalKeyboardKey.arrowRight, + 'up': LogicalKeyboardKey.arrowUp, + 'down': LogicalKeyboardKey.arrowDown, + }.entries) + SingleActivator(entry.value, meta: true, alt: true): () => + _emit('navigate', {'direction': entry.key}), + const SingleActivator(LogicalKeyboardKey.keyD, control: true, alt: true): + () => _emit('split-requested', {'orientation': 'horizontal'}), + const SingleActivator(LogicalKeyboardKey.keyD, meta: true, alt: true): + () => _emit('split-requested', {'orientation': 'horizontal'}), + const SingleActivator( + LogicalKeyboardKey.keyD, + control: true, + alt: true, + shift: true, + ): () => _emit('split-requested', {'orientation': 'vertical'}), + const SingleActivator( + LogicalKeyboardKey.keyD, + meta: true, + alt: true, + shift: true, + ): () => _emit('split-requested', {'orientation': 'vertical'}), + const SingleActivator(LogicalKeyboardKey.backslash, control: true): + () => _emit('split-requested', {'orientation': 'horizontal'}), + const SingleActivator(LogicalKeyboardKey.backslash, meta: true): + () => _emit('split-requested', {'orientation': 'horizontal'}), + const SingleActivator( + LogicalKeyboardKey.backslash, + control: true, + shift: true, + ): () => _emit('split-requested', {'orientation': 'vertical'}), + const SingleActivator(LogicalKeyboardKey.backslash, meta: true, shift: true): + () => _emit('split-requested', {'orientation': 'vertical'}), + const SingleActivator(LogicalKeyboardKey.keyW, control: true): + _closeSelected, + const SingleActivator(LogicalKeyboardKey.keyW, meta: true): + _closeSelected, + const SingleActivator(LogicalKeyboardKey.keyW, control: true, shift: true): + () => _emit('pane-closed'), + const SingleActivator(LogicalKeyboardKey.keyW, meta: true, shift: true): + () => _emit('pane-closed'), + }; + + return CallbackShortcuts( + bindings: shortcuts, + child: Focus( + focusNode: _focusNode, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + _focusNode.requestFocus(); + _emit('pane-focused'); + }, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: _focused + ? Theme.of(context).colorScheme.primary.withAlpha(140) + : Colors.transparent, + width: 2, + ), + borderRadius: BorderRadius.circular(4), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _tabBar(context), + Expanded(child: _content(context)), + ], + ), + ), + ), + ), + ); + } + + void _closeSelected() { + final selected = _selectedId; + if (selected.isNotEmpty) _emit('tab-closed', {'tab': selected}); + } + + Widget _tabBar(BuildContext context) { + final theme = Theme.of(context); + return Container( + height: 30, + color: theme.colorScheme.surfaceContainerHighest, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + for (var i = 0; i < _tabs.length; i++) _tabChip(_tabs[i], i), + _trailingIndicator(), + ], + ), + ); + } + + Widget _trailingIndicator() => AnimatedSize( + duration: const Duration(milliseconds: 120), + child: _tabDropIndex == _tabs.length + ? Container(width: 2, height: 18, color: Colors.blueAccent) + : const SizedBox.shrink(), + ); + + Widget _tabChip(int tabNode, int index) { + final theme = Theme.of(context); + final tabId = _tabId(tabNode); + final selected = tabId == _selectedId; + final title = widget.ext.childString(tabNode, 'title') ?? tabId; + final dirty = widget.ext.childBool(tabNode, 'dirty'); + final closable = widget.ext.childBool(tabNode, 'closable', fallback: true); + + Widget chip = Container( + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 10), + color: selected + ? theme.colorScheme.surface + : Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (dirty) + const Padding( + padding: EdgeInsets.only(right: 4), + child: Icon(Icons.circle, size: 5), + ), + Text( + title, + style: theme.textTheme.labelMedium?.copyWith( + color: selected ? null : theme.hintColor, + ), + ), + if (closable) + GestureDetector( + onTap: () => _emit('tab-closed', {'tab': tabId}), + child: const Padding( + padding: EdgeInsets.only(left: 4), + child: Icon(Icons.close, size: 12), + ), + ), + ], + ), + ); + + chip = GestureDetector( + onTap: () { + _focusNode.requestFocus(); + _emit('tab-selected', {'tab': tabId}); + _emit('pane-focused'); + }, + child: chip, + ); + + chip = Draggable<_TabDrag>( + data: (tab: tabId, pane: _paneId), + feedback: Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(6), + boxShadow: kElevationToShadow[2], + ), + child: Text(title, style: theme.textTheme.labelMedium), + ), + ), + childWhenDragging: Opacity(opacity: 0.4, child: chip), + child: chip, + ); + + return DragTarget<_TabDrag>( + key: _chipKey(tabNode), + onMove: (details) { + final box = + _chipKey(tabNode).currentContext?.findRenderObject() as RenderBox?; + if (box == null) return; + final local = box.globalToLocal(details.offset); + // Left/right halves pick before/after this index. + setState( + () => _tabDropIndex = + local.dx < box.size.width / 2 ? index : index + 1, + ); + }, + onLeave: (_) { + if (_tabDropIndex == index || _tabDropIndex == index + 1) { + setState(() => _tabDropIndex = null); + } + }, + onAcceptWithDetails: (details) { + final target = _tabDropIndex ?? index; + setState(() => _tabDropIndex = null); + _emit('tab-moved', { + 'tab': details.data.tab, + 'index': target, + 'from-pane': details.data.pane, + }); + }, + builder: (context, candidates, _) => Stack( + children: [ + chip, + if (_tabDropIndex == index) + const Positioned( + left: 0, + top: 6, + bottom: 6, + child: SizedBox( + width: 2, + child: ColoredBox(color: Colors.blueAccent), + ), + ), + ], + ), + ); + } + + Widget _content(BuildContext context) => DragTarget<_TabDrag>( + key: _contentKey, + onMove: (details) => setState(() { + final box = _contentKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return; + final local = box.globalToLocal(details.offset); + final ex = (box.size.width * 0.25).clamp(48.0, 160.0); + final ey = (box.size.height * 0.25).clamp(48.0, 160.0); + _dropZone = local.dx < ex + ? 'left' + : local.dx > box.size.width - ex + ? 'right' + : local.dy < ey + ? 'top' + : local.dy > box.size.height - ey + ? 'bottom' + : 'center'; + }), + onLeave: (_) => setState(() => _dropZone = null), + onAcceptWithDetails: (details) => _acceptDrag(details.data, _dropZone ?? 'center'), + builder: (context, candidates, _) => Stack( + fit: StackFit.expand, + children: [ + // Every tab stays mounted so scroll/focus/editor state survives + // switching; only the selected one is visible and interactive. + for (final tabNode in _tabs) + Offstage( + offstage: _tabId(tabNode) != _selectedId, + child: TickerMode( + enabled: _tabId(tabNode) == _selectedId, + child: widget.ext.contentFor(tabNode), + ), + ), + if (_dropZone != null) _dropZoneOverlay(context, _dropZone!), + ], + ), + ); + + Widget _dropZoneOverlay(BuildContext context, String zone) { + final accent = Theme.of(context).colorScheme.primary; + if (zone == 'center') { + return IgnorePointer(child: ColoredBox(color: accent.withAlpha(26))); + } + final horizontal = zone == 'left' || zone == 'right'; + return IgnorePointer( + child: LayoutBuilder( + builder: (context, constraints) { + final primary = + horizontal ? constraints.maxWidth : constraints.maxHeight; + final extent = (primary * 0.35).clamp(48.0, double.infinity); + return Align( + alignment: switch (zone) { + 'left' => Alignment.centerLeft, + 'right' => Alignment.centerRight, + 'top' => Alignment.topCenter, + _ => Alignment.bottomCenter, + }, + child: Container( + width: horizontal ? extent : constraints.maxWidth, + height: horizontal ? constraints.maxHeight : extent, + color: accent.withAlpha(46), + ), + ); + }, + ), + ); + } +} + +// MARK: - split-tab + +/// Tab nodes are data carriers; a bare `split-tab` stacks its children. +final class _SplitTab extends StatelessWidget { + const _SplitTab(this.ext); + + final LUIFlutterExtensionContext ext; + + @override + Widget build(BuildContext context) => Stack( + children: [ + for (final child in ext.childIDs) Positioned.fill(child: ext.contentFor(child)), + ], + ); +} diff --git a/platform/qt/lib/CMakeLists.txt b/platform/qt/lib/CMakeLists.txt index 48f635d2..9ca673f2 100644 --- a/platform/qt/lib/CMakeLists.txt +++ b/platform/qt/lib/CMakeLists.txt @@ -36,6 +36,8 @@ qt_add_qml_module(lui_qml lui_extension_registry.cpp lui_qml_backend.h lui_qml_backend.cpp + lui_split_extensions.h + lui_split_extensions.cpp QML_FILES ../qml/LuiAccordion.qml ../qml/LuiAlert.qml @@ -87,6 +89,11 @@ qt_add_qml_module(lui_qml ../qml/LuiSpacer.qml ../qml/LuiSpinner.qml ../qml/LuiSplit.qml + ../qml/LuiSplitBranch.qml + ../qml/LuiSplitPane.qml + ../qml/LuiSplitTab.qml + ../qml/LuiSplitTabChip.qml + ../qml/LuiSplitView.qml ../qml/LuiStack.qml ../qml/LuiStatusBar.qml ../qml/LuiStep.qml diff --git a/platform/qt/lib/lui_split_extensions.cpp b/platform/qt/lib/lui_split_extensions.cpp new file mode 100644 index 00000000..83bff5c9 --- /dev/null +++ b/platform/qt/lib/lui_split_extensions.cpp @@ -0,0 +1,137 @@ +#include "lui_split_extensions.h" + +namespace LUI { +namespace { + +ExtensionProperty prop(const QString &name, ExtensionValueKind kind, + bool required = false) { + ExtensionProperty p; + p.name = name; + p.kind = kind; + p.isRequired = required; + return p; +} + +ExtensionEventSchema event(const QString &name, + QVector fields = {}) { + ExtensionEventSchema e; + e.name = name; + e.fields = std::move(fields); + return e; +} + +ExtensionEventField field(const QString &name, ExtensionValueKind kind, + bool required = false) { + ExtensionEventField f; + f.name = name; + f.kind = kind; + f.isRequired = required; + return f; +} + +QUrl qmlSource(const char *file) { + return QUrl(QStringLiteral("qrc:/qt/qml/Lui/") + QLatin1String(file)); +} + +} // namespace + +bool registerSplitExtensions(ExtensionRegistry ®istry, QString *error) { + { + ExtensionSpec spec; + spec.identifier = QStringLiteral("split-view"); + spec.fingerprint = QStringLiteral("lui-extension-v1|10:split-view|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:17:divider-thickness:float:optional:none,24:accessibility-identifier:string:optional:none,9:animation:bool:optional:none|events:"); + spec.componentSource = qmlSource("LuiSplitView.qml"); + spec.childIdentifiers = {QStringLiteral("split-branch"), + QStringLiteral("split-pane")}; + spec.properties = { + prop(QStringLiteral("divider-thickness"), ExtensionValueKind::Double), + prop(QStringLiteral("animation"), ExtensionValueKind::Boolean), + prop(QStringLiteral("accessibility-identifier"), + ExtensionValueKind::String), + }; + if (!registry.add(spec, error)) + return false; + } + { + ExtensionSpec spec; + spec.identifier = QStringLiteral("split-branch"); + spec.fingerprint = QStringLiteral("lui-extension-v1|12:split-branch|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:11:orientation:string:required:none,5:ratio:float:required:none|events:13:ratio-changed[5:ratio:float:required]"); + spec.componentSource = qmlSource("LuiSplitBranch.qml"); + spec.childIdentifiers = {QStringLiteral("split-branch"), + QStringLiteral("split-pane")}; + spec.properties = { + prop(QStringLiteral("orientation"), ExtensionValueKind::String, true), + prop(QStringLiteral("ratio"), ExtensionValueKind::Double, true), + }; + spec.events = { + event(QStringLiteral("ratio-changed"), + {field(QStringLiteral("ratio"), ExtensionValueKind::Double, + true)}), + }; + if (!registry.add(spec, error)) + return false; + } + { + ExtensionSpec spec; + spec.identifier = QStringLiteral("split-pane"); + spec.fingerprint = QStringLiteral("lui-extension-v1|10:split-pane|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:9:split-tab|properties:24:accessibility-identifier:string:optional:none,7:focused:bool:optional:none,7:pane-id:string:required:none,8:selected:string:optional:none|events:10:split-drop[3:tab:string:required,4:edge:string:required,9:from-pane:string:required],10:tab-closed[3:tab:string:required],11:pane-closed[],12:pane-focused[],12:tab-selected[3:tab:string:required],15:split-requested[11:orientation:string:required],8:navigate[9:direction:string:required],9:tab-moved[3:tab:string:required,5:index:int:required,9:from-pane:string:required]"); + spec.componentSource = qmlSource("LuiSplitPane.qml"); + spec.childIdentifiers = {QStringLiteral("split-tab")}; + spec.properties = { + prop(QStringLiteral("pane-id"), ExtensionValueKind::String, true), + prop(QStringLiteral("selected"), ExtensionValueKind::String), + prop(QStringLiteral("focused"), ExtensionValueKind::Boolean), + prop(QStringLiteral("accessibility-identifier"), + ExtensionValueKind::String), + }; + spec.events = { + event(QStringLiteral("tab-selected"), + {field(QStringLiteral("tab"), ExtensionValueKind::String, true)}), + event(QStringLiteral("tab-closed"), + {field(QStringLiteral("tab"), ExtensionValueKind::String, true)}), + event(QStringLiteral("tab-moved"), + {field(QStringLiteral("tab"), ExtensionValueKind::String, true), + field(QStringLiteral("index"), ExtensionValueKind::Integer, + true), + field(QStringLiteral("from-pane"), ExtensionValueKind::String, + true)}), + event(QStringLiteral("pane-focused")), + event(QStringLiteral("navigate"), + {field(QStringLiteral("direction"), ExtensionValueKind::String, + true)}), + event(QStringLiteral("split-requested"), + {field(QStringLiteral("orientation"), ExtensionValueKind::String, + true)}), + event(QStringLiteral("split-drop"), + {field(QStringLiteral("tab"), ExtensionValueKind::String, true), + field(QStringLiteral("from-pane"), ExtensionValueKind::String, + true), + field(QStringLiteral("edge"), ExtensionValueKind::String, + true)}), + event(QStringLiteral("pane-closed")), + }; + if (!registry.add(spec, error)) + return false; + } + { + ExtensionSpec spec; + spec.identifier = QStringLiteral("split-tab"); + spec.fingerprint = QStringLiteral("lui-extension-v1|9:split-tab|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:1|children:|properties:24:accessibility-identifier:string:optional:none,4:icon:string:optional:none,5:dirty:bool:optional:none,5:title:string:required:none,6:tab-id:string:required:none,8:closable:bool:optional:none|events:"); + spec.componentSource = qmlSource("LuiSplitTab.qml"); + spec.acceptsStandardChildren = true; + spec.properties = { + prop(QStringLiteral("tab-id"), ExtensionValueKind::String, true), + prop(QStringLiteral("title"), ExtensionValueKind::String, true), + prop(QStringLiteral("icon"), ExtensionValueKind::String), + prop(QStringLiteral("dirty"), ExtensionValueKind::Boolean), + prop(QStringLiteral("closable"), ExtensionValueKind::Boolean), + prop(QStringLiteral("accessibility-identifier"), + ExtensionValueKind::String), + }; + if (!registry.add(spec, error)) + return false; + } + return true; +} + +} // namespace LUI diff --git a/platform/qt/lib/lui_split_extensions.h b/platform/qt/lib/lui_split_extensions.h new file mode 100644 index 00000000..d31cf35a --- /dev/null +++ b/platform/qt/lib/lui_split_extensions.h @@ -0,0 +1,19 @@ +// Registers the Bonsplit-style split components (split-view, split-branch, +// split-pane, split-tab) on a LUI::ExtensionRegistry. Call before the +// registry is frozen into a LuiQmlBackend. +// +// The fingerprint literals mirror the schemas declared once in +// src/lui_split.ml; test/test_lui.ml ("split / host literals in sync") fails +// if they drift. + +#pragma once + +#include "lui_extension_registry.h" + +class QString; + +namespace LUI { + +bool registerSplitExtensions(ExtensionRegistry ®istry, QString *error); + +} // namespace LUI diff --git a/platform/qt/qml/LuiSplitBranch.qml b/platform/qt/qml/LuiSplitBranch.qml new file mode 100644 index 00000000..a10790a3 --- /dev/null +++ b/platform/qt/qml/LuiSplitBranch.qml @@ -0,0 +1,135 @@ +// split-branch: binary interior node. Lays out its two children along +// `orientation` ("horizontal" = side by side, "vertical" = stacked) split by +// `ratio` (0..1 share for the first child). Divider drags animate nothing; +// external ratio changes spring-animate. +import QtQuick +import QtQuick.Layouts + +Item { + id: branch + required property var node + + property bool fillsLayout: true + + readonly property bool horizontal: + node.prop("orientation", "horizontal") !== "vertical" + readonly property real sourceRatio: + Math.min(1, Math.max(0, Number(node.prop("ratio", 0.5)) || 0.5)) + + // Local ratio: follows user drags instantly; reconciles to sourceRatio + // when the app moves it. + property real ratio: sourceRatio + onSourceRatioChanged: { + if (!divider.dragging && Math.abs(sourceRatio - ratio) > 0.000001) + ratio = sourceRatio + } + + readonly property real thickness: _splitViewNumber("divider-thickness", 9) + readonly property bool animate: _splitViewBool("animation", true) + + function _splitViewProp(name, fallback) { + var n = node + while (n) { + if (n.identifier === "split-view") return n.prop(name, fallback) + n = n.parent + } + return fallback + } + function _splitViewNumber(name, fallback) { + return Math.max(1, Number(_splitViewProp(name, fallback))) + } + function _splitViewBool(name, fallback) { + return _splitViewProp(name, fallback) === true + } + + readonly property real available: Math.max( + (horizontal ? width : height) - thickness, 0) + readonly property real firstLength: available * ratio + + implicitWidth: 100 + implicitHeight: 100 + + Behavior on firstLength { + enabled: branch.animate && !divider.dragging + NumberAnimation { duration: 180; easing.type: Easing.OutCubic } + } + + // First child: leading edge to divider. + LuiNodeView { + node: branch.node && branch.node.children.length > 0 + ? branch.node.children[0] : null + x: 0; y: 0 + width: branch.horizontal ? branch.firstLength : branch.width + height: branch.horizontal ? branch.height : branch.firstLength + } + + // Second child: divider to trailing edge. + LuiNodeView { + node: branch.node && branch.node.children.length > 1 + ? branch.node.children[1] : null + x: branch.horizontal ? branch.firstLength + branch.thickness : 0 + y: branch.horizontal ? 0 : branch.firstLength + branch.thickness + width: branch.horizontal + ? Math.max(branch.width - branch.firstLength - branch.thickness, 0) + : branch.width + height: branch.horizontal + ? branch.height + : Math.max(branch.height - branch.firstLength - branch.thickness, 0) + } + + // Divider: thin visible line inside a wider hit target. + Item { + id: divider + property bool dragging: false + x: branch.horizontal ? branch.firstLength : 0 + y: branch.horizontal ? 0 : branch.firstLength + width: branch.horizontal ? branch.thickness : branch.width + height: branch.horizontal ? branch.height : branch.thickness + + Rectangle { + anchors.centerIn: parent + width: branch.horizontal ? 1 : parent.width + height: branch.horizontal ? parent.height : 1 + color: Qt.rgba(0.5, 0.5, 0.5, 0.5) + } + + MouseArea { + anchors.fill: parent + cursorShape: branch.horizontal ? Qt.SizeHorCursor + : Qt.SizeVerCursor + hoverEnabled: true + property real startRatio: 0 + property real grab: 0 + onPressed: function(mouse) { + divider.dragging = true + startRatio = branch.ratio + grab = branch.horizontal ? mouse.x : mouse.y + mouse.accepted = true + } + onPositionChanged: function(mouse) { + if (!divider.dragging || branch.available <= 0) return + var delta = (branch.horizontal ? mouse.x : mouse.y) - grab + branch.ratio = Math.min(1, Math.max(0, + startRatio + delta / branch.available)) + } + onReleased: function(mouse) { + if (!divider.dragging) return + divider.dragging = false + branch.node.emitExtensionEvent("ratio-changed", + {"ratio": branch.ratio}) + } + } + + // Keyboard access: arrows nudge the divider by 5%. + Keys.onLeftPressed: if (branch.horizontal) branch.nudge(-0.05) + Keys.onRightPressed: if (branch.horizontal) branch.nudge(0.05) + Keys.onUpPressed: if (!branch.horizontal) branch.nudge(-0.05) + Keys.onDownPressed: if (!branch.horizontal) branch.nudge(0.05) + focusPolicy: Qt.TabFocus + } + + function nudge(delta) { + ratio = Math.min(1, Math.max(0, ratio + delta)) + node.emitExtensionEvent("ratio-changed", {"ratio": ratio}) + } +} diff --git a/platform/qt/qml/LuiSplitPane.qml b/platform/qt/qml/LuiSplitPane.qml new file mode 100644 index 00000000..a402560b --- /dev/null +++ b/platform/qt/qml/LuiSplitPane.qml @@ -0,0 +1,226 @@ +// split-pane: a leaf pane — tab strip over the selected tab's content. +// Tabs drag to reorder within the strip, move to other panes, or drop on a +// pane edge to request a split. Pane-level shortcuts emit semantic events. +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: pane + required property var node + + property bool fillsLayout: true + + readonly property string paneId: String(node.prop("pane-id", "")) + readonly property bool paneFocused: node.prop("focused", false) === true + readonly property var tabNodes: node.children + + function tabIdOf(tabNode) { + return String(tabNode.prop("tab-id", "")) + } + function selectedId() { + var s = node.prop("selected", "") + if (s !== "") return String(s) + return tabNodes.length > 0 ? tabIdOf(tabNodes[0]) : "" + } + + // Drop-zone state: "" | "center" | "left" | "right" | "top" | "bottom". + property string dropZone: "" + // Index within the tab strip where a dropped tab would insert; -1 = none. + property int tabDropIndex: -1 + + focusPolicy: Qt.ClickFocus + onActiveFocusChanged: + if (activeFocus) node.emitExtensionEvent("pane-focused", {}) + + implicitWidth: 200 + implicitHeight: 150 + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // ---- tab strip ---- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: Qt.rgba(0.5, 0.5, 0.5, 0.12) + + Flickable { + id: strip + anchors.fill: parent + contentWidth: stripRow.width + contentHeight: height + clip: true + flickableDirection: Flickable.HorizontalFlick + + Row { + id: stripRow + height: strip.height + spacing: 0 + + Repeater { + model: pane.tabNodes + delegate: LuiSplitTabChip { + tabNode: modelData + index: model.index + paneItem: pane + selected: pane.tabIdOf(modelData) === pane.selectedId() + } + } + + // Trailing insertion indicator. + Rectangle { + width: 2 + height: 18 + visible: pane.tabDropIndex === pane.tabNodes.length + color: "#4488ff" + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + + // ---- content: every tab stays mounted; only selected is visible ---- + Item { + id: contentArea + Layout.fillWidth: true + Layout.fillHeight: true + + Repeater { + model: pane.tabNodes + delegate: LuiNodeView { + anchors.fill: contentArea + node: modelData + visible: pane.tabIdOf(modelData) === pane.selectedId() + } + } + + // Edge drop-zone highlight. + Rectangle { + visible: pane.dropZone !== "" + color: "#4488ff" + opacity: pane.dropZone === "center" ? 0.10 : 0.18 + x: pane.dropZone === "left" ? 0 + : pane.dropZone === "right" ? parent.width - _extent + : 0 + y: pane.dropZone === "top" ? 0 + : pane.dropZone === "bottom" ? parent.height - _extent + : 0 + width: pane.dropZone === "left" || pane.dropZone === "right" + ? _extent : parent.width + height: pane.dropZone === "top" || pane.dropZone === "bottom" + ? _extent : parent.height + readonly property real _extent: + Math.max(48, Math.min(160, + (pane.dropZone === "left" || pane.dropZone === "right" + ? parent.width : parent.height) * 0.35)) + Behavior on opacity { NumberAnimation { duration: 120 } } + } + + // Whole-pane drop target (body = center merge, edges = split). + DropArea { + anchors.fill: parent + keys: ["lui-split-tab"] + + function zoneAt(pos) { + var ex = Math.min(Math.max(width * 0.25, 48), 160) + var ey = Math.min(Math.max(height * 0.25, 48), 160) + if (pos.x < ex) return "left" + if (pos.x > width - ex) return "right" + if (pos.y < ey) return "top" + if (pos.y > height - ey) return "bottom" + return "center" + } + + onPositionChanged: function(drag) { + pane.dropZone = zoneAt(Qt.point(drag.x, drag.y)) + } + onExited: pane.dropZone = "" + onDropped: function(drop) { + var zone = pane.dropZone + pane.dropZone = "" + var payload = drop.source && drop.source.splitPayload + ? drop.source.splitPayload : null + if (!payload) { drop.accepted = false; return } + if (zone === "center") { + pane.node.emitExtensionEvent("tab-moved", { + "tab": payload.tab, + "index": pane.tabNodes.length, + "from-pane": payload.pane + }) + } else if (zone !== "") { + pane.node.emitExtensionEvent("split-drop", { + "tab": payload.tab, + "from-pane": payload.pane, + "edge": zone + }) + } + drop.accept(Qt.MoveAction) + } + } + } + } + + // Focus ring. + Rectangle { + anchors.fill: parent + color: "transparent" + border.width: 2 + border.color: "#4488ff" + opacity: pane.paneFocused ? 0.55 : 0 + radius: 4 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 120 } } + } + + // ---- pane-level keyboard commands ---- + Keys.onPressed: function(event) { + var mod = event.modifiers + var cmdOpt = (mod & Qt.ControlModifier) && (mod & Qt.AltModifier) + var cmd = (mod & Qt.ControlModifier) && !(mod & Qt.AltModifier) + var shift = (mod & Qt.ShiftModifier) + if (cmdOpt && !shift) { + var dir = "" + if (event.key === Qt.Key_Left) dir = "left" + else if (event.key === Qt.Key_Right) dir = "right" + else if (event.key === Qt.Key_Up) dir = "up" + else if (event.key === Qt.Key_Down) dir = "down" + if (dir !== "") { + pane.node.emitExtensionEvent("navigate", {"direction": dir}) + event.accepted = true + return + } + if (event.key === Qt.Key_D) { + pane.node.emitExtensionEvent("split-requested", + {"orientation": "horizontal"}) + event.accepted = true + return + } + } + if (cmdOpt && shift && event.key === Qt.Key_D) { + pane.node.emitExtensionEvent("split-requested", + {"orientation": "vertical"}) + event.accepted = true + return + } + if (cmd && event.key === Qt.Key_Backslash) { + pane.node.emitExtensionEvent("split-requested", + {"orientation": shift ? "vertical" : "horizontal"}) + event.accepted = true + return + } + if (cmd && event.key === Qt.Key_W) { + if (shift) { + pane.node.emitExtensionEvent("pane-closed", {}) + } else { + var sel = selectedId() + if (sel !== "") + pane.node.emitExtensionEvent("tab-closed", {"tab": sel}) + } + event.accepted = true + return + } + event.accepted = false + } +} diff --git a/platform/qt/qml/LuiSplitTab.qml b/platform/qt/qml/LuiSplitTab.qml new file mode 100644 index 00000000..a5b32da1 --- /dev/null +++ b/platform/qt/qml/LuiSplitTab.qml @@ -0,0 +1,23 @@ +// split-tab: data carrier for a pane's tab. The pane renders the selected +// tab through a LuiNodeView on this node, which lands here — stack the +// standard children so a bare split-tab still shows its content. +import QtQuick + +Item { + id: tab + required property var node + + property bool fillsLayout: true + + implicitWidth: childrenRepeater.count > 0 ? 100 : 0 + implicitHeight: childrenRepeater.count > 0 ? 100 : 0 + + Repeater { + id: childrenRepeater + model: tab.node ? tab.node.children : [] + delegate: LuiNodeView { + anchors.fill: tab + node: modelData + } + } +} diff --git a/platform/qt/qml/LuiSplitTabChip.qml b/platform/qt/qml/LuiSplitTabChip.qml new file mode 100644 index 00000000..bed2a0a6 --- /dev/null +++ b/platform/qt/qml/LuiSplitTabChip.qml @@ -0,0 +1,130 @@ +// One tab chip inside a split-pane's tab strip. Draggable (reorder within +// the strip, move to another pane, or drop on a pane edge to split) and +// itself a drop target for insertion before/after its index. +import QtQuick +import QtQuick.Controls + +Item { + id: chip + required property var tabNode + required property int index + required property var paneItem + required property bool selected + + readonly property string tabId: String(tabNode.prop("tab-id", "")) + readonly property string title: + String(tabNode.prop("title", tabId)) + readonly property bool dirty: tabNode.prop("dirty", false) === true + readonly property bool closable: + tabNode.prop("closable", true) !== false + + // Payload read by drop targets (drag.source.splitPayload). + readonly property var splitPayload: ({ "tab": tabId, "pane": paneItem.paneId }) + + width: chipRow.width + height: 30 + opacity: dragArea.active ? 0.6 : 1.0 + + Drag.active: dragArea.active + Drag.dragType: Drag.Automatic + Drag.supportedActions: Qt.MoveAction + Drag.keys: ["lui-split-tab"] + Drag.source: chip + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + + Row { + id: chipRow + height: chip.height + spacing: 4 + leftPadding: 10 + rightPadding: 10 + + Rectangle { + visible: chip.dirty + width: 5; height: 5; radius: 2.5 + color: "#888888" + anchors.verticalCenter: parent.verticalCenter + } + + Label { + text: chip.title + font.pixelSize: 12 + color: chip.selected ? "#dddddd" : "#999999" + elide: Text.ElideRight + maximumLineCount: 1 + anchors.verticalCenter: parent.verticalCenter + } + + Label { + visible: chip.closable + text: "×" + font.pixelSize: 13 + color: "#999999" + anchors.verticalCenter: parent.verticalCenter + MouseArea { + anchors.fill: parent + onClicked: chip.paneItem.node.emitExtensionEvent( + "tab-closed", {"tab": chip.tabId}) + } + } + } + + Rectangle { + anchors.fill: parent + color: chip.selected ? Qt.rgba(1, 1, 1, 0.08) : "transparent" + } + + // Leading insertion indicator for drops on this chip. + Rectangle { + width: 2 + height: 18 + visible: paneItem.tabDropIndex === chip.index + color: "#4488ff" + anchors.verticalCenter: parent.verticalCenter + } + + TapHandler { + onTapped: { + chip.paneItem.node.emitExtensionEvent( + "tab-selected", {"tab": chip.tabId}) + chip.paneItem.node.emitExtensionEvent("pane-focused", {}) + } + } + + DragHandler { + id: dragArea + target: null // drag via the Drag attached props; we don't move the chip + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + // Default take-over lets the drag win once the pointer passes the + // drag threshold, releasing TapHandler for plain clicks. + } + + DropArea { + anchors.fill: parent + keys: ["lui-split-tab"] + onPositionChanged: function(drag) { + // Left/right halves pick before/after this index. + paneItem.tabDropIndex = + drag.x < width / 2 ? chip.index : chip.index + 1 + paneItem.dropZone = "" + } + onExited: + if (paneItem.tabDropIndex === chip.index || + paneItem.tabDropIndex === chip.index + 1) + paneItem.tabDropIndex = -1 + onDropped: function(drop) { + var target = paneItem.tabDropIndex + paneItem.tabDropIndex = -1 + var payload = drop.source && drop.source.splitPayload + ? drop.source.splitPayload : null + if (!payload) { drop.accepted = false; return } + paneItem.node.emitExtensionEvent("tab-moved", { + "tab": payload.tab, + "index": target < 0 ? chip.index : target, + "from-pane": payload.pane + }) + drop.accept(Qt.MoveAction) + } + } +} diff --git a/platform/qt/qml/LuiSplitView.qml b/platform/qt/qml/LuiSplitView.qml new file mode 100644 index 00000000..d8569838 --- /dev/null +++ b/platform/qt/qml/LuiSplitView.qml @@ -0,0 +1,27 @@ +// split-view: root of a tabbed split surface. Renders its single child and +// carries the shared visual settings (divider thickness, animation) which +// descendants read through their node.parent chain. +import QtQuick +import QtQuick.Layouts + +Item { + id: root + required property var node + + property bool fillsLayout: true + + readonly property real dividerThickness: + Math.max(1, Number(node.prop("divider-thickness", 9))) + readonly property bool animationEnabled: + node.prop("animation", true) === true + + implicitWidth: childLoader.implicitWidth + implicitHeight: childLoader.implicitHeight + + LuiNodeView { + id: childLoader + anchors.fill: parent + node: root.node && root.node.children.length > 0 + ? root.node.children[0] : null + } +} diff --git a/platform/web/demo/lui-split.html b/platform/web/demo/lui-split.html new file mode 100644 index 00000000..5037b66f --- /dev/null +++ b/platform/web/demo/lui-split.html @@ -0,0 +1,79 @@ + + + + + + lui-split demo + + + + +
+ lui-split + ⌘/Ctrl+Alt+arrows navigate + ⌘/Ctrl+Alt+D split + ⌘/Ctrl+\ split + ⌘/Ctrl+W close tab + drag tabs to reorder / drop on edges to split + +
+
+ + + + diff --git a/platform/web/src/lui-split.css b/platform/web/src/lui-split.css new file mode 100644 index 00000000..c70f4d1d --- /dev/null +++ b/platform/web/src/lui-split.css @@ -0,0 +1,224 @@ +/* Bonsplit-style split panes — web styles. + All metrics are logical so both orientations share one rule set. */ + +.lui-split-view { + --lui-split-divider: 9px; + --lui-split-accent: oklch(0.62 0.19 255); + display: flex; + inline-size: 100%; + block-size: 100%; + min-block-size: 0; + min-inline-size: 0; +} + +.lui-split-branch { + display: flex; + inline-size: 100%; + block-size: 100%; + min-block-size: 0; + min-inline-size: 0; + + &.horizontal { flex-direction: row; } + &.vertical { flex-direction: column; } +} + +.lui-split-branch-child { + min-inline-size: 0; + min-block-size: 0; + overflow: hidden; + display: flex; +} + +.lui-split-divider { + flex: none; + position: relative; + z-index: 2; + touch-action: none; + + .horizontal & { + inline-size: var(--lui-split-divider); + cursor: col-resize; + } + .vertical & { + block-size: var(--lui-split-divider); + cursor: row-resize; + } + + /* 1px visible line centered inside the hit area. */ + &::after { + content: ''; + position: absolute; + background: var(--border, oklch(0.92 0 0)); + } + .horizontal &::after { + inset-block: 0; + inset-inline-start: calc(50% - 0.5px); + inline-size: 1px; + } + .vertical &::after { + inset-inline: 0; + inset-block-start: calc(50% - 0.5px); + block-size: 1px; + } + + &:hover::after, + [data-dragging] &::after { + background: var(--lui-split-accent); + } + .horizontal &:hover::after, + .horizontal [data-dragging] &::after { inline-size: 2px; } + .vertical &:hover::after, + .vertical [data-dragging] &::after { block-size: 2px; } + + &:focus-visible { + outline: 2px solid var(--lui-split-accent); + outline-offset: -2px; + } +} + +.lui-split-pane { + display: flex; + flex-direction: column; + inline-size: 100%; + block-size: 100%; + min-block-size: 0; + min-inline-size: 0; + border-radius: 4px; + outline: none; + box-shadow: inset 0 0 0 2px transparent; + + &[data-focused] { + box-shadow: inset 0 0 0 2px + color-mix(in oklch, var(--lui-split-accent) 55%, transparent); + } + + /* Dragging disables transitions so the 120fps feel comes from pointer + tracking, not eased catch-up. */ + [data-animation='off'] & * { transition: none !important; } +} + +.lui-split-strip { + display: flex; + align-items: center; + flex: none; + block-size: 30px; + overflow-x: auto; + scrollbar-width: thin; + background: color-mix( + in oklch, var(--card, oklch(1 0 0)) 94%, var(--foreground, oklch(0.14 0 0))); + border-block-end: 1px solid var(--border, oklch(0.92 0 0)); +} + +.lui-split-strip-indicator { + flex: none; + inline-size: 2px; + block-size: 18px; + margin-inline-start: 4px; + background: var(--lui-split-accent); + border-radius: 1px; + &[data-visible='false'] { display: none; } +} + +.lui-split-tab { + display: inline-flex; + align-items: center; + gap: 6px; + flex: none; + block-size: 30px; + padding-inline: 10px 6px; + font: 500 12px/1 system-ui, sans-serif; + color: var(--muted-foreground, oklch(0.55 0 0)); + cursor: default; + user-select: none; + position: relative; + border-inline-end: 1px solid transparent; + + &[data-active='true'] { + background: var(--card, oklch(1 0 0)); + color: var(--foreground, oklch(0.14 0 0)); + box-shadow: inset 0 1px 0 var(--card, oklch(1 0 0)); + } + &[data-dragging] { opacity: 0.4; } + &[data-drop-edge='before'] { + box-shadow: inset 2px 0 0 var(--lui-split-accent); + } + &[data-drop-edge='after'] { + box-shadow: inset -2px 0 0 var(--lui-split-accent); + } +} + +.lui-split-tab-dirty { + inline-size: 5px; + block-size: 5px; + border-radius: 50%; + background: currentcolor; + flex: none; +} + +.lui-split-tab-label { + max-inline-size: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lui-split-tab-close { + border: 0; + background: transparent; + color: inherit; + font-size: 13px; + line-height: 1; + padding: 1px 4px; + border-radius: 3px; + cursor: pointer; + opacity: 0.6; + + &:hover { opacity: 1; background: var(--accent, oklch(0.97 0 0)); } + &:focus-visible { outline: 1px solid var(--lui-split-accent); } +} + +.lui-split-content { + position: relative; + flex: 1; + min-block-size: 0; + min-inline-size: 0; +} + +.lui-split-tab-content { + position: absolute; + inset: 0; + &[data-active='false'] { visibility: hidden; } +} + +.lui-split-tab-node { + position: absolute; + inset: 0; +} + +.lui-split-drop-overlay { + position: absolute; + pointer-events: none; + z-index: 5; + inset: 0; + opacity: 0; + transition: opacity 90ms linear; + + &[data-zone='center'] { + opacity: 1; + background: color-mix( + in oklch, var(--lui-split-accent) 10%, transparent); + outline: 2px solid + color-mix(in oklch, var(--lui-split-accent) 60%, transparent); + outline-offset: -2px; + } + &[data-zone='left'], &[data-zone='right'], + &[data-zone='top'], &[data-zone='bottom'] { + opacity: 1; + background: color-mix( + in oklch, var(--lui-split-accent) 18%, transparent); + } + &[data-zone='left'] { inset: 0 auto 0 0; inline-size: 35%; } + &[data-zone='right'] { inset: 0 0 0 auto; inline-size: 35%; } + &[data-zone='top'] { inset: 0 0 auto 0; block-size: 35%; } + &[data-zone='bottom'] { inset: auto 0 0 0; block-size: 35%; } +} diff --git a/platform/web/src/lui-split.js b/platform/web/src/lui-split.js new file mode 100644 index 00000000..a46f2c0f --- /dev/null +++ b/platform/web/src/lui-split.js @@ -0,0 +1,535 @@ +// Bonsplit-style tabbed split panes — web renderer. +// +// The app owns the tree (see src/lui_split.ml); this module owns +// gesture-time visuals — drag previews, drop-zone highlight, live divider — +// so interactions run at display rate, and only committed actions are +// reported through the host. +// +// Host contract (duck-typed): +// host.render(node) -> HTMLElement for any node id (standard or +// extension children included) +// host.emit(node, name, values) -> report a committed event +// host.extState(node) -> { identifier, properties: {...}, children: [ids] } +// +// Entry points: +// LUISplit.fingerprints — schema literals, asserted in-sync by OCaml tests +// LUISplit.render(node, host) — render a split extension node to DOM +// LUISplit.mount(root, tree, onEvent) — standalone demo host: builds the +// DOM from a plain-object tree and applies event semantics locally. +// +// Depends on lui-split.css. + +const LUISplit = (() => { + // Schema fingerprints — keep byte-identical with src/lui_split.ml output + // and the literals in the other host files (checked by tests; the sync + // checker extracts single-line literals, so keep each on one line). + const fingerprints = { + 'split-view': + 'lui-extension-v1|10:split-view|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:17:divider-thickness:float:optional:none,24:accessibility-identifier:string:optional:none,9:animation:bool:optional:none|events:', + 'split-branch': + 'lui-extension-v1|12:split-branch|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:11:orientation:string:required:none,5:ratio:float:required:none|events:13:ratio-changed[5:ratio:float:required]', + 'split-pane': + 'lui-extension-v1|10:split-pane|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:9:split-tab|properties:24:accessibility-identifier:string:optional:none,7:focused:bool:optional:none,7:pane-id:string:required:none,8:selected:string:optional:none|events:10:split-drop[3:tab:string:required,4:edge:string:required,9:from-pane:string:required],10:tab-closed[3:tab:string:required],11:pane-closed[],12:pane-focused[],12:tab-selected[3:tab:string:required],15:split-requested[11:orientation:string:required],8:navigate[9:direction:string:required],9:tab-moved[3:tab:string:required,5:index:int:required,9:from-pane:string:required]', + 'split-tab': + 'lui-extension-v1|9:split-tab|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:1|children:|properties:24:accessibility-identifier:string:optional:none,4:icon:string:optional:none,5:dirty:bool:optional:none,5:title:string:required:none,6:tab-id:string:required:none,8:closable:bool:optional:none|events:', + }; + + const MIME = 'application/x-lui-split-tab'; + const prop = (state, name, fallback) => + state.properties[name] !== undefined ? state.properties[name] : fallback; + + // split-view: single child + settings broadcast via CSS vars on the root. + function renderView(node, host) { + const state = host.extState(node); + const el = document.createElement('div'); + el.className = 'lui-split-view'; + const thickness = Number(prop(state, 'divider-thickness', 9)); + el.style.setProperty('--lui-split-divider', `${Math.max(1, thickness)}px`); + if (!prop(state, 'animation', true)) el.dataset.animation = 'off'; + const id = prop(state, 'accessibility-identifier', ''); + if (id) el.dataset.testid = id; + for (const child of state.children) el.append(host.render(child)); + return el; + } + + // split-branch: two children separated by a draggable divider. + function renderBranch(node, host) { + const state = host.extState(node); + const horizontal = prop(state, 'orientation', 'horizontal') !== 'vertical'; + let ratio = Number(prop(state, 'ratio', 0.5)); + + const el = document.createElement('div'); + el.className = `lui-split-branch ${horizontal ? 'horizontal' : 'vertical'}`; + const first = document.createElement('div'); + first.className = 'lui-split-branch-child'; + const divider = document.createElement('div'); + divider.className = 'lui-split-divider'; + divider.tabIndex = 0; + divider.setAttribute('role', 'separator'); + divider.setAttribute( + 'aria-orientation', horizontal ? 'vertical' : 'horizontal'); + const second = document.createElement('div'); + second.className = 'lui-split-branch-child'; + if (state.children[0] !== undefined) { + first.append(host.render(state.children[0])); + } + if (state.children[1] !== undefined) { + second.append(host.render(state.children[1])); + } + el.append(first, divider, second); + + const apply = () => { + first.style.flex = `${ratio} 1 0`; + second.style.flex = `${1 - ratio} 1 0`; + divider.setAttribute('aria-valuenow', Math.round(ratio * 100)); + }; + divider.setAttribute('aria-valuemin', '0'); + divider.setAttribute('aria-valuemax', '100'); + apply(); + + let dragStart = null; + divider.addEventListener('pointerdown', (event) => { + dragStart = { + ratio, + point: horizontal ? event.clientX : event.clientY, + }; + divider.setPointerCapture(event.pointerId); + el.dataset.dragging = 'true'; + event.preventDefault(); + }); + divider.addEventListener('pointermove', (event) => { + if (!dragStart) return; + const span = horizontal ? el.clientWidth : el.clientHeight; + const gap = dividerThickness(el); + const available = Math.max(span - gap, 1); + const point = horizontal ? event.clientX : event.clientY; + ratio = Math.min( + 1, Math.max(0, dragStart.ratio + (point - dragStart.point) / available)); + apply(); + }); + const finish = () => { + if (!dragStart) return; + dragStart = null; + delete el.dataset.dragging; + host.emit(node, 'ratio-changed', { ratio }); + }; + divider.addEventListener('pointerup', finish); + divider.addEventListener('pointercancel', finish); + divider.addEventListener('keydown', (event) => { + const delta = { + ArrowLeft: horizontal ? -0.05 : 0, + ArrowRight: horizontal ? 0.05 : 0, + ArrowUp: !horizontal ? -0.05 : 0, + ArrowDown: !horizontal ? 0.05 : 0, + }[event.key]; + if (!delta) return; + ratio = Math.min(1, Math.max(0, ratio + delta)); + apply(); + host.emit(node, 'ratio-changed', { ratio }); + event.preventDefault(); + }); + return el; + } + + function dividerThickness(el) { + const view = el.closest('.lui-split-view'); + if (!view) return 9; + return parseFloat( + view.style.getPropertyValue('--lui-split-divider')) || 9; + } + + // split-pane: tab strip + content. Drops on content edges split the pane; + // drops on the strip reorder/move tabs. + function renderPane(node, host) { + const state = host.extState(node); + const paneId = String(prop(state, 'pane-id', '')); + const focused = prop(state, 'focused', false); + const tabs = state.children; + + const el = document.createElement('div'); + el.className = 'lui-split-pane'; + el.tabIndex = 0; + if (focused) el.dataset.focused = 'true'; + const accId = prop(state, 'accessibility-identifier', ''); + if (accId) el.dataset.testid = accId; + + const strip = document.createElement('div'); + strip.className = 'lui-split-strip'; + strip.setAttribute('role', 'tablist'); + const content = document.createElement('div'); + content.className = 'lui-split-content'; + el.append(strip, content); + + const tabId = (tabNode) => + String(host.extState(tabNode).properties['tab-id'] ?? ''); + const selected = String(prop(state, 'selected', '')) || + (tabs.length ? tabId(tabs[0]) : ''); + + tabs.forEach((tabNode, index) => { + strip.append(renderChip(node, tabNode, index, selected, host)); + }); + strip.append(trailingIndicator(el)); + + tabs.forEach((tabNode, index) => { + const child = host.render(tabNode); + child.classList.add('lui-split-tab-content'); + const active = tabId(tabNode) === selected; + child.dataset.active = active ? 'true' : 'false'; + child.inert = !active; + content.append(child); + }); + + // Drop overlay — sized/anchored per zone. + const overlay = document.createElement('div'); + overlay.className = 'lui-split-drop-overlay'; + overlay.dataset.zone = ''; + content.append(overlay); + + content.addEventListener('dragover', (event) => { + if (!event.dataTransfer.types.includes(MIME)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + const rect = content.getBoundingClientRect(); + const ex = Math.min(Math.max(rect.width * 0.25, 48), 160); + const ey = Math.min(Math.max(rect.height * 0.25, 48), 160); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + overlay.dataset.zone = + x < ex ? 'left' + : x > rect.width - ex ? 'right' + : y < ey ? 'top' + : y > rect.height - ey ? 'bottom' + : 'center'; + }); + content.addEventListener('dragleave', (event) => { + if (!content.contains(event.relatedTarget)) { + overlay.dataset.zone = ''; + } + }); + content.addEventListener('drop', (event) => { + const zone = overlay.dataset.zone || 'center'; + overlay.dataset.zone = ''; + const payload = event.dataTransfer.getData(MIME); + event.preventDefault(); + const [fromPane, tab] = payload.split('\t'); + if (!tab) return; + if (zone === 'center') { + host.emit(node, 'tab-moved', { + tab, 'from-pane': fromPane, index: tabs.length, + }); + } else { + host.emit(node, 'split-drop', { + tab, 'from-pane': fromPane, edge: zone, + }); + } + }); + + el.addEventListener('pointerdown', () => { + el.focus(); + host.emit(node, 'pane-focused', {}); + }); + + el.addEventListener('keydown', (event) => { + const mod = event.metaKey || event.ctrlKey; + if (mod && event.altKey) { + const direction = { + ArrowLeft: 'left', ArrowRight: 'right', + ArrowUp: 'up', ArrowDown: 'down', + }[event.key]; + if (direction) { + host.emit(node, 'navigate', { direction }); + event.preventDefault(); + return; + } + if (event.key === 'd' || event.key === 'D') { + host.emit(node, 'split-requested', { + orientation: event.shiftKey ? 'vertical' : 'horizontal', + }); + event.preventDefault(); + } + } else if (mod && event.key === '\\') { + host.emit(node, 'split-requested', { + orientation: event.shiftKey ? 'vertical' : 'horizontal', + }); + event.preventDefault(); + } else if (mod && (event.key === 'w' || event.key === 'W')) { + if (event.shiftKey) { + host.emit(node, 'pane-closed', {}); + } else if (selected) { + host.emit(node, 'tab-closed', { tab: selected }); + } + event.preventDefault(); + } + }); + return el; + } + + function trailingIndicator(pane) { + const el = document.createElement('div'); + el.className = 'lui-split-strip-indicator'; + el.dataset.visible = 'false'; + pane._stripIndicator = el; + return el; + } + + function renderChip(paneNode, tabNode, index, selected, host) { + const state = host.extState(tabNode); + const tabId = String(prop(state, 'tab-id', '')); + const paneId = String( + host.extState(paneNode).properties['pane-id'] ?? ''); + + const chip = document.createElement('div'); + chip.className = 'lui-split-tab'; + chip.dataset.tab = tabId; + chip.setAttribute('role', 'tab'); + chip.setAttribute('aria-selected', tabId === selected ? 'true' : 'false'); + chip.dataset.active = tabId === selected ? 'true' : 'false'; + chip.draggable = true; + + if (prop(state, 'dirty', false)) { + const dot = document.createElement('span'); + dot.className = 'lui-split-tab-dirty'; + chip.append(dot); + } + const label = document.createElement('span'); + label.className = 'lui-split-tab-label'; + label.textContent = String(prop(state, 'title', tabId)); + chip.append(label); + + if (prop(state, 'closable', true)) { + const close = document.createElement('button'); + close.className = 'lui-split-tab-close'; + close.type = 'button'; + close.setAttribute('aria-label', `Close ${label.textContent}`); + close.textContent = '×'; + close.addEventListener('click', (event) => { + event.stopPropagation(); + host.emit(paneNode, 'tab-closed', { tab: tabId }); + }); + chip.append(close); + } + + chip.addEventListener('click', () => { + host.emit(paneNode, 'tab-selected', { tab: tabId }); + host.emit(paneNode, 'pane-focused', {}); + }); + chip.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + host.emit(paneNode, 'tab-selected', { tab: tabId }); + event.preventDefault(); + } + }); + + chip.addEventListener('dragstart', (event) => { + event.dataTransfer.setData(MIME, `${paneId}\t${tabId}`); + event.dataTransfer.effectAllowed = 'move'; + chip.dataset.dragging = 'true'; + }); + chip.addEventListener('dragend', () => { + delete chip.dataset.dragging; + }); + + // Reorder within the strip: drop on left/right half of a chip inserts + // before/after it; the sibling indicator marks the target slot. + chip.addEventListener('dragover', (event) => { + if (!event.dataTransfer.types.includes(MIME)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + const rect = chip.getBoundingClientRect(); + const after = event.clientX - rect.left > rect.width / 2; + chip.dataset.dropEdge = after ? 'after' : 'before'; + }); + chip.addEventListener('dragleave', () => { + delete chip.dataset.dropEdge; + }); + chip.addEventListener('drop', (event) => { + event.preventDefault(); + const payload = event.dataTransfer.getData(MIME); + const [fromPane, tab] = payload.split('\t'); + if (!tab) return; + const rect = chip.getBoundingClientRect(); + const after = event.clientX - rect.left > rect.width / 2; + delete chip.dataset.dropEdge; + host.emit(paneNode, 'tab-moved', { + tab, 'from-pane': fromPane, index: index + (after ? 1 : 0), + }); + }); + return chip; + } + + // split-tab: data carrier; stacks its standard children. + function renderTab(node, host) { + const el = document.createElement('div'); + el.className = 'lui-split-tab-node'; + for (const child of host.extState(node).children) { + el.append(host.render(child)); + } + return el; + } + + const renderers = { + 'split-view': renderView, + 'split-branch': renderBranch, + 'split-pane': renderPane, + 'split-tab': renderTab, + }; + + function render(node, host) { + const state = host.extState(node); + const renderer = renderers[state.identifier]; + if (!renderer) { + throw new Error(`lui-split: unknown identifier ${state.identifier}`); + } + return renderer(node, host); + } + + // --- standalone demo host ------------------------------------------------- + // mount(root, tree, onEvent) renders a plain-object tree + // { identifier, properties, children: [node | {kind:'element', html}] } + // and applies split semantics locally: move/split/close mutate the tree and + // re-render. Useful for demos and for wiring a real transport later. + function mount(root, tree, onEvent = () => {}) { + let index = 0; + const byId = new Map(); + const fresh = () => `n${++index}`; + const intern = (node) => { + if (!node.id) node.id = fresh(); + byId.set(node.id, node); + (node.children || []).forEach(intern); + return node; + }; + intern(tree); + + const host = { + // Accepts node ids (normal render path) or child objects (demo trees + // keep children inline). + extState: (n) => (typeof n === 'object' ? n : byId.get(n)), + render: (id) => { + const node = byId.get(id) || id; + if (node.identifier) return render(node.id, host); + // Standard leaf: { html } or { element }. + const div = document.createElement('div'); + if (node.element) div.append(node.element); + else div.innerHTML = node.html || ''; + return div; + }, + emit: (node, name, values) => { + onEvent(name, values); + apply(tree, node, name, values); + redraw(); + }, + }; + + function redraw() { + byId.clear(); + intern(tree); + root.replaceChildren(render(tree.id, host)); + } + redraw(); + + // Minimal local semantics so demos feel alive: the real backend applies + // these in OCaml and re-emits the tree. + function apply(rootNode, nodeId, name, values) { + const node = byId.get(nodeId); + if (!node) return; + const paneOf = (pred, from) => { + const stack = [rootNode]; + while (stack.length) { + const cur = stack.pop(); + if ((cur.children || []).some((c) => c.id === from)) return cur; + (cur.children || []).forEach((c) => stack.push(c)); + } + return null; + }; + switch (name) { + case 'tab-selected': + node.properties.selected = values.tab; + break; + case 'tab-closed': { + const i = (node.children || []).findIndex( + (c) => c.properties['tab-id'] === values.tab); + if (i >= 0) node.children.splice(i, 1); + if (node.properties.selected === values.tab) { + node.properties.selected = node.children[0] + ? node.children[0].properties['tab-id'] + : ''; + } + break; + } + case 'tab-moved': { + const source = paneOf( + null, findPaneId(rootNode, values['from-pane'])); + const target = paneOf(null, node.id); + if (!source || !target) break; + const i = (source.children || []).findIndex( + (c) => c.properties['tab-id'] === values.tab); + if (i < 0) break; + const [tab] = source.children.splice(i, 1); + target.children.splice( + Math.min(values.index, target.children.length), 0, tab); + target.properties.selected = values.tab; + break; + } + case 'split-drop': { + const sourceId = findPaneId(rootNode, values['from-pane']); + const source = paneOf(null, sourceId); + if (!source) break; + const i = (source.children || []).findIndex( + (c) => c.properties['tab-id'] === values.tab); + if (i < 0) break; + const [tab] = source.children.splice(i, 1); + const freshPane = { + identifier: 'split-pane', + properties: { + 'pane-id': fresh(), + selected: tab.properties['tab-id'], + }, + children: [tab], + }; + const vertical = values.edge === 'left' || values.edge === 'right'; + const before = values.edge === 'left' || values.edge === 'top'; + const branch = { + identifier: 'split-branch', + properties: { + orientation: vertical ? 'horizontal' : 'vertical', + ratio: 0.5, + }, + children: before + ? [freshPane, node] + : [node, freshPane], + }; + // Replace `node` in its parent with the new branch. + const parent = paneOf(null, node.id); + if (parent) { + const j = parent.children.indexOf(node); + parent.children[j] = branch; + } else { + Object.assign(rootNode, branch); + } + break; + } + default: + break; + } + } + + function findPaneId(rootNode, paneId) { + const stack = [rootNode]; + while (stack.length) { + const cur = stack.pop(); + if (cur.properties && cur.properties['pane-id'] === paneId) { + return cur.id; + } + (cur.children || []).forEach((c) => stack.push(c)); + } + return null; + } + + return { tree, redraw }; + } + + return { fingerprints, render, mount }; +})(); + +if (typeof module !== 'undefined') module.exports = LUISplit; +if (typeof window !== 'undefined') window.LUISplit = LUISplit; diff --git a/platform/winui/LUI.WinUI/LUISplitExtensions.cs b/platform/winui/LUI.WinUI/LUISplitExtensions.cs new file mode 100644 index 00000000..17f4acb4 --- /dev/null +++ b/platform/winui/LUI.WinUI/LUISplitExtensions.cs @@ -0,0 +1,953 @@ +// Bonsplit-style tabbed split panes for the WinUI backend. +// +// The OCaml app owns the tree (see src/lui_split.ml); these controls own +// gesture-time visuals — drag feedback, drop-zone highlight, live divider — +// and only committed actions cross the bridge. +// +// Register both halves at startup: +// LUISplitExtensions.Register(backend); +// (specs first so create-extension ops validate, then visuals so nodes +// render.) + +using System; +using System.Collections.Generic; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Windows.ApplicationModel.DataTransfer; +using Windows.System; +using Windows.UI.Core; + +namespace LUI.WinUI +{ + public static class LUISplitExtensions + { + const string ViewFingerprint = + "lui-extension-v1|10:split-view|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:17:divider-thickness:float:optional:none,24:accessibility-identifier:string:optional:none,9:animation:bool:optional:none|events:"; + const string BranchFingerprint = + "lui-extension-v1|12:split-branch|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:11:orientation:string:required:none,5:ratio:float:required:none|events:13:ratio-changed[5:ratio:float:required]"; + const string PaneFingerprint = + "lui-extension-v1|10:split-pane|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:9:split-tab|properties:24:accessibility-identifier:string:optional:none,7:focused:bool:optional:none,7:pane-id:string:required:none,8:selected:string:optional:none|events:10:split-drop[3:tab:string:required,4:edge:string:required,9:from-pane:string:required],10:tab-closed[3:tab:string:required],11:pane-closed[],12:pane-focused[],12:tab-selected[3:tab:string:required],15:split-requested[11:orientation:string:required],8:navigate[9:direction:string:required],9:tab-moved[3:tab:string:required,5:index:int:required,9:from-pane:string:required]"; + const string TabFingerprint = + "lui-extension-v1|9:split-tab|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:1|children:|properties:24:accessibility-identifier:string:optional:none,4:icon:string:optional:none,5:dirty:bool:optional:none,5:title:string:required:none,6:tab-id:string:required:none,8:closable:bool:optional:none|events:"; + + static readonly LUIExtensionProperty AccessibilityIdentifier = + new LUIExtensionProperty( + "accessibility-identifier", LUIExtensionValueKind.String); + + public static void Register(LUIWinUIBackend backend) + { + LUIExtensionRegistry registry = backend.Backend.Extensions; + registry.Register(new LUIExtensionSpec( + "split-view", ViewFingerprint, + childIdentifiers: new[] { "split-branch", "split-pane" }, + properties: new[] + { + new LUIExtensionProperty( + "divider-thickness", LUIExtensionValueKind.DoubleValue), + new LUIExtensionProperty( + "animation", LUIExtensionValueKind.Boolean), + AccessibilityIdentifier, + })); + registry.Register(new LUIExtensionSpec( + "split-branch", BranchFingerprint, + childIdentifiers: new[] { "split-branch", "split-pane" }, + properties: new[] + { + new LUIExtensionProperty( + "orientation", LUIExtensionValueKind.String, + isRequired: true), + new LUIExtensionProperty( + "ratio", LUIExtensionValueKind.DoubleValue, + isRequired: true), + }, + events: new[] + { + new LUIExtensionEventSchema( + "ratio-changed", + new[] + { + new LUIExtensionEventField( + "ratio", LUIExtensionValueKind.DoubleValue, + isRequired: true), + }), + })); + registry.Register(new LUIExtensionSpec( + "split-pane", PaneFingerprint, + childIdentifiers: new[] { "split-tab" }, + properties: new[] + { + new LUIExtensionProperty( + "pane-id", LUIExtensionValueKind.String, + isRequired: true), + new LUIExtensionProperty( + "selected", LUIExtensionValueKind.String), + new LUIExtensionProperty( + "focused", LUIExtensionValueKind.Boolean), + AccessibilityIdentifier, + }, + events: new[] + { + new LUIExtensionEventSchema( + "tab-selected", + new[] + { + new LUIExtensionEventField( + "tab", LUIExtensionValueKind.String, + isRequired: true), + }), + new LUIExtensionEventSchema( + "tab-closed", + new[] + { + new LUIExtensionEventField( + "tab", LUIExtensionValueKind.String, + isRequired: true), + }), + new LUIExtensionEventSchema( + "tab-moved", + new[] + { + new LUIExtensionEventField( + "tab", LUIExtensionValueKind.String, + isRequired: true), + new LUIExtensionEventField( + "index", LUIExtensionValueKind.Integer, + isRequired: true), + new LUIExtensionEventField( + "from-pane", LUIExtensionValueKind.String, + isRequired: true), + }), + new LUIExtensionEventSchema("pane-focused"), + new LUIExtensionEventSchema( + "navigate", + new[] + { + new LUIExtensionEventField( + "direction", LUIExtensionValueKind.String, + isRequired: true), + }), + new LUIExtensionEventSchema( + "split-requested", + new[] + { + new LUIExtensionEventField( + "orientation", LUIExtensionValueKind.String, + isRequired: true), + }), + new LUIExtensionEventSchema( + "split-drop", + new[] + { + new LUIExtensionEventField( + "tab", LUIExtensionValueKind.String, + isRequired: true), + new LUIExtensionEventField( + "from-pane", LUIExtensionValueKind.String, + isRequired: true), + new LUIExtensionEventField( + "edge", LUIExtensionValueKind.String, + isRequired: true), + }), + new LUIExtensionEventSchema("pane-closed"), + })); + registry.Register(new LUIExtensionSpec( + "split-tab", TabFingerprint, + acceptsStandardChildren: true, + properties: new[] + { + new LUIExtensionProperty( + "tab-id", LUIExtensionValueKind.String, + isRequired: true), + new LUIExtensionProperty( + "title", LUIExtensionValueKind.String, + isRequired: true), + new LUIExtensionProperty( + "icon", LUIExtensionValueKind.String), + new LUIExtensionProperty( + "dirty", LUIExtensionValueKind.Boolean), + new LUIExtensionProperty( + "closable", LUIExtensionValueKind.Boolean), + AccessibilityIdentifier, + })); + + backend.RegisterExtensionVisual( + "split-view", new LUIExtensionVisual( + _ => new SplitViewControl(), + context => + { + var control = (SplitViewControl)FindControl(context); + control.Update(context); + })); + backend.RegisterExtensionVisual( + "split-branch", new LUIExtensionVisual( + _ => new SplitBranchControl(), + context => + { + var control = (SplitBranchControl)FindControl(context); + control.Update(context); + })); + backend.RegisterExtensionVisual( + "split-pane", new LUIExtensionVisual( + _ => new SplitPaneControl(), + context => + { + var control = (SplitPaneControl)FindControl(context); + control.Update(context); + })); + backend.RegisterExtensionVisual( + "split-tab", new LUIExtensionVisual( + _ => new SplitTabControl(), + context => + { + var control = (SplitTabControl)FindControl(context); + control.Update(context); + })); + } + + // The factory returned the control itself; Update receives a fresh + // context — recover the control via the element map. + static FrameworkElement FindControl(LUIExtensionContext context) => + context.Sync.ElementFor(context.NodeId).Control; + + internal static string? StringProp( + LUIExtensionNodeState state, string name) => + state.Properties.TryGetValue(name, out LUIWireValue? value) + ? value.AsString + : null; + + internal static bool BoolProp( + LUIExtensionNodeState state, string name, bool fallback = false) => + state.Properties.TryGetValue(name, out LUIWireValue? value) + ? value.AsBool ?? fallback + : fallback; + + internal static double DoubleProp( + LUIExtensionNodeState state, string name, double fallback) => + state.Properties.TryGetValue(name, out LUIWireValue? value) + ? value.AsFloat ?? fallback + : fallback; + + internal static string ChildString( + LUIExtensionContext context, long childId, string name) + { + var state = context.Backend.RequireExtensionState(childId); + return StringProp(state, name) ?? ""; + } + + internal static bool ChildBool( + LUIExtensionContext context, long childId, string name, + bool fallback = false) + { + var state = context.Backend.RequireExtensionState(childId); + return BoolProp(state, name, fallback); + } + + // Drag payload: pane-id TAB tab-id. + internal static string PackDrag(string pane, string tab) => + pane + "\t" + tab; + + internal static bool UnpackDrag( + string payload, out string pane, out string tab) + { + int split = payload.IndexOf('\t'); + pane = split < 0 ? "" : payload.Substring(0, split); + tab = split < 0 ? "" : payload.Substring(split + 1); + return split > 0; + } + } + + // MARK: - split-view + + /// Hosts the single split-tree child and broadcasts view settings. + public sealed class SplitViewControl : Grid + { + public double DividerThickness { get; private set; } = 9; + public bool AnimationEnabled { get; private set; } = true; + + public void Update(LUIExtensionContext context) + { + DividerThickness = Math.Clamp( + LUISplitExtensions.DoubleProp( + context.State, "divider-thickness", 9), + 1, double.MaxValue); + AnimationEnabled = LUISplitExtensions.BoolProp( + context.State, "animation", true); + Children.Clear(); + if (context.Children.Count > 0) + { + Children.Add(context.Children[0]); + } + } + } + + // MARK: - split-branch + + public sealed class SplitBranchControl : Grid + { + readonly ContentControl _divider = new ContentControl(); + readonly Border _dividerLine = new Border(); + LUIExtensionContext? _context; + IReadOnlyList _children = + Array.Empty(); + bool _horizontal = true; + double _ratio = 0.5; + bool _dragging; + double _dragStartRatio; + double _dragStartPoint; + + public SplitBranchControl() + { + // ContentControl gives the divider keyboard focus for arrow-key + // nudging; the visible 1px line is its content. + _dividerLine.Background = (Brush)Application.Current.Resources[ + "CardStrokeColorDefaultBrush"]; + _divider.Content = _dividerLine; + _divider.HorizontalContentAlignment = + HorizontalAlignment.Center; + _divider.VerticalContentAlignment = + VerticalAlignment.Center; + _divider.VerticalAlignment = VerticalAlignment.Stretch; + _divider.HorizontalAlignment = HorizontalAlignment.Stretch; + _divider.PointerPressed += OnDividerPressed; + _divider.PointerMoved += OnDividerMoved; + _divider.PointerReleased += OnDividerReleased; + _divider.PointerCaptureLost += OnDividerCaptureLost; + _divider.KeyDown += OnDividerKeyDown; + _divider.IsTabStop = true; + _divider.UseSystemFocusVisuals = true; + } + + public void Update(LUIExtensionContext context) + { + _context = context; + _children = context.Children; + _horizontal = LUISplitExtensions.StringProp( + context.State, "orientation") != "vertical"; + if (!_dragging) + { + _ratio = Math.Clamp( + LUISplitExtensions.DoubleProp( + context.State, "ratio", 0.5), + 0, 1); + } + Relayout(); + } + + void Relayout() + { + double gap = DividerThickness(); + Children.Clear(); + RowDefinitions.Clear(); + ColumnDefinitions.Clear(); + if (_horizontal) + { + ColumnDefinitions.Add( + new ColumnDefinition( + new GridLength(_ratio, GridUnitType.Star))); + ColumnDefinitions.Add( + new ColumnDefinition( + new GridLength(gap, GridUnitType.Pixel))); + ColumnDefinitions.Add( + new ColumnDefinition( + new GridLength(1 - _ratio, GridUnitType.Star))); + Place(0, 0, 0); + Place(1, 0, 2); + SetColumn(_divider, 1); + _divider.Width = gap; + _divider.Height = double.NaN; + _divider.Padding = new Thickness(0); + _dividerLine.Width = 1; + _dividerLine.Height = double.NaN; + } + else + { + RowDefinitions.Add( + new RowDefinition( + new GridLength(_ratio, GridUnitType.Star))); + RowDefinitions.Add( + new RowDefinition( + new GridLength(gap, GridUnitType.Pixel))); + RowDefinitions.Add( + new RowDefinition( + new GridLength(1 - _ratio, GridUnitType.Star))); + Place(0, 0, 0); + Place(1, 2, 0); + SetRow(_divider, 1); + _divider.Width = double.NaN; + _divider.Height = gap; + _divider.Padding = new Thickness(0); + _dividerLine.Width = double.NaN; + _dividerLine.Height = 1; + } + Children.Add(_divider); + } + + void Place(int index, int row, int column) + { + if (index >= _children.Count) return; + SetRow(_children[index], row); + SetColumn(_children[index], column); + Children.Add(_children[index]); + } + + double DividerThickness() + { + // Read the nearest ancestor split-view's thickness. + var context = _context; + if (context == null) return 9; + long? parent = context.State.Parent; + while (parent != null) + { + if (context.Backend.ExtensionStates.TryGetValue( + parent.Value, out LUIExtensionNodeState? state)) + { + if (state.Identifier == "split-view") + { + return LUISplitExtensions.DoubleProp( + state, "divider-thickness", 9); + } + parent = state.Parent; + } + else + { + return 9; + } + } + return 9; + } + + void OnDividerPressed( + object sender, PointerRoutedEventArgs args) + { + _dragging = true; + _dragStartRatio = _ratio; + Windows.Foundation.Point point = + args.GetCurrentPoint(this).Position; + _dragStartPoint = _horizontal ? point.X : point.Y; + _divider.CapturePointer(args.Pointer); + args.Handled = true; + } + + void OnDividerMoved( + object sender, PointerRoutedEventArgs args) + { + if (!_dragging) return; + double extent = _horizontal ? ActualWidth : ActualHeight; + double gap = DividerThickness(); + double available = Math.Max(extent - gap, 1); + Windows.Foundation.Point point = + args.GetCurrentPoint(this).Position; + double current = _horizontal ? point.X : point.Y; + _ratio = Math.Clamp( + _dragStartRatio + (current - _dragStartPoint) / available, + 0, 1); + Relayout(); + args.Handled = true; + } + + void OnDividerReleased( + object sender, PointerRoutedEventArgs args) + { + if (!_dragging) return; + _dragging = false; + EmitRatio(); + args.Handled = true; + } + + void OnDividerCaptureLost(object sender, PointerRoutedEventArgs args) + { + if (!_dragging) return; + _dragging = false; + EmitRatio(); + } + + void OnDividerKeyDown(object sender, KeyRoutedEventArgs args) + { + double delta = 0; + switch (args.Key) + { + case VirtualKey.Left when _horizontal: + case VirtualKey.Up when !_horizontal: + delta = -0.05; + break; + case VirtualKey.Right when _horizontal: + case VirtualKey.Down when !_horizontal: + delta = 0.05; + break; + } + if (delta != 0) + { + _ratio = Math.Clamp(_ratio + delta, 0, 1); + Relayout(); + EmitRatio(); + args.Handled = true; + } + } + + void EmitRatio() => + _context?.EmitEvent( + "ratio-changed", + new Dictionary + { + ["ratio"] = LUIWireValue.Of(_ratio), + }); + } + + // MARK: - split-pane + + public sealed class SplitPaneControl : Grid + { + readonly StackPanel _strip = new StackPanel + { + Orientation = Orientation.Horizontal, + }; + readonly Grid _contentHost = new Grid(); + readonly Border _dropOverlay = new Border(); + readonly Border _focusRing = new Border(); + LUIExtensionContext? _context; + string _dropZone = ""; + + public SplitPaneControl() + { + RowDefinitions.Add(new RowDefinition( + new GridLength(32, GridUnitType.Pixel))); + RowDefinitions.Add(new RowDefinition( + new GridLength(1, GridUnitType.Star))); + + var stripScroller = new ScrollViewer + { + HorizontalScrollMode = ScrollMode.Auto, + HorizontalScrollBarVisibility = + ScrollBarVisibility.Auto, + VerticalScrollMode = ScrollMode.Disabled, + Content = _strip, + }; + SetRow(stripScroller, 0); + + _dropOverlay.IsHitTestVisible = false; + _dropOverlay.Visibility = Visibility.Collapsed; + _dropOverlay.Background = + new SolidColorBrush( + Windows.UI.Color.FromArgb(40, 96, 205, 255)); + _dropOverlay.BorderBrush = + new SolidColorBrush( + Windows.UI.Color.FromArgb(160, 96, 205, 255)); + _dropOverlay.BorderThickness = new Thickness(2); + _contentHost.Children.Add(_dropOverlay); + _contentHost.AllowDrop = true; + _contentHost.DragOver += OnContentDragOver; + _contentHost.Drop += OnContentDrop; + _contentHost.DragLeave += (_, _) => ClearDropZone(); + SetRow(_contentHost, 1); + + _focusRing.IsHitTestVisible = false; + _focusRing.BorderThickness = new Thickness(2); + _focusRing.CornerRadius = new CornerRadius(4); + + Children.Add(stripScroller); + Children.Add(_contentHost); + Children.Add(_focusRing); + + Tapped += (_, _) => + { + Focus(FocusState.Programmatic); + _context?.EmitEvent("pane-focused"); + }; + KeyDown += OnKeyDown; + } + + public void Update(LUIExtensionContext context) + { + _context = context; + bool focused = LUISplitExtensions.BoolProp( + context.State, "focused"); + _focusRing.BorderBrush = focused + ? new SolidColorBrush( + Windows.UI.Color.FromArgb(140, 96, 205, 255)) + : new SolidColorBrush( + Windows.UI.Color.FromArgb(0, 0, 0, 0)); + + string selected = SelectedTab(context); + RebuildStrip(context, selected); + RebuildContent(context, selected); + } + + string PaneId() => + _context == null + ? "" + : LUISplitExtensions.StringProp( + _context.State, "pane-id") ?? ""; + + static string SelectedTab(LUIExtensionContext context) + { + string? selected = LUISplitExtensions.StringProp( + context.State, "selected"); + if (!string.IsNullOrEmpty(selected)) return selected; + if (context.State.Children.Count > 0) + { + return LUISplitExtensions.ChildString( + context, context.State.Children[0], "tab-id"); + } + return ""; + } + + void RebuildStrip(LUIExtensionContext context, string selected) + { + _strip.Children.Clear(); + for (int i = 0; i < context.State.Children.Count; i++) + { + _strip.Children.Add( + MakeChip(context, context.State.Children[i], selected)); + } + } + + FrameworkElement MakeChip( + LUIExtensionContext context, long tabNode, string selected) + { + string tabId = LUISplitExtensions.ChildString( + context, tabNode, "tab-id"); + string title = LUISplitExtensions.ChildString( + context, tabNode, "title"); + bool dirty = LUISplitExtensions.ChildBool( + context, tabNode, "dirty"); + bool closable = LUISplitExtensions.ChildBool( + context, tabNode, "closable", true); + bool active = tabId == selected; + + var row = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + }; + if (dirty) + { + row.Children.Add(new FontIcon + { + Glyph = "●", + FontSize = 6, + VerticalAlignment = VerticalAlignment.Center, + }); + } + row.Children.Add(new TextBlock + { + Text = title.Length == 0 ? tabId : title, + VerticalAlignment = VerticalAlignment.Center, + Opacity = active ? 1 : 0.65, + }); + if (closable) + { + var close = new Button + { + Content = new FontIcon { Glyph = "", FontSize = 8 }, + Padding = new Thickness(2), + MinWidth = 20, + MinHeight = 20, + Background = null, + }; + close.Click += (_, _) => + _context?.EmitEvent( + "tab-closed", + new Dictionary + { + ["tab"] = LUIWireValue.Of(tabId), + }); + row.Children.Add(close); + } + + var chip = new Border + { + Child = row, + Padding = new Thickness(10, 0, 6, 0), + Height = 32, + CanDrag = true, + AllowDrop = true, + Background = active + ? (Brush)Application.Current.Resources[ + "LayerFillColorDefaultBrush"] + : new SolidColorBrush( + Windows.UI.Color.FromArgb(0, 0, 0, 0)), + }; + chip.Tapped += (_, _) => + { + Focus(FocusState.Programmatic); + _context?.EmitEvent( + "tab-selected", + new Dictionary + { + ["tab"] = LUIWireValue.Of(tabId), + }); + _context?.EmitEvent("pane-focused"); + }; + chip.DragStarting += (_, args) => + args.Data.SetText( + LUISplitExtensions.PackDrag(PaneId(), tabId)); + chip.DragOver += (_, args) => + { + args.AcceptedOperation = DataPackageOperation.Move; + args.Handled = true; + }; + chip.Drop += (_, args) => + { + // Half of the chip chooses before/after insertion. + Windows.Foundation.Point point = + args.GetPosition(chip); + int index = context.State.Children.IndexOf(tabNode); + if (index < 0) index = context.State.Children.Count; + if (point.X >= chip.ActualWidth / 2) index++; + args.Handled = true; + CompleteDrop(args, index); + }; + return chip; + } + + async void CompleteDrop(DragEventArgs args, int index) + { + var deferral = args.GetDeferral(); + string payload = await args.DataView.GetTextAsync(); + deferral.Complete(); + if (!LUISplitExtensions.UnpackDrag( + payload, out string fromPane, out string tab)) + { + return; + } + _context?.EmitEvent( + "tab-moved", + new Dictionary + { + ["tab"] = LUIWireValue.Of(tab), + ["index"] = LUIWireValue.Of((long)index), + ["from-pane"] = LUIWireValue.Of(fromPane), + }); + } + + void RebuildContent(LUIExtensionContext context, string selected) + { + _contentHost.Children.Clear(); + var children = context.Children; + var ids = context.State.Children; + for (int i = 0; i < children.Count && i < ids.Count; i++) + { + FrameworkElement child = children[i]; + child.Visibility = + LUISplitExtensions.ChildString( + context, ids[i], "tab-id") == selected + ? Visibility.Visible + : Visibility.Collapsed; + _contentHost.Children.Add(child); + } + _contentHost.Children.Add(_dropOverlay); + } + + void OnContentDragOver(object sender, DragEventArgs args) + { + Windows.Foundation.Point point = + args.GetPosition(_contentHost); + double ex = Math.Clamp( + _contentHost.ActualWidth * 0.25, 48, 160); + double ey = Math.Clamp( + _contentHost.ActualHeight * 0.25, 48, 160); + string zone; + if (point.X < ex) zone = "left"; + else if (point.X > _contentHost.ActualWidth - ex) zone = "right"; + else if (point.Y < ey) zone = "top"; + else if (point.Y > _contentHost.ActualHeight - ey) + { + zone = "bottom"; + } + else zone = "center"; + if (zone != _dropZone) + { + _dropZone = zone; + ShowDropZone(zone); + } + args.AcceptedOperation = DataPackageOperation.Move; + args.Handled = true; + } + + void ShowDropZone(string zone) + { + _dropOverlay.Visibility = Visibility.Visible; + _dropOverlay.Margin = new Thickness(0); + _dropOverlay.HorizontalAlignment = zone switch + { + "left" => HorizontalAlignment.Left, + "right" => HorizontalAlignment.Right, + _ => HorizontalAlignment.Stretch, + }; + _dropOverlay.VerticalAlignment = zone switch + { + "top" => VerticalAlignment.Top, + "bottom" => VerticalAlignment.Bottom, + _ => VerticalAlignment.Stretch, + }; + _dropOverlay.Width = zone is "left" or "right" + ? Math.Clamp(_contentHost.ActualWidth * 0.35, 48, + double.MaxValue) + : double.NaN; + _dropOverlay.Height = zone is "top" or "bottom" + ? Math.Clamp(_contentHost.ActualHeight * 0.35, 48, + double.MaxValue) + : double.NaN; + } + + void ClearDropZone() + { + _dropZone = ""; + _dropOverlay.Visibility = Visibility.Collapsed; + } + + async void OnContentDrop(object sender, DragEventArgs args) + { + string zone = _dropZone.Length == 0 ? "center" : _dropZone; + ClearDropZone(); + var deferral = args.GetDeferral(); + string payload = await args.DataView.GetTextAsync(); + deferral.Complete(); + if (!LUISplitExtensions.UnpackDrag( + payload, out string fromPane, out string tab)) + { + return; + } + args.Handled = true; + if (zone == "center") + { + var children = _context?.State.Children; + _context?.EmitEvent( + "tab-moved", + new Dictionary + { + ["tab"] = LUIWireValue.Of(tab), + ["index"] = LUIWireValue.Of( + (long)(children?.Count ?? 0)), + ["from-pane"] = LUIWireValue.Of(fromPane), + }); + } + else + { + _context?.EmitEvent( + "split-drop", + new Dictionary + { + ["tab"] = LUIWireValue.Of(tab), + ["from-pane"] = LUIWireValue.Of(fromPane), + ["edge"] = LUIWireValue.Of(zone), + }); + } + } + + void OnKeyDown(object sender, KeyRoutedEventArgs args) + { + VirtualKeyModifiers modifiers = + InputKeyboardSource.GetKeyStateForCurrentThread( + VirtualKey.Control) + .HasFlag(CoreVirtualKeyStates.Down) + ? VirtualKeyModifiers.Control + : VirtualKeyModifiers.None; + if (InputKeyboardSource.GetKeyStateForCurrentThread( + VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down)) + { + modifiers |= VirtualKeyModifiers.Menu; + } + if (InputKeyboardSource.GetKeyStateForCurrentThread( + VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down)) + { + modifiers |= VirtualKeyModifiers.Shift; + } + + bool nav = modifiers.HasFlag(VirtualKeyModifiers.Control) && + modifiers.HasFlag(VirtualKeyModifiers.Menu); + if (nav) + { + string? direction = args.Key switch + { + VirtualKey.Left => "left", + VirtualKey.Right => "right", + VirtualKey.Up => "up", + VirtualKey.Down => "down", + _ => null, + }; + if (direction != null) + { + EmitNavigate(direction); + args.Handled = true; + return; + } + if (args.Key == VirtualKey.D) + { + EmitSplit( + modifiers.HasFlag(VirtualKeyModifiers.Shift) + ? "vertical" + : "horizontal"); + args.Handled = true; + return; + } + } + if (modifiers.HasFlag(VirtualKeyModifiers.Control) && + args.Key == (VirtualKey)220) + { + EmitSplit( + modifiers.HasFlag(VirtualKeyModifiers.Shift) + ? "vertical" + : "horizontal"); + args.Handled = true; + return; + } + if (modifiers.HasFlag(VirtualKeyModifiers.Control) && + args.Key == VirtualKey.W) + { + if (modifiers.HasFlag(VirtualKeyModifiers.Shift)) + { + _context?.EmitEvent("pane-closed"); + } + else if (_context != null) + { + string selected = SelectedTab(_context); + if (selected.Length > 0) + { + _context.EmitEvent( + "tab-closed", + new Dictionary + { + ["tab"] = LUIWireValue.Of(selected), + }); + } + } + args.Handled = true; + } + } + + void EmitNavigate(string direction) => + _context?.EmitEvent( + "navigate", + new Dictionary + { + ["direction"] = LUIWireValue.Of(direction), + }); + + void EmitSplit(string orientation) => + _context?.EmitEvent( + "split-requested", + new Dictionary + { + ["orientation"] = LUIWireValue.Of(orientation), + }); + } + + // MARK: - split-tab + + /// Tab nodes are data carriers; a bare split-tab stacks its children. + public sealed class SplitTabControl : Grid + { + public void Update(LUIExtensionContext context) + { + Children.Clear(); + foreach (FrameworkElement child in context.Children) + { + Children.Add(child); + } + } + } +} diff --git a/src/lui_split.ml b/src/lui_split.ml new file mode 100644 index 00000000..fffcc359 --- /dev/null +++ b/src/lui_split.ml @@ -0,0 +1,982 @@ +(* Bonsplit-style tabbed split panes as LUI extension components. + + The application owns the split tree as ordinary data; each platform + backend renders it natively and owns gesture-time visuals (tab drag + tracking, drop indicators, divider feedback, animations) while reporting + semantic events back so the app can patch the tree. + + Tree shape: + split_view + split_branch ~orientation ~ratio [ left; right ] + split_pane ~pane_id [ split_tab ~tab_id ~title [content]; ... ] + A bare split_pane may be mounted directly under split_view for a + single-pane surface. + + Identity is app-assigned: [~pane_id] and [~tab_id] are stable strings the + app uses to track selection and resolve events. *) + +open Lui_protocol + +let profiles = + [ + { profile_os = MacOS; profile_host = SwiftUIHost }; + { profile_os = IOS; profile_host = SwiftUIHost }; + { profile_os = LinuxOS; profile_host = QMLHost }; + { profile_os = MacOS; profile_host = QMLHost }; + { profile_os = WindowsOS; profile_host = QMLHost }; + { profile_os = AndroidOS; profile_host = FlutterHost }; + { profile_os = IOS; profile_host = FlutterHost }; + { profile_os = LinuxOS; profile_host = FlutterHost }; + { profile_os = MacOS; profile_host = FlutterHost }; + { profile_os = WindowsOS; profile_host = FlutterHost }; + { profile_os = WindowsOS; profile_host = WinUIHost }; + { profile_os = WebOS; profile_host = WebHost }; + ] + +let accessibility_identifier_property = + Lui_extension.property "accessibility-identifier" Lui_extension.StringScalar + false None + +let split_view_schema = + Lui_extension.component "split-view" profiles false + [ "split-branch"; "split-pane" ] + [ + Lui_extension.property "divider-thickness" Lui_extension.FloatScalar + false None; + Lui_extension.property "animation" Lui_extension.BoolScalar false None; + accessibility_identifier_property; + ] + [] + +let split_branch_schema = + Lui_extension.component "split-branch" profiles false + [ "split-branch"; "split-pane" ] + [ + Lui_extension.property "orientation" Lui_extension.StringScalar true + None; + Lui_extension.property "ratio" Lui_extension.FloatScalar true None; + ] + [ + Lui_extension.event "ratio-changed" + [ Lui_extension.event_field "ratio" Lui_extension.FloatScalar true ]; + ] + +let split_pane_schema = + Lui_extension.component "split-pane" profiles false [ "split-tab" ] + [ + Lui_extension.property "pane-id" Lui_extension.StringScalar true None; + Lui_extension.property "selected" Lui_extension.StringScalar false None; + Lui_extension.property "focused" Lui_extension.BoolScalar false None; + accessibility_identifier_property; + ] + [ + Lui_extension.event "tab-selected" + [ Lui_extension.event_field "tab" Lui_extension.StringScalar true ]; + Lui_extension.event "tab-closed" + [ Lui_extension.event_field "tab" Lui_extension.StringScalar true ]; + Lui_extension.event "tab-moved" + [ + Lui_extension.event_field "tab" Lui_extension.StringScalar true; + Lui_extension.event_field "index" Lui_extension.IntScalar true; + Lui_extension.event_field "from-pane" Lui_extension.StringScalar true; + ]; + Lui_extension.event "pane-focused" []; + Lui_extension.event "navigate" + [ + Lui_extension.event_field "direction" Lui_extension.StringScalar true; + ]; + Lui_extension.event "split-requested" + [ + Lui_extension.event_field "orientation" Lui_extension.StringScalar + true; + ]; + Lui_extension.event "split-drop" + [ + Lui_extension.event_field "tab" Lui_extension.StringScalar true; + Lui_extension.event_field "from-pane" Lui_extension.StringScalar true; + Lui_extension.event_field "edge" Lui_extension.StringScalar true; + ]; + Lui_extension.event "pane-closed" []; + ] + +let split_tab_schema = + Lui_extension.component "split-tab" profiles true [] + [ + Lui_extension.property "tab-id" Lui_extension.StringScalar true None; + Lui_extension.property "title" Lui_extension.StringScalar true None; + Lui_extension.property "icon" Lui_extension.StringScalar false None; + Lui_extension.property "dirty" Lui_extension.BoolScalar false None; + Lui_extension.property "closable" Lui_extension.BoolScalar false None; + accessibility_identifier_property; + ] + [] + +let register_into registry = + Lui_extension.register_component registry split_view_schema; + Lui_extension.register_component registry split_branch_schema; + Lui_extension.register_component registry split_pane_schema; + Lui_extension.register_component registry split_tab_schema + +let registry () = + let registry = Lui_extension.registry () in + register_into registry; + Lui_extension.freeze registry; + registry + +type split_branch_ratio_changed = { + event_node : int; + ratio : float; +} + +type split_pane_tab_selected = { + event_node : int; + tab : string; +} + +type split_pane_tab_closed = { + event_node : int; + tab : string; +} + +type split_pane_tab_moved = { + event_node : int; + tab : string; + index : int; + from_pane : string; +} + +type split_pane_focused = { event_node : int } + +type split_pane_navigate = { + event_node : int; + direction : string; +} + +type split_pane_split_requested = { + event_node : int; + orientation : string; +} + +type split_pane_split_drop = { + event_node : int; + tab : string; + from_pane : string; + edge : string; +} + +type split_pane_pane_closed = { event_node : int } + +let decode_split_branch_ratio_changed = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "ratio-changed" + && String.equal identifier "split-branch" -> ( + match String_map.find_opt "ratio" values with + | Some (FloatValue ratio) -> Some { event_node = node; ratio } + | _ -> None) + | _ -> None + +let decode_split_pane_tab_selected = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "tab-selected" + && String.equal identifier "split-pane" -> ( + match String_map.find_opt "tab" values with + | Some (StringValue tab) -> + Some ({ event_node = node; tab } : split_pane_tab_selected) + | _ -> None) + | _ -> None + +let decode_split_pane_tab_closed = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "tab-closed" + && String.equal identifier "split-pane" -> ( + match String_map.find_opt "tab" values with + | Some (StringValue tab) -> + Some ({ event_node = node; tab } : split_pane_tab_closed) + | _ -> None) + | _ -> None + +let decode_split_pane_tab_moved = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "tab-moved" + && String.equal identifier "split-pane" -> ( + match + ( String_map.find_opt "tab" values, + String_map.find_opt "index" values, + String_map.find_opt "from-pane" values ) + with + | Some (StringValue tab), Some (IntValue index), Some (StringValue from_pane) + -> + Some { event_node = node; tab; index; from_pane } + | _ -> None) + | _ -> None + +let decode_split_pane_pane_focused = function + | ExtensionEvent (node, identifier, event_name, _values) + when String.equal event_name "pane-focused" + && String.equal identifier "split-pane" -> + Some ({ event_node = node } : split_pane_focused) + | _ -> None + +let decode_split_pane_navigate = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "navigate" + && String.equal identifier "split-pane" -> ( + match String_map.find_opt "direction" values with + | Some (StringValue direction) -> + Some { event_node = node; direction } + | _ -> None) + | _ -> None + +let decode_split_pane_split_requested = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "split-requested" + && String.equal identifier "split-pane" -> ( + match String_map.find_opt "orientation" values with + | Some (StringValue orientation) -> + Some { event_node = node; orientation } + | _ -> None) + | _ -> None + +let decode_split_pane_split_drop = function + | ExtensionEvent (node, identifier, event_name, values) + when String.equal event_name "split-drop" + && String.equal identifier "split-pane" -> ( + match + ( String_map.find_opt "tab" values, + String_map.find_opt "from-pane" values, + String_map.find_opt "edge" values ) + with + | Some (StringValue tab), Some (StringValue from_pane), + Some (StringValue edge) -> + Some { event_node = node; tab; from_pane; edge } + | _ -> None) + | _ -> None + +let decode_split_pane_pane_closed = function + | ExtensionEvent (node, identifier, event_name, _values) + when String.equal event_name "pane-closed" + && String.equal identifier "split-pane" -> + Some ({ event_node = node } : split_pane_pane_closed) + | _ -> None + +let on_event context node decode handler = + Lui_ui.on_event context node (fun raw -> + match decode raw with + | Some event -> handler event + | None -> ()) + +let split_view ?key ?divider_thickness ?divider_thickness_signal ?animation + ?animation_signal ?accessibility_identifier + ?accessibility_identifier_signal (children : Lui_elements.t list) : + Lui_elements.t = + fun context parent -> + let node = Lui_ui.extension context "split-view" in + Option.iter (Lui_ui.key context node) key; + Option.iter + (fun value -> + Lui_ui.extension_property context node "divider-thickness" + (FloatValue value)) + divider_thickness; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "divider-thickness" + (Signal.map (fun value -> FloatValue value) signal)) + divider_thickness_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "animation" (BoolValue value)) + animation; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "animation" + (Signal.map (fun value -> BoolValue value) signal)) + animation_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "accessibility-identifier" + (StringValue value)) + accessibility_identifier; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "accessibility-identifier" + (Signal.map (fun value -> StringValue value) signal)) + accessibility_identifier_signal; + Lui_elements.attach context parent node; + Lui_elements.mount_children context node children; + node + +let split_branch ?key ~orientation ~ratio ?ratio_signal ?on_ratio_changed + (children : Lui_elements.t list) : Lui_elements.t = + fun context parent -> + let node = Lui_ui.extension context "split-branch" in + Option.iter (Lui_ui.key context node) key; + Lui_ui.extension_property context node "orientation" + (StringValue + (match orientation with + | `horizontal -> "horizontal" + | `vertical -> "vertical")); + Lui_ui.extension_property context node "ratio" (FloatValue ratio); + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "ratio" + (Signal.map (fun value -> FloatValue value) signal)) + ratio_signal; + Option.iter + (on_event context node decode_split_branch_ratio_changed) + on_ratio_changed; + Lui_elements.attach context parent node; + Lui_elements.mount_children context node children; + node + +let split_pane ?key ~pane_id ?selected ?selected_signal ?focused + ?focused_signal ?accessibility_identifier ?on_tab_selected ?on_tab_closed + ?on_tab_moved ?on_pane_focused ?on_navigate ?on_split_requested + ?on_split_drop ?on_pane_closed (children : Lui_elements.t list) : + Lui_elements.t = + fun context parent -> + let node = Lui_ui.extension context "split-pane" in + Option.iter (Lui_ui.key context node) key; + Lui_ui.extension_property context node "pane-id" (StringValue pane_id); + Option.iter + (fun value -> + Lui_ui.extension_property context node "selected" (StringValue value)) + selected; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "selected" + (Signal.map (fun value -> StringValue value) signal)) + selected_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "focused" (BoolValue value)) + focused; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "focused" + (Signal.map (fun value -> BoolValue value) signal)) + focused_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "accessibility-identifier" + (StringValue value)) + accessibility_identifier; + Option.iter + (on_event context node decode_split_pane_tab_selected) + on_tab_selected; + Option.iter + (on_event context node decode_split_pane_tab_closed) + on_tab_closed; + Option.iter + (on_event context node decode_split_pane_tab_moved) + on_tab_moved; + Option.iter + (on_event context node decode_split_pane_pane_focused) + on_pane_focused; + Option.iter (on_event context node decode_split_pane_navigate) on_navigate; + Option.iter + (on_event context node decode_split_pane_split_requested) + on_split_requested; + Option.iter + (on_event context node decode_split_pane_split_drop) + on_split_drop; + Option.iter + (on_event context node decode_split_pane_pane_closed) + on_pane_closed; + Lui_elements.attach context parent node; + Lui_elements.mount_children context node children; + node + +let split_tab ?key ~tab_id ~title ?title_signal ?icon ?icon_signal ?dirty + ?dirty_signal ?closable ?accessibility_identifier + (children : Lui_elements.t list) : Lui_elements.t = + fun context parent -> + let node = Lui_ui.extension context "split-tab" in + Option.iter (Lui_ui.key context node) key; + Lui_ui.extension_property context node "tab-id" (StringValue tab_id); + Lui_ui.extension_property context node "title" (StringValue title); + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "title" + (Signal.map (fun value -> StringValue value) signal)) + title_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "icon" (StringValue value)) + icon; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "icon" + (Signal.map (fun value -> StringValue value) signal)) + icon_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "dirty" (BoolValue value)) + dirty; + Option.iter + (fun signal -> + Lui_ui.extension_property_signal context node "dirty" + (Signal.map (fun value -> BoolValue value) signal)) + dirty_signal; + Option.iter + (fun value -> + Lui_ui.extension_property context node "closable" (BoolValue value)) + closable; + Option.iter + (fun value -> + Lui_ui.extension_property context node "accessibility-identifier" + (StringValue value)) + accessibility_identifier; + Lui_elements.attach context parent node; + Lui_elements.mount_children context node children; + node + +(* The shared controller: owns the split tree as data, applies pointer/keyboard + events to it, and renders it back into extension nodes. Platform backends + emit events; [Model] is the policy — identical behavior on every host. *) +module Model = struct + type direction = + [ `left + | `right + | `up + | `down + ] + + type orientation = + [ `horizontal + | `vertical + ] + + (* Drop edges are physical sides of a pane; navigation directions differ. *) + type edge = + [ `left + | `right + | `top + | `bottom + ] + + type tab = { + tab_id : string; + tab_title : string; + tab_icon : string option; + tab_dirty : bool; + tab_closable : bool; + } + + let tab ?icon ?(dirty = false) ?(closable = true) ~tab_id ~title () = + { + tab_id; + tab_title = title; + tab_icon = icon; + tab_dirty = dirty; + tab_closable = closable; + } + + type pane = { + pane_id : string; + pane_tabs : tab list; + pane_selected : string option; + } + + let pane ?selected ~pane_id tabs = { pane_id; pane_tabs = tabs; pane_selected = selected } + + type node = + | Leaf of pane + | Split of { + split_id : string; + split_orientation : orientation; + split_ratio : float; + split_first : node; + split_second : node; + } + + type t = { + root : node; + focused : string option; + next_id : int; + } + + let create ?focused root = + { root; focused; next_id = 0 } + + let root state = state.root + + let focused state = state.focused + + let fresh_id state prefix = + let id = Printf.sprintf "%s-%d" prefix state.next_id in + (id, { state with next_id = state.next_id + 1 }) + + type action = + | Select_tab of string * string + | Close_tab of string * string + | Move_tab of { + move_tab : string; + move_from : string; + move_to : string; + move_index : int; + } + | Split_drop of { + drop_tab : string; + drop_from : string; + drop_target : string; + drop_edge : edge; + } + | Close_pane of string + | Focus_pane of string + | Navigate of string * direction + | Request_split of string * orientation + | Set_ratio of string * float + + let pane_selected_id p = + match p.pane_selected with + | Some id when List.exists (fun t -> String.equal t.tab_id id) p.pane_tabs -> + id + | _ -> ( + match p.pane_tabs with + | first :: _ -> first.tab_id + | [] -> "") + + let rec map_panes f = function + | Leaf p -> f p + | Split s -> + Split + { + s with + split_first = map_panes f s.split_first; + split_second = map_panes f s.split_second; + } + + let find_pane pane_id node = + let found = ref None in + ignore + (map_panes + (fun p -> + if String.equal p.pane_id pane_id then found := Some p; + Leaf p) + node); + !found + + (* Remove a pane and collapse a split whose child vanished. [`Removed] at + the root means the last pane was closed; callers decide whether to keep + an empty root pane or leave the state untouched. *) + let rec remove_pane pane_id = function + | Leaf p when String.equal p.pane_id pane_id -> `Removed p + | Leaf p -> `Keep (Leaf p) + | Split s -> ( + match + (remove_pane pane_id s.split_first, remove_pane pane_id s.split_second) + with + | `Removed _, `Keep keep -> `Keep keep + | `Keep keep, `Removed _ -> `Keep keep + | `Keep first, `Keep second -> + `Keep (Split { s with split_first = first; split_second = second }) + | `Removed a, `Removed _ -> `Removed a) + + let replace_pane pane_id f node = + map_panes + (fun p -> if String.equal p.pane_id pane_id then f p else Leaf p) + node + + let detach_tab pane_id tab_id node = + let taken = ref None in + let node' = + map_panes + (fun p -> + if String.equal p.pane_id pane_id then + let remaining = + List.filter + (fun t -> + if String.equal t.tab_id tab_id then begin + taken := Some t; + false + end + else true) + p.pane_tabs + in + Leaf + { + p with + pane_tabs = remaining; + pane_selected = + (match p.pane_selected with + | Some s when String.equal s tab_id -> None + | other -> other); + } + else Leaf p) + node + in + (!taken, node') + + (* Drop a pane left empty by a move/detach; the root's last pane stays. *) + let prune pane_id node = + match find_pane pane_id node with + | Some { pane_tabs = []; _ } -> ( + match remove_pane pane_id node with + | `Removed last -> Leaf { last with pane_tabs = [] } + | `Keep pruned -> pruned) + | _ -> node + + let clamp_ratio v = Float.max 0.0 (Float.min 1.0 v) + + let insert_tab pane_id tab index node = + replace_pane pane_id + (fun p -> + let index = + if index < 0 then 0 + else if index > List.length p.pane_tabs then List.length p.pane_tabs + else index + in + let rec split_at n items = + if n <= 0 then ([], items) + else + match items with + | x :: rest -> + let prefix, suffix = split_at (n - 1) rest in + (x :: prefix, suffix) + | [] -> ([], []) + in + let prefix, suffix = split_at index p.pane_tabs in + Leaf + { + p with + pane_tabs = prefix @ tab :: suffix; + pane_selected = Some tab.tab_id; + }) + node + + let edge_orientation = function + | `left | `right -> `horizontal + | `top | `bottom -> `vertical + + let edge_is_first = function + | `left | `top -> true + | `right | `bottom -> false + + let direction_of_string = function + | "left" -> Some `left + | "right" -> Some `right + | "up" -> Some `up + | "down" -> Some `down + | _ -> None + + let edge_of_string = function + | "left" -> Some `left + | "right" -> Some `right + | "top" -> Some `top + | "bottom" -> Some `bottom + | _ -> None + + let orientation_of_string = function + | "horizontal" -> `horizontal + | _ -> `vertical + + (* Pane ancestry, innermost split first. *) + let ancestry pane_id root = + let rec go acc = function + | Leaf p -> if String.equal p.pane_id pane_id then Some acc else None + | Split s -> ( + match + go + ((`first, s.split_orientation, s.split_first, s.split_second) + :: acc) + s.split_first + with + | Some _ as hit -> hit + | None -> + go + ((`second, s.split_orientation, s.split_first, s.split_second) + :: acc) + s.split_second) + in + go [] root + + let nearest_leaf side node = + let rec descend = function + | Leaf p -> p.pane_id + | Split s -> ( + match side with + | `first -> descend s.split_first + | `second -> descend s.split_second) + in + descend node + + let navigate pane_id direction state = + let axis : orientation = + match direction with + | `left | `right -> `horizontal + | `up | `down -> `vertical + in + match ancestry pane_id state.root with + | None | Some [] -> state + | Some path -> + let rec climb = function + | [] -> None + | (side, orientation, first, second) :: rest -> + if orientation = axis then + match direction, side with + | `left, `second | `up, `second -> + Some (nearest_leaf `second first) + | `right, `first | `down, `first -> + Some (nearest_leaf `first second) + | _ -> climb rest + else climb rest + in + (match climb path with + | Some target -> { state with focused = Some target } + | None -> state) + + let update state action = + match action with + | Select_tab (pane_id, tab_id) -> + { + state with + root = + replace_pane pane_id + (fun p -> + if List.exists (fun t -> String.equal t.tab_id tab_id) p.pane_tabs + then Leaf { p with pane_selected = Some tab_id } + else Leaf p) + state.root; + focused = Some pane_id; + } + | Focus_pane pane_id -> { state with focused = Some pane_id } + | Set_ratio (split_id, ratio) -> + let rec apply = function + | Leaf p -> Leaf p + | Split s -> + if String.equal s.split_id split_id then + Split + { s with split_ratio = clamp_ratio ratio } + else + Split + { + s with + split_first = apply s.split_first; + split_second = apply s.split_second; + } + in + { state with root = apply state.root } + | Close_tab (pane_id, tab_id) -> + let _, root = detach_tab pane_id tab_id state.root in + let root = prune pane_id root in + let state = { state with root } in + (match state.focused with + | Some f when Option.is_none (find_pane f state.root) -> + { state with focused = None } + | _ -> state) + | Close_pane pane_id -> ( + match remove_pane pane_id state.root with + | `Removed last -> + (* The last pane cannot close — keep it, emptied of tabs. *) + { + state with + root = Leaf { last with pane_tabs = [] }; + focused = Some pane_id; + } + | `Keep root -> + let state = { state with root } in + (match state.focused with + | Some f when Option.is_none (find_pane f state.root) -> + { state with focused = None } + | _ -> state)) + | Move_tab { move_tab; move_from; move_to; move_index } -> + if String.equal move_from move_to then + match find_pane move_from state.root with + | None -> state + | Some source -> + let from_index = + List.find_index + (fun t -> String.equal t.tab_id move_tab) + source.pane_tabs + in + (match from_index with + | None -> state + | Some from_index -> + let index = + if move_index > from_index then move_index - 1 else move_index + in + let tab, root = detach_tab move_from move_tab state.root in + (match tab with + | None -> state + | Some tab -> + { state with root = insert_tab move_to tab index root })) + else ( + let tab, root = detach_tab move_from move_tab state.root in + match tab with + | None -> state + | Some tab -> + let root = prune move_from root in + { state with root = insert_tab move_to tab move_index root }) + | Split_drop { drop_tab; drop_from; drop_target; drop_edge } -> ( + match find_pane drop_from state.root with + | None -> state + | Some source + when String.equal drop_from drop_target + && List.length source.pane_tabs <= 1 -> + (* Splitting a pane by dragging its only tab onto itself is a no-op. *) + state + | Some _ -> ( + let tab, root = detach_tab drop_from drop_tab state.root in + match tab with + | None -> state + | Some tab -> + let root = prune drop_from root in + let new_pane_id, state = fresh_id state "pane" in + let new_pane = + Leaf + { + pane_id = new_pane_id; + pane_tabs = [ tab ]; + pane_selected = Some tab.tab_id; + } + in + let split_id, state = fresh_id state "split" in + let orientation = edge_orientation drop_edge in + let first_is_new = edge_is_first drop_edge in + (* The dropped edge gets the smaller share, like Bonsplit's + edgeRatio. *) + let ratio = if first_is_new then 0.25 else 0.75 in + let root = + replace_pane drop_target + (fun p -> + Split + { + split_id; + split_orientation = orientation; + split_ratio = ratio; + split_first = + (if first_is_new then new_pane else Leaf p); + split_second = + (if first_is_new then Leaf p else new_pane); + }) + root + in + { state with root; focused = Some new_pane_id })) + | Request_split (pane_id, orientation) -> ( + let split_id, state = fresh_id state "split" in + let new_pane_id, state = fresh_id state "pane" in + match find_pane pane_id state.root with + | None -> state + | Some _ -> + let root = + replace_pane pane_id + (fun p -> + Split + { + split_id; + split_orientation = orientation; + split_ratio = 0.5; + split_first = Leaf p; + split_second = + Leaf + { + pane_id = new_pane_id; + pane_tabs = []; + pane_selected = None; + }; + }) + state.root + in + { state with root; focused = Some new_pane_id }) + | Navigate (pane_id, direction) -> navigate pane_id direction state + + (* Decode a backend event into an action, tagging it with the pane/branch + identity the caller knows at mount time. *) + let pane_action pane_id raw = + match raw with + | ExtensionEvent (_node, identifier, name, _values) + when String.equal identifier "split-pane" -> ( + match name with + | "tab-selected" -> + Option.map + (fun (e : split_pane_tab_selected) -> Select_tab (pane_id, e.tab)) + (decode_split_pane_tab_selected raw) + | "tab-closed" -> + Option.map + (fun (e : split_pane_tab_closed) -> Close_tab (pane_id, e.tab)) + (decode_split_pane_tab_closed raw) + | "tab-moved" -> + Option.map + (fun (e : split_pane_tab_moved) -> + Move_tab + { + move_tab = e.tab; + move_from = e.from_pane; + move_to = pane_id; + move_index = e.index; + }) + (decode_split_pane_tab_moved raw) + | "pane-focused" -> Some (Focus_pane pane_id) + | "navigate" -> + Option.bind + (decode_split_pane_navigate raw) + (fun (e : split_pane_navigate) -> + Option.map + (fun direction -> Navigate (pane_id, direction)) + (direction_of_string e.direction)) + | "split-requested" -> + Option.map + (fun (e : split_pane_split_requested) -> + Request_split (pane_id, orientation_of_string e.orientation)) + (decode_split_pane_split_requested raw) + | "split-drop" -> + Option.bind + (decode_split_pane_split_drop raw) + (fun (e : split_pane_split_drop) -> + Option.map + (fun edge -> + Split_drop + { + drop_tab = e.tab; + drop_from = e.from_pane; + drop_target = pane_id; + drop_edge = edge; + }) + (edge_of_string e.edge)) + | "pane-closed" -> Some (Close_pane pane_id) + | _ -> None) + | _ -> None + + (* Render the tree. [build] supplies each tab's content children; [dispatch] + receives decoded actions — feed them to your model's update, then back + through [update]. *) + let render ?key ?divider_thickness ?animation ~build ~dispatch state : + Lui_elements.t = + let with_handler element handle context parent = + let node = element context parent in + Lui_ui.on_event context node handle; + node + in + let render_node node = + let rec go = function + | Leaf p -> + with_handler + (split_pane ~pane_id:p.pane_id ~selected:(pane_selected_id p) + ~focused: + (match state.focused with + | Some f -> String.equal f p.pane_id + | None -> false) + (List.map + (fun t -> + split_tab ~tab_id:t.tab_id ~title:t.tab_title + ?icon:t.tab_icon ~dirty:t.tab_dirty + ~closable:t.tab_closable (build t)) + p.pane_tabs)) + (fun raw -> + match pane_action p.pane_id raw with + | Some action -> dispatch action + | None -> ()) + | Split s -> + with_handler + (split_branch ~orientation:s.split_orientation + ~ratio:s.split_ratio + [ go s.split_first; go s.split_second ]) + (fun raw -> + match decode_split_branch_ratio_changed raw with + | Some event -> dispatch (Set_ratio (s.split_id, event.ratio)) + | None -> ()) + in + go node + in + split_view ?key ?divider_thickness ?animation [ render_node state.root ] +end diff --git a/src/lui_split.mli b/src/lui_split.mli new file mode 100644 index 00000000..7744f96e --- /dev/null +++ b/src/lui_split.mli @@ -0,0 +1,280 @@ +(** Bonsplit-style tabbed split panes as LUI extension components. + + The application owns the split tree as ordinary data; each platform + backend renders it natively (tab strip, draggable dividers, drop + indicators, animations, keyboard navigation) and reports semantic events + back so the app can patch the tree. + + Tree shape: + [split_view + [ split_branch ~orientation ~ratio [ left; right ] + | split_pane ~pane_id [ split_tab ~tab_id ~title [content]; ... ] ]] + A bare [split_pane] may be mounted directly under [split_view] for a + single-pane surface. + + Identity is app-assigned: [~pane_id] and [~tab_id] are stable strings the + app uses to track selection and resolve events. *) + +val split_view_schema : Lui_extension.extension_component_schema +val split_branch_schema : Lui_extension.extension_component_schema +val split_pane_schema : Lui_extension.extension_component_schema +val split_tab_schema : Lui_extension.extension_component_schema + +val register_into : Lui_extension.extension_registry -> unit +(** Register all four split schemas into an existing (unfrozen) extension + registry, e.g. one shared with application-specific extensions. *) + +val registry : unit -> Lui_extension.extension_registry +(** A fresh frozen registry containing only the split schemas. *) + +type split_branch_ratio_changed = { + event_node : int; + ratio : float; +} +(** Committed divider position; emitted once per drag (on release) and once + per keyboard nudge. *) + +type split_pane_tab_selected = { + event_node : int; + tab : string; (** the tab's [tab-id] *) +} + +type split_pane_tab_closed = { + event_node : int; + tab : string; +} +(** Close-button click, middle-click, or the platform's close-tab shortcut on + this tab. *) + +type split_pane_tab_moved = { + event_node : int; (** the target (dropped-onto) pane *) + tab : string; + index : int; (** insertion index within the target pane's tab list *) + from_pane : string; (** source [pane-id]; equals the target's when + reordering within one pane *) +} + +type split_pane_focused = { event_node : int } + +type split_pane_navigate = { + event_node : int; + direction : string; (** "left" | "right" | "up" | "down" *) +} +(** Directional focus request (option/alt-modified arrows). The app maps the + direction onto a neighbouring pane and sets its [~focused] prop. *) + +type split_pane_split_requested = { + event_node : int; + orientation : string; (** "horizontal" | "vertical" *) +} + +type split_pane_split_drop = { + event_node : int; (** the pane whose edge received the drop *) + tab : string; + from_pane : string; + edge : string; (** "left" | "right" | "top" | "bottom" *) +} +(** A tab dropped on a pane edge asks the app to split this pane in that + direction and move the tab into the new pane. *) + +type split_pane_pane_closed = { event_node : int } + +val decode_split_branch_ratio_changed : + Lui_protocol.event -> split_branch_ratio_changed option +val decode_split_pane_tab_selected : + Lui_protocol.event -> split_pane_tab_selected option +val decode_split_pane_tab_closed : + Lui_protocol.event -> split_pane_tab_closed option +val decode_split_pane_tab_moved : + Lui_protocol.event -> split_pane_tab_moved option +val decode_split_pane_pane_focused : + Lui_protocol.event -> split_pane_focused option +val decode_split_pane_navigate : + Lui_protocol.event -> split_pane_navigate option +val decode_split_pane_split_requested : + Lui_protocol.event -> split_pane_split_requested option +val decode_split_pane_split_drop : + Lui_protocol.event -> split_pane_split_drop option +val decode_split_pane_pane_closed : + Lui_protocol.event -> split_pane_pane_closed option + +val split_view : + ?key:string -> + ?divider_thickness:float -> + ?divider_thickness_signal:float Signal.signal -> + ?animation:bool -> + ?animation_signal:bool Signal.signal -> + ?accessibility_identifier:string -> + ?accessibility_identifier_signal:string Signal.signal -> + Lui_elements.t list -> + Lui_elements.t +(** [split_view [root]] mounts the split surface. [~divider_thickness] is the + divider hit target in points (platforms render a 1-2pt visible line + centred inside it); [~animation:false] disables spring/transition + animation. *) + +val split_branch : + ?key:string -> + orientation:[ `horizontal | `vertical ] -> + ratio:float -> + ?ratio_signal:float Signal.signal -> + ?on_ratio_changed:(split_branch_ratio_changed -> unit) -> + Lui_elements.t list -> + Lui_elements.t +(** Internal node with exactly two [split_branch] or [split_pane] children. + [~orientation:`horizontal] places children side by side (a vertical + divider); [`vertical] stacks them. [~ratio] is the first child's share of + the axis (0.0-1.0). *) + +val split_pane : + ?key:string -> + pane_id:string -> + ?selected:string -> + ?selected_signal:string Signal.signal -> + ?focused:bool -> + ?focused_signal:bool Signal.signal -> + ?accessibility_identifier:string -> + ?on_tab_selected:(split_pane_tab_selected -> unit) -> + ?on_tab_closed:(split_pane_tab_closed -> unit) -> + ?on_tab_moved:(split_pane_tab_moved -> unit) -> + ?on_pane_focused:(split_pane_focused -> unit) -> + ?on_navigate:(split_pane_navigate -> unit) -> + ?on_split_requested:(split_pane_split_requested -> unit) -> + ?on_split_drop:(split_pane_split_drop -> unit) -> + ?on_pane_closed:(split_pane_pane_closed -> unit) -> + Lui_elements.t list -> + Lui_elements.t +(** Leaf pane: a tab strip over the selected tab's content. [~selected] is + the [tab-id] to show (defaults to the first tab). *) + +val split_tab : + ?key:string -> + tab_id:string -> + title:string -> + ?title_signal:string Signal.signal -> + ?icon:string -> + ?icon_signal:string Signal.signal -> + ?dirty:bool -> + ?dirty_signal:bool Signal.signal -> + ?closable:bool -> + ?accessibility_identifier:string -> + Lui_elements.t list -> + Lui_elements.t +(** One tab: [~title]/[~icon]/[~dirty] dress the strip entry; the standard + children are the tab's content, rendered only while selected. *) + +(** The shared controller. Owns the split tree as plain data, applies + backend events to it, and renders it back into extension nodes, so + selection/move/split/navigate policy is identical on every platform. + Apps keep a [Model.t] in their model, dispatch [Model.action] on events + (usually via the [~dispatch] wired by {!Model.render}), and re-render. + + {[ + let update model = function + | Split_action action -> + { model with split = Lui_split.Model.update model.split action } + | ... + and view model = + Lui_split.Model.render model.split ~dispatch:(fun a -> ...) + ~build:(fun tab -> [ page_content tab ]) + ]} *) +module Model : sig + type direction = + [ `left + | `right + | `up + | `down + ] + + type orientation = + [ `horizontal + | `vertical + ] + + type edge = + [ `left + | `right + | `top + | `bottom + ] + + type tab = { + tab_id : string; + tab_title : string; + tab_icon : string option; + tab_dirty : bool; + tab_closable : bool; + } + + val tab : + ?icon:string -> + ?dirty:bool -> + ?closable:bool -> + tab_id:string -> + title:string -> + unit -> + tab + + type pane = { + pane_id : string; + pane_tabs : tab list; + pane_selected : string option; + } + + val pane : ?selected:string -> pane_id:string -> tab list -> pane + + type node = + | Leaf of pane + | Split of { + split_id : string; + split_orientation : orientation; + split_ratio : float; + split_first : node; + split_second : node; + } + + type t + + val create : ?focused:string -> node -> t + val root : t -> node + val focused : t -> string option + + type action = + | Select_tab of string * string + | Close_tab of string * string + | Move_tab of { + move_tab : string; + move_from : string; + move_to : string; + move_index : int; + } + | Split_drop of { + drop_tab : string; + drop_from : string; + drop_target : string; + drop_edge : edge; + } + | Close_pane of string + | Focus_pane of string + | Navigate of string * direction + | Request_split of string * orientation + | Set_ratio of string * float + (** Semantic intents decoded from backend events. *) + + val update : t -> action -> t + (** Policies: moving a tab selects it in the target pane; a tab dropped on + a pane edge splits that pane (new pane takes the smaller 25% share); a + pane emptied by a close or move is removed and its split collapses; + the last pane is never removed. *) + + val render : + ?key:string -> + ?divider_thickness:float -> + ?animation:bool -> + build:(tab -> Lui_elements.t list) -> + dispatch:(action -> unit) -> + t -> + Lui_elements.t + (** Renders the tree under one [split-view] node and wires every pane and + branch event through [pane_action]/decoders into [dispatch]. *) +end diff --git a/test/test_lui.ml b/test/test_lui.ml index 8e627f39..1fa8c630 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -1031,6 +1031,309 @@ let test_json_view_parse () = | Ok _ -> true | Error _ -> false) +(* Lui_split: the Bonsplit-style tabbed split-pane extension set. *) + +let split_fingerprint identifier = + let registry = Lui_split.registry () in + match Lui_extension.schema registry identifier with + | Some schema -> Lui_extension.fingerprint schema + | None -> Alcotest.fail ("unregistered schema " ^ identifier) + +let test_split_fingerprints () = + Alcotest.(check string) "split-view fingerprint" + "lui-extension-v1|10:split-view|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:17:divider-thickness:float:optional:none,24:accessibility-identifier:string:optional:none,9:animation:bool:optional:none|events:" + (split_fingerprint "split-view"); + Alcotest.(check string) "split-branch fingerprint" + "lui-extension-v1|12:split-branch|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:12:split-branch,10:split-pane|properties:11:orientation:string:required:none,5:ratio:float:required:none|events:13:ratio-changed[5:ratio:float:required]" + (split_fingerprint "split-branch"); + Alcotest.(check string) "split-pane fingerprint" + "lui-extension-v1|10:split-pane|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:0|children:9:split-tab|properties:24:accessibility-identifier:string:optional:none,7:focused:bool:optional:none,7:pane-id:string:required:none,8:selected:string:optional:none|events:10:split-drop[3:tab:string:required,4:edge:string:required,9:from-pane:string:required],10:tab-closed[3:tab:string:required],11:pane-closed[],12:pane-focused[],12:tab-selected[3:tab:string:required],15:split-requested[11:orientation:string:required],8:navigate[9:direction:string:required],9:tab-moved[3:tab:string:required,5:index:int:required,9:from-pane:string:required]" + (split_fingerprint "split-pane"); + Alcotest.(check string) "split-tab fingerprint" + "lui-extension-v1|9:split-tab|profiles:android/flutter,ios/flutter,ios/swiftui,linux/flutter,linux/qml,macos/flutter,macos/qml,macos/swiftui,web/web,windows/flutter,windows/qml,windows/winui|standard-children:1|children:|properties:24:accessibility-identifier:string:optional:none,4:icon:string:optional:none,5:dirty:bool:optional:none,5:title:string:required:none,6:tab-id:string:required:none,8:closable:bool:optional:none|events:" + (split_fingerprint "split-tab") + +(* Every host source that carries lui-extension-v1 literals is checked so + drift fails here rather than as a blank screen on that platform. The + files are read from the source tree directly; test/dune only tracks the + gallery source as a dependency. *) +let split_host_sources () = + let root = source_root () in + [ "platform/apple/Sources/LUIAppleBackend/LUISplit.swift"; + "platform/qt/lib/lui_split_extensions.cpp"; + "platform/flutter/lib/lui_flutter_split.dart"; + "platform/winui/LUI.WinUI/LUISplitExtensions.cs"; + "platform/web/src/lui-split.js" ] + |> List.filter_map (fun rel -> + let path = Filename.concat root rel in + if Sys.file_exists path then Some (read_file path) else None) + +let test_split_host_literals_in_sync () = + let registry = Lui_split.registry () in + let mismatches = + split_host_sources () + |> List.concat_map (Lui_extension_check.check_registry registry) + in + match mismatches with + | [] -> () + | _ -> + Alcotest.failf "extension fingerprint drift:\n%s" + (Lui_extension_check.describe_mismatches mismatches) + +let split_view_fixture _context _model_source _send = + Lui_split.split_view + [ Lui_split.split_branch ~orientation:`horizontal ~ratio:0.4 + [ Lui_split.split_pane ~pane_id:"editor" + [ Lui_split.split_tab ~tab_id:"welcome" ~title:"Welcome" + [ Lui_elements.text ~value:"editor content" [] ]; + Lui_split.split_tab ~tab_id:"repl" ~title:"REPL" ~closable:false + [ Lui_elements.text ~value:"repl content" [] ] ]; + Lui_split.split_pane ~pane_id:"outline" + [ Lui_split.split_tab ~tab_id:"toc" ~title:"Outline" + [ Lui_elements.text ~value:"outline content" [] ] ] ] ] + +let test_split_extension_ops () = + batches := []; + let backend = + { + Lui_protocol.backend_profile = + Lui_protocol.profile Lui_protocol.MacOS Lui_protocol.SwiftUIHost; + apply_batch = (fun batch -> batches := batch :: !batches; true); + } + in + let app = + Lui_app.create_with_extensions backend + (Lui_split.registry ()) () + (fun model _action -> model) + split_view_fixture + in + ignore (Lui_app.start app); + flush_app app; + let ops = all_ops () in + let creates identifier = + List.exists + (function + | Lui_protocol.CreateExtension (_, id, _) -> String.equal id identifier + | _ -> false) + ops + in + Alcotest.(check bool) "creates split-view" true (creates "split-view"); + Alcotest.(check bool) "creates split-branch" true (creates "split-branch"); + Alcotest.(check bool) "creates split-pane (x2)" true + (List.length + (List.filter + (function + | Lui_protocol.CreateExtension (_, "split-pane", _) -> true + | _ -> false) + ops) + = 2); + Alcotest.(check bool) "creates split-tab (x3)" true + (List.length + (List.filter + (function + | Lui_protocol.CreateExtension (_, "split-tab", _) -> true + | _ -> false) + ops) + = 3); + Alcotest.(check bool) "pane-id prop lands" true + (List.exists + (function + | Lui_protocol.SetExtensionProp (_, "pane-id", + Lui_protocol.StringValue "outline") -> + true + | _ -> false) + ops); + Alcotest.(check bool) "ratio prop lands" true + (List.exists + (function + | Lui_protocol.SetExtensionProp (_, "ratio", + Lui_protocol.FloatValue r) -> + Float.abs (r -. 0.4) < 0.001 + | _ -> false) + ops); + Alcotest.(check bool) "orientation prop lands" true + (List.exists + (function + | Lui_protocol.SetExtensionProp (_, "orientation", + Lui_protocol.StringValue "horizontal") -> + true + | _ -> false) + ops); + ignore (Lui_app.dispose app) + +let test_split_event_decoders () = + let open Lui_protocol in + let event name entries = + ExtensionEvent + (42, "split-pane", name, + String_map.of_list entries) + in + (match + Lui_split.decode_split_pane_tab_moved + (event "tab-moved" + [ ("tab", StringValue "a"); + ("index", IntValue 2); + ("from-pane", StringValue "p1") ]) + with + | Some { Lui_split.event_node; tab; index; from_pane } -> + Alcotest.(check int) "node" 42 event_node; + Alcotest.(check string) "tab" "a" tab; + Alcotest.(check int) "index" 2 index; + Alcotest.(check string) "from-pane" "p1" from_pane + | None -> Alcotest.fail "tab-moved did not decode"); + (match + Lui_split.decode_split_pane_split_drop + (event "split-drop" + [ ("tab", StringValue "a"); + ("from-pane", StringValue "p1"); + ("edge", StringValue "right") ]) + with + | Some { Lui_split.event_node; tab; from_pane; edge } -> + Alcotest.(check int) "node" 42 event_node; + Alcotest.(check string) "tab" "a" tab; + Alcotest.(check string) "from-pane" "p1" from_pane; + Alcotest.(check string) "edge" "right" edge + | None -> Alcotest.fail "split-drop did not decode"); + (* cross-identifier and missing-field events must not decode *) + Alcotest.(check bool) "wrong identifier rejected" true + (Lui_split.decode_split_pane_tab_selected + (ExtensionEvent + (42, "other", "tab-selected", + String_map.of_list [ ("tab", StringValue "a") ])) + = None); + Alcotest.(check bool) "missing required field rejected" true + (Lui_split.decode_split_pane_tab_moved + (event "tab-moved" [ ("tab", StringValue "a") ]) + = None); + match + Lui_split.decode_split_branch_ratio_changed + (ExtensionEvent + (7, "split-branch", "ratio-changed", + String_map.of_list [ ("ratio", FloatValue 0.7) ])) + with + | Some { Lui_split.event_node; ratio } -> + Alcotest.(check int) "branch node" 7 event_node; + Alcotest.(check bool) "ratio" true (Float.abs (ratio -. 0.7) < 0.001) + | None -> Alcotest.fail "ratio-changed did not decode" + + +let two_pane_state () = + Lui_split.Model.create ~focused:"editor" + (Lui_split.Model.Split + { + split_id = "root-split"; + split_orientation = `horizontal; + split_ratio = 0.5; + split_first = + Lui_split.Model.Leaf + (Lui_split.Model.pane ~pane_id:"editor" + [ + Lui_split.Model.tab ~tab_id:"a" ~title:"A" (); + Lui_split.Model.tab ~tab_id:"b" ~title:"B" (); + ]); + split_second = + Lui_split.Model.Leaf + (Lui_split.Model.pane ~pane_id:"outline" + [ Lui_split.Model.tab ~tab_id:"c" ~title:"C" () ]); + }) + +let pane_ids node = + let rec collect acc = function + | Lui_split.Model.Leaf p -> p.pane_id :: acc + | Split { split_first; split_second; _ } -> + collect (collect acc split_first) split_second + in + List.rev (collect [] node) + +let tab_ids pane = + List.map (fun t -> t.Lui_split.Model.tab_id) pane.Lui_split.Model.pane_tabs + +let find_pane pane_id node = + let rec go = function + | Lui_split.Model.Leaf p -> + if String.equal p.pane_id pane_id then Some p else None + | Split { split_first; split_second; _ } -> ( + match go split_first with + | Some _ as hit -> hit + | None -> go split_second) + in + go node + +let test_split_model () = + let open Lui_split.Model in + let state = two_pane_state () in + (* tab selection *) + let state = + update state (Select_tab ("editor", "b")) + in + Alcotest.(check (option string)) "selected tab" (Some "b") + (find_pane "editor" (root state) + |> Option.map (fun p -> p.pane_selected) + |> Option.join); + (* moving a tab across panes selects it in the target *) + let state = + update state + (Move_tab + { move_tab = "a"; move_from = "editor"; move_to = "outline"; + move_index = 1 }) + in + Alcotest.(check (list string)) "outline tabs" [ "c"; "a" ] + (find_pane "outline" (root state) |> Option.map tab_ids + |> Option.value ~default:[]); + Alcotest.(check (option string)) "moved tab selected" (Some "a") + (find_pane "outline" (root state) + |> Option.map (fun p -> p.pane_selected) + |> Option.join); + (* same-pane move adjusts the index for the removed slot *) + let state = + update state + (Move_tab + { move_tab = "c"; move_from = "outline"; move_to = "outline"; + move_index = 2 }) + in + Alcotest.(check (list string)) "reordered" [ "a"; "c" ] + (find_pane "outline" (root state) |> Option.map tab_ids + |> Option.value ~default:[]); + (* edge drop splits the pane and the dropped edge gets the small share; + dropping a foreign tab keeps the source pane in place *) + let state = + update state + (Split_drop + { drop_tab = "c"; drop_from = "outline"; drop_target = "editor"; + drop_edge = `right }) + in + Alcotest.(check int) "three panes" 3 (List.length (pane_ids (root state))); + (match root state with + | Split { split_first = Split s; _ } -> + Alcotest.(check bool) "split axis" true + (match s.split_orientation with + | `horizontal -> true + | `vertical -> false); + Alcotest.(check (float 0.001)) "dropped edge share" 0.75 s.split_ratio; + (match s.split_first with + | Leaf p -> Alcotest.(check string) "source keeps id" "editor" p.pane_id + | Split _ -> Alcotest.fail "expected leaf") + | _ -> Alcotest.fail "expected an inner edge split"); + (* navigation crosses the split in the matching axis *) + let state = update state (Focus_pane "editor") in + let state = update state (Navigate ("editor", `right)) in + Alcotest.(check (option string)) "focus moved right" (Some "pane-0") + (focused state); + let state = update state (Navigate ("pane-0", `up)) in + Alcotest.(check (option string)) "no pane above" (Some "pane-0") + (focused state); + (* closing the last tab removes the pane and collapses the split *) + let state = + List.fold_left update state + [ Close_tab ("outline", "a") ] + in + Alcotest.(check (list string)) "pane collapsed" [ "editor"; "pane-0" ] + (pane_ids (root state)); + (* the last pane never closes *) + let state = update state (Close_pane "editor") in + let state = update state (Close_pane "pane-0") in + Alcotest.(check int) "last pane survives" 1 (List.length (pane_ids (root state))) + let () = Alcotest.run "lui" [ @@ -1098,4 +1401,14 @@ let () = test_host_literals_in_sync; Alcotest.test_case "drift is caught" `Quick test_drift_is_caught; ] ); + ( "split", + [ + Alcotest.test_case "canonical fingerprints" `Quick + test_split_fingerprints; + Alcotest.test_case "host literals in sync" `Quick + test_split_host_literals_in_sync; + Alcotest.test_case "extension ops" `Quick test_split_extension_ops; + Alcotest.test_case "event decoders" `Quick test_split_event_decoders; + Alcotest.test_case "model reduce" `Quick test_split_model; + ] ); ] diff --git a/tooling/generate_extension_api.mjs b/tooling/generate_extension_api.mjs index 8b801ba6..84df536a 100644 --- a/tooling/generate_extension_api.mjs +++ b/tooling/generate_extension_api.mjs @@ -65,7 +65,7 @@ const osNames = { const hostNames = { generic: 'GenericHost', web: 'WebHost', swiftui: 'SwiftUIHost', - flutter: 'FlutterHost', + flutter: 'FlutterHost', qml: 'QMLHost', winui: 'WinUIHost', }; const munge = (name) => name.replace(/-/g, '_'); @@ -178,16 +178,24 @@ function eventDecoder(comp, event) { ? ` ${munge(f.name)} = ${munge(f.name)};` : ` ${munge(f.name)} = (match ${munge(f.name)}_opt with Some (${scalarKinds[f.kind].wire} value) -> Some value | _ -> None);` ); + if (fields.length === 0) { + return `let decode_${name} = function + | ExtensionEvent (node, identifier, event_name, _values) + when String.equal event_name ${JSON.stringify(event.name)} + && String.equal identifier ${JSON.stringify(comp.identifier)} -> + Some ({ event_node = node } : ${name}) + | _ -> None`; + } return `let decode_${name} = function | ExtensionEvent (node, identifier, event_name, values) when String.equal event_name ${JSON.stringify(event.name)} && String.equal identifier ${JSON.stringify(comp.identifier)} -> (match (${lookups.join(', ')}) with | (${pattern.join(', ')})${guard.length ? ` when ${guard.join(' && ')}` : ''} -> - Some { + Some ({ event_node = node; ${bindings.join('\n')} - } + } : ${name}) | _ -> None) | _ -> None`; } From 89255c064fa0c3d3bfdf9c090e8b1d58a94c674a Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 26 Sep 2026 11:51:16 -0700 Subject: [PATCH 2/8] Add examples/split macOS demo app Headless OCaml runner + native bridge dylib + AppKit/SwiftUI host that registers the split extensions via LUISplit.register, forwards extension events over lui_ocaml_extension_event, and shows the demo fixture driven by Lui_split.Model. --- examples/split/dune | 11 + examples/split/macos-appkit/App/Info.plist | 26 ++ examples/split/macos-appkit/Package.swift | 19 + .../Sources/LUISplitDemo/main.swift | 345 ++++++++++++++++++ examples/split/macos-appkit/build-app.sh | 28 ++ examples/split/main.ml | 24 ++ examples/split/model.ml | 38 ++ examples/split/native/dune | 27 ++ examples/split/native/split_bridge.ml | 115 ++++++ examples/split/view.ml | 32 ++ 10 files changed, 665 insertions(+) create mode 100644 examples/split/dune create mode 100644 examples/split/macos-appkit/App/Info.plist create mode 100644 examples/split/macos-appkit/Package.swift create mode 100644 examples/split/macos-appkit/Sources/LUISplitDemo/main.swift create mode 100755 examples/split/macos-appkit/build-app.sh create mode 100644 examples/split/main.ml create mode 100644 examples/split/model.ml create mode 100644 examples/split/native/dune create mode 100644 examples/split/native/split_bridge.ml create mode 100644 examples/split/view.ml diff --git a/examples/split/dune b/examples/split/dune new file mode 100644 index 00000000..5ffb8865 --- /dev/null +++ b/examples/split/dune @@ -0,0 +1,11 @@ +(library + (name split_app) + (modules model view) + (libraries lui ocaml-signal) + (preprocess (pps lui_ppx)) + (wrapped false)) + +(executable + (name main) + (modules main) + (libraries split_app)) diff --git a/examples/split/macos-appkit/App/Info.plist b/examples/split/macos-appkit/App/Info.plist new file mode 100644 index 00000000..8ce245e8 --- /dev/null +++ b/examples/split/macos-appkit/App/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + LUISplitDemo + CFBundleIdentifier + com.logseq.lui.split + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + LUI Split + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + + diff --git a/examples/split/macos-appkit/Package.swift b/examples/split/macos-appkit/Package.swift new file mode 100644 index 00000000..2ac758da --- /dev/null +++ b/examples/split/macos-appkit/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "LUISplitDemo", + platforms: [.macOS(.v14)], + dependencies: [ + .package(path: "../../../platform/apple"), + ], + targets: [ + .executableTarget( + name: "LUISplitDemo", + dependencies: [ + .product(name: "LUIAppleBackend", package: "apple"), + ] + ), + ] +) diff --git a/examples/split/macos-appkit/Sources/LUISplitDemo/main.swift b/examples/split/macos-appkit/Sources/LUISplitDemo/main.swift new file mode 100644 index 00000000..bab9ee5d --- /dev/null +++ b/examples/split/macos-appkit/Sources/LUISplitDemo/main.swift @@ -0,0 +1,345 @@ +#if canImport(AppKit) +import AppKit +import Darwin +import Foundation +import LUIAppleBackend +import SwiftUI + +private typealias PatchCallback = @convention(c) (UnsafePointer?) -> Void +private typealias StartFunction = @convention(c) (PatchCallback?, Int32, Int32) -> Int32 +private typealias StopFunction = @convention(c) () -> Int32 +private typealias AppearFunction = @convention(c) (Int64) -> Int32 +private typealias PressFunction = @convention(c) (Int64) -> Int32 +private typealias LongPressFunction = @convention(c) (Int64) -> Int32 +private typealias TextChangedFunction = + @convention(c) (Int64, UnsafePointer?) -> Int32 +private typealias SubmitFunction = @convention(c) (Int64) -> Int32 +private typealias DismissFunction = @convention(c) (Int64) -> Int32 +private typealias DoublePressFunction = @convention(c) (Int64) -> Int32 +private typealias ToggleChangedFunction = @convention(c) (Int64, Int32) -> Int32 +private typealias RadioChangedFunction = @convention(c) (Int64) -> Int32 +private typealias SliderChangedFunction = @convention(c) (Int64, Double) -> Int32 +private typealias ExtensionEventFunction = + @convention(c) ( + Int64, UnsafePointer?, UnsafePointer?, UnsafePointer? + ) -> Int32 + +nonisolated(unsafe) private var activeHost: SplitDemoHost? + +nonisolated(unsafe) private let receivePatch: PatchCallback = { source in + guard let source else { return } + let json = String(cString: source) + MainActor.assumeIsolated { + activeHost?.apply(json: json) + } +} + +// Host-side flat encoding matching Lui_json.parse_values on the OCaml side. +private func luiExtensionValuesJSON(_ values: [String: LUIExtensionValue]) -> String { + func escape(_ string: String) -> String { + var result = "" + for scalar in string.unicodeScalars { + switch scalar { + case "\\": result += "\\\\" + case "\"": result += "\\\"" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + default: + if scalar.value < 0x20 { + result += String(format: "\\u%04x", scalar.value) + } else { + result.unicodeScalars.append(scalar) + } + } + } + return result + } + let fields = values + .sorted { $0.key < $1.key } + .map { name, value -> String in + switch value { + case let .string(text): + return "\"\(escape(name))\":\"\(escape(text))\"" + case let .bool(flag): + return "\"\(escape(name))\":\(flag ? "true" : "false")" + case let .int(number): + return "\"\(escape(name))\":\(number)" + case let .double(number): + return "\"\(escape(name))\":\(number)" + } + } + return "{\(fields.joined(separator: ","))}" +} + +@MainActor +private final class NativeSplitRuntime { + private let handle: UnsafeMutableRawPointer + private let startFunction: StartFunction + private let stopFunction: StopFunction + private let appearFunction: AppearFunction + private let pressFunction: PressFunction + private let longPressFunction: LongPressFunction + private let textChangedFunction: TextChangedFunction + private let submitFunction: SubmitFunction + private let dismissFunction: DismissFunction + private let doublePressFunction: DoublePressFunction + private let toggleChangedFunction: ToggleChangedFunction + private let radioChangedFunction: RadioChangedFunction + private let sliderChangedFunction: SliderChangedFunction + private let extensionEventFunction: ExtensionEventFunction + + init(path: String) throws { + guard let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) else { + throw RuntimeError(String(cString: dlerror())) + } + self.handle = handle + startFunction = try Self.load("lui_ocaml_start", from: handle) + stopFunction = try Self.load("lui_ocaml_stop", from: handle) + appearFunction = try Self.load("lui_ocaml_appear", from: handle) + pressFunction = try Self.load("lui_ocaml_press", from: handle) + longPressFunction = try Self.load("lui_ocaml_long_press", from: handle) + textChangedFunction = try Self.load("lui_ocaml_text_changed", from: handle) + submitFunction = try Self.load("lui_ocaml_submit", from: handle) + dismissFunction = try Self.load("lui_ocaml_dismiss", from: handle) + doublePressFunction = try Self.load("lui_ocaml_double_press", from: handle) + toggleChangedFunction = try Self.load("lui_ocaml_toggle_changed", from: handle) + radioChangedFunction = try Self.load("lui_ocaml_radio_changed", from: handle) + sliderChangedFunction = try Self.load("lui_ocaml_slider_changed", from: handle) + extensionEventFunction = try Self.load("lui_ocaml_extension_event", from: handle) + } + + func start() throws { + guard startFunction(receivePatch, 1, 2) == 1 else { + throw RuntimeError("LUI Split initialization failed") + } + } + + func stop() { + _ = stopFunction() + } + + func appear(node: Int) { _ = appearFunction(Int64(node)) } + + func press(node: Int) { _ = pressFunction(Int64(node)) } + + func longPress(node: Int) { _ = longPressFunction(Int64(node)) } + + func textChanged(node: Int, text: String) { + text.withCString { source in + _ = textChangedFunction(Int64(node), source) + } + } + + func submit(node: Int) { _ = submitFunction(Int64(node)) } + + func dismiss(node: Int) { _ = dismissFunction(Int64(node)) } + + func doublePress(node: Int) { _ = doublePressFunction(Int64(node)) } + + func toggleChanged(node: Int, checked: Bool) { + _ = toggleChangedFunction(Int64(node), checked ? 1 : 0) + } + + func radioChanged(node: Int) { _ = radioChangedFunction(Int64(node)) } + + func sliderChanged(node: Int, value: Double) { + _ = sliderChangedFunction(Int64(node), value) + } + + func extensionEvent( + node: Int, identifier: String, name: String, + values: [String: LUIExtensionValue] + ) { + let json = luiExtensionValuesJSON(values) + identifier.withCString { identifierSource in + name.withCString { nameSource in + json.withCString { jsonSource in + _ = extensionEventFunction( + Int64(node), identifierSource, nameSource, jsonSource) + } + } + } + } + + private static func load( + _ name: String, + from handle: UnsafeMutableRawPointer + ) throws -> Function { + guard let symbol = dlsym(handle, name) else { + throw RuntimeError("Missing native symbol: \(name)") + } + return unsafeBitCast(symbol, to: Function.self) + } +} + +private struct RuntimeError: Error, CustomStringConvertible { + let description: String + + init(_ description: String) { + self.description = description + } +} + +@MainActor +private func splitExtensionRegistry() -> LUIAppleExtensionRegistry { + let registry = LUIAppleExtensionRegistry() + do { + try LUISplit.register(in: registry) + } catch { + assertionFailure("split extension registry failed: \(error)") + } + return registry +} + +@MainActor +private final class SplitDemoHost: NSObject, NSApplicationDelegate, NSWindowDelegate { + private let backend: LUIAppleBackend + private let content = NSView() + private var runtime: NativeSplitRuntime? + private var window: NSWindow? + private var hostedRootID: Int? + + override init() { + do { + backend = try LUIAppleBackend( + extensionRegistry: splitExtensionRegistry()) + } catch { + assertionFailure("split backend failed: \(error)") + backend = LUIAppleBackend() + } + super.init() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + do { + let path = try libraryPath() + let runtime = try NativeSplitRuntime(path: path) + self.runtime = runtime + activeHost = self + backend.onEvent = { [weak self] event in + switch event { + case let .appear(node): + self?.runtime?.appear(node: node) + case let .press(node): + self?.runtime?.press(node: node) + case let .longPress(node): + self?.runtime?.longPress(node: node) + case let .textChanged(node, text): + self?.runtime?.textChanged(node: node, text: text) + case let .submit(node): + self?.runtime?.submit(node: node) + case let .toggleChanged(node, checked): + self?.runtime?.toggleChanged(node: node, checked: checked) + case let .change(node): + self?.runtime?.radioChanged(node: node) + case let .valueChanged(node, value): + self?.runtime?.sliderChanged(node: node, value: value) + case let .dismiss(node): + self?.runtime?.dismiss(node: node) + case let .doublePress(node): + self?.runtime?.doublePress(node: node) + case let .`extension`(node, identifier, name, values): + self?.runtime?.extensionEvent( + node: node, identifier: identifier, name: name, + values: values) + } + } + makeWindow() + try runtime.start() + window?.makeKeyAndOrderFront(nil) + if #available(macOS 14.0, *) { + NSApplication.shared.activate() + } else { + NSApplication.shared.activate(ignoringOtherApps: true) + } + } catch { + present(error: error) + NSApplication.shared.terminate(nil) + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + func applicationWillTerminate(_ notification: Notification) { + runtime?.stop() + runtime = nil + activeHost = nil + } + + func apply(json: String) { + do { + try backend.apply(json: json) + refreshRoot() + } catch { + present(error: error) + } + } + + private func makeWindow() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 860, height: 560), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "LUI · Split" + window.center() + window.contentView = content + window.delegate = self + self.window = window + } + + private func refreshRoot() { + guard let rootID = backend.rootIDs.first else { + content.subviews.forEach { $0.removeFromSuperview() } + hostedRootID = nil + return + } + guard hostedRootID != rootID else { return } + content.subviews.forEach { $0.removeFromSuperview() } + let root = NSHostingView( + rootView: LUISwiftUIRoot(backend: backend, rootID: rootID) + .padding(8) + ) + root.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(root) + NSLayoutConstraint.activate([ + root.leadingAnchor.constraint(equalTo: content.leadingAnchor), + root.trailingAnchor.constraint(equalTo: content.trailingAnchor), + root.topAnchor.constraint(equalTo: content.topAnchor), + root.bottomAnchor.constraint(equalTo: content.bottomAnchor), + ]) + hostedRootID = rootID + } + + private func libraryPath() throws -> String { + if let path = ProcessInfo.processInfo.environment["LUI_SPLIT_LIBRARY"], + !path.isEmpty { + return path + } + let executable = URL(fileURLWithPath: ProcessInfo.processInfo.arguments[0]) + return executable + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Frameworks/liblui_split.dylib") + .path + } + + private func present(error: Error) { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "LUI Split could not start" + alert.informativeText = String(describing: error) + alert.runModal() + } +} + +private let application = NSApplication.shared +private let delegate = SplitDemoHost() +application.setActivationPolicy(.regular) +application.delegate = delegate +application.run() +#endif diff --git a/examples/split/macos-appkit/build-app.sh b/examples/split/macos-appkit/build-app.sh new file mode 100755 index 00000000..5369c16c --- /dev/null +++ b/examples/split/macos-appkit/build-app.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +apple_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +project_dir=$(CDPATH= cd -- "$apple_dir/../../.." && pwd) +configuration=${1:-debug} +bundle="$apple_dir/.build/$configuration/LUISplitDemo.app" +executable="$apple_dir/.build/$configuration/LUISplitDemo" +backend_library="$apple_dir/.build/$configuration/libLUIAppleBackend.dylib" +native_library="$project_dir/_build/default/examples/split/native/liblui_split.dylib" + +cd "$project_dir" +opam exec -- dune build examples/split/native/liblui_split.dylib +swift build --package-path "$apple_dir" --configuration "$configuration" --product LUISplitDemo + +rm -rf "$bundle" +mkdir -p "$bundle/Contents/MacOS" "$bundle/Contents/Frameworks" +cp "$apple_dir/App/Info.plist" "$bundle/Contents/Info.plist" +cp "$executable" "$bundle/Contents/MacOS/LUISplitDemo" +cp "$backend_library" "$bundle/Contents/Frameworks/libLUIAppleBackend.dylib" +cp "$native_library" "$bundle/Contents/Frameworks/liblui_split.dylib" +install_name_tool -add_rpath @executable_path/../Frameworks \ + "$bundle/Contents/MacOS/LUISplitDemo" +codesign --force --sign - "$bundle/Contents/Frameworks/libLUIAppleBackend.dylib" +codesign --force --sign - "$bundle/Contents/Frameworks/liblui_split.dylib" +codesign --force --deep --sign - "$bundle" + +echo "$bundle" diff --git a/examples/split/main.ml b/examples/split/main.ml new file mode 100644 index 00000000..cdf86404 --- /dev/null +++ b/examples/split/main.ml @@ -0,0 +1,24 @@ +(* Headless demo: mounts the split demo against a printing backend so the + extension wire ops are visible. Run with `dune exec examples/split`. *) + +let backend = + { + Lui_protocol.backend_profile = + Lui_protocol.profile Lui_protocol.MacOS Lui_protocol.SwiftUIHost; + apply_batch = + (fun batch -> + Printf.printf "patch batch generation=%d ops=%d\n%!" + batch.Lui_protocol.generation + (List.length batch.Lui_protocol.ops); + true); + } + +let () = + let app = + Lui_app.create_with_extensions backend + (Lui_split.registry ()) + Model.initial Model.update View.view + in + ignore (Lui_app.start app); + ignore (Lui_app.flush app); + ignore (Lui_app.dispose app) diff --git a/examples/split/model.ml b/examples/split/model.ml new file mode 100644 index 00000000..434ca988 --- /dev/null +++ b/examples/split/model.ml @@ -0,0 +1,38 @@ +(* Split demo model: the pane tree lives in the shared Lui_split.Model + controller; host gesture/keyboard events arrive as actions. *) + +type t = { panes : Lui_split.Model.t } + +type action = Split of Lui_split.Model.action + +let initial = + let open Lui_split.Model in + { + panes = + create ~focused:"editor" + (Split + { + split_id = "root"; + split_orientation = `horizontal; + split_ratio = 0.6; + split_first = + Leaf + (pane ~pane_id:"editor" ~selected:"welcome" + [ + tab ~tab_id:"welcome" ~title:"Welcome" (); + tab ~tab_id:"notes" ~title:"Notes.md" ~dirty:true (); + tab ~tab_id:"repl" ~title:"REPL" (); + ]); + split_second = + Leaf + (pane ~pane_id:"sidebar" ~selected:"outline" + [ + tab ~tab_id:"outline" ~title:"Outline" (); + tab ~tab_id:"backlinks" ~title:"Backlinks" (); + ]); + }); + } + +let update model = function + | Split action -> + { panes = Lui_split.Model.update model.panes action } diff --git a/examples/split/native/dune b/examples/split/native/dune new file mode 100644 index 00000000..a1ea7b68 --- /dev/null +++ b/examples/split/native/dune @@ -0,0 +1,27 @@ +(library + (name split_bridge) + (modules split_bridge) + (libraries lui ocaml-signal split_app) + (wrapped false)) + +(rule + (enabled_if + (= %{system} "macosx")) + (target liblui_split.dylib) + (deps + ../../../platform/native/lui_ocaml_bridge.c + split_bridge.cmxa + ../split_app.cmxa + ../../../src/lui.cmxa) + (action + (run + sh + -c + "ocamlfind ocamlopt -linkpkg -linkall -package ocaml-signal \ + %{dep:../../../src/lui.cmxa} %{dep:../split_app.cmxa} \ + %{dep:split_bridge.cmxa} \ + -output-complete-obj -o split_bridge_complete.o && \ + cc -shared -o %{target} \ + %{dep:../../../platform/native/lui_ocaml_bridge.c} \ + split_bridge_complete.o \ + -I\"$(ocamlfind ocamlc -where)\" -lpthread"))) diff --git a/examples/split/native/split_bridge.ml b/examples/split/native/split_bridge.ml new file mode 100644 index 00000000..d18a3c4d --- /dev/null +++ b/examples/split/native/split_bridge.ml @@ -0,0 +1,115 @@ +(* Native bridge for the Swift host: registers the OCaml-side entry points + that platform/native/lui_ocaml_bridge.c calls back into. Each entry point + returns the JSON patch batch produced since the previous call. *) + +open Lui_protocol + +let latest_patch = ref "" + +let current_app : (Model.t, Model.action) Lui_app.reducer_app option ref = + ref None + +let app () = + match !current_app with + | Some value -> value + | None -> invalid_arg "Split bridge is not started" + +let operating_system = function + | 1 -> MacOS + | 2 -> IOS + | 3 -> AndroidOS + | 4 -> LinuxOS + | 5 -> WindowsOS + | _ -> GenericOS + +let host_kind = function + | 1 -> WebHost + | 2 -> SwiftUIHost + | 3 -> FlutterHost + | 4 -> QMLHost + | 5 -> WinUIHost + | _ -> GenericHost + +let backend host_profile = + { + backend_profile = host_profile; + apply_batch = + (fun batch -> + latest_patch := Lui_wire.encode_batch batch; + true); + } + +let initialize platform_code host_code = + latest_patch := ""; + let value = + Lui_app.create_with_extensions + (backend + (profile (operating_system platform_code) (host_kind host_code))) + (Lui_split.registry ()) + Model.initial Model.update View.view + in + current_app := Some value; + ignore (Lui_app.start value); + ignore (Lui_app.flush value); + !latest_patch + +let dispatch event = + latest_patch := ""; + let value = app () in + ignore (Lui_app.dispatch_event value event); + ignore (Lui_app.flush value); + !latest_patch + +let appear node = dispatch (Appear node) + +let press node = dispatch (Press node) + +let long_press node = dispatch (LongPress node) + +let text_changed node text = dispatch (TextChanged (node, text)) + +let submit node = dispatch (Submit node) + +let dismiss node = dispatch (Dismiss node) + +let double_press node = dispatch (DoublePress node) + +let toggle_changed node checked = dispatch (ToggleChanged (node, checked)) + +let radio_changed node = dispatch (Change node) + +let slider_changed node value = dispatch (ValueChanged (node, value)) + +(* Extension events travel as a flat JSON object of scalar values; decode it + and let the runtime validate the payload against the declared schema. *) +let extension_event node identifier name json_values = + let values = + match Lui_json.parse_values json_values with + | values -> values + | exception Lui_json.Parse_error message -> + invalid_arg ("invalid extension event payload: " ^ message) + in + dispatch (ExtensionEvent (node, identifier, name, values)) + +let dispose () = + latest_patch := ""; + ignore (Lui_app.dispose (app ())); + !latest_patch + +let root_node () = Lui_app.root_node (app ()) + +let () = + Callback.register "lui_ocaml_init" initialize; + Callback.register "lui_ocaml_appear" appear; + Callback.register "lui_ocaml_press" press; + Callback.register "lui_ocaml_long_press" long_press; + Callback.register "lui_ocaml_text_changed" text_changed; + Callback.register "lui_ocaml_submit" submit; + Callback.register "lui_ocaml_dismiss" dismiss; + Callback.register "lui_ocaml_double_press" double_press; + Callback.register "lui_ocaml_toggle_changed" toggle_changed; + Callback.register "lui_ocaml_radio_changed" radio_changed; + Callback.register "lui_ocaml_slider_changed" slider_changed; + Callback.register "lui_ocaml_extension_event" extension_event; + Callback.register "lui_ocaml_dispose" dispose; + Callback.register "lui_ocaml_root_node" root_node diff --git a/examples/split/view.ml b/examples/split/view.ml new file mode 100644 index 00000000..3bf70038 --- /dev/null +++ b/examples/split/view.ml @@ -0,0 +1,32 @@ +(* Split demo view: one Lui_split.Model-rendered tree. Tab bodies are plain + elements; the split extensions own the chrome. *) + +open Lui_elements + +let tab_page (tab : Lui_split.Model.tab) : t list = + [ + column ~gap:8 ~padding:24 + [ + text ~value:tab.Lui_split.Model.tab_title []; + paragraph + ~value: + "Drag tabs to reorder or move them across panes; drop on an edge \ + to split. Cmd-Alt arrows move focus, Cmd-Alt-D splits, Cmd-W \ + closes." + []; + ]; + ] + +let view _context model_source send : t = + column ~gap:0 + [ + dyn + ~equal:(fun (a : Model.t) (b : Model.t) -> + a.Model.panes == b.Model.panes) + (fun (model : Model.t) -> + Lui_split.Model.render + ~build:(fun tab -> tab_page tab) + ~dispatch:(fun action -> ignore (send (Model.Split action))) + model.Model.panes) + model_source; + ] From 3409206cc10ec4d8edbed8758ef9084269119fc8 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 26 Sep 2026 12:01:07 -0700 Subject: [PATCH 3/8] Address review findings on split library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model: Move_tab no-ops when the target pane is gone (stale drop) instead of dropping the tab; Close_tab honors tab_closable - Flutter divider drag accumulates per-update deltas instead of anchoring to the drag start ratio - Qt divider drag measures in the branch's coordinate frame (the divider moves under the pointer); ratio 0 no longer coerced to 0.5 - Web demo host: drop handlers resolve panes by pane-id (was picking the parent split), same-pane reorder adjusts the index, empty-source selection repair; untrusted leaf content renders as text — mount() gains an explicit allowHTML option the demo opts into - Gallery Apple host registers the split extensions so the new section's nodes resolve --- .../LUIComponentsApp/GalleryExtensions.swift | 1 + platform/flutter/lib/lui_flutter_split.dart | 9 +-- platform/qt/qml/LuiSplitBranch.qml | 15 +++-- platform/web/demo/lui-split.html | 2 +- platform/web/src/lui-split.js | 59 +++++++++++-------- src/lui_split.ml | 41 +++++++++---- test/test_lui.ml | 30 ++++++++++ 7 files changed, 110 insertions(+), 47 deletions(-) diff --git a/examples/components/ios-swiftui/Sources/LUIComponentsApp/GalleryExtensions.swift b/examples/components/ios-swiftui/Sources/LUIComponentsApp/GalleryExtensions.swift index 49b9664a..026900c0 100644 --- a/examples/components/ios-swiftui/Sources/LUIComponentsApp/GalleryExtensions.swift +++ b/examples/components/ios-swiftui/Sources/LUIComponentsApp/GalleryExtensions.swift @@ -33,6 +33,7 @@ func galleryExtensionRegistry() throws -> LUIAppleExtensionRegistry { AnyView(EmptyView()) } ) + try LUISplit.register(in: registry) try registry.registerTweak( LUIAppleTweak( identifier: "gallery-accent", diff --git a/platform/flutter/lib/lui_flutter_split.dart b/platform/flutter/lib/lui_flutter_split.dart index e0eae119..b091c984 100644 --- a/platform/flutter/lib/lui_flutter_split.dart +++ b/platform/flutter/lib/lui_flutter_split.dart @@ -288,7 +288,6 @@ final class _SplitBranch extends StatefulWidget { final class _SplitBranchState extends State<_SplitBranch> { late double _ratio = _sourceRatio; - double? _dragStart; bool _dragging = false; double get _sourceRatio => @@ -358,19 +357,15 @@ final class _SplitBranchState extends State<_SplitBranch> { child: _Divider( horizontal: _horizontal, animate: settings.animationEnabled && !_dragging, - onDragStart: () { - _dragging = true; - _dragStart = _ratio; - }, + onDragStart: () => _dragging = true, onDrag: (delta) { setState( - () => _ratio = ((_dragStart ?? _ratio) + delta / available) + () => _ratio = (_ratio + delta / available) .clamp(0.0, 1.0), ); }, onDragEnd: () { _dragging = false; - _dragStart = null; _emit(); }, onNudge: _nudge, diff --git a/platform/qt/qml/LuiSplitBranch.qml b/platform/qt/qml/LuiSplitBranch.qml index a10790a3..3d10319d 100644 --- a/platform/qt/qml/LuiSplitBranch.qml +++ b/platform/qt/qml/LuiSplitBranch.qml @@ -13,8 +13,12 @@ Item { readonly property bool horizontal: node.prop("orientation", "horizontal") !== "vertical" - readonly property real sourceRatio: - Math.min(1, Math.max(0, Number(node.prop("ratio", 0.5)) || 0.5)) + readonly property real sourceRatio: { + // `0` is a valid ratio — only fall back when the prop is absent. + var value = Number(node.prop("ratio", 0.5)) + if (!isFinite(value)) value = 0.5 + return Math.min(1, Math.max(0, value)) + } // Local ratio: follows user drags instantly; reconciles to sourceRatio // when the app moves it. @@ -103,12 +107,15 @@ Item { onPressed: function(mouse) { divider.dragging = true startRatio = branch.ratio - grab = branch.horizontal ? mouse.x : mouse.y + // Measure in the branch's frame: the divider itself moves. + var point = mapToItem(branch, mouse.x, mouse.y) + grab = branch.horizontal ? point.x : point.y mouse.accepted = true } onPositionChanged: function(mouse) { if (!divider.dragging || branch.available <= 0) return - var delta = (branch.horizontal ? mouse.x : mouse.y) - grab + var point = mapToItem(branch, mouse.x, mouse.y) + var delta = (branch.horizontal ? point.x : point.y) - grab branch.ratio = Math.min(1, Math.max(0, startRatio + delta / branch.available)) } diff --git a/platform/web/demo/lui-split.html b/platform/web/demo/lui-split.html index 5037b66f..a9aad9e2 100644 --- a/platform/web/demo/lui-split.html +++ b/platform/web/demo/lui-split.html @@ -73,7 +73,7 @@

${title}

LUISplit.mount(document.getElementById('root'), tree, (name, values) => { log.textContent = `${name} ${JSON.stringify(values)}`; - }); + }, { allowHTML: true }); diff --git a/platform/web/src/lui-split.js b/platform/web/src/lui-split.js index a46f2c0f..db368c49 100644 --- a/platform/web/src/lui-split.js +++ b/platform/web/src/lui-split.js @@ -388,7 +388,7 @@ const LUISplit = (() => { // { identifier, properties, children: [node | {kind:'element', html}] } // and applies split semantics locally: move/split/close mutate the tree and // re-render. Useful for demos and for wiring a real transport later. - function mount(root, tree, onEvent = () => {}) { + function mount(root, tree, onEvent = () => {}, options = {}) { let index = 0; const byId = new Map(); const fresh = () => `n${++index}`; @@ -407,10 +407,12 @@ const LUISplit = (() => { render: (id) => { const node = byId.get(id) || id; if (node.identifier) return render(node.id, host); - // Standard leaf: { html } or { element }. + // Standard leaf: { element } wins; { html } renders as text unless + // the caller marks the tree trusted with { allowHTML: true }. const div = document.createElement('div'); if (node.element) div.append(node.element); - else div.innerHTML = node.html || ''; + else if (node.html && options.allowHTML) div.innerHTML = node.html; + else div.textContent = node.html || ''; return div; }, emit: (node, name, values) => { @@ -432,7 +434,7 @@ const LUISplit = (() => { function apply(rootNode, nodeId, name, values) { const node = byId.get(nodeId); if (!node) return; - const paneOf = (pred, from) => { + const parentOf = (from) => { const stack = [rootNode]; while (stack.length) { const cur = stack.pop(); @@ -441,6 +443,17 @@ const LUISplit = (() => { } return null; }; + const findPane = (paneId) => { + const stack = [rootNode]; + while (stack.length) { + const cur = stack.pop(); + if (cur.properties && cur.properties['pane-id'] === paneId) { + return cur; + } + (cur.children || []).forEach((c) => stack.push(c)); + } + return null; + }; switch (name) { case 'tab-selected': node.properties.selected = values.tab; @@ -457,27 +470,38 @@ const LUISplit = (() => { break; } case 'tab-moved': { - const source = paneOf( - null, findPaneId(rootNode, values['from-pane'])); - const target = paneOf(null, node.id); - if (!source || !target) break; + const source = findPane(values['from-pane']); + const target = node; + if (!source || !target || !target.children) break; const i = (source.children || []).findIndex( (c) => c.properties['tab-id'] === values.tab); if (i < 0) break; const [tab] = source.children.splice(i, 1); + // Same-pane move: the removed slot shifts the drop index. + const index = source === target && values.index > i + ? values.index - 1 + : values.index; target.children.splice( - Math.min(values.index, target.children.length), 0, tab); + Math.min(index, target.children.length), 0, tab); target.properties.selected = values.tab; + if (source !== target && source.children.length > 0) { + source.properties.selected = source.children[0] + .properties['tab-id']; + } break; } case 'split-drop': { - const sourceId = findPaneId(rootNode, values['from-pane']); - const source = paneOf(null, sourceId); + const source = findPane(values['from-pane']); if (!source) break; const i = (source.children || []).findIndex( (c) => c.properties['tab-id'] === values.tab); if (i < 0) break; const [tab] = source.children.splice(i, 1); + if (source.properties.selected === values.tab + && source.children.length > 0) { + source.properties.selected = source.children[0] + .properties['tab-id']; + } const freshPane = { identifier: 'split-pane', properties: { @@ -499,7 +523,7 @@ const LUISplit = (() => { : [node, freshPane], }; // Replace `node` in its parent with the new branch. - const parent = paneOf(null, node.id); + const parent = parentOf(node.id); if (parent) { const j = parent.children.indexOf(node); parent.children[j] = branch; @@ -513,17 +537,6 @@ const LUISplit = (() => { } } - function findPaneId(rootNode, paneId) { - const stack = [rootNode]; - while (stack.length) { - const cur = stack.pop(); - if (cur.properties && cur.properties['pane-id'] === paneId) { - return cur.id; - } - (cur.children || []).forEach((c) => stack.push(c)); - } - return null; - } return { tree, redraw }; } diff --git a/src/lui_split.ml b/src/lui_split.ml index fffcc359..292632e3 100644 --- a/src/lui_split.ml +++ b/src/lui_split.ml @@ -756,14 +756,28 @@ module Model = struct } in { state with root = apply state.root } - | Close_tab (pane_id, tab_id) -> - let _, root = detach_tab pane_id tab_id state.root in - let root = prune pane_id root in - let state = { state with root } in - (match state.focused with - | Some f when Option.is_none (find_pane f state.root) -> - { state with focused = None } - | _ -> state) + | Close_tab (pane_id, tab_id) -> ( + let closable = + match find_pane pane_id state.root with + | Some pane -> + (match + List.find_opt + (fun (t : tab) -> String.equal t.tab_id tab_id) + pane.pane_tabs + with + | Some t -> t.tab_closable + | None -> false) + | None -> false + in + if not closable then state + else + let _, root = detach_tab pane_id tab_id state.root in + let root = prune pane_id root in + let state = { state with root } in + (match state.focused with + | Some f when Option.is_none (find_pane f state.root) -> + { state with focused = None } + | _ -> state)) | Close_pane pane_id -> ( match remove_pane pane_id state.root with | `Removed last -> @@ -779,8 +793,11 @@ module Model = struct | Some f when Option.is_none (find_pane f state.root) -> { state with focused = None } | _ -> state)) - | Move_tab { move_tab; move_from; move_to; move_index } -> - if String.equal move_from move_to then + | Move_tab { move_tab; move_from; move_to; move_index } -> ( + match find_pane move_to state.root with + | None -> state + | Some _ -> + if String.equal move_from move_to then match find_pane move_from state.root with | None -> state | Some source -> @@ -800,13 +817,13 @@ module Model = struct | None -> state | Some tab -> { state with root = insert_tab move_to tab index root })) - else ( + else ( let tab, root = detach_tab move_from move_tab state.root in match tab with | None -> state | Some tab -> let root = prune move_from root in - { state with root = insert_tab move_to tab move_index root }) + { state with root = insert_tab move_to tab move_index root })) | Split_drop { drop_tab; drop_from; drop_target; drop_edge } -> ( match find_pane drop_from state.root with | None -> state diff --git a/test/test_lui.ml b/test/test_lui.ml index 1fa8c630..230f64d1 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -1329,6 +1329,36 @@ let test_split_model () = in Alcotest.(check (list string)) "pane collapsed" [ "editor"; "pane-0" ] (pane_ids (root state)); + (* a stale move to a vanished pane leaves the tab in place *) + let state = + update state + (Move_tab + { move_tab = "b"; move_from = "editor"; move_to = "ghost"; + move_index = 0 }) + in + Alcotest.(check bool) "tab survived stale move" true + (Option.is_some + (Option.bind + (find_pane "editor" (root state)) + (fun p -> + List.find_opt + (fun t -> String.equal t.Lui_split.Model.tab_id "b") + p.pane_tabs))); + (* non-closable tabs reject Close_tab *) + let guarded = + create + (Leaf + (pane ~pane_id:"locked" + [ + tab ~tab_id:"open" ~title:"Open" (); + tab ~tab_id:"fixed" ~title:"Fixed" ~closable:false (); + ])) + in + let guarded = update guarded (Close_tab ("locked", "fixed")) in + Alcotest.(check int) "protected tab kept" 2 + (match find_pane "locked" (root guarded) with + | Some p -> List.length p.pane_tabs + | None -> -1); (* the last pane never closes *) let state = update state (Close_pane "editor") in let state = update state (Close_pane "pane-0") in From f9c19fb5e4d77a74de329e1e420e1f67577110c7 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 26 Sep 2026 12:11:22 -0700 Subject: [PATCH 4/8] Fix demo-host and macOS shortcut bugs found in e2e testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web standalone host: - emit() no longer redraws on focus-only events — the pane pointerdown emitted pane-focused, replacing the DOM mid-gesture and eating every chip click and x close - apply() resolves panes by pane-id (was matching the parent branch's children, so tab-moved/split-drop emitted but never changed layout) - ratio-changed is applied locally so divider drags no longer snap back - same-pane reorder adjusts for the removed slot; emptied panes are pruned and degenerate branches collapse; focus is patched in place and restored after structural redraws - Option+D produces '∂' on macOS, so match event.code KeyD instead of event.key; strip accepts drops at its end SwiftUI: - ⌘⌥arrows: arrow presses carry an implicit .function modifier, so strict modifiers equality never matched — require a superset instead - ⌘⌥D: Option+D produces '∂', register that equivalent too - tab close button: the chip's select/drag gestures swallowed the inner Button's clicks; use a high-priority tap on the xmark instead --- .../Sources/LUIAppleBackend/LUISplit.swift | 33 ++- platform/web/src/lui-split.js | 245 +++++++++++++++--- 2 files changed, 226 insertions(+), 52 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISplit.swift b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift index 7ddd9537..a0c65803 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISplit.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift @@ -507,17 +507,18 @@ private struct LUISplitPaneNode: View { .lineLimit(1) .foregroundStyle(isSelected ? .primary : .secondary) if tab.closable { - Button { - context.tryEmit("tab-closed", values: ["tab": .string(tab.tabID)]) - } label: { - Image(systemName: "xmark") - .font(.system(size: 8, weight: .bold)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .frame(width: 14, height: 14) - .contentShape(Rectangle()) - .accessibilityLabel("Close \(tab.title)") + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 14, height: 14) + .contentShape(Rectangle()) + .accessibilityLabel("Close \(tab.title)") + .accessibilityAddTraits(.isButton) + .highPriorityGesture( + TapGesture().onEnded { + context.tryEmit( + "tab-closed", values: ["tab": .string(tab.tabID)]) + }) } } .padding(.horizontal, 10) @@ -638,6 +639,12 @@ private struct LUISplitPaneKeys: ViewModifier { guard press.modifiers.contains([.command, .option]) else { return .ignored } return split(press.modifiers.contains(.shift) ? "vertical" : "horizontal") } + // Option+D produces '∂' rather than 'd', so the letter key never + // reaches the handler above on macOS. + .onKeyPress(KeyEquivalent("∂"), phases: .down) { press in + guard press.modifiers.contains([.command, .option]) else { return .ignored } + return split(press.modifiers.contains(.shift) ? "vertical" : "horizontal") + } .onKeyPress(KeyEquivalent("\\"), phases: .down) { press in guard press.modifiers.contains(.command) else { return .ignored } return split(press.modifiers.contains(.shift) ? "vertical" : "horizontal") @@ -656,7 +663,9 @@ private struct LUISplitPaneKeys: ViewModifier { private func navigate( _ press: KeyPress, modifiers: EventModifiers, direction: String ) -> KeyPress.Result { - guard press.modifiers == modifiers else { return .ignored } + // Arrow presses carry an implicit .function modifier, so compare + // only the command/option bits we actually require. + guard press.modifiers.isSuperset(of: modifiers) else { return .ignored } context.tryEmit("navigate", values: ["direction": .string(direction)]) return .handled } diff --git a/platform/web/src/lui-split.js b/platform/web/src/lui-split.js index db368c49..751a2390 100644 --- a/platform/web/src/lui-split.js +++ b/platform/web/src/lui-split.js @@ -149,7 +149,9 @@ const LUISplit = (() => { const el = document.createElement('div'); el.className = 'lui-split-pane'; el.tabIndex = 0; + el.dataset.paneId = paneId; if (focused) el.dataset.focused = 'true'; + if (host.registerPane) host.registerPane(node, el); const accId = prop(state, 'accessibility-identifier', ''); if (accId) el.dataset.testid = accId; @@ -170,6 +172,22 @@ const LUISplit = (() => { }); strip.append(trailingIndicator(el)); + // Dropping on the strip body (past the last chip) appends to the pane. + strip.addEventListener('dragover', (event) => { + if (!event.dataTransfer.types.includes(MIME)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }); + strip.addEventListener('drop', (event) => { + const payload = event.dataTransfer.getData(MIME); + event.preventDefault(); + const [fromPane, tab] = payload.split('\t'); + if (!tab) return; + host.emit(node, 'tab-moved', { + tab, 'from-pane': fromPane, index: tabs.length, + }); + }); + tabs.forEach((tabNode, index) => { const child = host.render(tabNode); child.classList.add('lui-split-tab-content'); @@ -241,7 +259,7 @@ const LUISplit = (() => { event.preventDefault(); return; } - if (event.key === 'd' || event.key === 'D') { + if (event.code === 'KeyD') { host.emit(node, 'split-requested', { orientation: event.shiftKey ? 'vertical' : 'horizontal', }); @@ -344,6 +362,7 @@ const LUISplit = (() => { }); chip.addEventListener('drop', (event) => { event.preventDefault(); + event.stopPropagation(); const payload = event.dataTransfer.getData(MIME); const [fromPane, tab] = payload.split('\t'); if (!tab) return; @@ -400,10 +419,12 @@ const LUISplit = (() => { }; intern(tree); + const paneEls = new Map(); const host = { // Accepts node ids (normal render path) or child objects (demo trees // keep children inline). extState: (n) => (typeof n === 'object' ? n : byId.get(n)), + registerPane: (node, el) => paneEls.set(node.id, el), render: (id) => { const node = byId.get(id) || id; if (node.identifier) return render(node.id, host); @@ -417,20 +438,56 @@ const LUISplit = (() => { }, emit: (node, name, values) => { onEvent(name, values); - apply(tree, node, name, values); + const kind = apply(tree, node, name, values); + if (kind === 'focus') { + // Focus is visual-only: patch the border in place so the + // in-flight click/keyboard event keeps its target and focus. + allPanes(tree).forEach((p) => { + const el = paneEls.get(p.id); + if (!el) return; + if (p.properties.focused) el.dataset.focused = 'true'; + else delete el.dataset.focused; + }); + return; + } redraw(); }, }; function redraw() { + // Structural redraw replaces the DOM; carry focus to the rebuilt + // element so keyboard navigation survives a close/split. + const focusedPaneId = + typeof document !== 'undefined' + && document.activeElement + && document.activeElement.dataset + ? document.activeElement.dataset.paneId + : null; byId.clear(); + paneEls.clear(); intern(tree); root.replaceChildren(render(tree.id, host)); + if (focusedPaneId) { + const pane = allPanes(tree).find( + (p) => p.properties['pane-id'] === focusedPaneId); + const el = pane && paneEls.get(pane.id); + if (el) el.focus(); + } } redraw(); // Minimal local semantics so demos feel alive: the real backend applies // these in OCaml and re-emits the tree. + const allPanes = (rootNode) => { + const panes = []; + (function walk(n) { + if (!n) return; + if (n.identifier === 'split-pane') panes.push(n); + (n.children || []).forEach(walk); + })(rootNode); + return panes; + }; + function apply(rootNode, nodeId, name, values) { const node = byId.get(nodeId); if (!node) return; @@ -454,10 +511,110 @@ const LUISplit = (() => { } return null; }; + const setFocused = (pane) => { + allPanes(rootNode).forEach( + (p) => { p.properties.focused = p === pane; }); + }; + // Remove emptied panes and collapse degenerate branches; the root + // view always keeps at least one pane. + const prune = () => { + let lastRemoved = null; + (function walk(n) { + if (!n.children) return; + n.children.forEach(walk); + n.children = n.children.filter((c) => { + if (c.identifier === 'split-pane' && c.children.length === 0) { + lastRemoved = c; + return false; + } + return true; + }); + n.children = n.children.flatMap((c) => + (c.identifier === 'split-branch' && c.children.length === 1) + ? c.children + : [c]); + })(rootNode); + if (!rootNode.children.length && lastRemoved) { + rootNode.children = [lastRemoved]; + } + }; + const splitPane = (pane, orientation, before = false) => { + if (pane.children.length < 2) return; + const selectedId = String( + pane.properties.selected + || (pane.children[0] ? pane.children[0].properties['tab-id'] : '')); + if (!selectedId) return; + const i = pane.children.findIndex( + (c) => c.properties['tab-id'] === selectedId); + if (i < 0) return; + const [tab] = pane.children.splice(i, 1); + if (pane.children.length) { + pane.properties.selected = + pane.children[Math.min(i, pane.children.length - 1)] + .properties['tab-id']; + } + const freshPane = { + identifier: 'split-pane', + properties: { 'pane-id': fresh(), selected: selectedId, + focused: true }, + children: [tab], + }; + const branch = { + identifier: 'split-branch', + properties: { + orientation, + // the fresh edge gets a quarter share, like Bonsplit + ratio: before ? 0.25 : 0.75, + }, + children: before ? [freshPane, pane] : [pane, freshPane], + }; + const parent = parentOf(pane.id); + if (parent) { + parent.children[parent.children.indexOf(pane)] = branch; + } else { + Object.assign(rootNode, branch); + } + setFocused(freshPane); + prune(); + }; + switch (name) { case 'tab-selected': node.properties.selected = values.tab; break; + case 'pane-focused': + setFocused(node); + return 'focus'; + case 'ratio-changed': + node.properties.ratio = values.ratio; + break; + case 'navigate': { + const panes = allPanes(rootNode); + const cur = panes.indexOf(node); + if (cur < 0 || panes.length < 2) break; + const step = + (values.direction === 'left' || values.direction === 'up') + ? -1 : 1; + setFocused( + panes[(cur + step + panes.length) % panes.length]); + return 'focus'; + } + case 'split-requested': + splitPane(node, values.orientation); + break; + case 'pane-closed': { + const panes = allPanes(rootNode); + const cur = panes.indexOf(node); + const parent = parentOf(node.id); + if (!parent) break; // last pane stays + parent.children.splice(parent.children.indexOf(node), 1); + prune(); + const rest = allPanes(rootNode); + if (rest.length) { + setFocused(rest[Math.max(0, Math.min(cur, rest.length - 1))]); + } + break; + } case 'tab-closed': { const i = (node.children || []).findIndex( (c) => c.properties['tab-id'] === values.tab); @@ -467,6 +624,16 @@ const LUISplit = (() => { ? node.children[0].properties['tab-id'] : ''; } + const hadTabs = node.children.length > 0; + if (!hadTabs) { + const panes = allPanes(rootNode); + const cur = panes.indexOf(node); + prune(); + const rest = allPanes(rootNode); + if (rest.length) { + setFocused(rest[Math.max(0, Math.min(cur, rest.length - 1))]); + } + } break; } case 'tab-moved': { @@ -484,51 +651,49 @@ const LUISplit = (() => { target.children.splice( Math.min(index, target.children.length), 0, tab); target.properties.selected = values.tab; - if (source !== target && source.children.length > 0) { - source.properties.selected = source.children[0] - .properties['tab-id']; + setFocused(target); + if (source !== target) { + if (source.children.length > 0) { + source.properties.selected = source.children[0] + .properties['tab-id']; + } else { + prune(); + } } break; } case 'split-drop': { const source = findPane(values['from-pane']); + const target = node; if (!source) break; - const i = (source.children || []).findIndex( - (c) => c.properties['tab-id'] === values.tab); - if (i < 0) break; - const [tab] = source.children.splice(i, 1); - if (source.properties.selected === values.tab - && source.children.length > 0) { - source.properties.selected = source.children[0] - .properties['tab-id']; - } - const freshPane = { - identifier: 'split-pane', - properties: { - 'pane-id': fresh(), - selected: tab.properties['tab-id'], - }, - children: [tab], - }; - const vertical = values.edge === 'left' || values.edge === 'right'; - const before = values.edge === 'left' || values.edge === 'top'; - const branch = { - identifier: 'split-branch', - properties: { - orientation: vertical ? 'horizontal' : 'vertical', - ratio: 0.5, - }, - children: before - ? [freshPane, node] - : [node, freshPane], - }; - // Replace `node` in its parent with the new branch. - const parent = parentOf(node.id); - if (parent) { - const j = parent.children.indexOf(node); - parent.children[j] = branch; + const horizontal = + values.edge === 'left' || values.edge === 'right'; + const before = + values.edge === 'left' || values.edge === 'top'; + if (source === target) { + // Self split: move the dragged tab to a new edge pane. + if (source.children.length < 2) break; + const idx = source.children.findIndex( + (c) => c.properties['tab-id'] === values.tab); + if (idx < 0) break; + source.properties.selected = values.tab; + splitPane(source, horizontal ? 'horizontal' : 'vertical', + before); } else { - Object.assign(rootNode, branch); + const i = (source.children || []).findIndex( + (c) => c.properties['tab-id'] === values.tab); + if (i < 0) break; + // Move the tab into the target pane first, then split it out + // toward the edge so focus/orientation handling stays shared. + const [tab] = source.children.splice(i, 1); + target.children.push(tab); + target.properties.selected = values.tab; + if (source.children.length > 0) { + source.properties.selected = source.children[0] + .properties['tab-id']; + } + splitPane(target, horizontal ? 'horizontal' : 'vertical', + before); } break; } From e39fdb164ada2b69e4e6a8d11b181724ca195064 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 26 Sep 2026 12:28:44 -0700 Subject: [PATCH 5/8] Fix remaining interaction bugs from re-verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Web: model focus is authoritative — DOM focus follows it after redraws and on navigate, so keyboard chains (nav→close) act on the right pane and focus survives structural redraws - SwiftUI: inspect the press's typed characters for the split shortcut (Option rewrites 'd' to '∂'); ⌘⌥D may still be reserved by macOS Dock autohide, in which case ⌘\ is the reliable split --- .agents/skills/testing-lui-demos/SKILL.md | 64 +++++++++++++++++++ .../Sources/LUIAppleBackend/LUISplit.swift | 18 +++--- platform/web/src/lui-split.js | 30 +++++---- 3 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 .agents/skills/testing-lui-demos/SKILL.md diff --git a/.agents/skills/testing-lui-demos/SKILL.md b/.agents/skills/testing-lui-demos/SKILL.md new file mode 100644 index 00000000..cba20249 --- /dev/null +++ b/.agents/skills/testing-lui-demos/SKILL.md @@ -0,0 +1,64 @@ +--- +name: testing-lui-demos +description: How to run and test LUI demo apps — the standalone web demos in platform/web/demo and the native macOS demo apps under examples/ — including macOS keyboard-shortcut and accessibility quirks. +--- + +# Testing LUI demos + +## Web demo (platform/web/demo/*.html) + +Self-contained pages that load `../src/lui-*.js` + `.css`. Open directly via +`file://` — no server needed: + + open -a "Google Chrome" "file:///Users/devin/repos/lui/platform/web/demo/.html" + +The demo host (`LUISplit.mount`) renders a plain-object tree and *echoes every +emitted event into the header `#log` element* — read it to verify which event +fired even when the local `apply()` doesn't mutate the layout. + +Gotchas discovered while testing lui-split: + +- `mount()`'s `emit()` calls `redraw()` unconditionally — every `pane-focused` + emit on pointerdown re-renders the DOM mid-gesture, so clicks on tab chips + can be eaten before `click` dispatches. If clicks don't register, suspect + this redraw, not aim. +- HTML5 drag-and-drop works with synthesized mouse drags (mouse down, move in + ~4-6 steps, screenshot while held, release). Drop only lands on elements with + a `dragover` handler that calls `preventDefault` — empty strip space rejects + the drop. +- Keyboard shortcuts only fire while a `.lui-split-pane` has DOM focus, and + any pointerdown kills focus (see above) — use the **Tab key** to focus a pane + instead of clicking. Each keypress that emits an event redraws and drops + focus again, so re-Tab before every shortcut. +- **macOS Option+letter produces a special character** (Option+D = ∂), so + `event.key === 'd'` style checks never match — test ⌘⌥/Ctrl+Alt+letter + shortcuts on macOS with suspicion; Ctrl+Alt+arrows are fine (arrows have no + character mapping). Prefer Ctrl over Cmd in Chrome (Cmd+W/Cmd+Alt+arrows are + browser shortcuts). Even after adding `KeyEquivalent("∂")`/event.code + handlers, verify empirically — ⌥⌘D is also the system "Dock autohide" + shortcut (check `defaults read com.apple.dock autohide` before/after). +- Structural redraws (split/close) drop DOM focus even when restore logic + exists — the keypress right after a split may be dead. Press Tab to refocus + a pane between keyboard assertions, and verify each shortcut by the #log + text, not just by layout. + +## Native macOS demo app (examples/split) + +Build/run: `bash examples/split/macos-appkit/build-app.sh`, then the bundle is +at `examples/split/macos-appkit/.build/debug/LUISplitDemo.app`. Quit/relaunch +for a clean state: `osascript -e 'quit app "LUISplitDemo"'` then `open -a `. +The process/app name is `LUISplitDemo` but the accessibility name is +`LUI Split` — query it with `computer query target=macos app="LUI Split"`. + +Gotchas: + +- Tab chips merge their children into one AX element + (`accessibilityElement(children: .combine)`), so the per-tab × button is NOT + in the accessibility tree — click it by coordinates or press it via the + chip's combined element. +- A blue ring around a pane marks the model's `focused` pane. +- onKeyPress letter equivalents (e.g. `KeyEquivalent("d")`) don't match + Option-modified keys on macOS (∂ again) — ⌘\ works, ⌘⌥D may not. +- Arrow key events carry an implicit `.function` modifier; handlers that check + `press.modifiers == [.command, .option]` (strict equality) never fire for + arrows — `.contains` is required. diff --git a/platform/apple/Sources/LUIAppleBackend/LUISplit.swift b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift index a0c65803..80e4756c 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISplit.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift @@ -635,15 +635,15 @@ private struct LUISplitPaneKeys: ViewModifier { navigate(press, modifiers: [.command, .option], direction: "down") } let splits = navigation - .onKeyPress(KeyEquivalent("d"), phases: .down) { press in - guard press.modifiers.contains([.command, .option]) else { return .ignored } - return split(press.modifiers.contains(.shift) ? "vertical" : "horizontal") - } - // Option+D produces '∂' rather than 'd', so the letter key never - // reaches the handler above on macOS. - .onKeyPress(KeyEquivalent("∂"), phases: .down) { press in - guard press.modifiers.contains([.command, .option]) else { return .ignored } - return split(press.modifiers.contains(.shift) ? "vertical" : "horizontal") + // ⌘⌥D: Option rewrites the typed character ('∂' on US layouts), + // so inspect the press rather than matching a KeyEquivalent. + .onKeyPress(phases: .down) { press in + let chars = press.characters.lowercased() + guard chars == "d" || chars == "∂", + press.modifiers.isSuperset(of: [.command, .option]) + else { return .ignored } + return split( + press.modifiers.contains(.shift) ? "vertical" : "horizontal") } .onKeyPress(KeyEquivalent("\\"), phases: .down) { press in guard press.modifiers.contains(.command) else { return .ignored } diff --git a/platform/web/src/lui-split.js b/platform/web/src/lui-split.js index 751a2390..9e58618b 100644 --- a/platform/web/src/lui-split.js +++ b/platform/web/src/lui-split.js @@ -448,31 +448,33 @@ const LUISplit = (() => { if (p.properties.focused) el.dataset.focused = 'true'; else delete el.dataset.focused; }); + setTimeout(focusModelPane, 0); return; } redraw(); }, }; + const focusModelPane = () => { + // Model focus is authoritative — point real DOM focus at it so + // navigate/close chains keep working after redraws. + const pane = allPanes(tree).find((p) => p.properties.focused); + const el = pane && paneEls.get(pane.id); + if (el && typeof document !== 'undefined' + && document.activeElement !== el) { + el.focus(); + } + }; + function redraw() { - // Structural redraw replaces the DOM; carry focus to the rebuilt - // element so keyboard navigation survives a close/split. - const focusedPaneId = - typeof document !== 'undefined' - && document.activeElement - && document.activeElement.dataset - ? document.activeElement.dataset.paneId - : null; byId.clear(); paneEls.clear(); intern(tree); root.replaceChildren(render(tree.id, host)); - if (focusedPaneId) { - const pane = allPanes(tree).find( - (p) => p.properties['pane-id'] === focusedPaneId); - const el = pane && paneEls.get(pane.id); - if (el) el.focus(); - } + // Defer past the in-flight event: replacing the focused element + // mid-handler makes the browser drop focus to afterwards. + const refocus = () => setTimeout(focusModelPane, 0); + if (typeof setTimeout !== 'undefined') refocus(); } redraw(); From dd2be292f27473a07944c61a95a7541ee1b54fe9 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 26 Sep 2026 12:36:31 -0700 Subject: [PATCH 6/8] Focus fix follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Web: registerPane keys by the node id string the renderer passes, not node.id (undefined) — focus-follows-model and the in-place focused border now actually run - SwiftUI: ⇧⌥D types 'Î'; accept it too. ⌘⌥D is consumed by macOS Dock autohide before reaching apps — ⌘\ remains the split shortcut --- platform/apple/Sources/LUIAppleBackend/LUISplit.swift | 4 +++- platform/web/src/lui-split.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISplit.swift b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift index 80e4756c..1f791aaf 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISplit.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift @@ -638,8 +638,10 @@ private struct LUISplitPaneKeys: ViewModifier { // ⌘⌥D: Option rewrites the typed character ('∂' on US layouts), // so inspect the press rather than matching a KeyEquivalent. .onKeyPress(phases: .down) { press in + // ⌥D types '∂' and ⇧⌥D types 'Î' on US layouts; ⌘⌥D itself is + // eaten by macOS Dock autohide before it reaches the app. let chars = press.characters.lowercased() - guard chars == "d" || chars == "∂", + guard chars == "d" || chars == "∂" || chars == "î", press.modifiers.isSuperset(of: [.command, .option]) else { return .ignored } return split( diff --git a/platform/web/src/lui-split.js b/platform/web/src/lui-split.js index 9e58618b..b23cf625 100644 --- a/platform/web/src/lui-split.js +++ b/platform/web/src/lui-split.js @@ -424,7 +424,7 @@ const LUISplit = (() => { // Accepts node ids (normal render path) or child objects (demo trees // keep children inline). extState: (n) => (typeof n === 'object' ? n : byId.get(n)), - registerPane: (node, el) => paneEls.set(node.id, el), + registerPane: (node, el) => paneEls.set(node, el), render: (id) => { const node = byId.get(id) || id; if (node.identifier) return render(node.id, host); From 6c740f728b729eafc17a3299db72a61bd29f303b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 26 Sep 2026 17:18:04 -0700 Subject: [PATCH 7/8] Slide fresh panes open with a spring, like Bonsplit A branch that mounts after the split-view's first present starts collapsed on the fresh side and animates to its target ratio; the initial tree still renders at full size --- .../Sources/LUIAppleBackend/LUISplit.swift | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISplit.swift b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift index 1f791aaf..c4de918c 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISplit.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift @@ -144,11 +144,25 @@ private struct LUISplitSettingsKey: EnvironmentKey { static let defaultValue = LUISplitSettings.fallback } +/// Tracks whether the split-view already presented once, so branches that +/// appear later (a fresh split) can slide open instead of popping in. +private final class LUISplitInsertionState: @unchecked Sendable { + var didPresent = false +} + +private struct LUISplitInsertionKey: EnvironmentKey { + static let defaultValue = LUISplitInsertionState() +} + private extension EnvironmentValues { var splitSettings: LUISplitSettings { get { self[LUISplitSettingsKey.self] } set { self[LUISplitSettingsKey.self] = newValue } } + var splitInsertion: LUISplitInsertionState { + get { self[LUISplitInsertionKey.self] } + set { self[LUISplitInsertionKey.self] = newValue } + } } private extension LUIAppleExtensionViewContext { @@ -206,6 +220,7 @@ private var luiSplitSelectedTabColor: Color { private struct LUISplitViewNode: View { let context: LUIAppleExtensionViewContext + @State private var insertion = LUISplitInsertionState() var body: some View { let settings = LUISplitSettings( @@ -214,7 +229,9 @@ private struct LUISplitViewNode: View { ) context.content .environment(\.splitSettings, settings) + .environment(\.splitInsertion, insertion) .accessibilityIdentifier(context.string("accessibility-identifier") ?? "") + .onAppear { insertion.didPresent = true } } } @@ -224,8 +241,10 @@ private struct LUISplitBranchNode: View { let context: LUIAppleExtensionViewContext @Environment(\.splitSettings) private var settings @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.splitInsertion) private var insertion @State private var fractionState: LUISplitFractionState @State private var dragStartFraction: Double? + @State private var didAppear = false init(context: LUIAppleExtensionViewContext) { self.context = context @@ -284,7 +303,25 @@ private struct LUISplitBranchNode: View { ) } } - .onAppear { reconcile(sourceRatio: context.double("ratio", default: 0.5)) } + .onAppear { + let target = context.double("ratio", default: 0.5) + guard !didAppear else { reconcile(sourceRatio: target); return } + didAppear = true + if insertion.didPresent, animation != nil { + // Fresh split: start collapsed on the new side — the fresh + // pane holds the smaller share (edge drops) or is the + // second child (split requests) — then spring it open. + fractionState.applyUserFraction(target < 0.5 ? 0 : 1) + let spring = Animation.spring(duration: 0.25, bounce: 0.1) + DispatchQueue.main.async { + withAnimation(spring) { + fractionState.applyUserFraction(target) + } + } + } else { + reconcile(sourceRatio: target) + } + } .onChange(of: context.double("ratio", default: 0.5)) { _, value in reconcile(sourceRatio: value) } From 2b1bd22168e25289c56b34c85cd72f7a409998ec Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 26 Sep 2026 17:55:26 -0700 Subject: [PATCH 8/8] Fix drop and focus bugs found in the polish pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Center-pane drops sent index Int.max, which exceeds the wire's JSON int range (2^62) and decodes as a float, so tab-moved silently failed; send the real append index - The focused property now drives FocusState, so navigate focuses the real pane — ⌘W after ⌘⌥arrow acts on the navigated pane --- .agents/skills/testing-lui-demos/SKILL.md | 27 +++++++++++++++++++ .../Sources/LUIAppleBackend/LUISplit.swift | 7 ++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.agents/skills/testing-lui-demos/SKILL.md b/.agents/skills/testing-lui-demos/SKILL.md index cba20249..57a018f7 100644 --- a/.agents/skills/testing-lui-demos/SKILL.md +++ b/.agents/skills/testing-lui-demos/SKILL.md @@ -62,3 +62,30 @@ Gotchas: - Arrow key events carry an implicit `.function` modifier; handlers that check `press.modifiers == [.command, .option]` (strict equality) never fire for arrows — `.contains` is required. +- Drag drops land on three distinct targets with different code paths: tab + chips (insert-at-index), pane edges (split-drop, outer 25% clamped + [48,160]pt — the strip row itself counts as the *top edge* zone, so + "drop onto the strip" can split instead of move), and pane body center + (append move). Verify each path separately — a pass on one does not + prove the others. +- Beware large-int sentinel values in event payloads: the JSON bridge's + OCaml parser only accepts ints < 2^62 (`lui_json.ml` guard), so a + `.int(Int.max)` "append" sentinel decodes as a Float and the whole event + can silently fail to decode. If a drop emits but nothing happens, check + the payload magnitudes. +- Model focus (border ring) and real SwiftUI key focus (`hasKeyFocus`) can + diverge — `navigate` moves the border but keys may still hit the + previously-clicked pane. After navigating, verify a subsequent shortcut + acts on the navigated-to pane, not the previously focused one. +- To verify split/insert animations in a recording: record at 60fps (the + spring is ~250ms), then extract frames with ffmpeg around the moment — + `recording_stop` writes a `*-annotations.json` next to the video whose + `edited_time_s` values map annotation/action timestamps into the + exported video's timeline. +- Window positioning via System Events: batching + `set {size, position} of window 1 to {...}` fails with -10003; set + `position` and `size` in two separate commands. +- Synthesized drags need real gesture timing: `left_click_drag` can commit + edge drops, but chip/strip targets are more reliable with an explicit + `left_mouse_down` → several `mouse_move` steps → pause (`wait` ~0.7s, + pane highlight/ghost visible) → `left_mouse_up` sequence. diff --git a/platform/apple/Sources/LUIAppleBackend/LUISplit.swift b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift index c4de918c..549a35a7 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISplit.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISplit.swift @@ -483,6 +483,9 @@ private struct LUISplitPaneNode: View { .contentShape(Rectangle()) .focusable() .focused($hasKeyFocus) + .onChange(of: isFocused) { _, focused in + hasKeyFocus = focused + } .onTapGesture { hasKeyFocus = true context.tryEmit("pane-focused") @@ -500,6 +503,7 @@ private struct LUISplitPaneNode: View { paneID: paneID, context: context, paneSize: paneSize, + tabCount: tabs.count, zone: $dropZone, tabDropIndex: $tabDropIndex ) @@ -729,6 +733,7 @@ private struct LUISplitPaneDropDelegate: DropDelegate { let paneID: String let context: LUIAppleExtensionViewContext let paneSize: CGSize + let tabCount: Int @Binding var zone: LUISplitDropZone? @Binding var tabDropIndex: Int? @@ -776,7 +781,7 @@ private struct LUISplitPaneDropDelegate: DropDelegate { "tab-moved", values: [ "tab": .string(payload.tab), - "index": .int(Int.max), // append + "index": .int(tabCount), // append "from-pane": .string(payload.pane), ]) case .edge(let edge):