Skip to content

feat: rewrite with rust and gpui - #1030

Draft
ayangweb wants to merge 874 commits into
masterfrom
next
Draft

ayangweb wants to merge 874 commits into
masterfrom
next

Conversation

@ayangweb

Copy link
Copy Markdown
Owner

No description provided.

@ayangweb
ayangweb marked this pull request as draft August 28, 2026 06:15
@ayangweb
ayangweb force-pushed the next branch 2 times, most recently from 56c3f87 to fb72bce Compare September 2, 2026 12:41
ayangweb and others added 30 commits September 20, 2026 20:36
The changelog's newest entry is 2.0.0 while the workspace still declared
1.1.0, so `just release-notes` could not find the entry it was asked to
publish. The workspace now declares 2.0.0 and Cargo.lock follows for the
fifteen member packages; no dependency moved.
The General page carried five groups and twenty-five settings that
belonged to four unrelated areas, so a user had to scroll before they
could find the switch they wanted, and the sidebar offered only four
entries for all of it. Four of those groups now stand on their own next
to General, Models, Shortcuts and About: Overlay holds the model window's
behaviour, appearance and frame-rate limit, Interaction how the model
reacts to behaviour shortcuts and to the pointer, Input the keyboard
release fallback and the gamepad dead zones, and Application runtime
status, system integration and startup and updates. A page that still
holds more than one concern shows those concerns as a second level in the
sidebar, which is the only nesting the component offers.

The configuration recovery notice moved out of the pages and now renders
above the settings component, because a notice that lives on one page is
invisible to anyone who navigated elsewhere while the restore action is
reachable from every page. It also gained the semantic node it never had:
the window's accessibility tree is built by hand and GPUI is constructed
with its own adapter disabled, so a screen reader used to meet the
restore button and never learn what it was about. Its visible label and
its accessible name now come from a single owner, which is how the two
came to describe different actions in the first place.

Two headings that only repeated the row beneath them are gone, and the
settings error table now names its keys in full instead of assembling
them from a suffix. That last change is what makes the two new guard
tests possible: one asserts that every key the source asks for exists,
the other that every key in the catalog is asked for. Between them they
cover both directions of the mistake this work started from, a key that
renders as its own name and a key that no longer reaches any screen.
The shortcuts page offered two bulk mutations — restore every binding to
its defaults and clear every binding — that duplicated what per-row
editing already covers and hid the model-behavior re-assignment rule
behind a button. Drop the buttons together with their whole chain:
view methods, accessibility nodes, focus handles, the dedicated
RestoreDefaultShortcuts IPC command, the application-level reset, the
locale entries, and the tests that only exercised them. The contract
note now states the surviving asymmetry (cleared model behaviors are
re-filled on the next model activation) without referencing the button.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
The shortcuts page switched between its window and model scopes with a tab bar, which gave the page a second parallel navigation level that the accessibility tree never exposed. The two scopes are now titled groups, which the settings component renders as second-level sidebar entries, and the tab state, the TabBar/Tab usage, and the in-page duplicated title are gone. Row identity stays on the one combined order the keyboard tab order and the accessibility node ids are numbered from, and the scope groups carry search keywords so the page stays reachable through the sidebar search box.
Login startup is the more consequential of the two launch preferences, so
it now sits first in the startup-and-updates group, with the automatic
update check below it. The accessibility tree keeps the same order so
screen readers read the switches in the order they appear.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
Login startup registers the running executable with the operating system, so
the registration outlives the process that wrote it and points at whatever
executable was current at the time. A development build's executable is a
Cargo build output: cargo build, cargo clean or a profile switch replaces or
removes it, which leaves a login item pointing at a binary that no longer
exists. The capability therefore belongs to a released product, and ADR-0051
supersedes the part of ADR-0043 that had extended it to development builds.

The gate sits in the application layer, where update_check_available() already
answers the same kind of question: bongocat-app reports the build environment
as the reason without calling bongocat-platform at all, and the read and the
write direction answer with the same state so a client never observes the
capability changing between them. The write is a no-op rather than an error,
because the control that sends it is disabled and a stale window or a scripted
client must not raise a failure the user cannot act on. The platform crate, its
auto-launch backend and the environment isolation naming are untouched.

