Add list suite: sections, disclosure rows, swipe actions, scroll + visible-range events - #74
Conversation
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.
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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.
There was a problem hiding this comment.
💡 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".
| rangeDebounce?.cancel() | ||
| rangeDebounce = Task { @MainActor in | ||
| try? await Task.sleep(nanoseconds: 80_000_000) | ||
| guard !Task.isCancelled else { return } | ||
| emitVisibleRange() |
There was a problem hiding this comment.
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 👍 / 👎.
| } else { | ||
| rows(section.childIDs) | ||
| } footer: { | ||
| Text(verbatim: footer.text) | ||
| .modifier(LUIListSectionSeparatorModifier( | ||
| visibility: sectionModel?.separatorVisibility | ||
| )) |
There was a problem hiding this comment.
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 👍 / 👎.
| } else { | ||
| pending.append(childID) | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
| _NodeKind.listSection || | ||
| _NodeKind.listSectionHeader || | ||
| _NodeKind.listSectionFooter || | ||
| _NodeKind.swipeActions => column(), | ||
| _NodeKind.swipeAction => Text(text), |
There was a problem hiding this comment.
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 👍 / 👎.
| private func rowID(forKey key: String) -> Int? { | ||
| flatRowOrder.first { backend.model(id: $0)?.rowKey == key } | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
| .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" | ||
| ) |
There was a problem hiding this comment.
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 👍 / 👎.
| case NodeKind::SwipeAction: | ||
| return oneOf(property, {Property::TextValue, Property::InlineIconName, | ||
| Property::VariantValue, Property::EdgeValue, | ||
| Property::Enabled, Property::BackgroundValue, | ||
| Property::PressEnabled}); |
There was a problem hiding this comment.
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 👍 / 👎.
| | SwipeActions -> "lui-swipe-actions" | ||
| | SwipeAction -> "lui-swipe-action" |
There was a problem hiding this comment.
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 👍 / 👎.
| 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) |
There was a problem hiding this comment.
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.
|
End-to-end verified the list suite on iOS Simulator (SwiftUI backend) using a scratch app that feeds JSON ops into
Not exercised: macOS rendering (swipe actions are trackpad-only there), |
…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)
…mber picked event code to 13
An empty ForEach mounts no view, so .fileImporter/.photosPicker/.sheet on LUIFilePickerView never presented when the node had no children.
Summary
Ports the capabilities of the journal app's private
journal-listextension 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 kindslist-section,list-section-header,list-section-footer,swipe-actions,swipe-action; new propertieskey,separator,style,scroll-target,scroll-anchor,scroll-token,scroll-animated,track-visible-range,edge; new list eventsscroll-completed(node, token:int, outcome:string)andvisible-range(node, first:int, last:int). Generated files regenerated viatooling/generate_component_schema.mjs.OCaml API (
lui_elements)listgains?style ?scroll_target ?scroll_anchor ?scroll_token ?scroll_animated ?track_visible_range ?on_scroll_completed ?on_visible_rangelist_itemgains?separator ?swipe_actions;?keynow also sets the wirekeyprop (scroll-target identity). Disclosure rows reuse the existing?expanded ?on_togglepair.list_section ?key ?separator ?header ?footer rows(header/footer are arbitrary elements mounted intolist-section-header/list-section-footerslot nodes),swipe_actions,swipe_action ?text ?icon ?variant ?edge ?background ?disabled ?on_press.Validation (
lui_protocol) —list-sectionmust sit underlistand accepts onlylist-item/list-section-header/list-section-footer;swipe-actionsunderlist-item,swipe-actionunderswipe-actions;expandedon alist-itemrequirestoggle-enabledbut no longer requires thetreeitemrole (disclosure rows aren't tree rows);swipe-actionrequirestextoricon.Apple backend (
LUIAppleBackend)LUIListSectionPolicy.explicitSectionsgroupslist-sectionchildren into SwiftUISections (non-section children fall back to the existing heading/footnote inference, unchanged).LUIListItemViewrenders aDisclosureGroupwhen the row haslist-itemchildren; nested rows appear as list rows andToggleChangedreports expansion.LUIListItemSwipeActionsModifierprefers an explicitswipe-actionschild (both edges viaedge,allowsFullSwipe: false,variant: "destructive"→Button(role: .destructive),background→ tint); without one it keeps the existing trailing-swipe derivation fromcontext-menu.LUIListViewwraps the list inScrollViewReader; ascroll-tokenbump scrolls to the row whosekeymatchesscroll-targetand emitsscroll-completedwith outcomesucceeded|missing-target|cancelled|superseded|positioning-failed(a newer token completes the in-flight requestsuperseded).track-visible-rangeattaches appear/disappear reporting to each row and emits debounced (~80 ms)visible-rangewith flat payload-order positions (lastexclusive), only on change.stylemaps to.plain/.inset/.insetGroupedon iOS (.plain/.inseton macOS, preserving existing defaults);separatormaps to.listRowSeparator/.listSectionSeparatorvisibility.Other backends — Flutter/Qt/WinUI get schema-parity validation only; the new kinds degrade to plain containers, matching how other unimplemented kinds degrade.
Tests
list suite rulestest intest/test_lui.mlcovers child/parent rules, property support + value vocab, event support, disclosure relaxation, and the swipe-action label/icon requirement.LUI list suitesuite inLUIBackendParityTests.swiftcovers batch construction of a grouped list with disclosure + swipe rows, validation rejections, andpress/toggleChanged/scrollCompleted/visibleRangeemission.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