Phase 2: widget breadth #10

Merged
rleibman merged 2 commits from phase/2-widget-breadth into main 2026-09-29 10:33:35 -07:00
Owner

Phase 2 of docs/13-phases.md. Slice 1 has landed; slice 2 is in progress on this branch, so expect further commits before this is ready to merge.

Slice 1 — Toggle, Spacer, ProgressBar, ActivityIndicator

Four widgets on GTK and Android, each with unit tests and a self-test check that reads the value back out of the real toolkit rather than out of the signal that drove it.

Widget GTK4 Android
Toggle GtkSwitch Switch
Spacer empty GtkBox View with weight
ProgressBar GtkProgressBar ProgressBar, horizontal
Spinner GtkSpinner ProgressBar, indeterminate

Two decisions that shape the catalogue

Both are the same rule: do not promise an API some platforms cannot keep.

  • Toggle carries no label. A GtkSwitch and a UISwitch have nowhere to put one; only Android's Switch does. A caption that two of four renderers would silently drop is worse than no caption, so it is a sibling — Row()(Label("Hide completed"), Spacer(), Toggle(hide)(hide.set)). That is what a settings row looks like on all four, and it is why Spacer is in the same slice.
  • ActivityIndicator has no "running" prop. It spins while mounted, so Show(busy)(Spinner()) is what stops it. The reconciler already does this; a second mechanism for the same thing is one more to keep in step.

Toggle is a separate WidgetKind rather than a style flag on Checkbox because the platforms disagree about which control a given setting is — that is the app's judgement, not the framework's.

Prop.Progress is Option[Double], where None is indeterminate and deliberately not Some(0.0): every toolkit here renders those differently, so collapsing them would make "nothing has happened yet" and "we cannot say" look identical.

Measured

GTK Android
Switch reflects a signal write yes yes
Flipping it filters the list yes yes
Progress bar vs model fraction 0.750 0.750
Spinner present only while busy yes yes
Self-test checks 24 33

The bar is driven to a genuine fraction first. Everything is done by that point in the self-test, and a 1.0-against-1.0 comparison would also pass if the renderer clamped every value to full — the first version of this check had exactly that hole.

114 unit tests pass.

Not papered over

  • docs/12 still reports 9 of 32, not 13. Its own rule is that a widget is done when every renderer has it. The Apple half is #9.
  • The Apple renderer throws for the four new kinds, naming the widget, with codes 9–12 reserved. sui_create's default: branch returns a separator, so letting a new kind through would render a progress bar as a hairline rule — a wrong widget that looks like a layout bug and sends whoever hits it looking somewhere else entirely.
  • ColorRole.Accent does not reach ProgressBar. On Android the bar renders in the platform's default blue while the buttons are the app's branded green. Visible in docs/screenshots/android-catalogue.png, recorded in docs/12, flagged in #9, and not fixed here — per-subtree theming is phase 4.

Slice 2 (in progress)

Slider, SecureField, Radio, Stepper. IconButton and Link are deferred out of phase 2 on purpose: the first needs an icon/resource system and the second needs platform URL opening, and neither should be improvised inside a widget.

🤖 Generated with Claude Code