The switch is now built by hand, because the packaged switch field constructs
Switch::new("check") internally and cannot carry a tooltip, and this row is the
only place the reason can be shown. Its disabled state answers one question
only, whether the build offers the capability at all: whether the control can
act right now stays with the action the accessibility node already reports, so
a released build keeps the switch normally available while the snapshot is
still loading. The hover text is the catalog entry the row already uses, which
is what keeps the two from drifting apart.
Fifteen catalog keys only restated the row's own label ("Show or hide the
model window", "Open the application when you sign in"), so they are gone
from both locales and from the settings rows that carried them. The rows
that keep a description carry something the label cannot: value ranges,
the meaning of 0, what "keep on screen" may cover, the dead-zone and
release-fallback definitions, and every open-at-login state that needs
repair or is unavailable.

The labels were not left to carry that weight alone: "Overlay" is now
"Model window", the name the window already used everywhere else and the
one the Chinese sidebar carried from the start, and the click-through,
keep-on-screen, mirror, delay and runtime strings now say what they mean
instead of leaning on a description that is no longer there.

The open-at-login row is the one conditional case: its description is now
an Option that only the states the switch cannot explain fill in, so the
two settled states render without a second line while ADR-0051's shared
row-and-tooltip sentence is untouched. The accessibility tree follows the
same rule, because a switch node's value is the description text: a row
that lost its copy no longer announces it as the control's value.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
Both overlay backends arm hide-on-pointer-hover only while that switch is
on and input is running, so the delay seconds are read by nothing when
the switch that owns them is off. The row still accepted edits, which
invited the user to tune a value that could not do anything yet.

A shared predicate now answers whether the delay applies at all, and the
settings row, the two accessible steppers and the stepper action all read
it: the row renders disabled through the component disabled state, which
forwards to the number input, the steppers report themselves unusable
instead of clickable, and a click that arrives from a tree rendered
before the switch changed is dropped. What the user recorded is
deliberately kept, so switching the feature back on restores their delay
rather than a default.

The settings-window smoke covers the new gate: it runs with the default
configuration, where the switch is off, so the increase stepper changing
from usable to unusable is asserted against the real tree.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
Each model counts its own behavior defaults from the first slot now. The
assignment treated every chord in the configuration as taken, so the
second model activated continued where the first stopped: with the three
preset models, which declare seven behaviors each, it started at Cmd+8
and the third at Cmd+15. Only the model being assigned and the
application commands count as taken, because exactly one model's
behaviors are live at a time.

That also settles what the platform may register. `active_shortcuts` used
to compile the whole configuration, so every model's chords reached the
operating system even though only the live model's can ever fire: the
dispatcher drops a target whose model is not active. The other halves now
never get that far, and `prepare_model` and `select_model` rebuild the
table once the activation has succeeded, which is what makes the model
being left stop answering its chords and the incoming one answer its own
without a restart. `select_model` never rebuilt the table, so the
incoming model's shortcuts used to do nothing until the next launch or
the behavior switch was toggled.

Conflict detection follows the same scoping: commands stay unique among
themselves and may not be shadowed by a behavior, each model's own
bindings stay unique, and the same chord under two models is now legal.
`ShortcutConfig::active_bindings` is the projection that keeps a compiled
table unambiguous, and `NativeConfig::validate`, the capture check on the
settings page and the application all agree on it.
A model behavior row was labelled with the model id and the package's
own identity: "standard (motion:CAT_motion:0)", or
"standard (expression:live2d_expression0.exp3.json)". That identity is
what the configuration stores and what the row lookup matches on, but
it is not a name. It exposes the motion group a clip happens to live in
and the file name of an expression, and it reads as noise in a list
whose only decision is which chord plays which clip.

