diff --git a/crates/b2-desktop/CLAUDE.md b/crates/b2-desktop/CLAUDE.md index 8f8bb89..f1c15da 100644 --- a/crates/b2-desktop/CLAUDE.md +++ b/crates/b2-desktop/CLAUDE.md @@ -225,6 +225,26 @@ Every new surface owes all four. They are cheap while you're building it and exp the gate is *not*: `conflicts()` asks a same-scope question, and scope buys nothing here — a menu accelerator is taken before the webview is consulted, so `menuOverlaps` compares across every scope. + + **One section is B2's own.** View ▸ Zoom In / Zoom Out / Actual Size (⌘= ⌘- ⌘0) are the only rows + that are not a `PredefinedMenuItem` restating a chord macOS assigned: they are `Item::Command`, + and B2 *chooses* those keystrokes. They live in the menu rather than in `bindings.ts` because + macOS expects them there with their chords printed beside them, and an accelerator the menu owns + never reaches the webview — a chord spelled in both places is a chord that only ever fires once, + from the menu. Two things follow. The host must now *do* something when a menu item fires, so + `main.rs` has an `on_menu_event` that emits the item's id (`MENU_COMMAND_EVENT`, mirrored in + `ui/src/api.ts`) and stops there — what a size means is `ui/src/zoom.ts`'s, because this crate + holds no logic. And the accelerator must be spelled twice, once for the UI's parser and once for + muda's; `muda_accelerator` **derives** the second from the first, because Tauri parses an + accelerator with `.parse().ok()` and a spelling it can't read becomes a menu line that silently + has no shortcut. If you add another `Item::Command`, that derivation and `menukeys.ts` are what + must move with it. + + What that trade costs, stated once so nobody re-discovers it as a bug: ⌘⇧= does **not** zoom in. + Tauri takes the accelerator as a string and splits it on `+`, so `Key::Character("+")` is + unreachable and the item's key equivalent is `=` — which AppKit matches only without ⇧. Zed's + View menu is spelled the same way. The fix, if it is ever wanted, is a webview binding for + `Mod-+` / `Mod-_` alone: different keystrokes from the menu's, so `menuOverlaps` stays empty. - **`ui/src/chat.ts`** — the chat pane's pure logic, and sidenav.ts's client rather than its rival ([#155](https://github.com/AlteredCraft/B2/issues/155)). Chat is the right column's *third mode*, so its transcript is emitted as the same `SideRow` shape discovery's cards are, and `sideRows` delegates here diff --git a/crates/b2-desktop/src/commands.rs b/crates/b2-desktop/src/commands.rs index 843df4e..126b474 100644 --- a/crates/b2-desktop/src/commands.rs +++ b/crates/b2-desktop/src/commands.rs @@ -565,6 +565,24 @@ pub fn menu_chords() -> Vec { crate::menu::chords() } +/// Set the window's **page zoom** — the whole rendering scaled the way ⌘+ does in Safari, +/// which is what B2's ⌘= / ⌘- / ⌘0 mean (`ui/src/zoom.ts`). WebKit's `pageZoom`, and the +/// one thing here that is genuinely the host's: a webview cannot zoom itself, and scaling +/// with CSS would grow the text while the pixel-sized chrome around it stayed put. +/// +/// A pass-through by design. The ladder, its walls, the snapping of an off-ladder value +/// and the remembering of the choice are all the UI's, tested there in node — this crate +/// holds no rule about *which* sizes are allowed, exactly as it holds no rule about which +/// column widths are. `factor` is a scale, so it is only ever the positive, bounded number +/// `adoptZoom` produced; the guard here is the type, and anything stranger is a webview +/// the platform refuses to render, which is what the error variant is for. +#[tauri::command] +pub fn set_zoom(window: tauri::WebviewWindow, factor: f64) -> Result<(), CmdError> { + window + .set_zoom(factor) + .map_err(|e| CmdError::ZoomFailed(e.to_string())) +} + /// **Flow ④ — one grounded answer**: condense -> retrieve -> assemble -> stream -> cite, /// all of it behind `Vault::ask`. The host's whole contribution is the shape of the /// *delivery*: Tauri runs the `(async)` body on a worker thread, tokens stream to the diff --git a/crates/b2-desktop/src/error.rs b/crates/b2-desktop/src/error.rs index 1edfe5e..25bf9b0 100644 --- a/crates/b2-desktop/src/error.rs +++ b/crates/b2-desktop/src/error.rs @@ -48,6 +48,13 @@ pub enum CmdError { /// to the webview. #[error("clipboard read failed: {0}")] ClipboardFailed(String), + /// The webview refused a page-zoom change (`set_zoom`, the ⌘= / ⌘- / ⌘0 family). + /// WebKit's `pageZoom` does not fail in practice, so this exists to keep the handler + /// honest rather than to describe something a user is likely to see — the same shape + /// as [`CmdError::OpenFailed`]: the platform's detail, logged in full server-side, + /// generic to the webview. + #[error("could not change the window size: {0}")] + ZoomFailed(String), /// `import_file` was handed a payload that isn't base64 — the drop transport's own /// failure, before the façade ever sees bytes. Not something the user did: it means /// the frontend's encoder produced something the host can't read, so the message @@ -148,6 +155,10 @@ pub fn user_message(err: &CmdError) -> String { CmdError::ClipboardFailed(_) => { "Couldn't read the clipboard. Copy the text again, then paste.".to_string() } + CmdError::ZoomFailed(_) => { + "Couldn't change the text size. Try again, or press ⌘0 to go back to the default." + .to_string() + } CmdError::VaultRequired => { "No vault open. Launch B2 with a vault path, or set B2_VAULT_PATH to your vault folder." .to_string() diff --git a/crates/b2-desktop/src/main.rs b/crates/b2-desktop/src/main.rs index eb37567..9010879 100644 --- a/crates/b2-desktop/src/main.rs +++ b/crates/b2-desktop/src/main.rs @@ -37,7 +37,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use std::time::Duration; -use tauri::Manager; +use tauri::{Emitter, Manager}; use watch::VaultWatcher; /// How often [`AppState::cancel_and_wait_for_reindex`] re-asserts the cancel flag and @@ -406,6 +406,14 @@ fn main() { // — and AppKit dispatches them before the webview sees a key, so the keyboard // registry can't observe them either. `menu::MENU` is that list, made data. .menu(menu::build) + // ...and the handler for the lines in it that are B2's own rather than the + // platform's (the View menu's three sizes). The host forwards the chosen id and + // stops there: what a size *means* — the ladder, its ends, remembering it — is + // `ui/src/zoom.ts`'s, because this crate holds no logic. A predefined item never + // arrives here; those are handled natively, which is the point of them. + .on_menu_event(|app, event| { + let _ = app.emit(menu::MENU_COMMAND_EVENT, event.id().0.as_str()); + }) // The dialog plugin backs the native folder picker for `choose_vault`. It is // driven host-side only; the webview gets no dialog permission (capabilities/ // default.json), so it can never open a dialog itself. @@ -473,6 +481,7 @@ fn main() { commands::embed_device, commands::embed_stats, commands::menu_chords, + commands::set_zoom, commands::ask, commands::cancel_ask, commands::chat_setup, diff --git a/crates/b2-desktop/src/menu.rs b/crates/b2-desktop/src/menu.rs index 1cc27a2..6c36f1b 100644 --- a/crates/b2-desktop/src/menu.rs +++ b/crates/b2-desktop/src/menu.rs @@ -22,9 +22,20 @@ //! macOS. Neither survives here. use serde::Serialize; -use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu}; +use tauri::menu::{AboutMetadata, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::{AppHandle, Runtime}; +/// The event the host emits when one of B2's **own** menu items is chosen — the View +/// menu's three zoom lines, today. The payload is the item's [`ItemSpec::id`], and +/// `ui/src/api.ts` carries the mirror of this string; change the two together. +/// +/// Why an event at all, rather than the host simply zooming. The *rule* — the ladder of +/// sizes, its walls, and remembering the choice — lives in `ui/src/zoom.ts`, because a +/// reading size is a viewing preference and this crate holds no logic (the one rule). +/// AppKit just happens to be where the keystroke lands, so the host's whole job is to +/// say which line was chosen and let the frontend decide what that means. +pub const MENU_COMMAND_EVENT: &str = "menu-command"; + /// The native behavior an item delegates to — one variant per [`PredefinedMenuItem`] /// constructor B2 uses. An enum rather than a function pointer so [`MENU`] stays a /// plain, readable table that the tests below can walk. @@ -47,6 +58,13 @@ enum Item { /// macOS's name for `maximize` — the label the platform itself uses. Zoom, Separator, + /// **B2's own**, rather than a native behavior delegated to. The only kind of item + /// here that has no `PredefinedMenuItem` behind it: choosing it emits + /// [`MENU_COMMAND_EVENT`] carrying the row's id, and the frontend decides what it + /// means. Its accelerator is derived from the row's `keys` ([`muda_accelerator`]), + /// so — unlike every predefined row above, whose chord muda assigns and this table + /// merely restates — this is a chord B2 actually chooses. + Command, } /// One line of the menu. @@ -177,14 +195,42 @@ const MENU: &[SectionSpec] = &[ }, ], }, + // The one section with items of B2's own. The three sizes are here rather than in + // `ui/src/bindings.ts` for the reason this module exists at all: a menu accelerator + // is dispatched before the key window's responder chain, so a chord spelled in both + // places is a chord the webview never receives. Since macOS expects Zoom In / Zoom + // Out / Actual Size to *be* in the View menu — with their chords printed beside them, + // which is where most people find them — the menu is the honest owner, and the + // registry stays out of these three keystrokes entirely. SectionSpec { title: "View", - items: &[ItemSpec { - id: "view.fullscreen", - item: Item::Fullscreen, - label: "Toggle Full Screen", - keys: Some("Mod-Ctrl-f"), - }], + items: &[ + ItemSpec { + id: "view.zoom-in", + item: Item::Command, + label: "Zoom In", + keys: Some("Mod-="), + }, + ItemSpec { + id: "view.zoom-out", + item: Item::Command, + label: "Zoom Out", + keys: Some("Mod--"), + }, + ItemSpec { + id: "view.zoom-reset", + item: Item::Command, + label: "Actual Size", + keys: Some("Mod-0"), + }, + SEPARATOR, + ItemSpec { + id: "view.fullscreen", + item: Item::Fullscreen, + label: "Toggle Full Screen", + keys: Some("Mod-Ctrl-f"), + }, + ], }, SectionSpec { title: "Window", @@ -236,6 +282,36 @@ pub fn chords() -> Vec { .collect() } +/// One chord, translated from the registry's spelling into the one Tauri's accelerator +/// parser reads (`Mod-Shift-z` → `CmdOrCtrl+Shift+z`). +/// +/// **Derived rather than written down**, and that is the whole point of the function: an +/// [`Item::Command`] row would otherwise carry the same chord twice — once for the UI to +/// mirror and once for muda to bind — with nothing but care keeping them equal. Tauri +/// takes the accelerator as a string and *silently drops one it can't parse* +/// (`.parse().ok()`), so the failure mode of a drifted second spelling is not an error +/// but a menu item that quietly has no shortcut. One source, no drift, no silence. +/// +/// The split is CodeMirror's own rule, `-(?!$)`, for the reason `parseChord` gives: `-` +/// is both the separator and a key you can press, so `Mod--` is ⌘ plus the hyphen. +fn muda_accelerator(chord: &str) -> String { + let mut parts: Vec<&str> = Vec::new(); + let mut rest = chord; + // Cut at every `-` that isn't the last character; what's left when none remains is + // the key. `split` can't express "not at the end", so this walks it. + while let Some(i) = rest[..rest.len().saturating_sub(1)].find('-') { + parts.push(&rest[..i]); + rest = &rest[i + 1..]; + } + let mods = parts.iter().map(|m| match *m { + "Mod" => "CmdOrCtrl", + other => other, + }); + mods.chain(std::iter::once(rest)) + .collect::>() + .join("+") +} + /// Build the menu [`MENU`] describes — what `tauri::Builder::menu` installs. pub fn build(app: &AppHandle) -> tauri::Result> { let about = about_metadata(app); @@ -243,13 +319,33 @@ pub fn build(app: &AppHandle) -> tauri::Result> { for section in MENU { let submenu = Submenu::new(app, section.title, true)?; for spec in section.items { - submenu.append(&predefined(app, spec, &about)?)?; + submenu.append(item_for(app, spec, &about)?.as_ref())?; } menu.append(&submenu)?; } Ok(menu) } +/// One [`ItemSpec`] as the native item it becomes — predefined for everything the +/// platform already does, and B2's own for [`Item::Command`]. +/// +/// Boxed because those are two unrelated types and a submenu takes `&dyn IsMenuItem`; +/// it is one allocation per row, once, at launch. +fn item_for( + app: &AppHandle, + spec: &ItemSpec, + about: &AboutMetadata<'static>, +) -> tauri::Result>> { + if spec.item == Item::Command { + // `spec.id` is the payload the frontend switches on, so the item's menu id and + // the row's id are the same string by construction. + let accel = spec.keys.map(muda_accelerator); + let item = MenuItem::with_id(app, spec.id, spec.label, true, accel)?; + return Ok(Box::new(item)); + } + Ok(Box::new(predefined(app, spec, about)?)) +} + /// The About panel's contents, from the same sources `Menu::default` reads: the /// package info and the bundle config. /// @@ -294,6 +390,10 @@ fn predefined( Item::Minimize => PredefinedMenuItem::minimize(app, text), Item::Zoom => PredefinedMenuItem::maximize(app, text), Item::Separator => PredefinedMenuItem::separator(app), + // Unreachable: `item_for` takes this branch before calling here. Handled rather + // than `unreachable!()` — a panic in the menu builder is a window that never + // opens, and a separator is the harmless thing to draw if the two ever disagree. + Item::Command => PredefinedMenuItem::separator(app), } } @@ -319,14 +419,18 @@ mod tests { /// accelerator syntax would want) leaking into a table the UI parses with /// CodeMirror's. `menukeys.test.ts` runs the real parser over the mirror. fn is_registry_chord(spec: &str) -> bool { - let mut parts = spec.split('-').collect::>(); - let Some(key) = parts.pop() else { - return false; - }; - let key_ok = key.len() == 1 - && key - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()); + // The same `-(?!$)` cut `muda_accelerator` makes, and for the same reason: `-` is + // both the separator and a key, so ⌘- is spelled `Mod--`. + let mut parts: Vec<&str> = Vec::new(); + let mut key = spec; + while let Some(i) = key[..key.len().saturating_sub(1)].find('-') { + parts.push(&key[..i]); + key = &key[i + 1..]; + } + // One character, and never an uppercase one — `parseChord` lowercases what it + // reads, so an uppercase key here is a chord that parses to something else. + // Symbols are allowed: `=` and `-` are the View menu's. + let key_ok = key.len() == 1 && !key.chars().any(|c| c.is_ascii_uppercase()); key_ok && parts .iter() @@ -390,6 +494,59 @@ mod tests { // And the guard has teeth: the spelling this is here to keep out. assert!(!is_registry_chord("CmdOrCtrl+C")); assert!(!is_registry_chord("Mod-Meh-c")); + assert!(!is_registry_chord("Mod-C")); + // ...and it accepts the two symbol keys the View menu is spelled with. + assert!(is_registry_chord("Mod--")); + assert!(is_registry_chord("Mod-=")); + } + + #[test] + fn a_command_item_carries_a_chord_muda_can_actually_parse() { + // The one place B2 *chooses* an accelerator rather than restating one macOS + // assigned — and Tauri drops an unparseable accelerator silently + // (`.parse().ok()`), so a wrong spelling here is a menu line with no shortcut and + // no complaint. `muda_accelerator` is the single source; this pins what it emits. + assert_eq!(muda_accelerator("Mod-="), "CmdOrCtrl+="); + assert_eq!(muda_accelerator("Mod--"), "CmdOrCtrl+-"); + assert_eq!(muda_accelerator("Mod-0"), "CmdOrCtrl+0"); + assert_eq!(muda_accelerator("Mod-Shift-z"), "CmdOrCtrl+Shift+z"); + assert_eq!(muda_accelerator("Mod-Ctrl-f"), "CmdOrCtrl+Ctrl+f"); + // A bare key keeps its lone self rather than becoming an empty modifier. + assert_eq!(muda_accelerator("-"), "-"); + assert_eq!(muda_accelerator("f"), "f"); + + // And every command row in the real table survives the trip: modifiers muda + // knows, one key left over, nothing empty. + for spec in all_items().filter(|s| s.item == Item::Command) { + let keys = spec.keys.unwrap_or_else(|| panic!("{}: no chord", spec.id)); + let accel = muda_accelerator(keys); + let mut tokens = accel.split('+').collect::>(); + let key = tokens.pop().unwrap_or_default(); + assert_eq!(key.chars().count(), 1, "{}: key is {key:?}", spec.id); + for t in tokens { + assert!( + matches!(t, "CmdOrCtrl" | "Ctrl" | "Shift" | "Alt"), + "{}: muda doesn't know the modifier {t:?}", + spec.id + ); + } + } + } + + #[test] + fn a_command_item_is_addressable_and_every_other_item_is_not() { + // The event payload is the row's id, so a command row without one is a menu line + // the frontend cannot act on. The converse matters just as much: a predefined row + // must stay predefined, because those are what route the platform's editing + // selectors into the webview (copy and paste work *because* of them). + for spec in all_items().filter(|s| s.item == Item::Command) { + assert!(!spec.id.is_empty(), "a command item with no id"); + assert!( + spec.keys.is_some(), + "{}: a command item with no chord — it would be mouse-only (K1)", + spec.id + ); + } } #[test] @@ -417,6 +574,9 @@ mod tests { "edit.copy Mod-c Copy", "edit.paste Mod-v Paste", "edit.select-all Mod-a Select All", + "view.zoom-in Mod-= Zoom In", + "view.zoom-out Mod-- Zoom Out", + "view.zoom-reset Mod-0 Actual Size", "view.fullscreen Mod-Ctrl-f Toggle Full Screen", "window.minimize Mod-m Minimize", ] diff --git a/ui/src/api.ts b/ui/src/api.ts index df35b42..b1cc61e 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -71,6 +71,18 @@ export function isWriteConflict(e: unknown): boolean { */ export const VAULT_CHANGED_EVENT = "vault-changed"; +/** + * A menu line of **B2's own** was chosen — the View menu's three sizes, today. The payload + * is the item's id as `crates/b2-desktop/src/menu.rs` spells it (`view.zoom-in`), which is + * the same id `menukeys.ts` mirrors. Must equal the host's `MENU_COMMAND_EVENT`; change + * both together. + * + * The host forwards rather than acts because AppKit is only where the *keystroke* lands: + * a menu accelerator never reaches the webview's keydown handler, so this event is how a + * chord the menu owns reaches the code that owns what it means (`zoom.ts`). + */ +export const MENU_COMMAND_EVENT = "menu-command"; + export const api = { /** Step 0's seam proof: round-trips a trivial command through the Rust host. */ ping: (): Promise => invoke("ping"), @@ -366,12 +378,27 @@ export const api = { */ menuChords: (): Promise => invoke("menu_chords"), + /** + * Scale the whole window — WebKit page zoom, what ⌘= / ⌘- / ⌘0 drive (`zoom.ts`). + * The host is a pass-through here: `factor` is already a rung off the ladder, because + * the ladder and its walls are the frontend's rule, not the host's. + */ + setZoom: (factor: number): Promise => invoke("set_zoom", { factor }), + /** * Subscribe to the host's debounced filesystem-watch pulse (#14). `handler` fires once * per burst of external Markdown changes; the returned promise resolves to an unlisten - * function (unused here — the subscription lives for the window's lifetime). This is the - * only `listen` in the app, kept behind the seam like every `invoke`. + * function (unused here — the subscription lives for the window's lifetime). Kept + * behind the seam like every `invoke`. */ onVaultChanged: (handler: () => void): Promise => listen(VAULT_CHANGED_EVENT, () => handler()), + + /** + * Subscribe to B2's own menu lines (the View menu's sizes). `handler` gets the item's + * id; an id it doesn't recognize is not an error — the host declares the menu, so the + * honest response to a line this build doesn't know about is to ignore it. + */ + onMenuCommand: (handler: (id: string) => void): Promise => + listen(MENU_COMMAND_EVENT, (e) => handler(e.payload)), }; diff --git a/ui/src/bindings.ts b/ui/src/bindings.ts index 141e7a9..21247e9 100644 --- a/ui/src/bindings.ts +++ b/ui/src/bindings.ts @@ -31,7 +31,7 @@ // function now switches on a binding id (`tree.row.next`) instead of a key name // ("ArrowDown"). Still one owner per mapping, and now both halves are visible. // - Not here: the app menu bar's chords — ⌘Q ⌘W ⌘M ⌘H ⌥⌘H ⌘Z ⇧⌘Z ⌘X ⌘C ⌘V ⌘A -// ⌃⌘F. Those aren't key → action mappings this file could own at all: the host +// ⌃⌘F ⌘= ⌘- ⌘0. Those aren't key → action mappings this file could own at all: the host // declares the menu (crates/b2-desktop/src/menu.rs) and AppKit dispatches its // accelerators before the key window's responder chain, so the webview never gets a // keydown for them. What they are to *this* table is a list of keystrokes a new diff --git a/ui/src/main.ts b/ui/src/main.ts index d74f7b9..27e6fd3 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -98,7 +98,15 @@ import { menuDrift } from "./menukeys"; import { markdownForPaste } from "./paste"; import { icon } from "./icons"; import { activeAfter, countLabel, FIND_CAP, findMatches, locate, stepActive, type Match } from "./findbar"; -import { BOUNDS, initPanes } from "./panes"; +import { BOUNDS, initPanes, visiblePanes } from "./panes"; +import { + DEFAULT_ZOOM, + hiddenNotice, + loadZoom, + saveZoom, + stepZoom, + type Direction, +} from "./zoom"; import { reconcileIndex } from "./reconcile"; import type { ResourceExplainView } from "./types"; import { @@ -2391,6 +2399,107 @@ function setTheme(theme: ThemePref): void { render(); } +// --- text size (View ▸ Zoom In / Zoom Out / Actual Size) --------------------------- +// +// The appearance preference's sibling, and stored the same way for the same reason: a +// reading size is a viewing choice, never vault state. Two things differ. +// +// Where it lands: a theme is a `data-theme` attribute the stylesheet reads, but no +// stylesheet can scale px-sized chrome, so this one leaves the webview and comes back as +// WebKit page zoom (zoom.ts's header is the argument in full). +// +// And where the *keystroke* lands. ⌘= / ⌘- / ⌘0 are not in the registry: they are the +// View menu's, declared in `crates/b2-desktop/src/menu.rs`, because macOS expects Zoom +// In / Zoom Out / Actual Size to live there with their chords printed beside them — and +// an accelerator the menu owns is dispatched before the key window's responder chain, so +// a keydown for one never arrives here to be dispatched. The host emits the chosen item's +// id instead, which is what `initMenuCommands` below listens for. The upshot is that these +// three work from anywhere the window has focus, including mid-edit and behind a dialog, +// with no guard of their own — a size control you have to leave a text field to reach +// would be a size control that fails exactly when you need it. +// +// Module-local rather than in `state`, and the reason is panes.ts's: nothing renders +// from it, so putting it in the model would only invite a repaint the zoom already did. + +let zoom = DEFAULT_ZOOM; + +/** Hand a size to the host and remember it — the one place the IPC call lives, so both + * callers below agree on what "the size is now applied" means. + * + * Resolves when the window has actually been scaled, and **never rejects**: a refusal + * can only come from a window that is going away or from running outside Tauri at all, + * and neither is worth a toast about a size the user can plainly see didn't change — + * still less worth failing a boot over. The two callers both wait on it, and a promise + * that can reject would make one of them a hang. */ +function pushZoom(next: number): Promise { + zoom = next; + saveZoom(next); + return api.setZoom(next).catch(() => { + // Non-fatal: the app is fully usable at whatever size it is currently drawn. + }); +} + +/** A size the user just asked for: apply it, then say so if it cost a column. + * + * Page zoom narrows the *layout* viewport, so style.css's breakpoints treat a ⌘= exactly + * as they treat dragging the window narrower — and at some size discovery goes, then the + * file tree. That is the responsive layout doing its job, and B2 doesn't refuse the step + * over it (zoom.ts's `hiddenNotice` argues why); it just stops being a surprise. + * + * The notice is **measured, not predicted**, because the breakpoints are the + * stylesheet's and this file must not hold a second copy of them — which fixes when it + * can be taken. Not before the host has scaled the window (there would be nothing to + * see), and not on the frame it does (WebKit lays out on one frame and + * `getComputedStyle` can answer on the next). So: the round trip, then two frames. */ +function applyZoom(next: number): void { + const before = visiblePanes(); + void pushZoom(next).then(() => + requestAnimationFrame(() => + requestAnimationFrame(() => { + const notice = hiddenNotice(before, visiblePanes()); + if (notice) flash(notice); + }), + ), + ); +} + +/** One rung, in `dir`. Silent at the ends — the ladder's walls are walls, not errors. */ +function nudgeZoom(dir: Direction): void { + const next = stepZoom(zoom, dir); + if (next !== zoom) applyZoom(next); +} + +/** Read the saved size and hand it to the host — and **wait for it**, which is the whole + * point of this being separate from `applyZoom`. + * + * Page zoom changes the CSS viewport, so everything after this in `boot` depends on it + * having landed: `buildShell` + the first `render` would otherwise paint one frame at + * 100% and jump, the appearance preference's flash-of-the-wrong-thing in a second form, + * and `initPanes` settles the columns against `clientWidth` — a width that is about to + * change under it. (The resize a zoom fires would eventually correct the columns; it + * can't un-paint the frame.) One IPC round trip of blank window is the cheaper half of + * that trade. + * + * No column notice here, and not by accident: at boot there is no shell yet, so "which + * columns were showing before" has no answer, and the honest thing to report about a + * size the user chose in a previous session is nothing at all. */ +async function loadZoomPref(): Promise { + const saved = loadZoom(); + zoom = saved; + if (saved !== DEFAULT_ZOOM) await pushZoom(saved); +} + +/** Listen for the menu lines that are B2's own. One `switch`, and an id it doesn't know + * falls through silently: the host declares the menu, so a line from a newer build is + * something to ignore, not something to fail on. */ +function initMenuCommands(): void { + void api.onMenuCommand((id) => { + if (id === "view.zoom-in") nudgeZoom(1); + else if (id === "view.zoom-out") nudgeZoom(-1); + else if (id === "view.zoom-reset" && zoom !== DEFAULT_ZOOM) applyZoom(DEFAULT_ZOOM); + }); +} + // --- the customizable keyboard (GH #121) ------------------------------------------ // // The same localStorage idiom as the appearance preference above, for the same reason: a @@ -5399,6 +5508,8 @@ async function loadMenuChords(): Promise { async function boot(): Promise { loadTheme(); // stamp the saved appearance onto before the first paint + await loadZoomPref(); // and the saved size — awaited, because it changes what "the viewport" means below + initMenuCommands(); // View ▸ Zoom In / Zoom Out / Actual Size arrive from the host const lostChords = loadKeymap(); // the user's chords, before anything paints or dispatches one loadEmbedReminderPref(); // honor a persisted "don't remind me" before the banner can paint buildShell(); diff --git a/ui/src/menukeys.ts b/ui/src/menukeys.ts index ca83978..318179f 100644 --- a/ui/src/menukeys.ts +++ b/ui/src/menukeys.ts @@ -46,6 +46,17 @@ export const MENU_CHORDS = [ { id: "edit.copy", label: "Copy", keys: "Mod-c" }, { id: "edit.paste", label: "Paste", keys: "Mod-v" }, { id: "edit.select-all", label: "Select All", keys: "Mod-a" }, + // The View menu's three are the one part of this list that is **B2's own choice** of + // chord rather than a restatement of one macOS assigned. They are here, not in + // bindings.ts, for this module's whole reason: a menu accelerator is dispatched before + // the key window's responder chain, so a chord in both places is a chord the webview + // never receives — and macOS expects these three to live in View, with their keys + // printed beside them. Choosing them means the host also has to *do* something when + // they fire, which the predefined rows never needed: `menu.rs` emits the item's id and + // `zoom.ts` decides what a size means. + { id: "view.zoom-in", label: "Zoom In", keys: "Mod-=" }, + { id: "view.zoom-out", label: "Zoom Out", keys: "Mod--" }, + { id: "view.zoom-reset", label: "Actual Size", keys: "Mod-0" }, { id: "view.fullscreen", label: "Toggle Full Screen", keys: "Mod-Ctrl-f" }, { id: "window.minimize", label: "Minimize", keys: "Mod-m" }, ] as const satisfies readonly MenuChord[]; diff --git a/ui/src/panes.ts b/ui/src/panes.ts index daeae68..35f6d6c 100644 --- a/ui/src/panes.ts +++ b/ui/src/panes.ts @@ -120,15 +120,31 @@ function shown(el: HTMLElement | null): boolean { return !!el && getComputedStyle(el).display !== "none"; } +/** The pane element the breakpoints act on, by name. */ +function paneEl(pane: Pane): HTMLElement | null { + return document.getElementById(pane === "tree" ? "tree-pane" : "side-pane"); +} + +/** + * Which side columns the stylesheet is drawing right now — **asked, never computed**. + * + * The breakpoints live in style.css and nothing here knows their widths; this reads the + * outcome. Exported because zoom is the other thing that can cross one: page zoom divides + * the layout viewport by its scale, so a ⌘= is a window-narrowing as far as the + * stylesheet is concerned, and `main.ts` compares this across a step to notice a column + * the step cost. A second reader, same measurement — which is the point of it being one + * function rather than a width each caller has to know. + */ +export function visiblePanes(): Shown { + return { tree: shown(paneEl("tree")), side: shown(paneEl("side")) }; +} + /** * Wire the gutters and start tracking the layout. Call once, after the shell exists. * `root` is the `.layout` grid; it owns the width vars and is what we measure against. */ export function initPanes(root: HTMLElement): void { - const paneEl = (pane: Pane): HTMLElement | null => - document.getElementById(pane === "tree" ? "tree-pane" : "side-pane"); - - const visible = (): Shown => ({ tree: shown(paneEl("tree")), side: shown(paneEl("side")) }); + const visible = visiblePanes; /** What's actually on screen: the request, settled against the window as it is now. */ const effective = (): PaneWidths => fit(desired, root.clientWidth, visible()); diff --git a/ui/src/zoom.test.ts b/ui/src/zoom.test.ts new file mode 100644 index 0000000..b995948 --- /dev/null +++ b/ui/src/zoom.test.ts @@ -0,0 +1,148 @@ +// The zoom ladder (zoom.ts), pinned. Pure arithmetic over a fixed list — no DOM, no +// host — so node runs it straight off the source via its native type-stripping: +// `npm test`. +// +// Dependency-free by the same rule as panes.test.ts: a hand-rolled `assert` rather than +// node:assert, which would drag @types/node into a frontend that needs no Node types. +// What's worth pinning is the step algebra every ⌘= / ⌘- routes through, and the +// adoption rule that stands between a hand-edited localStorage value and a window +// nobody can read. +import { type Columns, DEFAULT_ZOOM, STEPS, adoptZoom, hiddenNotice, stepZoom } from "./zoom.ts"; + +let passed = 0; + +function assert(cond: boolean, msg: string): void { + if (!cond) throw new Error(`assertion failed: ${msg}`); +} +function equal(actual: number, expected: number, msg: string): void { + assert(actual === expected, `${msg} — expected ${expected}, got ${actual}`); +} +function check(name: string, fn: () => void): void { + fn(); + passed++; + console.log(` ok ${name}`); +} + +// --- the ladder itself --------------------------------------------------------------- + +check("the ladder is sorted, has no duplicates, and contains 100%", () => { + for (let i = 1; i < STEPS.length; i++) { + assert(STEPS[i] > STEPS[i - 1], `step ${i} (${STEPS[i]}) must exceed ${STEPS[i - 1]}`); + } + assert(STEPS.includes(DEFAULT_ZOOM), "the default must be a rung, or ⌘0 lands off-ladder"); +}); + +// --- stepping ------------------------------------------------------------------------ + +check("a step up moves to the next rung", () => { + equal(stepZoom(1, 1), STEPS[STEPS.indexOf(1) + 1], "up from 100%"); +}); + +check("a step down moves to the previous rung", () => { + equal(stepZoom(1, -1), STEPS[STEPS.indexOf(1) - 1], "down from 100%"); +}); + +check("the top rung is a wall, not a wrap", () => { + const top = STEPS[STEPS.length - 1]; + equal(stepZoom(top, 1), top, "up from the top"); +}); + +check("the bottom rung is a wall, not a wrap", () => { + const bottom = STEPS[0]; + equal(stepZoom(bottom, -1), bottom, "down from the bottom"); +}); + +check("a value between rungs steps to the rung on that side, never past it", () => { + // 1.05 sits between 1 and 1.1: up is 1.1, down is 1 — both one rung away, so a value + // that drifted off the ladder is back on it after a single keypress in either + // direction. Stepping past the nearer rung would make ⌘- feel like it skipped. + const between = (STEPS[STEPS.indexOf(1)] + STEPS[STEPS.indexOf(1) + 1]) / 2; + equal(stepZoom(between, 1), STEPS[STEPS.indexOf(1) + 1], "up from between"); + equal(stepZoom(between, -1), 1, "down from between"); +}); + +check("a value below the ladder climbs onto its bottom rung", () => { + equal(stepZoom(0.1, 1), STEPS[0], "up from far below"); + equal(stepZoom(0.1, -1), STEPS[0], "down from far below"); +}); + +check("a value above the ladder settles onto its top rung", () => { + const top = STEPS[STEPS.length - 1]; + equal(stepZoom(99, -1), top, "down from far above"); + equal(stepZoom(99, 1), top, "up from far above"); +}); + +// --- reading a stored value ------------------------------------------------------------ + +check("a rung is adopted unchanged", () => { + for (const s of STEPS) equal(adoptZoom(s), s, `rung ${s}`); +}); + +check("a value between rungs snaps to the nearest one", () => { + equal(adoptZoom(1.04), 1, "just above 100%"); + equal(adoptZoom(1.09), STEPS[STEPS.indexOf(1) + 1], "just below the next rung"); +}); + +check("a value outside the ladder is clamped to its ends", () => { + equal(adoptZoom(0.01), STEPS[0], "far below"); + equal(adoptZoom(50), STEPS[STEPS.length - 1], "far above"); +}); + +check("anything that isn't a finite number is the default", () => { + for (const raw of [null, undefined, "1.5", {}, [], Number.NaN, Infinity, -Infinity, 0]) { + equal(adoptZoom(raw), DEFAULT_ZOOM, `${String(raw)}`); + } +}); + +check("a negative value is the default, not a mirrored size", () => { + // A negative page zoom is not a smaller window, it is an unrenderable one — so this + // is a refusal, not a clamp onto the bottom rung. + equal(adoptZoom(-1), DEFAULT_ZOOM, "negative"); +}); + +// --- announcing a column the step cost --------------------------------------------------- + +function notice(before: Columns, after: Columns): string { + return hiddenNotice(before, after) ?? ""; +} +const BOTH: Columns = { tree: true, side: true }; + +check("a step that hides nothing says nothing", () => { + equal(notice(BOTH, BOTH).length, 0, "nothing lost"); + equal(notice({ tree: true, side: false }, { tree: true, side: false }).length, 0, "already gone"); +}); + +check("losing one column names that column", () => { + assert( + notice(BOTH, { tree: true, side: false }).includes("Discovery"), + "the side column is discovery", + ); + assert( + notice(BOTH, { tree: false, side: true }).includes("file tree"), + "the left column is the file tree", + ); +}); + +check("losing both is one sentence, not two", () => { + const msg = notice(BOTH, { tree: false, side: false }); + assert(msg.includes("file tree") && msg.includes("discovery"), `names both: ${msg}`); + equal(msg.split(".").filter((s) => s.trim().length > 0).length, 1, "sentences"); +}); + +check("a column coming back is not announced", () => { + // Zooming out reveals; a revealed column is its own notice, and saying so would make + // every ⌘- talk back. + equal(notice({ tree: false, side: false }, BOTH).length, 0, "both back"); + equal(notice({ tree: true, side: false }, BOTH).length, 0, "one back"); +}); + +check("a loss and a gain in one step reports only the loss", () => { + // Not reachable by zooming (the breakpoints nest), but the rule is "announce losses", + // and a rule that quietly depends on the breakpoints nesting is one that breaks when + // they stop. + const msg = notice({ tree: true, side: false }, { tree: false, side: true }); + assert(msg.includes("file tree"), `names the loss: ${msg}`); + assert(!msg.includes("iscovery"), `and not the gain: ${msg}`); +}); + +console.log(`\n${passed} passed`); diff --git a/ui/src/zoom.ts b/ui/src/zoom.ts new file mode 100644 index 0000000..1053d59 --- /dev/null +++ b/ui/src/zoom.ts @@ -0,0 +1,151 @@ +// Global text size — ⌘= / ⌘- / ⌘0. +// +// **What actually scales, and why it isn't CSS.** The stylesheet sizes everything in +// px, deliberately (a 13px chip and a 15px body are drawn to a pixel grid, not derived +// from a root em), so there is no `html { font-size }` to turn. Growing text alone would +// also be the wrong answer: at 140% the note reads bigger while the icons, the row +// heights and the pane gutters stay put, and the layout comes apart. So this is real +// **page zoom** — WebKit's `pageZoom`, reached through the host's `set_zoom` command — +// which scales the whole rendering the way ⌘+ does in Safari. Everything grows together, +// `100vh` still means the window, and no rule in style.css has to know it happened. +// +// **Where the rules live.** The host is a pass-through: it hands a number to the webview +// and nothing else (crates/b2-desktop/CLAUDE.md — hold no logic). The ladder, the walls +// and the reading-back of a stored value are here, pure and tested, which is the same +// split panes.ts makes for column widths. +// +// **Where the preference lives.** localStorage, like the theme and the pane widths and +// the rebound chords: a viewing choice, never vault state. It never touches the host's +// config, the index, or a byte of Markdown — drop the vault on another machine and the +// notes are identical; the size you read them at is that machine's business. + +const KEY = "b2:zoom"; + +/** 100% — where B2 starts, and where ⌘0 goes back to. A rung by construction (the suite + * pins it), so a reset always lands the ladder somewhere ⌘=/⌘- can walk from. */ +export const DEFAULT_ZOOM = 1; + +/** + * The rungs, ascending. Browser-ish spacing — fine steps around 100% where a small + * adjustment is the whole point, coarser out at the ends where the next useful size is + * further away. + * + * A fixed ladder rather than "multiply by 1.1": a multiplier accumulates float dust + * across a dozen presses, and it gives ⌘0 nothing exact to return to. It also makes the + * walls honest — the ends of this list *are* the limits, so there is no separate min/max + * to keep in step with it. + */ +export const STEPS: readonly number[] = [0.75, 0.85, 0.9, 1, 1.1, 1.25, 1.4, 1.6, 1.8, 2]; + +/** Which way a step goes. */ +export type Direction = 1 | -1; + +/** + * One rung `dir` from `current`. + * + * Off-ladder input is the interesting case, and the rule is *never overshoot*: a value + * between two rungs steps to the one on that side, so a size that drifted (a hand-edited + * store, a ladder that changed under an old preference) is back on the ladder after a + * single keypress instead of jumping two sizes. Past either end it settles onto that end + * — pressing ⌘- at 300% should bring you to the top rung, not to 200%'s neighbour. + * + * At the ends it is a wall, not a wrap: ⌘= held down should stop, never snap back to + * tiny. + */ +export function stepZoom(current: number, dir: Direction): number { + if (dir === 1) { + const next = STEPS.find((s) => s > current); + return next ?? STEPS[STEPS.length - 1]; + } + // Scan from the top for the first rung strictly below — the mirror of `find` above. + for (let i = STEPS.length - 1; i >= 0; i--) { + if (STEPS[i] < current) return STEPS[i]; + } + return STEPS[0]; +} + +/** + * An unknown value, read into a size B2 will actually apply. + * + * Defensive in the same shape as `adoptOverrides` and panes.ts's `load`, and for the same + * reason: localStorage is a file a human can edit, and this one is handed straight to the + * renderer. A non-number, a NaN, an infinity or a zero-or-negative scale is not a small + * window — it is an unrenderable one — so those are refused outright and become the + * default. A finite positive number that simply isn't a rung is snapped to the nearest, + * which is what lets the ladder be re-tuned later without stranding anyone's preference. + */ +export function adoptZoom(raw: unknown): number { + if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return DEFAULT_ZOOM; + let best = STEPS[0]; + for (const s of STEPS) { + if (Math.abs(s - raw) < Math.abs(best - raw)) best = s; + } + return best; +} + +// --- what a step costs ------------------------------------------------------------------- + +/** + * Which side columns the stylesheet is currently drawing — `panes.ts`'s `Shown`, and + * deliberately the same shape, because it is the same question asked by the same means: + * ask the browser, never compute it. + * + * That is the whole design of the notice below. The breakpoints live in `style.css` + * (discovery goes below 1040px of layout width, the tree below 720px) and page zoom + * divides the window by the scale, so "how far can I zoom before a column goes" is + * `window width ÷ 1040` — a number that moves with every window resize and that this + * module would have to keep in step with a stylesheet it can't see. Measuring the result + * instead means there is no second copy of a breakpoint anywhere, and no arithmetic to be + * wrong about. + */ +export interface Columns { + tree: boolean; + side: boolean; +} + +/** + * What to say about a zoom step that hid a column, or `null` when it hid none. + * + * A note rather than a wall, which is the choice worth writing down. B2 could refuse the + * step instead — but the ceiling is the window's width divided by the breakpoint, so it + * moves whenever the window is resized, and a ⌘= that worked yesterday and does nothing + * today is a worse surprise than a column that goes away with a reason. The columns come + * back on ⌘-, and someone on a small screen who would rather have big text than a side + * column is allowed to have it. + * + * Only *losses* are announced. A column reappearing announces itself. + */ +export function hiddenNotice(before: Columns, after: Columns): string | null { + const lost = (k: keyof Columns): boolean => before[k] && !after[k]; + const tree = lost("tree"); + const side = lost("side"); + if (tree && side) return "The file tree and discovery are hidden at this size."; + if (tree) return "The file tree is hidden at this size."; + if (side) return "Discovery is hidden at this size."; + return null; +} + +// --- persistence ----------------------------------------------------------------------- + +/** Read the saved size. Unreadable or unavailable storage is 100% — never a thrown boot. */ +export function loadZoom(): number { + try { + const text = localStorage.getItem(KEY); + if (!text) return DEFAULT_ZOOM; + return adoptZoom(JSON.parse(text)); + } catch { + // Unavailable (private mode) or not JSON at all: the size B2 ships with. + return DEFAULT_ZOOM; + } +} + +/** Persist the size. The default removes the entry rather than storing `1`, so a user + * who presses ⌘0 leaves nothing behind to go stale against a future ladder. */ +export function saveZoom(zoom: number): void { + try { + if (zoom === DEFAULT_ZOOM) localStorage.removeItem(KEY); + else localStorage.setItem(KEY, JSON.stringify(zoom)); + } catch { + // Non-fatal: the size still holds for this session if it can't persist. + } +}