Phase 2 of `docs/13-phases.md`. **Slice 1 has landed; slice 2 is in progress on this branch**, so expect further commits before this is ready to merge. ## Slice 1 — `Toggle`, `Spacer`, `ProgressBar`, `ActivityIndicator` Four widgets on GTK and Android, each with unit tests **and** a self-test check that reads the value back out of the real toolkit rather than out of the signal that drove it. | Widget | GTK4 | Android | |---|---|---| | `Toggle` | `GtkSwitch` | `Switch` | | `Spacer` | empty `GtkBox` | `View` with weight | | `ProgressBar` | `GtkProgressBar` | `ProgressBar`, horizontal | | `Spinner` | `GtkSpinner` | `ProgressBar`, indeterminate | ## Two decisions that shape the catalogue Both are the same rule: **do not promise an API some platforms cannot keep.** - **`Toggle` carries no label.** A `GtkSwitch` and a `UISwitch` have nowhere to put one; only Android's `Switch` does. A caption that two of four renderers would silently drop is worse than no caption, so it is a sibling — `Row()(Label("Hide completed"), Spacer(), Toggle(hide)(hide.set))`. That is what a settings row looks like on all four, and it is why `Spacer` is in the same slice. - **`ActivityIndicator` has no "running" prop.** It spins while mounted, so `Show(busy)(Spinner())` is what stops it. The reconciler already does this; a second mechanism for the same thing is one more to keep in step. `Toggle` is a separate `WidgetKind` rather than a style flag on `Checkbox` because the platforms disagree about which control a given setting *is* — that is the app's judgement, not the framework's. `Prop.Progress` is `Option[Double]`, where **`None` is indeterminate and deliberately not `Some(0.0)`**: every toolkit here renders those differently, so collapsing them would make "nothing has happened yet" and "we cannot say" look identical. ## Measured | | GTK | Android | |---|---|---| | Switch reflects a signal write | yes | yes | | Flipping it filters the list | yes | yes | | Progress bar vs model fraction | **0.750** | **0.750** | | Spinner present only while `busy` | yes | yes | | Self-test checks | 24 | 33 | The bar is driven to a genuine fraction first. Everything is done by that point in the self-test, and a 1.0-against-1.0 comparison would also pass if the renderer clamped every value to full — the first version of this check had exactly that hole. 114 unit tests pass. ## Not papered over - **`docs/12` still reports 9 of 32, not 13.** Its own rule is that a widget is done when *every* renderer has it. The Apple half is #9. - **The Apple renderer throws** for the four new kinds, naming the widget, with codes 9–12 reserved. `sui_create`'s `default:` branch returns a separator, so letting a new kind through would render a progress bar as a hairline rule — a wrong widget that looks like a layout bug and sends whoever hits it looking somewhere else entirely. - **`ColorRole.Accent` does not reach `ProgressBar`.** On Android the bar renders in the platform's default blue while the buttons are the app's branded green. Visible in `docs/screenshots/android-catalogue.png`, recorded in `docs/12`, flagged in #9, and *not* fixed here — per-subtree theming is phase 4. ## Slice 2 (in progress) `Slider`, `SecureField`, `Radio`, `Stepper`. `IconButton` and `Link` are deferred out of phase 2 on purpose: the first needs an icon/resource system and the second needs platform URL opening, and neither should be improvised inside a widget. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Four widgets, on GTK and Android, each with unit tests and a check in both
self-tests that reads the value back out of the real toolkit.

Two catalogue-shaping decisions, both the same rule - do not promise an API
some platforms cannot keep:

  Toggle carries no label. A GtkSwitch and a UISwitch have nowhere to put
  one; only Android's Switch does. Promising a caption that two of four
  renderers would silently drop is worse than not having it, so it is a
  sibling: Row()(Label("Dark mode"), Spacer(), Toggle(dark)(dark.set)). That
  is what a settings row looks like on all of them, and it is why Spacer is
  in the same slice.

  ActivityIndicator has no "running" prop. It spins while it is mounted, so
  Show(loading)(Spinner()) stops it - the reconciler already does this, and a
  second mechanism for the same thing is one to keep in step.

Toggle is a separate WidgetKind rather than a style flag on Checkbox because
the platforms disagree about which control a given setting *is*, and that is
the app's judgement, not the framework's.

Prop.Progress is Option[Double]: None is indeterminate and is deliberately
not Some(0.0). Every toolkit here renders them differently - an
indeterminate bar animates, a determinate one at zero does not - so
collapsing them would make "nothing has happened yet" and "we cannot say"
look identical.

Renderers:

- GTK: GtkSwitch, empty GtkBox, GtkProgressBar, GtkSpinner. A switch has no
  2-argument "changed" signal - state-set also carries the new state and
  activate is a keybinding a mouse never fires - so it binds notify::active,
  which carries a GParamSpec and needed a third trampoline arity.