Rows are named by flattened position now: every motion group in
declaration order, then every expression. The kind and the number are
carried on the row as `BehaviorOrdinal` and resolved by
`ShortcutRow::name`, which counts motions and expressions separately so
the numbering is continuous across groups and never repeats inside a
kind. The flattening follows the row order `bongocat-app` already
assigns the automatic chords in, so with the built-in models "Motion 1"
is the motion that got the first digit rather than merely appearing to
match it.

The row keeps its `(model_id, behavior_id)` identity, so what a shortcut
fires is unchanged, and the accessibility labels resolve the same name,
so a screen reader hears what the list shows. Both locale keys are
spelled out in their match arms instead of being assembled, which is the
only shape the source scan in `bongocat-i18n` can see.
The platform shortcut owner mirrors the shared table into real OS
registrations keyed by the hotkey id — the chord — and skipped every chord
that was already registered. Each model numbers its behaviour chords from
the primary modifier's first digit, so a model switch leaves the same
chords in the table and changes only the model behind them: the incoming
model's bindings were never registered with their own targets, and the
platform kept answering for the model being left. The dispatcher drops a
target whose model is not active, so every behaviour shortcut of the new
model went silent until the next launch.

A chord that left the table was also unregistered but never forgotten, so
the same chord could not be registered again once it re-entered: after
switching away and back, a model's shortcuts stayed dead for good.

The mirror now plans each change as removed, added and retargeted, and
applies it: a chord that stays registered updates its target in place,
because re-registering it is refused on Windows and duplicates the
registration on macOS, while a chord that leaves is unregistered and
forgotten. The registration map and the separate target map are one map
now — the split was what let them drift apart.

`HotkeyRegistrar` separates the OS surface from the reconciliation so the
mirror is covered by tests with a fake registrar: a model switch hands the
shared chords to the incoming model, a chord that re-enters the table is
registered again, and an unchanged table touches nothing.
Each shortcut scope now carries its own enable switch as the first row of its
group, so the list that stops working and the switch that explains it are
never on two different pages. The model gate moves off the Interaction page,
and application shortcuts gain a gate of their own
(`shortcuts.commands_enabled`, default `true`).

Both gates only decide whether their half reaches the platform table: neither
clears nor rewrites a recorded chord, so switching one back on needs no
re-recording, and the settings payload cannot rewrite a gate while a chord is
recorded. The gates live in `ShortcutConfig::active_bindings`, the single
projection of the bindings that are live right now. Only the model gate
defaults to off, the opt-in narrowing recorded in item 91.

Decision: ADR-0052.
A gate switch now binds to the settings it controls through one unified
rule (new window::setting_gate::SettingGate): the switch itself is only
disabled where editing is structurally impossible, while the gated rows
dim whole-row - label included, matching how SettingItem::disabled dims a
packaged field - drop their click and key handlers, leave the tab order
and the accessibility tree, and their mutators refuse requests, so a
client acting on a tree rendered before the switch flipped still changes
nothing. A capture left running when its gate turns off is cancelled
instead of writing a binding nothing displays.

Both shortcut gates adopt the rule, and the hover-hide-delay row now
reads the same gate instead of deriving its own condition. No gate ever
rewrites the gated value, and the transient in-flight save flag stays out
of every gate. This supersedes the part of ADR-0052 that kept the rows
editable while a gate was off; ADR-0053 records the unified rule.

Decision: ADR-0053.
The model card actions reached for hand-inlined Lucide SVGs because the
component IconName enum does not export a pencil or a trash can. The
application already registers AllAssets, so the full gpui-kit assets
catalog is available at runtime: swap both buttons to
gpui_kit::assets::IconName (SquarePen, Trash) and drop the inline SVG
constants.
The delete control used to swap itself for an inline confirm/cancel pair,
which changed the shape of the card while the question was up. It now
opens a PopConfirm - the project's own wrapper over the official Popover
(crates/bongocat-ui/src/pop_confirm.rs) - so the card stays put. Version
0.6.4 ships no PopConfirm primitive and no Popover::arrow, so the arrow
waits for a dependency bump.

