Skip to content

Add LUI split: Bonsplit-style tabbed split panes for all platforms - #59

Merged
tiensonqin merged 8 commits into
mainfrom
devin/1790447478-lui-split
Sep 27, 2026
Merged

tiensonqin merged 8 commits into
mainfrom
devin/1790447478-lui-split

Conversation

@tiensonqin

@tiensonqin tiensonqin commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

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 in src/lui_split.ml and rendered natively per backend via the existing Lui_extension mechanism (profiles gate them off GenericOS/GenericHost). Lui_split.Model is the Bonsplit BonsplitController analog: a pure OCaml t + action reducer (update) + render that emits the extension tree and wires pane_action decoders into a dispatch callback — identical split policy on every platform. Apps embed Model.t and dispatch actions:

| SplitPanes action ->
  { model with split_panes = Lui_split.Model.update model.split_panes action }

(* view *)
Lui_split.Model.render
  ~build:(fun tab -> [ my_tab_content tab ])
  ~dispatch:(fun a -> send (Model.SplitPanes a))
  model.split_panes

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's edgeRatio), navigate{direction}, split-requested, pane-focused, pane-closed, ratio-changed.

Per platform:

  • Apple (LUISplit.swift): LUISplit.register(in:), LUISplitSettings via EnvironmentValues, ⌘⌥D/⌘\/⌘W/⌘⇧W/⌘⌥arrows shortcuts, DragSource/edge drop zones.
  • Qt (5 QML files + LUI::registerSplitExtensions): Loader-mounted components, Drag/DropArea with lui-split-tab keys, Ctrl/Alt shortcuts.
  • Flutter (lui_flutter_split.dart): LUIFlutterSplit.register, Draggable/DragTarget chips (global→local offset via GlobalKeys), CallbackShortcuts, Offstage+TickerMode per-tab content.
  • WinUI (LUISplitExtensions.cs): SplitView/Branch/Pane/TabControl visuals, CanDrag/AllowDrop, modifier-key shortcuts via InputKeyboardSource. Not compile-verified locally (no dotnet on this machine) — CI covers LUI.WinUI.
  • Web (lui-split.js/css + demo/lui-split.html): standalone renderer + LUISplit.mount demo host applying the same semantics locally.

Also: generator gains qml/winui hosts 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, like apple-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 runtest 28 pass, swift build, cmake + tst_lui_qml_backend 14/14, flutter analyze clean, 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


Devin Review

- 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
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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

View all findings in Devin Review

Devin Review

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
@devin-ai-integration

Copy link
Copy Markdown
Contributor

E2E-tested on both demo targets — the standalone web host (platform/web/demo/lui-split.html) and the native LUISplitDemo.app (SwiftUI/AppKit + real OCaml model). Several bugs were found and fixed along the way (emit-redraw eating clicks, paneOf resolving the parent instead of the pane, divider snap-back, macOS ⌥ character rewriting, .function modifier on arrows, × swallowed by chip gestures).

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. ⌘⌥D is consumed by macOS for Dock autohide before it reaches the app — verified the event never arrives. ⌘\ is the working split shortcut.

edge drop creates a ~25% pane
keyboard chain: split → navigate → close

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
@devin-ai-integration

Copy link
Copy Markdown
Contributor

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:

  • Center-pane drops silently no-op'd — the drop delegate sent index: Int.max, which overflows the OCaml bridge's JSON-int guard (2^62), decodes as a float, and fails the tab-moved decoder. Now sends the real append index.
  • Cross-pane chip drops — same silent-drop path via the pane delegate; now insert at the indicator position.
  • ⌘⌥arrows only moved the focus border — the focused property now drives FocusState, so nav → ⌘W closes the navigated pane's tab.

edge-drop result: new narrow pane at right
nav then ⌘W closed the navigated pane

Final golden-path video: launch (static first render) → select → × close → cross-pane move → edge-drop slide-in → divider → ⌘\ slide-in → ⌘⌥nav → ⌘W.

@tiensonqin
tiensonqin merged commit bb17376 into main Sep 27, 2026
4 checks passed
@tiensonqin
tiensonqin deleted the devin/1790447478-lui-split branch September 27, 2026 01:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant