Skip to content

feat(gui): X11 settings window, keyboard harness, and a fill-based focus row - #25

Merged
FAZuH merged 11 commits into
v0.4from
feat/gui-keyboard-operability
Oct 10, 2026
Merged

FAZuH merged 11 commits into
v0.4from
feat/gui-keyboard-operability

Conversation

@FAZuH

@FAZuH FAZuH commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Summary

Tickets 01 and 02 of .scratch/2026-10-04_gui-keyboard-operability/, one commit each. Ticket 01 makes the settings window work on X11 and lands a keyboard harness; ticket 02 replaces the focus ring with a row fill so the indicator stops crossing label text.

Each commit is independently verified. More tickets land on this branch as they are finished, so review by commit rather than by the diff as a whole.

Commits

95e1cab — X11 support and the keyboard harness. hyprlay gui died in Create event loop on an X11 session because the Linux build enabled iced's wayland feature only. Enabling x11 fixes it; the overlay daemon is untouched (layer-shell only, ADR-004). scripts/gui-keyboard-e2e.sh drives the real release binary under Xvfb with i3, sends real keys with xdotool, screenshots with ImageMagick, and asserts 13 properties with a non-zero exit on failure.

be26219 — focus indicator as a row fill. A border's edge ran through the label text and clipped the number input on a slider row. The row now paints a fill behind its content instead.

The tradeoff in ticket 02, stated plainly

The fill is a weaker signal than the ring it replaces: 2.37:1 against the panel, where the ring held 9.76:1. A darker fill was tried first and is not an option, because the panel is so dark that even pure black beneath it measures 1.27:1. A lighter fill is what a 1216x50 band needs in order to read as focused.

That lightening costs MUTED its audited contrast, since MUTED clears 4.78:1 only on a fill at most as light as the panel itself. So the focused row's label lifts to BRIGHT, which holds 5.15:1 against the fill. Unfocused rows are unchanged. The label lifting was not asked for; it is forced by the fill being lighter, and the alternative is a focused row whose label is unreadable.

FOCUS_RING stays in the theme, because the button styles still use it. Only the row indicator moved.

Validation

  • Harness, 15 passed 0 failed, after ticket 02. Two assertions added, both for criteria a whole-window diff cannot reach:
    • Tab walks focus onto a focused slider row, so the before/after captures have an assertion behind them
    • focus moves the indicator and not the page: a fixed crop below two adjacent rows is 0 px different over 1216x300
  • Criterion "unfocused rows look exactly as before" is now pixel-exact: all eight non-field-focused states are 0 px different, and the indicator's rectangle measures y=361..457 in both builds.
  • cargo test --workspace --all-targets — 217 + 122 + 14 + 6 + 3 + 3 + 3 + 2, 0 failed
  • cargo clippy --all-features --all-targets --no-deps -- -D warnings — clean
  • cargo +nightly fmt --all --check — clean

Screenshots

Before and after captures of a focused slider row are in the ticket's
discussion: the ring's edge struck through "offset x:" and clipped the
number input's top edge; the fill crosses nothing.

What the harness prints it cannot reach

No daemon runs, so a keystroke reaching the daemon is unproven. The overlay cannot run on X11 at all. Nothing presses Enter or Space yet. And a pixel diff says "something changed", never "the right thing changed".

Checklist

  • Changes are limited to a single logical unit
  • Code follows the repo's style and lint rules
  • Tests updated/added where relevant
  • Docs updated where relevant
  • I ran the repo's checks (lint/test/typecheck) before submitting

The focus indicator was a container border around the row's label and
control. On a slider row its edge ran straight through the label text and
clipped the top of the number input, which is the overlap the owner
reported. A fill is painted behind the row's content instead, so nothing
crosses what is being read.

The fill is lighter than the panel, and that is forced rather than chosen.
A darker fill cannot be an indicator here: the panel is so dark that even
pure black beneath it is only 1.27:1, and a lighter band across a
1216x50 row is what actually reads as focused. Lightening it costs `MUTED`
the contrast the audit measured, since `MUTED` clears 4.78:1 only on a
fill at most as light as the panel itself — that is, on nothing. So the
focused row's own label lifts to `BRIGHT`, which holds 5.15:1 against this
fill, and unfocused rows keep `MUTED` on the panel where the audit put
it.

This is a weaker signal than the ring it replaces, which held 9.76:1
against its background; the fill sits at 2.37:1 against the panel, and
what carries the focus now is the fill plus the lifted label together
rather than the stroke alone. The row's own size does the rest.

The row keeps the 6.0 radius the ring had. A border with no width and no
colour draws nothing, and iced clips the fill to that radius, so the
focused row keeps the shape it had while painting no stroke. `Container`'s
layout reads no style at all, so moving focus moves no pixel of the page.

Two harness assertions added, because a whole-window diff cannot say what
the ticket asks:

  Tab walks focus to the next slider row — a focused row of the kind
  where the overlap actually showed, so the before and after captures
  have an assertion behind them rather than only a pixel count.

  focus moves the indicator, not the page — focus moves between two
  adjacent on-screen rows and a fixed crop below them must be
  byte-identical, which is criterion 5 and had no coverage. Measured
  0 px over 1216x300.

Criterion 4 is now pixel-exact rather than eyeballed: all eight
non-field-focused states are 0 px different from before, and the
indicator's rectangle measures y=361..457 in both builds. Same box,
painted as fill instead of stroke.

Harness: 15 passed, 0 failed. The `FOCUS_RING` constant stays, because
the button styles still use it; only the row indicator moved.
@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Release Preview

Next version: v0.4.0

0.4.0 (2026-10-10)

Overlay

  • Fixed roster rows clipping after a daemon restart

Platforms

  • Reduced release download size by about half
  • Reduced memory use of the overlay and settings window by about 90%

FAZuH added 7 commits October 4, 2026 21:37
…control

Ticket 02 gave the setting rows a fill instead of a ring. The nine chrome
controls kept the ring, so Tab changed the indicator's shape as it moved:
a wide band on a row, a thin outline on a button. One shape is easier to
read and cheaper to keep.

The fill folds into the control's own background through one helper,
`focus_background`, which takes the button's `Status`. That parameter is
load-bearing. A fill *is* the enabled cue here — it is lighter than the
panel — so a disabled button wearing one would read brighter than its
enabled neighbours and claim to be pressable. "Clear changes" is disabled
whenever the config is clean, which is the ordinary case, so this is not a
corner. The fill is gated on `Status != Disabled`, and a focused disabled
control keeps its inert background.

That leaves a disabled control with no way to show focus, since its
background must not move. Its label lifts to `BRIGHT` instead, which is
the only cue left that does not claim pressability. Measured 122 -> 221 on
the disabled label in the harness, against a dim 122 unfocused.

Two things lose something, and both are the visible cost of one shape:

`Save` and the selected sidebar item are the two elements the accent is
reserved for, and focus takes the accent from both. Focus is transient and
one control holds it at a time, and focus is the stronger signal, so this
is the right trade. It is asserted rather than assumed: the harness reads
the pixel at the button's centre in both states.

`FOCUS_RING` and `focus_border` are deleted. Every caller was checked
first: the four style closures were the only ones, and the harness asserts
that no pixel of the old ring colour survives anywhere in the window, so
the ring is gone rather than covered.

Harness: 28 passed, 0 failed. Six new assertions, because a whole-window
diff cannot tell a fill from an outline:

  a focused enabled button's interior pixel is the fill; the same button
  unfocused is not
  a focused disabled button is byte-identical to its unfocused background
  a focused disabled button's label lifts, measured not eyeballed
  Save and the selected sidebar item trade accent for fill, both states
  no pixel of the old ring colour remains anywhere
  focus moves the button without moving the page, 0 px over 600x760

Three of the new probes were broken when first written and are fixed here,
which is worth recording because each failed silently rather than loudly.
`assert_pixel` compared a bare uppercase hex from ImageMagick against a
lowercase constant carrying a `#`, so nine correct assertions all read as
failures. `label_peak` took `maxima` of an RGB image, which is a
per-channel value reporting one arbitrary channel instead of luminance.
And `assert_label_lift` read its geometry from `$5` while its callers
passed three arguments after the label, so the crop was empty and the
comparison read as " -> " rather than as an error.

Label contrast on the fill is pinned at the audited 4.78:1 for both
`BRIGHT` and white, since a plain button and an accent button carry
different label colours.
The two Connection rows were mouse-only. They are `key: None` in the field
registry, and tab order is built from the rows that carry a key, so Tab
walked straight past both. They are the two fields a keyboard user most
needs, since a fresh install cannot connect without them.

They get a `FocusTarget::Credential` variant rather than a `Key`. That is
the larger diff and the correct one: every reset path builds its commands
out of `Key` — `revert_commands` walks `Key::ALL`, and both section and
global reset speak `Command::Reset*` — so giving a credential a key would
put a secret on a wire whose job is carrying settings, and would put it in
the set that reset is defined over. `Field(Key)` keeps meaning exactly what
it means today: a config key. The pinned invariant that the keyed rows'
keys equal `Key::ALL` is untouched and still true, and
`the_credential_rows_claim_no_config_key` asserts from the row side that a
credential row never acquires one.

The cost, stated: two row-identity mechanisms instead of one. The rows are
identified by label, because the label is already this registry's own row
identity — `label_tip_lookup` keys on it — so a row is a credential
exactly when its label says so, and a third credential is one more line in
that table rather than a second copy of `FIELDS`. The palettes row stays
keyless *and* mouse-only, which the same function documents.

Two things the harness could not have found without being written:

iced's focus is not the ring. A `text_input` is the only widget here with
real keyboard focus, so moving focus onto a credential has to call
`operation::focus` on its input, and moving off has to call
`focusable::unfocus`. Without the second half, Tab away from a credential
keeps swallowing keystrokes into a row the user has left. The harness
asserts this directly: typing after Tab adds 0 px to either field, *and*
the caret is gone — the caret is the evidence focus left the input, rather
than the ring merely moving.

The credential reveal needed a case built for it. Tabbing to the Connection
section always passes a keyed row first, and that row reveals on its own
account, so the reveal looked covered when it was not exercised. Scrolling
the rows off the top with the wheel, then Shift+Tab back, is the case that
needs it. `Jump::Row` had to become id-keyed for this: the old geometry
table was indexed by `Key::ALL` and had nowhere to put a row with no key.

The secret stays masked, and focus does not reveal it. Both halves of the
fixture carry the *same* string, so an unmasked secret would render the
client's own glyphs at the client's own position — comparing the two rows'
text bands is a real claim, and dropping `.secure(true)` makes them match.
Backed numerically too: 35 bullet pixels against the id's 187, focused and
again after a mouse click.

`Enter` on a focused credential is a deliberate no-op, pinned by a test.
Committing the pair is the "apply connection" button's job, and a
credential that grew an Enter arm would commit a half-typed secret with
no confirmation.

Harness: 54 passed, 0 failed, up from 28. Each mechanism was falsified by
reverting it: dropping credentials from tab order fails 6 assertions,
stubbing the unfocus to a no-op fails the typing assertion, removing the
credential reveal fails 3. At unit level, injecting a credential into the
command list fails `no_reset_command_carries_a_credential`.

The harness also now sets `XDG_CONFIG_HOME` next to `HOME`, because
`dirs::config_dir()` reads it first and a stray value in the caller's
environment would otherwise read the real config and ignore the preloaded
`auth.json`.
Corner preset, anchor, roster order and monitor were focusable but inert:
Enter did nothing on them and the arrows did nothing at all. They are
rows where the answer is one of a fixed set, so a keyboard should be able
to move through that set without a mouse.

Each row already had its options in a table it iterated to render. The
keyboard walks those same tables through `fields::options(gui, key)`, so
there is no second list to drift: the order the chips appear in is the
order the arrow keys step through. `commands::step_command` is the seam,
returning the neighbour in that order or `None` past either end.

The boundary does not wrap. Left on the first option and Right on the
last produce no command, no task, and no mirror write, so nothing is
spawned for the socket. Held keys were worth testing rather than assuming:
this X11 seat does deliver auto-repeat, confirmed first with a held Tab
walking several targets. Three seconds of held Right from the second
option lands on the last and stops there, and holding it again leaves the
window byte-identical.

Steps reach the daemon by the same route a click does. Monitor goes
through `Message::SwitchMonitor`, since it is shell-routed and restarts the
surface; everything else goes through a new `Message::SetOption` that
resolves to `Command::Set`. No daemon runs in the harness, so that half is
covered at the unit seam: arrowing produces exactly the expected command
with the right key and value, and produces nothing at the boundary.

`Left` and `Right` now route through the shortcut dispatcher rather than
being swallowed. This is safe because reaching the dispatcher already
means no text input holds real focus — iced spends an arrow on whichever
input owns typing — and a row with no options yields no task, so number
and colour rows stay inert for tickets 05 and 06.

`palettes` is deliberately not an option select. It is keyless and
mouse-only, it has no current value to step away from since it writes
three keys at once, and it has no focus target, so nothing could reach it
regardless. `the_palettes_row_is_not_an_option_select` pins that omission
as a decision rather than an oversight, and it belongs with the per-field
keyboard work.

Harness: 86 passed, 0 failed, up from 54, with the baseline confirmed
first so the 32 added assertions are the only delta. Every check probes
the selected chip's own pixel — accent for the chosen one, the field grey
for the rest — rather than "something changed", so a special-case render
would fail.

`Message::SetOption` exists only so a keyboard step can reuse the generic
apply path. If a later ticket makes a row settable by mouse and keyboard
with the same payload, it should collapse into the existing per-row
messages.
…nput

The sixteen numeric settings were focusable and inert. Every arrow key
did nothing, and Enter did nothing, which left the numbers the one part
of the window a keyboard could neither read nor change.

Right and Up now increase, Left and Down decrease, by one. There was no
separate stepper button to reuse: a number row is slider, input and
reset, and the slider *was* the stepper at iced's default of 1. That 1 is
now written down once as `NUM_STEP`, handed to the slider explicitly so
the value is deliberate rather than inherited, and read by the same
`step_command` the keyboard uses. One definition, two consumers.

Ticket 04 already sent Left and Right to option selects, and number rows
were left inert precisely because they offer no choices. Rather than two
owners for one key, `step_command` decides by the row's kind in one
place: a numeric key has bounds and no options, a choice row has options
and no bounds, so the two arms cannot both match. The key-routing arm then
dispatches on the *value's* shape and never asks which row kind it hit.

Bounds come from the shared table and the step clamps to them. Clamping
here matters more than it looks: `apply_config` *refuses* an out-of-range
number rather than clamping it, so a step that overshot would be a failed
command on every press at the edge. From a bound, the step that would
leave the range produces no command at all, exactly as a choice row stops
at its last option. `offset-min` and `offset-max` have a second edge —
`apply_config` refuses a pair that would cross — so that walk comes to
rest one short of the declared bound. That rule stays in the domain; the
GUI does not get a second copy of it.

Enter hands the row's input the keyboard and draws a caret; Enter commits,
applies and hands typing back; Tab commits the same way; Escape leaves
without applying. Committing a draft reuses the domain's own refusal text
— `error: spacing <0-24>`, from `key.parse_value` — rather than a second
wording, and returning `None` from the commit is what keeps the caret
where it is, so a refused value holds its text for correction instead of
vanishing.

That last part needed a mechanism the app did not have. A focused
`text_input` *captures* Escape to drop its own focus, and
`keyboard::listen()` only delivers ignored events, so the app never saw
it: Escape would have left a refused draft on screen after the caret had
gone, and a later Tab would keep re-committing and re-refusing a value
the user had already discarded. One narrow listener picks up Escape with
`status == Captured`. "Am I typing" is not tracked as state because it
cannot be — iced gives no blur notification — which is exactly why this
was found by testing rather than by reading.

Harness: 125 passed, 0 failed, up from 86, all on the `spacing` row
reached by 18 Tabs from the anchor. The claims name drawn colours: the
handle at six positions along the track, the input's focused border, the
focus fill, and the caret's pixel count. Identical claims are made over
the track and over the digits separately, never over the whole row band,
whose input border shifts by one channel per re-render and changes colour
with focus.

Two harness findings worth keeping. The Enter hand-off is an effect, so a
keystroke sent with no settle after Enter races it and lands nowhere.
And the status-bar refusal is unprovable in the harness, because the 2 s
status probe overwrites the status line; that half is a unit test and the
script's summary says so rather than implying coverage.

**The wire half is unproven.** No daemon runs in the harness, so "reaches
the daemon" is asserted at the `Command` seam as in ticket 04, pinning
`set spacing 5` and `set offset-x -11` as exact wire text.

Out of scope, found on the way, not touched: Escape does not clear the
search while the search box holds focus, for the same capture reason as
above — the existing "Escape leaves the search view" assertion passes on
the caret disappearing, not on the search clearing. `Space` shares
`activate_focus` with Enter, so it also enters a number row's input. And
`offset slider minimum`/`maximum` render no slider, so they have arrows
but no handle to watch.
Escape on a number input dropped the draft and showed the row's value
again. Losing a half-typed number on the key people reach for when they
change their mind is unintuitive, so Escape now keeps it, the same as Tab
and Enter.

