Skip to content

Add list suite: sections, disclosure rows, swipe actions, scroll + visible-range events - #74

Merged
RCmerci merged 22 commits into
mainfrom
devin/1790579983-list-suite
Sep 29, 2026
Merged

RCmerci merged 22 commits into
mainfrom
devin/1790579983-list-suite

Conversation

@RCmerci

@RCmerci RCmerci commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Ports the capabilities of the journal app's private journal-list extension into standard, general-purpose lui elements: grouped sections with arbitrary header/footer content, disclosure rows, per-edge swipe actions, programmatic scroll requests, and visible-range tracking — all named generically so any app can use them.

Schema (schema/components.json) — new node kinds list-section, list-section-header, list-section-footer, swipe-actions, swipe-action; new properties key, separator, style, scroll-target, scroll-anchor, scroll-token, scroll-animated, track-visible-range, edge; new list events scroll-completed(node, token:int, outcome:string) and visible-range(node, first:int, last:int). Generated files regenerated via tooling/generate_component_schema.mjs.

OCaml API (lui_elements)

  • list gains ?style ?scroll_target ?scroll_anchor ?scroll_token ?scroll_animated ?track_visible_range ?on_scroll_completed ?on_visible_range
  • list_item gains ?separator ?swipe_actions; ?key now also sets the wire key prop (scroll-target identity). Disclosure rows reuse the existing ?expanded ?on_toggle pair.
  • New constructors: list_section ?key ?separator ?header ?footer rows (header/footer are arbitrary elements mounted into list-section-header/list-section-footer slot nodes), swipe_actions, swipe_action ?text ?icon ?variant ?edge ?background ?disabled ?on_press.