Two details the surface had to get right:

- The accept button wears the primary variant, not the danger one. The
  warning icon already carries the destructive reading, and a red button
  under a red icon says the same thing twice.
- The icon and the sentence are centred against each other. An icon box
  is shorter than a line of text, so top alignment left the glyph
  floating five pixels above the words.

Enter has to be claimed on a wrapper around each button rather than on the
button itself. gpui dispatches an action before it dispatches on_key_down,
and the popover binds Enter to its own toggle on its own key context, so a
handler on the button would never run: the surface would close without
reporting anything, which is indistinguishable from a decline.

models.delete_confirmation now holds the whole sentence instead of a
template filled by format!, so models.actions.confirm_deletion is gone.

Two defects found on the way:

- The shortcut capture smoke expected every row to follow the global
  editing state alone. ADR-0053 made a row report itself disabled when its
  scope's switch is off, and the model scope starts off, so every row of
  that page was judged wrong and the run stopped before the model page
  smoke ever ran. The expectation now asks each row's own scope.

- The i18n crate gains a third key gate. rust_i18n's i18n! is a proc macro
  that reads the locale files while it expands and records no dependency on
  them, so freshness rests entirely on build.rs's rerun-if-changed. Miss
  that path and the crate serves the previous catalog while every other
  check stays green, because validate-locales.py and both existing key
  scans read the JSON rather than the catalog that ships. On 2026-09-21 the
  model delete confirmation rendered the retired template
  %{status} %{confirm_deletion} with the whole suite passing.
  compiled_catalog_matches_the_files_on_disk compares the compiled value
  against the file a rebuild would read.

ADR-0047's decision text now says the delete asks in a popover rather than
in two stages.
The delete control was hidden on the active card, so removing the model you
were looking at meant switching to another one first. An imported model is now
deletable at all times.

Deleting the selected model switches to the standard preset before the package
is removed: the runtime must never be left holding a model whose files are
about to disappear, and a switch that fails aborts the delete instead of
pulling them out from under the live model. Application::delete_model states
that rule once, in is_selected_installed_model, replacing the two separate
checks it used to make; ApplicationError::SelectedModelDeletion is gone with
it.

SettingsErrorCode::SelectedModelCannotBeDeleted and its two locale strings
follow, because nothing can reach them any more and the message they carried -
"switch to another model first" - describes a rule the app no longer has.

Verified: just check; cargo test --locked --workspace (no failed target);
tools/validate-locales.py (2 locales, 286 keys each); both new assertions
proved to fail under mutation and restored byte-for-byte; the models page
smoke twice, exit code 0.
apply_theme is a public API whose parameter is `&impl HasWindowHandle`, so
theme.rs imports the trait unconditionally - including the
`#[cfg(not(any(target_os = "macos", target_os = "windows")))]` platform module,
where the theme itself is a documented no-op. The dependency, however, sat in
the macOS/Windows target section, so the Linux build never had it and clippy
failed with two `error[E0432]: unresolved import raw_window_handle`.

raw-window-handle is a cross-platform handle abstraction (Wayland, X11, Win32
and AppKit all implement it), not a platform binding, so it moves to the plain
[dependencies] table. Cargo.lock is unchanged.

This is one of the long-standing CI failures, not a regression: the
`Test Native workspace (ubuntu-latest)` job has failed on `Run Clippy` in every
run of the baseline, with the same two errors.

Verified: `cargo check -p bongocat-platform`; a pair of isolated probes compiled
for x86_64-unknown-linux-gnu, where the target-gated layout reproduces the CI
error (E0432, exit 101) and the unconditional one resolves (exit 0); the
dependency audit and `just check` pass. Not verified locally: the full Linux
workspace build, which needs a Linux C toolchain this machine does not have.
Ubuntu's clippy step runs `-p bongocat-app --all-targets --features ... -- -D warnings`,
and `behavior_shortcut_primary_modifiers` is read by exactly one test: the
model-activation case, which is gated to macOS/Windows because starting a
rendering application is a platform capability. On Linux the caller disappears
and the helper is left with no user, which `-D warnings` promotes from a
dead_code warning into an error.