A kept draft cannot become a wrong value, because `apply_num` writes
through `apply_config`, which *rejects* an out-of-range number rather than
clamping it. So the split is: a value the bounds allow applies as it
commits, and one they refuse leaves the draft in the box with the
domain's own wording in the status bar. `spacing` is 0..=24, so `9` lands
and `99` does not.

Escape still hands typing back to the row, which is what it means — iced
dropped the input's focus to produce the message we receive. The draft
therefore survives with the keyboard elsewhere, and Enter resumes it: the
box still renders the draft, so re-entering the input picks up exactly
where the user left off instead of starting over. Both are asserted, the
resume by probing the input's focused border and the digits either side of
it.

Tab is unchanged, and deliberately. A refused Tab stays on the row so the
text can be fixed, which was the original design and is right: Tab means
"commit", and committing something that is not a value should ask rather
than move on. `a_kept_draft_is_fixable_rather_than_a_dead_end` pins that a
kept draft is escapable rather than a trap — fix the text, and the row is
an ordinary stop again.

Typing appends, so correcting `998` means clearing before retyping. That
is the input behaving like an input, and the harness now walks the whole
loop: append makes the text worse and still refuses, clear-and-retype
applies, and only then does Tab move the ring on.

Harness: 132 passed, 0 failed, up from 125. Two crop variables rather
than one, because the honest claims need different windows: the narrow
`INPUT_DIGITS` excludes the caret so "unchanged" claims cannot trip over a
blinking caret, and the wider `INPUT_FIELD` includes it so an appended or
cleared digit is inside the comparison. A single crop got one of the two
wrong, which is what the failures were.
Escape cleared the search only when the box did *not* hold the keyboard.
With the caret in it, iced's `text_input` captures Escape to drop its own
focus, so the app never saw the key: the caret went and the page stayed
filtered. Escape then looked broken for the one control whose whole
purpose is text.

The captured-Escape listener added for number inputs already receives this
event, so the fix is one arm on it. Telling the two capturing inputs apart
needs no new state: the search box is the app's only other one, so a
captured Escape on a row holding no typed text is the search box. That is
pinned both ways, by a test that clears the search and a test that keeps a
number draft while leaving the search alone, so the arms cannot trade
places.

The claim the harness was making was wrong in a way worth fixing in the
same commit. "Escape leaves the search view" asserted only that two shots
differ, which the caret disappearing satisfied on its own — it passed while
the search stayed applied, which is the bug. It now names what has to be
true: the search box is empty again, and the page is byte-identical to
what it was before searching started. That second claim is the strong one;
`plain_f` is the unfiltered page and `escape` restores it exactly.

135 harness assertions, up from 132, all green. 254 unit tests, clippy and
nightly fmt clean.

Worth noting for the next person here: `assert_region_pixels` takes the
geometry before the relation, and the two crops in this file now differ on
purpose — a narrow one that excludes the caret for "unchanged" claims, a
wider one that includes it so a changed digit is inside the comparison.
Getting either backwards fails loudly, which is the good case.
The section reset and the global reset existed, so a setting could be put
back only one section at a time. There was no per-setting reset, which is
the one a keyboard user wants: change something, do not like it, put that
one back.

`R` does exactly one thing. It resets the focused setting, and when the
ring is on a chrome control, a sidebar item or a credential it does
nothing at all, so R never becomes a second way to run Ctrl+R.

The wire form is `reset <group> <key>` — two words — because the one-word
form is impossible: `Key::Position` and `Key::Opacity` are spelled exactly
like their groups, and the existing `reset <group>` arm already claims
those words. A test pins the collision and asserts `reset position` is
still a group, so a later pass cannot shorten the grammar into ambiguity.
The group is read from `key.group()` rather than carried in the variant:
`ResetKey(Group::Layout, Key::Opacity)` used to compile and emit a line the
daemon's own parser rejects.

The reply is the key's canonical `key=value`, the same string `get <key>`
returns, so a caller reads back what it restored. It is also reachable
from the CLI, because a wire form only the GUI can speak is not a wire
form.

Two keys answer the same question differently, and deliberately.

`monitor` is refused: `error: monitor is never reset (use 'set monitor
<name>')`. It names an output on this machine, not a preference with a
default, and resetting it would relocate the overlay — restart work, which
is the one thing a reset should not do behind your back. It refuses
loudly rather than silently doing nothing, because a no-op reporting
success shows as OK in the status bar, which is a lie.