Validation (lui_protocol) — list-section must sit under list and accepts only list-item/list-section-header/list-section-footer; swipe-actions under list-item, swipe-action under swipe-actions; expanded on a list-item requires toggle-enabled but no longer requires the treeitem role (disclosure rows aren't tree rows); swipe-action requires text or icon.

Apple backend (LUIAppleBackend)

  • LUIListSectionPolicy.explicitSections groups list-section children into SwiftUI Sections (non-section children fall back to the existing heading/footnote inference, unchanged).
  • LUIListItemView renders a DisclosureGroup when the row has list-item children; nested rows appear as list rows and ToggleChanged reports expansion.
  • LUIListItemSwipeActionsModifier prefers an explicit swipe-actions child (both edges via edge, allowsFullSwipe: false, variant: "destructive" → Button(role: .destructive), background → tint); without one it keeps the existing trailing-swipe derivation from context-menu.
  • LUIListView wraps the list in ScrollViewReader; a scroll-token bump scrolls to the row whose key matches scroll-target and emits scroll-completed with outcome succeeded|missing-target|cancelled|superseded|positioning-failed (a newer token completes the in-flight request superseded).
  • track-visible-range attaches appear/disappear reporting to each row and emits debounced (~80 ms) visible-range with flat payload-order positions (last exclusive), only on change.
  • style maps to .plain/.inset/.insetGrouped on iOS (.plain/.inset on macOS, preserving existing defaults); separator maps to .listRowSeparator/.listSectionSeparator visibility.

Other backends — Flutter/Qt/WinUI get schema-parity validation only; the new kinds degrade to plain containers, matching how other unimplemented kinds degrade.

Tests

  • OCaml: new list suite rules test in test/test_lui.ml covers child/parent rules, property support + value vocab, event support, disclosure relaxation, and the swipe-action label/icon requirement.
  • Swift: new LUI list suite suite in LUIBackendParityTests.swift covers batch construction of a grouped list with disclosure + swipe rows, validation rejections, and press/toggleChanged/scrollCompleted/visibleRange emission.
  • Verified: dune build, dune runtest -j 4 (30 tests), make test-apple (136 tests) all pass.

Link to Devin session: https://app.devin.ai/sessions/e50c52020b01400c8a5898c6cde8ce97
Open in Devin Desktop: https://app.devin.ai/desktop/session/e50c52020b01400c8a5898c6cde8ce97?variant=devin
Requested by: @RCmerci

Adds three general-purpose container kinds so apps can compose pinned
chrome, floating controls, and fit-or-fallback content without host-side
hacks:

- edge-inset (EdgeInset): pins children after the first to a screen edge
  via .safeAreaInset(edge:) while the first child scrolls beneath. New
  'edge' prop (required; top|bottom|leading|trailing) picks the edge,
  new 'visible' bool animates the pinned region in/out, 'gap' maps to
  safeAreaInset spacing. Surface props style the pinned region (the node
  bypasses the container surface modifier); 'background' gains the 'bar'
  material name.
- overlay (Overlay): children[0] is the base and sizes the view; later
  children float over it via .overlay(alignment:). New 'alignment' prop
  (9-point grid: top-leading..bottom-trailing) on each child picks its
  anchor, falling back to the overlay's own alignment (default center).
  Distinct from stack's popover anchors (above|below|left|right), which
  can't express inside-corner placement.
- view-that-fits (ViewThatFits): renders ViewThatFits(in:), picking the
  first child that fits; reuses 'orientation' (horizontal|vertical,
  default horizontal) as the axis.

Validation parity across OCaml/Swift/Dart/C++/C#: edge-inset requires
'edge', vocabularies are closed, 'alignment' is allowed on any non-root
kind (position hint on overlay children; inert elsewhere).

Flutter/Qt/WinUI approximate (Stack+Positioned, anchors, LUIGrid); web
maps to grid-overlay CSS. OCaml constructors: Lui_elements.edge_inset,
overlay, view_that_fits, plus align combinator.
Three general-purpose element kinds sufficient to replace journal-media
style per-app extensions:

- file-image: loads a local path / file:// URL off the main actor via
  CGImageSource thumbnail decoding (NSCache keyed by path+pixel size,
  ~32MB cap), with loading spinner, failure icon fallback, and the
  standard press-enabled/Press path.
- file-preview: a non-rendering node that presents QuickLook via
  .quickLookPreview. Presence in the tree means presented, matching the
  sheet/dialog 'mount to present' pattern but on its own presentation
  lane (not isModalSurface, which forces a text title and sheet chrome).
  Interactive close emits Dismiss and is suppressed from re-asserting
  until the wire drops the node, same as modal dismissal.
- link: a container rendering SwiftUI Link(destination:) from a url
  prop; children (or text/icon props) form the label, so it composes
  inside list rows and overlays.

New wire properties: path, url, max-pixel-size. file-preview is a
restrictive kind (path + accessibility-identifier only). Flutter/Qt/
WinUI/web schema mirrors updated; Qt gains stub QML views; WinUI's
restrictive matrix covers file-preview automatically.
Adds a general-purpose numeric stepper element (kind number-stepper,
props value/min/max/step/text/enabled, emits ValueChanged) so apps can
express bounded numeric input with a step increment, and two presentation
props on sheet: detents (comma-separated medium|large|fraction) and
sizing (form|fitted|page).

Apple backend: LUINumberStepperView renders a SwiftUI Stepper over a
clamped range; sheets apply .presentationDetents and
.presentationSizing (iOS 18+/macOS 15+ gated) via
LUIModalPresentationPolicy on the shared modal surface, so navigation-form
sheets compose. Integral wire floats (e.g. value:60.0) decode as Int
through Foundation's JSONDecoder, so float-typed props now normalize
int -> double like grow/sourceX already did.

Web/melange renders an <input type=number>; qt, flutter, and winui gain
schema support and minimal renderers; winui coerces integral JSON
numbers for float props the same way.
Introduces a generic non-visual 'file-picker' node kind driven by the
schema: a 'request' token presents a picker for 'source'
(files/photos/camera), 'picked' emits a JSON payload echoing the token
plus per-file {path, name, content-type}, 'completion' releases retained
security-scoped resources, and a cancelled presentation reports through
'dismiss'. 'types' takes a comma-separated UTI list and 'multiple'
enables multi-select.

Apple backend mounts fileImporter, SwiftUI PhotosPicker, or an iOS-only
UIImagePickerController camera wrapper; macOS camera requests answer
with dismiss. Picked URLs keep their security scope (or temp copies for
photo/camera results) until completion echoes the token or the node is
dropped, and the iOS fileImporter never-calls-on-completion-on-cancel
quirk is covered by presentation-state cancel detection.

Flutter, Qt, and WinUI get schema parity and stub rendering only.
…visible-range tracking

Schema gains five node kinds (list-section, list-section-header,
list-section-footer, swipe-actions, swipe-action), nine properties
(key, separator, style, scroll-target, scroll-anchor, scroll-token,
scroll-animated, track-visible-range, edge), and two list events
(scroll-completed, visible-range).

OCaml: list_item gains ?expanded/?on_toggle disclosure support plus
?separator/?swipe_actions; list gains ?style, scroll request props and
?on_scroll_completed/?on_visible_range; new list_section, swipe_actions
and swipe_action constructors.

Apple backend: list-section children render as grouped sections with
arbitrary content headers/footers (existing heading/footnote inference
still applies when no explicit sections are present); a list-item with
children renders a DisclosureGroup; explicit swipe-actions render on
both edges while the context-menu trailing-swipe derivation is
preserved; scroll-target/scroll-token drive ScrollViewReader requests
reporting scroll-completed outcomes; track-visible-range emits
debounced flat-position visible-range events.

Flutter/Qt/WinUI gain schema-parity validation; rendering stays a
container fallback.
@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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-28T07:29:22.370829Z 2034b75 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Edge Inset page toggles the pinned region via 'visible' and shows top
and bottom bars over scrolling lists; Overlay floats aligned children
over a base card; View That Fits swaps expanded/compact variants on
window resize.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2034b75fce

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +5634 to +5638
rangeDebounce?.cancel()
rangeDebounce = Task { @MainActor in
try? await Task.sleep(nanoseconds: 80_000_000)
guard !Task.isCancelled else { return }
emitVisibleRange()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Recompute visible ranges when row ordering changes

When rows are inserted, removed, or reordered while the same row IDs remain visible, SwiftUI preserves the keyed row views, so no onAppear/onDisappear callback invokes this debounce. flatRowOrder and therefore the visible indices can change without another visible-range event, leaving consumers such as pagination logic with stale positions until the user scrolls. Schedule emission when the flattened row order changes as well as when visibility changes.

Useful? React with 👍 / 👎.

Comment on lines +5409 to +5413
} else {
rows(section.childIDs)
} footer: {
Text(verbatim: footer.text)
.modifier(LUIListSectionSeparatorModifier(
visibility: sectionModel?.separatorVisibility
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wrap headerless explicit groups in a Section

An explicit list-section with neither a header nor footer takes this branch and emits only its rows, so adjacent headerless sections collapse into the same implicit SwiftUI section. This also means the section separator modifier has no actual section boundary to control. Render Section { rows(...) } here so every explicit section remains a distinct native section.

Useful? React with 👍 / 👎.

Comment on lines +5239 to +5241
} else {
pending.append(childID)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve inferred headers beside explicit sections

When a list contains any explicit section, every run of non-section children is accumulated here as plain rows instead of being passed through the existing heading/footnote inference. Thus adding one list-section changes legacy siblings—for example, a heading followed by rows is rendered as an ordinary list row rather than a section header. Apply the existing section inference to each pending run before appending it.

Useful? React with 👍 / 👎.

Comment on lines +2837 to +2841
_NodeKind.listSection ||
_NodeKind.listSectionHeader ||
_NodeKind.listSectionFooter ||
_NodeKind.swipeActions => column(),
_NodeKind.swipeAction => Text(text),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Hide swipe-action metadata from Flutter row content

On Flutter, a list_item ~text:"Row" ~swipe_actions:[...] includes the new swipe-actions node in the ordinary children list, and listItem() prefers any children over the row text. Because this branch renders that metadata as a column of action labels, every swipe-enabled row loses its real label and displays the actions instead. Filter swipe-actions from list-item content just as context-menu is filtered.

Useful? React with 👍 / 👎.

Comment on lines +5569 to +5571
private func rowID(forKey key: String) -> Int? {
flatRowOrder.first { backend.model(id: $0)?.rowKey == key }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include section keys in scroll-target lookup

list_section ~key emits a wire key documented for scroll targeting, but this lookup searches only flatRowOrder, which contains the rows inside each section and never the section node itself. Consequently, targeting a section key always reports missing-target even though the native ForEach identifies that section by its node ID. Search explicit section nodes before or alongside their rows.

Useful? React with 👍 / 👎.

Comment on lines +5346 to +5353
.onChange(of: model.scrollToken, initial: true) { _, _ in
// No scroll surface of its own to scroll on.
if let token = model.scrollToken {
backend.performScrollCompleted(
node: model.id,
token: token,
outcome: "cancelled"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Deduplicate fallback scroll completions

For lists nested inside another scroll view, this initial: true callback emits cancelled every time the fallback view appears, even when the scroll token was already handled. Navigating away and back, remounting the view, or switching from the native path can therefore deliver multiple completion events for one token. Apply the same handledScrollToken guard used by handleScrollRequest before emitting.

Useful? React with 👍 / 👎.

Comment on lines +693 to +697
case NodeKind::SwipeAction:
return oneOf(property, {Property::TextValue, Property::InlineIconName,
Property::VariantValue, Property::EdgeValue,
Property::Enabled, Property::BackgroundValue,
Property::PressEnabled});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce swipe-action labels in parity validators

Qt now admits SwipeAction properties but its nodePropertiesSupported function has no corresponding requirement for nonempty text or icon, so a raw batch containing an action with only enabled passes validation even though OCaml, Apple, and Flutter reject it. The analogous WinUI validator has the same omission, breaking the stated schema parity on both backends; add the text-or-icon invariant to both node-property validators.

Useful? React with 👍 / 👎.

Comment on lines +92 to +93
| SwipeActions -> "lui-swipe-actions"
| SwipeAction -> "lui-swipe-action"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Hide swipe-action metadata in the web backend

The web backend creates the new SwipeActions node as an ordinary DOM child, while validate_list_item_content in lui_web_store.ml still excludes only context-menu from visible content. A normal list_item ~text:"Row" ~swipe_actions:[...] is therefore rejected as having both text and children before the batch commits, so using the new API blanks or prevents the list from updating on web. Treat SwipeActions as hidden metadata in both validation and DOM child placement.

Useful? React with 👍 / 👎.

Comment on lines +4029 to +4034
if swipeActions != nil {
content
.swipeActions(edge: .leading, allowsFullSwipe: false) {
ForEach(actionIDs(on: .leading), id: \.self) { actionID in
if let action = backend.model(id: actionID) {
LUISwipeActionView(model: action, backend: backend)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep context-menu access with explicit swipe actions

When a row has both explicit swipe-actions and an eligible context-menu, this branch correctly prefers the explicit actions, but swipeMenu remains nonnil and the row label consequently suppresses its ellipsis menu button. Because this branch also ignores the passed menu, every context-menu command becomes unreachable. When explicit actions are present, keep the context menu available through the ellipsis instead of classifying it as the active swipe menu.

Useful? React with 👍 / 👎.

…vent gates

- web: link renders as a structured <a> (icon + content spans) so
  inline icon, text, and children compose; url maps to href
  (+target=_blank, rel=noopener), path/max-pixel-size become
  data-* attributes with matching remove handlers; file-image clicks
  emit Press when press-enabled.
- apple: both filePreviewBindings filter on the presentation's
  rootID and only dismiss previews owned by that root — multiple
  LUISwiftUIRoots no longer share/dismiss one global preview.
- qt: performPress admits FileImage with the same press-enabled guard;
  performDismiss admits FilePreview (Dismiss is advertised for it).
- winui: PerformAction admits FileImage gated on PressEnabled;
  PerformDismiss admits FilePreview.
- WinUI: route the new kinds through SyncStackLike so children render
- Web: handle edge/visible/alignment in apply_secondary_property and
  remove_property; overlay children go position:absolute so they no
  longer contribute intrinsic size to the grid
- Protocol validators (OCaml/Swift/Qt/WinUI/Flutter): admit 'alignment'
  ahead of the restrictive kindProperties matrices, and whitelist the
  new kinds for 'foreground'
- Flutter: group pinned edge-inset children in an edge-appropriate
  Row/Column, honor 'gap', and use AlignmentDirectional so
  leading/trailing follow RTL
- background:'bar' gets a translucent surface fallback on Flutter, Qt,
  WinUI, and web
- Qt: bind anchors declaratively so retained delegates re-anchor when
  alignment/edge changes
- Hold file-picker operations on the backend keyed by node id so view
  teardown can't release retained files; drop-node releases them.
- Stream PhotosPicker items through FileRepresentation instead of
  loading whole assets into memory as Data.
- Guard UIImagePickerController.isSourceTypeAvailable(.camera) — a
  camera-less device now answers dismiss instead of throwing.
- Mark photo selections handled before clearing so async imports are
  not mistaken for cancels; finishCancelled also tears down the camera
  sheet; requests equal to completion no longer re-present.
- Flutter/WinUI backends answer unservable file-picker requests with
  dismiss after the batch commits; the Qt stub does the same in QML.
- Lui_elements.file_picker request/completion take a file_picker_token
  ([`String | `Int]) matching the wire's string|int property values.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

End-to-end verified the list suite on iOS Simulator (SwiftUI backend) using a scratch app that feeds JSON ops into LUIAppleBackend + LUISwiftUIRoot and logs every emitted event on screen.

Trailing swipe actions: red Delete + green Done

More evidence

Leading swipe action: blue Archive
Disclosure row expanded with nested rows + toggle-changed event
scroll-token bump centered Row 25, scroll-completed succeeded

  • list-section/list-section-header/list-section-footer group rows with arbitrary header/footer content under style: inset-grouped
  • separator prop wired (visible draws a hairline where hidden/default draws none)
  • Disclosure list-item (expanded + toggle-enabled) expands/collapses nested rows and emits toggle-changed
  • swipe-actions render on leading (Archive/blue tint) and trailing (Delete/red destructive + Done/green tint) edges; taps emit press
  • scroll-token bump scrolls to the keyed row and emits scroll-completed(outcome=succeeded); unknown key emits missing-target
  • track-visible-range emits debounced visible-range(first,last) on scroll with flat positions across sections

Not exercised: macOS rendering (swipe actions are trackpad-only there), scroll-animated=false, and list-inside-scroll fallback. Recording available in the Devin session.

…ction handling

- Apple: emit visible-range on flatRowOrder changes (insert/remove/reorder
  with same visible ids); wrap headerless explicit sections in Section{};
  apply heading/footnote inference to pending non-section runs; resolve
  scroll targets by section key; guard fallback cancelled emission with
  handledScrollToken; keep context-menu ellipsis when explicit
  swipe-actions exist.
- Flutter: exclude swipe-actions children from list-item row content.
- Web: treat swipe-actions as hidden metadata in list-item content
  validation and DOM placement (never mounted).
- Qt/WinUI: require text or icon on swipe-action for parity.
…re detents/sizing on all backends

- add platform/qt/qml/LuiNumberStepper.qml and register it in QML_FILES
- web: clamp number-stepper value to retained min/max and drop NaN before
  dispatching ValueChanged; route aria-label removal to the child input
- apply sheet detents (height fraction) and sizing (fitted width) on the
  web, Qt, Flutter, and WinUI surfaces instead of accepting them as no-ops
- winui: copy text to NumberBox.Header; flutter: wrap the stepper in a
  Semantics label and give the step buttons tooltips
- compare stepper min/max after applying defaults so a lone negative max
  is rejected (ocaml + all backend mirrors)
An empty ForEach mounts no view, so .fileImporter/.photosPicker/.sheet on
LUIFilePickerView never presented when the node had no children.
@RCmerci
RCmerci merged commit e114fca into main Sep 29, 2026
7 checks passed
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