Gate the helper the same way as its caller. The workspace sets no dead_code
lint and the crate has no `allow(dead_code)` anywhere, so matching the existing
`#[cfg]` style keeps the helper visible on the platforms that use it.

This was masked until now: the step used to die earlier, on `raw-window-handle`
resolving only in the macOS/Windows target section of `bongocat-platform`, so
the two `-p bongocat-app` clippy commands had never run on Linux.

Verified: `cargo fmt --all --check` and `cargo clippy -p bongocat-app
--all-targets --features storage-test-injection -- -D warnings` both pass on
macOS (the gate must not hide the helper where it is used); a pair of isolated
probes compiled for x86_64-unknown-linux-gnu reproduces the CI error verbatim
with the ungated shape (exit 101, "function ... is never used") and resolves
with the gated one (exit 0); `just check` exits 0.

Not verified: the full Linux workspace build, which needs a Linux C toolchain
for `ring` and `libdeflate-sys` that this machine does not have.
The settings window talked about the app's own machinery. "Pointer" is now
"Mouse" everywhere the setting is about the mouse - the Interaction page's
group, the mirror and ignore rows, and the hover-hide row and its delay. "Key
release fallback" is now "Key release timeout", and its description says what
the setting is for instead of naming the two mechanisms that have to fail
first: a captured key is released on its own when no key-up event arrives
within the time you set, so it never stays stuck.