`show-on-fullscreen` resets, and restarts. It is the other half of that
question: `reset layout` already resets it, so a per-key reset that skipped
it would be the one reset that does not. It is written directly rather
than through `apply`, which refuses the shell-routed keys, exactly as the
group arm writes it — and `daemon/mod.rs` gained the shell arm that saves
and re-execs when the value flips, without which the config would say
"off" while the running surface stayed bound.

Everything else routes through `key.apply(config, key.value_of(&defaults))`,
so one mutation rule per key and one source for defaults, the same path
`set` takes. The effects are whatever that key declares: `opacity` resizes,
`offset-x` reanchors, `auto-save` does neither.

**One real bug found on the way.** `mark_dirty` ran before `apply_config`,
so a *refused* change still marked the config unsaved — R on `monitor`
showed a red error and an unsaved badge for a no-op. The daemon's rule is
`persists && is_ok`; the GUI had only the first half. Fixed at the shared
seam rather than per call site: `apply_change` now owns "mirror the
command, tell the daemon, set the marker" as one unit, and marks from the
outcome. Checking the sibling paths found a second instance — `set
offset-min 600` with `offset-max` at 500 passes the GUI's bounds check and
is then refused by the domain's cross-check, marking dirty on a no-op.

The autosave rule survives that, and it was the trap: `mark_dirty` reads
the auto-save value from *before* the change on purpose, so `set auto-save
off` is judged by the switch that was still on when it was flipped.
`apply_change` captures the same pre-application value and adds the half
that was missing. Both auto-save states are tested; auto-save off was red
before and green after.

R does not fire while the search box holds the keyboard. Proved rather
than assumed, unit and end to end: Ctrl+F, type `rr`, and the toggle is
byte-identical to before the search.

Routing the existing reset buttons through this path is a net deletion of
13 lines: `Message::NumReset` and the colour button's GUI-computed default
are gone, so every reset in the app is now one command.

`position` also resets `rtl`, because a right-side corner preset enables
it — the same coupling `set position` has. That is pinned positively
rather than exempted, so the coupling is a stated fact rather than a hole.
Whether R on `position` should instead leave `rtl` alone is an open
question with the owner.

Harness 157 of 157, 272 lib tests, clippy and nightly fmt clean. The
socket half is unproven: no daemon runs in the harness, so the command and
its exact wire text are pinned at the `Command` seam.
@FAZuH
FAZuH force-pushed the feat/gui-keyboard-operability branch from e17b4fa to 2c365d8 Compare October 5, 2026 06:40
FAZuH added 3 commits October 6, 2026 13:37
Ticket 08 of .scratch/2026-10-04_gui-keyboard-operability/.

The sheet named five bindings after tickets 02-07 added a dozen more, so
every line it carried was true and most of the truth was missing. It now
carries thirteen lines read off the dispatcher: Tab/Shift+Tab, Enter/Space,
the arrows, R, Esc in both its roles, save, section reset, reset all,
search, and Ctrl+1..5 as jump/clear.

A driver test sweeps every letter, digit and named key in all four
modifier combinations across nine window states and diffs behaviour
against the parsed sheet, both directions: a honoured key with no line
fails, and a line naming a key the window ignores fails. Adding a binding
anywhere in update.rs fails the build until the sheet is updated.

Fit verified at the 800x600 default: ink x8..144, panel ends x159, bottom
y539 against a status bar at y564. Harness asserts all four. No dial moves:
static text, same 10px MUTED, existing panel.
Left on the first option jumps to the last, Right on the last jumps
to the first. Stop-at-boundary felt stuck on real hardware.

A single-option row steps to nothing instead of dirtying the config
with a no-op Set.
Colour rows rang but stayed keyboard-inert: the hex input had no widget
id, so focus could not be handed to it, and Enter fell through to a step
path that returns nothing for colours.

Enter now focuses the row's hex input with a visible caret, reusing the
number-row hand-off (operation::focus in, release_typing out). Escape
keeps the draft and Tab commits, same rule as numbers. Invalid hex is
refused with the domain's own wording, focus stays in the input, and the
row keeps its previous colour. Mouse picking is unchanged.
@FAZuH
FAZuH merged commit bc5da87 into v0.4 Oct 10, 2026
7 checks passed
@FAZuH
FAZuH deleted the feat/gui-keyboard-operability branch October 10, 2026 03:45
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