Repository navigation
feat(gui): X11 settings window, keyboard harness, and a fill-based focus row - #25
Merged
Merged
Conversation
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.
Release PreviewNext version: 0.4.0 (2026-10-10)Overlay
Platforms
|
…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
force-pushed
the
feat/gui-keyboard-operability
branch
from
October 5, 2026 06:40
e17b4fa to
2c365d8
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 guidied inCreate event loopon an X11 session because the Linux build enabled iced'swaylandfeature only. Enablingx11fixes it; the overlay daemon is untouched (layer-shell only, ADR-004).scripts/gui-keyboard-e2e.shdrives the real release binary under Xvfb with i3, sends real keys withxdotool, 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
MUTEDits audited contrast, sinceMUTEDclears 4.78:1 only on a fill at most as light as the panel itself. So the focused row's label lifts toBRIGHT, 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_RINGstays in the theme, because the button styles still use it. Only the row indicator moved.Validation
cargo test --workspace --all-targets— 217 + 122 + 14 + 6 + 3 + 3 + 3 + 2, 0 failedcargo clippy --all-features --all-targets --no-deps -- -D warnings— cleancargo +nightly fmt --all --check— cleanScreenshots
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