- Android: Switch is a CompoundButton exactly as CheckBox is, so Checked and
  OnCheckedChange needed no special case. A horizontal bar and a circular
  spinner are the same class with different styles, fixed at construction,
  which is why they are two kinds rather than one kind with a prop.
- Apple: throws, naming the widget, with codes 9-12 reserved. sui_create's
  default branch returns a separator, so letting a new kind through would
  render a progress bar as a hairline rule - a wrong widget that looks like a
  layout bug and sends whoever hits it looking somewhere else. Forgejo #9.

Measured, both toolkits, read back out of the widget rather than the signal:
the switch flips when the signal is written and filtering the list follows
it; the progress bar reads 0.750. The bar is driven to a genuine fraction
first - everything is done by that point in the self-test, and
1.0-against-1.0 would also pass if the renderer clamped every value to full.

docs/12 still says 9 of 32, not 13, because its own rule is that a widget is
done when every renderer has it.

Recorded honestly: on Android the ProgressBar renders in the platform's
default blue while the buttons are the app's branded green - ColorRole.Accent
does not reach it. Visible in the new screenshot, noted in docs/12, not fixed
here; per-subtree theming is phase 4.

114 tests pass; GTK self-test 24 checks, Android 33, both green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Slider and SecureField on GTK and Android, with unit tests and self-test
checks on both. But the more useful result is what did not get built.

docs/07 section 7.10 listed the v1 catalogue before any renderer existed.
Phase 2 is the first time each entry has been checked against four real
toolkits, and slice 2's other two candidates do not survive it. Verified
against the installed GTK4 headers and android-36/android.jar:

  Radio   - UIKit has no radio control at all. And mutual exclusion is a
            sibling *relationship*: Android expresses it structurally, since
            RadioGroup is a container, and GTK as a pointer to a sibling.
            Neither is a prop, and the contract has no grouping concept.
  Stepper - Android has none. NumberPicker is a scrolling wheel, a different
            control, and the Android idiom is a row of buttons the app
            composes - which the framework can already express.

Neither is built. Both are reclassified as app-level composition or
.platform escape hatches, and "32 widgets" is retired as the denominator: a
widget earns a place in the catalogue by existing on every platform we render
to, and forcing one in would mean two renderers faking it. Every remaining
section 7.10 entry gets this question asked before it is built, not after.
docs/12 section 12.2a.

The two that do survive:

  Slider - GtkScale / SeekBar. Prop.Value is in the app's units, never a
           fraction: an app choosing a volume between 0 and 11 says 7, not
           0.636. Android's SeekBar is integral, so that renderer converts,
           at 1000 steps - it is the only side that knows its own resolution.
           Prop.Range is sent before Prop.Value, because a value outside its
           bounds is meaningless and every toolkit clamps silently; the
           ordering is asserted rather than left to how the DSL reads.
  SecureField - a widget kind rather than a flag on TextField, because
           NSSecureTextField is a separate class and an Apple widget is
           chosen at create. On GTK it is the same GtkEntry with visibility
           off; on Android the same EditText with a password input type.

Both take the don't-disturb rule the text field already had: only write when
the value differs, or the write fights the user's drag.

One real bug caught by the Android self-test: SeekBar extends ProgressBar, so
"find the determinate progress bar" matched the slider too and reported two
bars. Excluded explicitly, with the reason recorded - the Apple issue is
warned about the equivalent.

Measured on both toolkits, read back out of the widget: progress bar 0.750,
slider 7.0 of 0-11 following a write to 3.5, exactly one of two text fields
masked and still holding its value.

117 tests; GTK self-test 29 checks, Android 38, both green. Screenshot
refreshed. Apple is #9, now covering six widgets and two reserved codes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rleibman referenced this pull request from a commit 2026-09-29 10:33:35 -07:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
rleibman/scala-ui!10
No description provided.