Skip to main content

max / audiofiles

Escape goes back, and the two windows nothing could open are deleted Max's ruling on `8d5519cb`: commit to inline navigation and give the shell a way back, rather than re-wiring the doors `49b7429` deleted. `quasi::panel::leave` is the whole of it. `Runtime::back` was written, popped `history`, and had never been called; `Outcome::Screen` was already pushing to that history through `remember`. Three conditions, each answering a way it could be wrong: the description gets first refusal, since `runtime.show` answers a `Step` when a described control claimed the press and every modal here carries `Act::key("esc")` on its Cancel; never while an overlay is open, since an overlay never entered history; and only where there is somewhere to go. What that lets go: `draw_settings`, `draw_sync`, six gates in `editor.rs`, `settings.show_manager`, `sync.show_panel`, and four `VfsModalUiState` fields. Two of the six gates were the one-way trip. The other four were the naming modals, which were never broken -- they answer `Outcome::Over`, so the shell's runtime layers them over the browser and `editor.rs`'s window path for them was simply never taken. Two parity tests moved off the deleted windows onto `draw_described`, a test-support seam beside `described_screen` that stands a bare runtime at an address and drives one frame. That is what the shell does to these two screens, minus the browser around them. `close_settings` became a navigation. It cleared the window's runtime after a library switch, which said nothing once Settings stopped being a window, so the three acts that change which library is open answer `Outcome::Goto("/")` instead. Two tests hold the distinction: those three leave, and scan, forget and the rest stay. THE TOUCH HALF: no. A `Chrome` binding carries an `Action`, an `Action` names a route, and "wherever I came from" is not one -- which route it is depends on history the runtime holds and the description does not. So `leave` is a host affordance a keyboard host can invent and a touch host cannot: its system back gesture has nothing described to bind to, and a webview's own history stack is unrelated to the runtime's. Filed as quasicoherent `33c27e81`, which recommends `Destination::Back` beside `Local` -- the precedent for a destination that is not a route. `leave`'s header names it, since that is what it replaces.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 22:55 UTC
Signed with PGP, not checked
Commit: e2386b69ba024881ed7d1ba1289d200f104cc02a
Parent: 7977ca1
5 files changed, +155 insertions, -162 deletions
@@ -92,10 +92,6 @@
92 92 // input to the live UI underneath (P2).
93 93 let modal_active = state.overlay.pending_confirm.is_some()
94 94 || state.bulk_modal.is_some()
95 - || state.vfs_modal.show_vfs_create
96 - || state.vfs_modal.vfs_rename_target.is_some()
97 - || state.vfs_modal.show_dir_create
98 - || state.vfs_modal.dir_rename_target.is_some()
99 95 || state.loose_files.show_loose_files_warning
100 96 || state.import_wf.pending_import_preflight.is_some();
101 97 if modal_active {
@@ -120,23 +116,10 @@
120 116 }
121 117 None => {}
122 118 }
123 - // The four name modals, all of them `quasi::naming`. Which one is showing is
124 - // still the app's own flag, so the address is built here rather than read
125 - // off anything a route knows.
126 - if state.vfs_modal.show_vfs_create {
127 - crate::quasi::panel::draw_naming(ctx, state, "New Vault", "/vaults/new");
128 - }
129 - if let Some((id, _)) = &state.vfs_modal.vfs_rename_target {
130 - let home = format!("/vaults/{}/rename", id.as_i64());
131 - crate::quasi::panel::draw_naming(ctx, state, "Rename Vault", &home);
132 - }
133 - if state.vfs_modal.show_dir_create {
134 - crate::quasi::panel::draw_naming(ctx, state, "New Folder", "/folders/new");
135 - }
136 - if let Some((id, _)) = &state.vfs_modal.dir_rename_target {
137 - let home = format!("/folders/{}/rename", id.as_i64());
138 - crate::quasi::panel::draw_naming(ctx, state, "Rename", &home);
139 - }
119 + // The four name modals are not drawn here. They answer `Outcome::Over`, so
120 + // the shell's own runtime layers them over the browser when their act is
121 + // pressed, and the window path this used to take was never entered -- the
122 + // flags gating it went with `49b7429`'s sidebar. Measured 2026-08-25.
140 123 if state.loose_files.show_loose_files_warning {
141 124 crate::quasi::panel::draw_integrity(ctx, state);
142 125 }
@@ -144,20 +127,10 @@
144 127 crate::quasi::panel::draw_preflight(ctx, state);
145 128 }
146 129
147 - // Settings window. Served from the description now, and `ui/settings_panel.rs`
148 - // is gone with the second call: five of its nine sections are not described
149 - // yet and are missing until they are, which is `da48cb6d`'s ruling applied
150 - // here rather than a coexistence this port would have had to invent.
151 - if state.settings.show_manager {
152 - crate::quasi::panel::draw_settings(ctx, state);
153 - }
154 -
155 - // Sync panel overlay. One call for both cases now: `None` is `Unconfigured`,
156 - // which says syncing is unavailable and offers nothing, where the shipped
157 - // side had a second window for it.
158 - if state.sync.show_panel {
159 - crate::quasi::panel::draw_sync(ctx, state, sync_manager);
160 - }
130 + // Settings and Cloud Sync are not drawn here either, and for a different
131 + // reason than the modals above: they answer `Outcome::Screen`, so the
132 + // toolbar's act replaces the shell's screen with them. That was a one-way
133 + // trip until 2026-08-25; `quasi::panel::leave` is the way off.
161 134
162 135 // Floating sample editor window
163 136 if state.edit.show_window {
@@ -275,14 +248,10 @@
275 248 ctx.input(|input| {
276 249 // Escape: dismiss dialogs in priority order
277 250 if input.key_pressed(egui::Key::Escape) {
278 - if state.settings.show_manager {
279 - state.settings.show_manager = false;
280 - } else if state.forge.show_window {
251 + if state.forge.show_window {
281 252 state.close_forge_window();
282 253 } else if state.edit.show_window {
283 254 state.close_edit_window();
284 - } else if state.sync.show_panel {
285 - state.sync.show_panel = false;
286 255 } else if state.bulk_modal.is_some() {
287 256 state.close_bulk_modal();
288 257 } else if state.import_wf.pending_import_preflight.is_some() {
@@ -55,8 +55,6 @@
55 55 /// from the description every frame, so their buffers have to outlive one.
56 56 #[derive(Debug, Default)]
57 57 pub struct Described {
58 - settings: Option<Runtime>,
59 - sync: Option<Runtime>,
60 58 export: Option<Runtime>,
61 59 detail: Option<Runtime>,
62 60 shell: Option<Runtime>,
@@ -107,80 +105,6 @@
107 105 pub(super) free: crate::export::FreeSpace,
108 106 }
109 107
110 - /// Draw the described settings window, and act on whatever was pressed.
111 - pub fn draw_settings(ctx: &egui::Context, state: &mut BrowserState) {
112 - // The Trash section reads a list the app loads on demand, and loading it is
113 - // `&mut`. Same housekeeping `draw_sync` does above its own window: a route
114 - // cannot fetch, so the host fetches before it asks. Cheap after the first
115 - // time — `ensure_trash_loaded` is a flag check once the list is in hand.
116 - state.ensure_trash_loaded();
117 -
118 - let intents = RefCell::new(Vec::new());
119 - let mut runtime = state.described.settings.take();
120 - let stale = state.described.stale;
121 - let host = Host {
122 - state,
123 - sync: None,
124 - themes: themes(),
125 - intents: &intents,
126 - };
127 - let closed = window(ctx, "Settings", &mut runtime, &host, "/settings", stale);
128 - state.described.settings = runtime;
129 - apply(ctx, state, None, intents.into_inner());
130 - if closed {
131 - state.settings.show_manager = false;
132 - state.described.settings = None;
133 - }
134 - }
135 -
136 - /// Draw the sync window, and act on whatever was pressed.
137 - ///
138 - /// `sync` is `None` when the app has no manager, and it becomes
139 - /// [`Unconfigured`], which says syncing is unavailable and offers nothing. The
140 - /// shipped panel had a second window for that case.
141 - pub fn draw_sync(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) {
142 - // Two pieces of housekeeping that came off `ui::sync_panel::draw_sync_panel`
143 - // when it was deleted. Neither is describable and neither is a control: they
144 - // are caches the window owns, dropped when what they were about is over.
145 - //
146 - // The auth URL is only meaningful while the Copy URL fallback is on screen,
147 - // and keeping it would show a stale PKCE state if the panel were reopened.
148 - if let Some(manager) = sync
149 - && !matches!(
150 - manager.status().state,
151 - audiofiles_sync::SyncState::Authenticating
152 - )
153 - && state.sync.auth_url.is_some()
154 - {
155 - state.sync.auth_url = None;
156 - }
157 - // The per-vault storage numbers go when the panel closes, so reopening
158 - // fetches fresh ones: the user may have imported or deleted since.
159 - if !state.sync.show_panel {
160 - state.sync.vfs_storage_fetched = false;
161 - state.sync.vfs_storage_cache.clear();
162 - state.sync.synced_bytes = None;
163 - state.sync.cap_picker_gib = None;
164 - }
165 -
166 - let intents = RefCell::new(Vec::new());
167 - let mut runtime = state.described.sync.take();
168 - let stale = state.described.stale;
169 - let host = Host {
170 - state,
171 - sync,
172 - themes: themes(),
173 - intents: &intents,
174 - };
175 - let closed = window(ctx, "Cloud Sync", &mut runtime, &host, "/sync", stale);
176 - state.described.sync = runtime;
177 - apply(ctx, state, sync, intents.into_inner());
178 - if closed {
179 - state.sync.show_panel = false;
180 - state.described.sync = None;
181 - }
182 - }
183 -
184 108 /// Draw the export flow, and act on whatever was pressed.
185 109 ///
186 110 /// One call for the whole flow, the way `draw_import` is: the shipped side was
@@ -474,10 +398,6 @@
474 398 // what `Intent::NamingDone` would have done.
475 399 if closed || finished {
476 400 state.described.naming = None;
477 - state.vfs_modal.show_vfs_create = false;
478 - state.vfs_modal.show_dir_create = false;
479 - state.vfs_modal.vfs_rename_target = None;
480 - state.vfs_modal.dir_rename_target = None;
481 401 state.vfs_modal.name_modal_error = None;
482 402 }
483 403 }
@@ -1000,13 +920,7 @@
1000 920 // into. What is left is the half a route cannot do.
1001 921 // Whichever of the four was up, and the error with it: the modal is
1002 922 // finished with, so a refusal it was showing is finished with too.
1003 - Intent::NamingDone => {
1004 - state.vfs_modal.show_vfs_create = false;
1005 - state.vfs_modal.show_dir_create = false;
1006 - state.vfs_modal.vfs_rename_target = None;
1007 - state.vfs_modal.dir_rename_target = None;
1008 - state.vfs_modal.name_modal_error = None;
1009 - }
923 + Intent::NamingDone => state.vfs_modal.name_modal_error = None,
1010 924 // Whichever of the three was up.
1011 925 Intent::BulkDone => state.close_bulk_modal(),
1012 926 Intent::VaultsChanged(say) => {
@@ -1488,7 +1402,6 @@
1488 1402 // instead, so by the time an intent lands the question has been put.
1489 1403 Intent::SwitchLibrary(path) => {
1490 1404 state.settings.pending_action = Some(crate::state::VaultAction::SwitchVault(path));
1491 - close_settings(state);
1492 1405 }
1493 1406 Intent::RenamingLibrary(target) => state.settings.rename_target = target,
1494 1407 Intent::RenameLibrary { path, name } => {
@@ -1526,7 +1439,6 @@
1526 1439 loose_files,
1527 1440 });
1528 1441 discard_draft(state);
1529 - close_settings(state);
1530 1442 }
1531 1443 }
1532 1444 Intent::AddExistingLibrary => {
@@ -1534,10 +1446,6 @@
1534 1446 state.settings.pending_action =
1535 1447 Some(crate::state::VaultAction::AddExistingVault { name, path });
1536 1448 discard_draft(state);
1537 - // Both commit paths close Settings: a Create makes the new
1538 - // library active, and an Add Existing typically motivates
1539 - // browsing it straight away.
1540 - close_settings(state);
1541 1449 }
1542 1450 }
1543 1451 Intent::DiscardLibraryDraft => discard_draft(state),
@@ -1754,16 +1662,6 @@
1754 1662 state.settings.create_loose_files = false;
1755 1663 }
1756 1664
1757 - /// Put the settings window away, and the runtime that was serving it.
1758 - ///
1759 - /// The runtime goes because it holds the address the window was showing, and a
1760 - /// window that closed on a switch should reopen at `/settings` rather than where
1761 - /// it left off.
1762 - fn close_settings(state: &mut BrowserState) {
1763 - state.settings.show_manager = false;
1764 - state.described.settings = None;
1765 - }
1766 -
1767 1665 /// Add a theme file to the custom themes, and show it.
1768 1666 ///
1769 1667 /// Lifted from the deleted `ui/settings_panel.rs`. Importing a theme selects it,
@@ -2304,7 +2202,9 @@
2304 2202 }
2305 2203
2306 2204 let step = runtime.show(ui, &immediate);
2205 + let taken = !matches!(step, Step::Idle);
2307 2206 perform(runtime, ui, host, step);
2207 + leave(runtime, ui, host, taken);
2308 2208
2309 2209 // One drain for every described window, rather than one per
2310 2210 // `draw_*`: a file is produced by a route and a route is reachable
@@ -2315,6 +2215,45 @@
2315 2215 locate(runtime, host.state, home);
2316 2216 }
2317 2217
2218 + /// Escape goes back, when nothing on the screen wanted it.
2219 + ///
2220 + /// **The way out of a described screen**, ruled by Max on 2026-08-25 after the
2221 + /// door sweep found that `/settings` and `/sync` replace the shell's screen and
2222 + /// nothing described a way off them. `Runtime::back` was written, popped
2223 + /// `history`, and had never been called.
2224 + ///
2225 + /// Three conditions, and each is the answer to a way this could be wrong:
2226 + ///
2227 + /// - **The description gets first refusal.** `runtime.show` answers a `Step`
2228 + /// when a described control claimed the press, and every modal in this app
2229 + /// carries `Act::key("esc")` on its Cancel. Firing as well would cancel the
2230 + /// modal *and* navigate off what was under it.
2231 + /// - **Never while an overlay is open.** An overlay is not a place and never
2232 + /// entered `history`, so going back from one would leave the layer stack up
2233 + /// and change the screen underneath it. Dismissing is the overlay's own
2234 + /// Cancel, which is the first condition.
2235 + /// - **Only where there is somewhere to go.** `back` answers `Step::Idle` on an
2236 + /// empty history, so the shell's own home is not a screen you can leave.
2237 + ///
2238 + /// # What this is not, and it is worth saying at the binding
2239 + ///
2240 + /// A host affordance rather than a described fact. Nothing in the vocabulary
2241 + /// says "this is the way back": `Chrome::bind` carries an `Action`, and an
2242 + /// action names a route rather than a movement through history. So a keyboard
2243 + /// host gets this and **a touch host gets nothing** -- its system back gesture
2244 + /// has no described thing to bind to. Measured 2026-08-25 and filed as
2245 + /// quasicoherent `33c27e81`; when that lands, this function is what it replaces.
2246 + fn leave(runtime: &mut Runtime, ui: &mut egui::Ui, host: &Host<'_>, taken: bool) {
2247 + if taken || runtime.overlaid() {
2248 + return;
2249 + }
2250 + if !ui.ctx().input(|input| input.key_pressed(egui::Key::Escape)) {
2251 + return;
2252 + }
2253 + let step = runtime.back();
2254 + perform(runtime, ui, host, step);
2255 + }
2256 +
2318 2257 /// One described window: draw it, act on it, and say whether it was closed.
2319 2258 fn window(
2320 2259 ctx: &egui::Context,
@@ -2697,6 +2636,31 @@
2697 2636 .collect()
2698 2637 }
2699 2638
2639 + /// Draw one described screen inline, for the parity tests.
2640 + ///
2641 + /// The companion to [`described_screen`], and it exists because two screens lost
2642 + /// the window that used to render them. `/settings` and `/sync` are reached by
2643 + /// navigating the shell now (2026-08-25), so there is no `draw_settings` to read
2644 + /// a description against; this stands a bare [`Runtime`] at the address and
2645 + /// drives one frame of it, which is what the shell does to them and all a parity
2646 + /// read needs.
2647 + ///
2648 + /// `refresh` is false: a parity read presses nothing, so there is nothing to
2649 + /// reload for, and asking twice would only widen what the fixture has to answer.
2650 + #[cfg(test)]
2651 + pub(super) fn draw_described(ui: &mut egui::Ui, state: &mut BrowserState, home: &str) {
2652 + let intents = RefCell::new(Vec::new());
2653 + let mut runtime = None;
2654 + let host = Host {
2655 + state,
2656 + sync: None,
2657 + themes: themes(),
2658 + intents: &intents,
2659 + };
2660 + inline(ui, &mut runtime, &host, home, false);
2661 + apply(ui.ctx(), state, None, intents.into_inner());
2662 + }
2663 +
2700 2664 /// The screen the app's own adapters answer at `address`, for the parity tests.
2701 2665 ///
2702 2666 /// The parity harness compares a described screen against the shipped panel it
@@ -804,22 +804,22 @@
804 804 //
805 805 // The comparison this replaces was against four hand-drawn bodies of
806 806 // `settings_panel.rs`, because the description covered four of the shipped
807 - // panel's nine sections. The file is deleted, so there is nothing left to
808 - // compare against and nothing left holding the other five: Storage, Trash,
809 - // License, the classifier and Import Theme are missing from the app until
810 - // each is described. `da48cb6d` is the ruling that allows the interval.
807 + // panel's nine sections. That file is deleted and so are the other five
808 + // sections' excuses: all nine are answered as of 2026-08-25.
809 + //
810 + // Drawn inline rather than in a window, which is the second thing that
811 + // changed: `draw_settings` is gone with the door that could never open it,
812 + // and Settings is a screen the shell navigates to. `draw_described` is what
813 + // the shell does to it, minus the browser around it.
811 814 let (mut state, _dir) = fixture();
812 - state.settings.show_manager = true;
813 815
814 816 let described = described(&super::panel::described_screen(&state, "/settings"));
815 817 let drawn = shipped(|ui| {
816 - super::panel::draw_settings(ui.ctx(), &mut state);
818 + super::panel::draw_described(ui, &mut state, "/settings");
817 819 });
818 820
819 821 described.addresses_resolve();
820 - Parity::strict()
821 - .in_a_window("Settings")
822 - .assert(&described, &drawn);
822 + Parity::strict().assert(&described, &drawn);
823 823 }
824 824
825 825 #[test]
@@ -1078,18 +1078,18 @@
1078 1078 // With no manager, which is the one sync state a test can stand up without
1079 1079 // a server. `Unconfigured` says syncing is unavailable and offers nothing,
1080 1080 // where the shipped side had a whole second window for it.
1081 + //
1082 + // Inline for the same reason Settings is: `draw_sync` went with the door
1083 + // nothing could open, and the shell navigates to `/sync` instead.
1081 1084 let (mut state, _dir) = fixture();
1082 - state.sync.show_panel = true;
1083 1085
1084 1086 let described = described(&super::panel::described_screen(&state, "/sync"));
1085 1087 let drawn = shipped(|ui| {
1086 - super::panel::draw_sync(ui.ctx(), &mut state, None);
1088 + super::panel::draw_described(ui, &mut state, "/sync");
1087 1089 });
1088 1090
1089 1091 described.addresses_resolve();
1090 - Parity::strict()
1091 - .in_a_window("Cloud Sync")
1092 - .assert(&described, &drawn);
1092 + Parity::strict().assert(&described, &drawn);
1093 1093 }
1094 1094
1095 1095 #[test]
@@ -80,8 +80,8 @@
80 80
81 81 use quasi_router::layout::{FieldKind, Tone};
82 82 use quasi_router::{
83 - Act, Action, Choice, Field, Locating, Node, Request, Response, RouteError, Router, Row, Slot,
84 - Tag,
83 + Act, Action, Choice, Field, Locating, Node, Outcome, Request, Response, RouteError, Router,
84 + Row, Slot, Tag,
85 85 };
86 86
87 87 use super::Panels;
@@ -89,6 +89,9 @@
89 89 /// The name a picked folder comes back under.
90 90 const FOLDER: &str = "folder";
91 91
92 + /// Where a library change leaves you: browsing the library it changed to.
93 + const BROWSER: &str = "/";
94 +
92 95 /// The name a typed library name is submitted under.
93 96 const NAME: &str = "name";
94 97
@@ -126,9 +129,13 @@
126 129 }
127 130
128 131 /// `POST /settings/storage/open/{at}`
132 + ///
133 + /// Leaves for the browser rather than answering with Settings again, which is
134 + /// what opening a library is for. Said as a navigation since 2026-08-25: it was
135 + /// the host clearing the window's runtime until Settings stopped being a window.
129 136 fn open(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
130 137 state.storage.open(row(&request)?);
131 - settled(state)
138 + Ok(Response::from(Outcome::Goto(Action::get(BROWSER))))
132 139 }
133 140
134 141 /// `POST /settings/storage/rename/{at}`
@@ -262,7 +269,7 @@
262 269 return Err(RouteError::not_found("the form is not finished"));
263 270 }
264 271 state.storage.create();
265 - settled(state)
272 + Ok(Response::from(Outcome::Goto(Action::get(BROWSER))))
266 273 }
267 274
268 275 /// `POST /settings/storage/add`
@@ -271,7 +278,7 @@
271 278 return Err(RouteError::not_found("the form is not finished"));
272 279 }
273 280 state.storage.add_existing();
274 - settled(state)
281 + Ok(Response::from(Outcome::Goto(Action::get(BROWSER))))
275 282 }
276 283
277 284 /// `POST /settings/storage/discard`
@@ -11817,3 +11817,56 @@
11817 11817 .any(|label| label == "Export current theme")
11818 11818 );
11819 11819 }
11820 +
11821 + #[test]
11822 + fn changing_which_library_is_open_leaves_settings_for_the_browser() {
11823 + // Opening a library is what Settings is being used *for*, so the three acts
11824 + // that change which one is open navigate rather than answering with Settings
11825 + // again. It was the host clearing the window's runtime until 2026-08-25,
11826 + // when Settings stopped being a window and a navigation became the only way
11827 + // to say it.
11828 + let mut storage = FakeStorage::two();
11829 + storage.libraries[1].reachable = true;
11830 + storage.draft = Draft {
11831 + name: "Kit".to_owned(),
11832 + folder: Some("/mnt/fast/Kit".to_owned()),
11833 + reference_in_place: false,
11834 + };
11835 +
11836 + for address in [
11837 + "/settings/storage/open/1",
11838 + "/settings/storage/create",
11839 + "/settings/storage/add",
11840 + ] {
11841 + let response = storing(&storage, Request::post(address)).expect("answered");
11842 + let Outcome::Goto(action) = &response.outcome else {
11843 + panic!(
11844 + "{address} answered {:?} rather than leaving",
11845 + response.outcome
11846 + );
11847 + };
11848 + assert_eq!(action.route(), Some("/"), "{address} went somewhere else");
11849 + }
11850 + assert_eq!(storage.asked(), ["open 1", "create", "add_existing"]);
11851 + }
11852 +
11853 + #[test]
11854 + fn every_other_storage_act_stays_on_the_settings_screen() {
11855 + // The converse, so the rule above is a distinction rather than a habit:
11856 + // scanning, renaming and the rest are things you do *to* a library while
11857 + // looking at the list, and leaving after each would be unusable.
11858 + let storage = FakeStorage::two();
11859 + for address in [
11860 + "/settings/storage/scan",
11861 + "/settings/storage/orphans",
11862 + "/settings/storage/forget/1",
11863 + "/settings/storage/discard",
11864 + ] {
11865 + let response = storing(&storage, Request::post(address)).expect("answered");
11866 + assert_eq!(
11867 + screen_of(&response).title,
11868 + "Settings",
11869 + "{address} left the screen"
11870 + );
11871 + }
11872 + }