Add LUI split: Bonsplit-style tabbed split panes for all platforms - #59
Conversation
- 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
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
There was a problem hiding this comment.
✅ Devin resolved all 9 findings on a7f1586
Fixed by Devin (9)
- Gallery rejects its new split section
- Stale drop loses a tab
- Protected tabs close by keyboard
- Flutter divider forgets prior drag motion
- Qt divider drag oscillates
- Web demo ignores tab moves and splits
- Qt zero split ratio becomes half
- Web demo reorders tabs one slot late
- Untrusted tab content executes as HTML
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.
- 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
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
- 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
- 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
|
E2E-tested on both demo targets — the standalone web host ( Web demo — fully green: click-select, × close, cross-pane tab move, edge-drop split at ~25% share, divider drag persists, in-strip reorder, Ctrl+Alt+D split, Ctrl+Alt+arrows navigate (focus follows the model), Ctrl+W close + empty-pane prune. Native app — green except one OS limitation: click select, × close, ⌘⌥arrows, cross-pane move, edge-drop ~25%, divider, ⌘\ split, ⌘W close+collapse all pass. |
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
- 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
|
Re-verified after the slide-in animation (6c740f7) — fresh panes spring open on both edge-drop and ⌘\ splits while the first render stays static. That pass surfaced three more bugs, all fixed in 2b1bd22:
Final golden-path video: launch (static first render) → select → × close → cross-pane move → edge-drop slide-in → divider → ⌘\ slide-in → ⌘⌥nav → ⌘W. |
Summary
Adds a Bonsplit-style tabbed split-pane library (
Lui_split) covering every lui backend: SwiftUI, Qt/QML, Flutter, WinUI, and Web — with drag-and-drop tab reordering, edge-drop splits, divider resizing, keyboard navigation, and platform-owned gesture animations (120fps: drag visuals never cross the bridge; only committed actions become events).Architecture: four extension components —
split-view>split-branch>split-pane>split-tab— declared once insrc/lui_split.mland rendered natively per backend via the existingLui_extensionmechanism (profiles gate them offGenericOS/GenericHost).Lui_split.Modelis the BonsplitBonsplitControlleranalog: a pure OCamlt+actionreducer (update) +renderthat emits the extension tree and wirespane_actiondecoders into adispatchcallback — identical split policy on every platform. Apps embedModel.tand dispatch actions:Events:
tab-selected,tab-closed,tab-moved{tab,index,from-pane},split-drop{tab,from-pane,edge}(edge drop → new pane at 0.25 share, matching Bonsplit'sedgeRatio),navigate{direction},split-requested,pane-focused,pane-closed,ratio-changed.Per platform:
LUISplit.swift):LUISplit.register(in:),LUISplitSettingsvia EnvironmentValues,⌘⌥D/⌘\/⌘W/⌘⇧W/⌘⌥arrowsshortcuts, DragSource/edge drop zones.LUI::registerSplitExtensions):Loader-mounted components,Drag/DropAreawithlui-split-tabkeys, Ctrl/Alt shortcuts.lui_flutter_split.dart):LUIFlutterSplit.register,Draggable/DragTargetchips (global→local offset viaGlobalKeys),CallbackShortcuts,Offstage+TickerModeper-tab content.LUISplitExtensions.cs):SplitView/Branch/Pane/TabControlvisuals,CanDrag/AllowDrop, modifier-key shortcuts viaInputKeyboardSource. Not compile-verified locally (no dotnet on this machine) — CI coversLUI.WinUI.lui-split.js/css+demo/lui-split.html): standalone renderer +LUISplit.mountdemo host applying the same semantics locally.Also: generator gains
qml/winuihosts and annotates decoded event records (fixes ambiguity when two events share a field shape); gallery registers the split components and adds a Model-driven demo section (Apple-gated, likeapple-map).Tests: fingerprint literals are asserted in sync across all six host sources (Swift/C++/Dart/C#/JS/OCaml), plus extension op round-trips, event decoders, and reducer coverage (move/index adjustment, edge-drop geometry, navigation, pane collapse, last-pane protection). Verified:
dune runtest28 pass,swift build,cmake+tst_lui_qml_backend14/14,flutter analyzeclean,node --check+ schema tests pass.Link to Devin session: https://app.devin.ai/sessions/becd167aced74606b789306295fef578
Open in Devin Desktop: https://app.devin.ai/desktop/session/becd167aced74606b789306295fef578?variant=devin
Requested by: @tiensonqin