The rest is the same idea applied wherever the copy named an internal concept.
The status line no longer connects to a "runtime", and a setting that fails to
apply no longer reports that a runtime declined it. The update errors talk
about "this version" instead of "this build", a release without a matching
download says there is no installer for this computer, the configuration
details say "configuration format" instead of "Schema", and the About page says
"Development build" / "Release build" instead of "Development" /
"Production". Some wording was only vague rather than internal: the Chinese
"open model location" is now "open its folder" (English already said "Open
model folder"), an empty behavior list says there are no motions available,
"Model definition is unsupported" says "model format", the backup count no
longer calls backups "candidates", and the open-at-login rows that need repair
say to turn the switch off and on instead of to "enable".

Nine catalog leaves are renamed because the copy under them no longer carries
the word the key named: settings.model_interaction.pointer.title becomes
.mouse.title, mirror_pointer_tracking and ignore_pointer_input become
mirror_mouse_tracking and ignore_mouse_input, and the overlay's
hide_on_pointer_hover and hide_on_pointer_hover_delay become
hide_on_mouse_hover and hide_on_mouse_hover_delay, along with the two shortcut
actions that adjust the delay. The configuration schema keeps its own names
(overlay.hide_on_pointer_hover, model_settings.mirror_pointer_tracking,
ignore_pointer, release_fallback_timeout_ms): those are the frozen v1 contract,
a different naming axis from the catalog, so a renamed key and its config field
deliberately no longer match.

The values are copied verbatim in three places that a grep for the Chinese copy
misses, so all three move with them: SettingsError's Display impl, which repeats
the catalog's English, the assertions in bongocat-ui's window tests and in
bongocat-i18n's own tests, and tools/tests/test_product_version_contract.py,
which asserts the About page's version line as source text. No accessibility
node changes: only values moved, no visible text was added.

Both catalogs keep 286 leaves. zh-CN changes 40 values and renames 9 leaves;
en-US changes 38 and renames the same 9, because open_location and
startup.unsupported_build were already idiomatic in English.

Verified: `just check` (34 targets, 0 failed, 815 passed); validate-locales.py
reports 286 keys per locale; the 63 tools tests pass; `cargo test -p
bongocat-i18n` passes both key guards and the compiled-catalog check;
--settings-window-smoke with --models-page-smoke, --configuration-recovery-smoke
and --settings-window-state-smoke all exit 0. Two mutations turn the guards red:
restoring one renamed key literal fails
source_referenced_keys_exist_in_the_catalog, and restoring the old Chinese
backup-count text fails the recovery assertion; both were reverted against a
sha256 check and re-run green.

Not verified: every Windows path (cfg(windows) tests and smoke cannot run
here); truncation of the longer English strings at 125/150/200% scaling; and
what a screen reader announces for the renamed group title. The ADRs and the
earlier TODO entries still quote the old copy in places; they are decision and
history records and were left alone.
Three follow-ups to the plain-language pass, chosen by the maintainer.

Two catalog messages said something other than what they mean. Importing the
folder that contains the model library reported "the model source cannot be
imported", which is both stilted and uninformative; that code fires only when
the selected folder is the store or one of its ancestors, so it now says that
and asks for a specific model folder. "Model storage is busy" named the store
rather than what the user is waiting on, and now says the models are busy with
another operation.

Three labels were imprecise in the other direction. "Texture is invalid" is the
one message in its group that does not name the model, so it becomes "Model
texture is invalid"; the Application page's "System integration" group holds
the menu bar, tray and taskbar icon switches, so it is "System icons"; and the
disk-full error says the disk holding the configuration is full rather than
that "configuration storage" is full.

Every error message in the catalog now starts with a capital letter. The 41
that started lowercase were the only sentence-case outliers in the catalog:
status, models and update entries all open with a capital. These are
notification bodies, not fragments, so the lowercase openings read as
mistakes. The SettingsError Display impl, which repeats the catalog's English
verbatim, and the assertions that pin it move with them.

Three documents still quoted the old labels, so a reader searching the UI for
them found nothing: ADR-0036's quote of the unsupported-source error,
ADR-0047's five mentions of the open-folder action, and the TODO's four
mentions of the same action plus its per-page settings inventory, which still
listed the pointer group and the key-release row under their old names.
Technical prose keeps "pointer", "state reconciliation" and "revision" where
it describes the mechanism rather than the copy, and the historical entries
that record the exact text restored at the time stay as they are.

Verified: `just check` (34 targets, 0 failed, 815 passed); validate-locales.py
reports 286 keys per locale; the 63 tools tests pass; `cargo test -p
bongocat-i18n`, `-p bongocat-ui --lib` and `-p bongocat-app --lib` are green.

Not verified: every Windows path (cfg(windows) tests and smoke cannot run
here); the rendered truncation of the longer English error messages; and what
a screen reader announces for the renamed group titles.
Two consistency leftovers from the plain-language pass.

The catalog used two ellipses: the update entries and the Chinese typographic
standard use the single-character ellipsis, while ten progress and status
entries in both locales - loading models, choosing a folder or archive, the
import stages, refreshing, saving, connecting, checking login startup - still
used three ASCII periods. The six tests that pin those strings move with them.

`status.refreshing` also still read "Refreshing runtime snapshot...", which is
the internal vocabulary the previous commit removed from its neighbour
"Connecting to runtime..."; it is now "Refreshing status...". A scan of all
286 English leaves finds no sentence that still opens with a lowercase letter
once placeholder, proper-noun and language-name values are set aside.

Verified: `just check` (34 targets, 0 failed, 815 passed); validate-locales.py
reports 286 keys per locale; the 63 tools tests pass; `cargo test -p
bongocat-i18n`, `-p bongocat-ui --lib` and `-p bongocat-app --lib` are green.

Not verified: every Windows path (cfg(windows) tests and smoke cannot run
here), and how the ellipsis renders in fonts outside the two shipped locales.
Recover damaged configuration from the newest valid backup or defaults without a restore flow, and remove the project-owned accessibility bridge and hidden-only UI state. Keep product and smoke settings windows on the same visible composition.
The window is created and shown before the settings service answers its first
snapshot, so the frames in that gap had no snapshot to read and rendered the
built-in defaults. A Simplified Chinese user watched the window redraw from
English as soon as the snapshot arrived, title bar included, and the update
window opened on the defaults too.

Both windows now start from the language and theme the product already resolved
at startup, which the settings service read for the first system menu anyway,
and render them until the first snapshot replaces them.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
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