App menu: the titlebar's "Settings and more" (Alt+F, F10, the palette, the account menu's last row) with rows from the site config; page zoom per tab in the host

Frame: AppMenu.tsx is a menu at the end of the titlebar cluster (before the caption buttons; macOS has none there). Its rows are `ShellSite.menu`: ordered groups of built-in rows by name and the app's own ({id, label, icon, shortcut, hint, when, checked, run | submenu}); `DEFAULT_MENU`, `menuWith` and `menuWithout` order, add and remove. A built-in row shows only when the app serves it (decks, `menu.languages`, themes, `menu.modes`, and `features`: zoom, store, workspaces, developer, updates), so no app gets a dead row. Language, Theme, Mode, Workspaces and Developer are submenus; Language and Theme apply live and keep the menu open, Mode asks the same keep-my-changes question as Settings (panel `menuModes()`). Keyboard: arrows, Home/End, type-ahead, the arrow along the reading direction opens a submenu, Esc closes one level; ARIA menu roles; submenus stay inside the window; one highlight for pointer and keys. It is an overlay like the other shell menus (the shell is raised, panes stay visible, nothing is dimmed).

Words: menuMessages.en.json (`wb.menu.*`), read through the framework i18n with that English as the fallback until the catalogs have them; words the panel already has are reused.

Host: `tab_zoom {id, factor}` (shell-only) sets a tab's page zoom with the engine's own zoom, clamped to 25%..500% and remembered per tab id in app data `zoom.json` (a number per tab id, nothing else; a closed tab's entry goes); without a factor it reads. A tab may forward four more shortcut names: `app-menu`, `zoom-in`, `zoom-out`, `zoom-reset`. The titlebar shows a zoom chip only while the pane in front is not at 100%.

create-waybetter: `tab_zoom` in the shell capability list; the template says which panel sections it lacks (store, updates) and how to add menu rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-09-30 21:52:51 +07:00
parent eed6bdd8f4
commit e5d43afffc
27 changed files with 1587 additions and 18 deletions

View file

@ -493,6 +493,8 @@ async fn tabs_sync(webview: Webview, app: AppHandle, items: Vec<TabLayout>, view
if shell_is_raised() { if shell_is_raised() {
host::raise_shell(&webview, true); host::raise_shell(&webview, true);
} }
// A new webview starts at 100%: this tab's remembered page zoom.
host::restore_tab_zoom(&app, &tab);
tab tab
} }
Err(error) => { Err(error) => {
@ -517,6 +519,7 @@ async fn tabs_sync(webview: Webview, app: AppHandle, items: Vec<TabLayout>, view
if let Ok(mut data) = profiles(&app) { if let Ok(mut data) = profiles(&app) {
data.tab_profiles.remove(&label); data.tab_profiles.remove(&label);
} }
host::forget_tab_zoom(&app, id);
hooks.tab_closed(&app, &label, TabClosed::Removed); hooks.tab_closed(&app, &label, TabClosed::Removed);
} }
} }
@ -545,6 +548,15 @@ async fn tab_action(webview: Webview, app: AppHandle, id: String, action: String
host::tab_action(&app, &id, &action) host::tab_action(&app, &id, &action)
} }
/// The page zoom of tab `id` (the app menu's zoom row, Ctrl with + / - / 0): sets it to `factor`
/// (clamped to 25%..500%, remembered per tab in app data `zoom.json`), or only reads it without
/// one. Returns the factor in effect. The engine's own page zoom; nothing is injected into the page.
#[tauri::command]
async fn tab_zoom(webview: Webview, app: AppHandle, id: String, factor: Option<f64>) -> Result<f64, String> {
shell_only(&webview)?;
host::tab_zoom(&app, &id, factor)
}
/// Gives keyboard focus back to the shell (e.g. Ctrl+L pressed inside a tab). /// Gives keyboard focus back to the shell (e.g. Ctrl+L pressed inside a tab).
#[tauri::command] #[tauri::command]
async fn shell_focus(webview: Webview) -> Result<(), String> { async fn shell_focus(webview: Webview) -> Result<(), String> {
@ -900,6 +912,8 @@ fn tab_builder(app: &AppHandle, hooks: &Arc<dyn SiteHooks>, tab: &NewTab, url: U
let finished = matches!(payload.event(), PageLoadEvent::Finished); let finished = matches!(payload.event(), PageLoadEvent::Finished);
if !finished { if !finished {
load_hooks.page_started(app, &tab); load_hooks.page_started(app, &tab);
// An engine that resets page zoom on navigation still shows this tab's.
host::restore_tab_zoom(app, &tab);
} }
emit_tab_state(app, TabState { id: load_id.clone(), url: Some(payload.url().to_string()), loading: Some(!finished), ..Default::default() }); emit_tab_state(app, TabState { id: load_id.clone(), url: Some(payload.url().to_string()), loading: Some(!finished), ..Default::default() });
if finished && site.is_origin(payload.url()) { if finished && site.is_origin(payload.url()) {
@ -957,6 +971,7 @@ fn host_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 's
tabs_sync, tabs_sync,
tab_navigate, tab_navigate,
tab_action, tab_action,
tab_zoom,
open_external_url, open_external_url,
shell_focus, shell_focus,
shell_ready, shell_ready,

View file

@ -12,7 +12,7 @@ const DEFAULT_OPTIONS: &str = "const options = { shortcuts: true, linkDrags: tru
/// not handle it: a forwarded shortcut is taken from the page (`preventDefault`). /// not handle it: a forwarded shortcut is taken from the page (`preventDefault`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BridgeOptions { pub struct BridgeOptions {
/// Browser shortcuts (new/close/reopen tab, reload, back/forward, split, palette, deck keys). /// Browser shortcuts (new/close/reopen tab, reload, back/forward, split, palette, the app menu, zoom, deck keys).
pub shortcuts: bool, pub shortcuts: bool,
/// Link drags out of (or into) the page, for the shell's split/tab drop zones. /// Link drags out of (or into) the page, for the shell's split/tab drop zones.
pub link_drags: bool, pub link_drags: bool,
@ -58,7 +58,7 @@ mod tests {
fn forwarded_shortcuts_are_ones_the_host_accepts() { fn forwarded_shortcuts_are_ones_the_host_accepts() {
for name in ["deck-toggle", "deck-compose", "column-page-prev", "column-page-next", "column-prev", "column-next", "reopen-tab", "new-tab", for name in ["deck-toggle", "deck-compose", "column-page-prev", "column-page-next", "column-prev", "column-next", "reopen-tab", "new-tab",
"open-panel", "close-tab", "focus-address", "command-palette", "prev-tab", "next-tab", "hard-reload", "reload", "split-down", "open-panel", "close-tab", "focus-address", "command-palette", "prev-tab", "next-tab", "hard-reload", "reload", "split-down",
"split-right", "back", "forward"] { "split-right", "back", "forward", "app-menu", "zoom-in", "zoom-out", "zoom-reset"] {
assert!(SOURCE.contains(&format!("'{name}'")), "{name} is not in the bridge"); assert!(SOURCE.contains(&format!("'{name}'")), "{name} is not in the bridge");
assert!(crate::tabs::valid_shortcut(name), "{name} would be refused"); assert!(crate::tabs::valid_shortcut(name), "{name} would be refused");
} }

View file

@ -11,6 +11,7 @@ pub const SHELL: &[&str] = &[
"tabs_sync", "tabs_sync",
"tab_navigate", "tab_navigate",
"tab_action", "tab_action",
"tab_zoom",
"open_external_url", "open_external_url",
"shell_focus", "shell_focus",
"shell_ready", "shell_ready",

View file

@ -89,6 +89,62 @@ pub fn tab_action<R: Runtime>(app: &AppHandle<R>, id: &str, action: &str) -> Res
result.map_err(|error| format!("TAB: {error}")) result.map_err(|error| format!("TAB: {error}"))
} }
/// The remembered page zoom per tab ([`crate::zoom`], app data `zoom.json`), read once.
fn zooms<R: Runtime>(app: &AppHandle<R>) -> std::sync::MutexGuard<'static, crate::zoom::ZoomMap> {
use std::sync::{Mutex, OnceLock};
static ZOOMS: OnceLock<Mutex<crate::zoom::ZoomMap>> = OnceLock::new();
ZOOMS
.get_or_init(|| Mutex::new(read_json::<crate::zoom::ZoomMap>(app, crate::zoom::ZOOM_FILE).unwrap_or_default().validated()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// `tab_zoom`: sets tab `id`'s page zoom to `factor` (clamped to 25%..500%, the engine's own page
/// zoom) and remembers it for that tab; without a factor only reads it. Returns the factor in effect.
pub fn tab_zoom<R: Runtime>(app: &AppHandle<R>, id: &str, factor: Option<f64>) -> Result<f64, String> {
if !policy::valid_tab_id(id) {
return Err("VALIDATION: invalid tab id".into());
}
let Some(factor) = factor else { return Ok(zooms(app).get(id)) };
let tab = tab_webview(app, id).ok_or("TAB: not found")?;
let (before, factor, saved) = {
let mut map = zooms(app);
let before = map.get(id);
let factor = map.set(id, factor)?;
(before, factor, map.clone())
};
if let Err(error) = tab.set_zoom(factor) {
// Not applied: what is remembered stays what the tab shows.
let _ = zooms(app).set(id, before);
return Err(format!("TAB: {error}"));
}
if factor != before {
write_json(app, crate::zoom::ZOOM_FILE, &saved)?;
}
Ok(factor)
}
/// A tab webview that was just created, or loads a page: it shows its remembered zoom (a new
/// webview starts at 100%). Tabs at 100% are left alone.
pub fn restore_tab_zoom<R: Runtime>(app: &AppHandle<R>, tab: &Webview<R>) {
let Some(id) = policy::tab_id(tab.label()) else { return };
let factor = zooms(app).get(id);
if factor != crate::zoom::DEFAULT {
let _ = tab.set_zoom(factor);
}
}
/// A closed tab's zoom is forgotten (a tab that comes back under the same id starts at 100%).
pub fn forget_tab_zoom<R: Runtime>(app: &AppHandle<R>, id: &str) {
let saved = {
let mut map = zooms(app);
map.forget(id).then(|| map.clone())
};
if let Some(map) = saved {
let _ = write_json(app, crate::zoom::ZOOM_FILE, &map);
}
}
/// A browser shortcut pressed inside tab `id`, forwarded to the shell (`tab-shortcut`). /// A browser shortcut pressed inside tab `id`, forwarded to the shell (`tab-shortcut`).
pub fn forward_shortcut<R: Runtime>(app: &AppHandle<R>, id: String, shortcut: String) -> Result<(), String> { pub fn forward_shortcut<R: Runtime>(app: &AppHandle<R>, id: String, shortcut: String) -> Result<(), String> {
if !tabs::valid_shortcut(&shortcut) { if !tabs::valid_shortcut(&shortcut) {

View file

@ -27,6 +27,7 @@
//! - [`settings`]: the shell's settings (home page, where links open, session restore) and the page's //! - [`settings`]: the shell's settings (home page, where links open, session restore) and the page's
//! feature flags, as validated data and the scripts that hand them to site tabs. //! feature flags, as validated data and the scripts that hand them to site tabs.
//! - [`modes`]: the onboarding mode the owner started from (the presets are the app's feature table). //! - [`modes`]: the onboarding mode the owner started from (the presets are the app's feature table).
//! - [`zoom`]: page zoom per tab (a clamped factor the shell sets, remembered in `zoom.json`).
//! - [`alerts`]: local keyword alerts (keywords, snippets, per-item/per-keyword rate limits). //! - [`alerts`]: local keyword alerts (keywords, snippets, per-item/per-keyword rate limits).
//! - [`media`]: gallery folders (path containment) and the opt-in clipboard history (private copies skipped). //! - [`media`]: gallery folders (path containment) and the opt-in clipboard history (private copies skipped).
//! - [`gfonts`]: self-hosted Google Fonts: CSS parsing, URL/woff2 checks, the cache format, the page script; //! - [`gfonts`]: self-hosted Google Fonts: CSS parsing, URL/woff2 checks, the cache format, the page script;
@ -69,6 +70,7 @@ pub mod testkit;
pub mod theme; pub mod theme;
pub mod update; pub mod update;
pub mod workspaces; pub mod workspaces;
pub mod zoom;
#[cfg(feature = "app")] #[cfg(feature = "app")]
pub mod app; pub mod app;

View file

@ -159,6 +159,7 @@ pub fn new_window_mode<'a>(on_site: bool, site_mode: &'a str, external_mode: &'a
pub const SHORTCUTS: &[&str] = &[ pub const SHORTCUTS: &[&str] = &[
"command-palette", "new-tab", "close-tab", "reopen-tab", "focus-address", "next-tab", "prev-tab", "reload", "command-palette", "new-tab", "close-tab", "reopen-tab", "focus-address", "next-tab", "prev-tab", "reload",
"back", "forward", "split-right", "split-down", "open-panel", "find", "hard-reload", "back", "forward", "split-right", "split-down", "open-panel", "find", "hard-reload",
"app-menu", "zoom-in", "zoom-out", "zoom-reset",
"column-prev", "column-next", "column-1", "column-2", "column-3", "column-4", "column-5", "column-6", "column-7", "column-prev", "column-next", "column-1", "column-2", "column-3", "column-4", "column-5", "column-6", "column-7",
"column-8", "column-9", "column-page-prev", "column-page-next", "column-mark-read", "deck-compose", "deck-toggle", "column-8", "column-9", "column-page-prev", "column-page-next", "column-mark-read", "deck-compose", "deck-toggle",
]; ];

View file

@ -0,0 +1,139 @@
//! Page zoom per tab. The shell alone sets it (`tab_zoom {id, factor}`, shell-only): the factor is
//! a plain number, clamped to 25%..500% and rounded to whole percent, applied natively to that
//! tab's webview (the engine's own page zoom, never CSS) and remembered per tab id in app data
//! `zoom.json`, so a tab keeps its zoom when its webview is created again (a restart with session
//! restore, a profile change). A tab at 100% has no entry; a closed tab's entry goes with it.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const ZOOM_FILE: &str = "zoom.json";
/// 25%: below this a page is unreadable and engines start refusing.
pub const MIN: f64 = 0.25;
/// 500%: the most the engines accept.
pub const MAX: f64 = 5.0;
pub const DEFAULT: f64 = 1.0;
/// Remembered tabs at most; past this the file is of no use and only grows.
pub const MAX_TABS: usize = 512;
/// The factor the host applies for a requested one: a finite number, clamped to [`MIN`]..[`MAX`]
/// and rounded to whole percent (so 110% is 1.1, not 1.1000000000000001).
pub fn normalize(factor: f64) -> Result<f64, String> {
if !factor.is_finite() {
return Err("VALIDATION: zoom is not a number".into());
}
Ok((factor.clamp(MIN, MAX) * 100.0).round() / 100.0)
}
/// The remembered factors by tab id (`zoom.json`). Only tabs away from 100% are kept.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ZoomMap(BTreeMap<String, f64>);
impl ZoomMap {
/// What was read from disk, made safe: valid tab ids only, every factor normalized, bounded.
pub fn validated(self) -> Self {
let mut out = BTreeMap::new();
for (id, factor) in self.0 {
if out.len() >= MAX_TABS {
break;
}
if let (true, Ok(factor)) = (crate::policy::valid_tab_id(&id), normalize(factor)) {
if factor != DEFAULT {
out.insert(id, factor);
}
}
}
Self(out)
}
/// The factor tab `id` shows (100% when nothing is remembered).
pub fn get(&self, id: &str) -> f64 {
self.0.get(id).copied().unwrap_or(DEFAULT)
}
/// Remembers `factor` for tab `id` and returns the factor in effect. 100% forgets the tab.
pub fn set(&mut self, id: &str, factor: f64) -> Result<f64, String> {
if !crate::policy::valid_tab_id(id) {
return Err("VALIDATION: invalid tab id".into());
}
let factor = normalize(factor)?;
if factor == DEFAULT {
self.0.remove(id);
} else {
if !self.0.contains_key(id) && self.0.len() >= MAX_TABS {
return Err("VALIDATION: too many zoomed tabs".into());
}
self.0.insert(id.to_owned(), factor);
}
Ok(factor)
}
/// A closed tab: true when something was remembered for it (the file changes).
pub fn forget(&mut self, id: &str) -> bool {
self.0.remove(id).is_some()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn factors_are_clamped_and_rounded_and_must_be_numbers() {
assert_eq!(normalize(1.0).unwrap(), 1.0);
assert_eq!(normalize(1.1000000000000001).unwrap(), 1.1);
assert_eq!(normalize(0.666).unwrap(), 0.67);
assert_eq!(normalize(0.01).unwrap(), MIN, "never below 25%");
assert_eq!(normalize(-3.0).unwrap(), MIN);
assert_eq!(normalize(40.0).unwrap(), MAX, "never above 500%");
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(normalize(bad).is_err(), "{bad}");
}
}
#[test]
fn a_tab_keeps_its_zoom_until_it_is_reset_or_closed() {
let mut map = ZoomMap::default();
assert_eq!(map.get("t1"), 1.0);
assert_eq!(map.set("t1", 1.25).unwrap(), 1.25);
assert_eq!(map.set("t2", 9.0).unwrap(), 5.0);
assert_eq!((map.get("t1"), map.get("t2"), map.len()), (1.25, 5.0, 2));
// 100% is the absence of an entry.
assert_eq!(map.set("t1", 1.0).unwrap(), 1.0);
assert_eq!(map.len(), 1);
assert!(map.forget("t2") && !map.forget("t2"));
assert!(map.is_empty());
// Only tab ids the host itself accepts.
assert!(map.set("../x", 1.5).is_err());
assert!(map.set("t1", f64::NAN).is_err());
assert!(map.is_empty());
}
#[test]
fn the_file_is_data_and_is_bounded() {
let mut map = ZoomMap::default();
map.set("t1", 1.5).unwrap();
let json = serde_json::to_string(&map).unwrap();
assert_eq!(json, r#"{"t1":1.5}"#);
assert_eq!(serde_json::from_str::<ZoomMap>(&json).unwrap().validated(), map);
// A hand-edited file: bad ids and neutral entries are dropped, factors are clamped.
let read = serde_json::from_str::<ZoomMap>(r#"{"t1":99,"t2":1,"bad id!":2,"t3":0.001}"#).unwrap().validated();
assert_eq!((read.get("t1"), read.get("t2"), read.get("t3"), read.len()), (5.0, 1.0, 0.25, 2));
assert!(serde_json::from_str::<ZoomMap>(r#"{"t1":"big"}"#).is_err());
for n in 0..MAX_TABS {
map.set(&format!("x{n}"), 2.0).unwrap_or(0.0);
}
assert_eq!(map.len(), MAX_TABS);
assert!(map.set("one-more", 2.0).is_err(), "bounded");
assert_eq!(map.set("t1", 3.0).unwrap(), 3.0, "a remembered tab can still change");
}
}

View file

@ -89,8 +89,15 @@
if (key === 'r') return event.shiftKey ? 'hard-reload' : 'reload'; if (key === 'r') return event.shiftKey ? 'hard-reload' : 'reload';
// Physical key: layouts like Turkish-Q produce other characters on this key. // Physical key: layouts like Turkish-Q produce other characters on this key.
if (event.code === 'Backslash' || event.code === 'IntlBackslash') return event.shiftKey ? 'split-down' : 'split-right'; if (event.code === 'Backslash' || event.code === 'IntlBackslash') return event.shiftKey ? 'split-down' : 'split-right';
// Page zoom is the shell's (`tab_zoom`): this tab only says which key was pressed.
if (key === '=' || key === '+' || event.code === 'NumpadAdd') return 'zoom-in';
if (key === '-' || event.code === 'NumpadSubtract') return 'zoom-out';
if (key === '0' || event.code === 'Numpad0') return 'zoom-reset';
} }
if (key === 'f5') return 'reload'; if (key === 'f5') return 'reload';
// The app menu, like a browser's: F10, and Alt+F where Alt is not a typing key (not on macOS).
if (key === 'f10' && !mod && !event.altKey && !event.shiftKey) return 'app-menu';
if (event.altKey && !mod && !event.shiftKey && event.code === 'KeyF' && !/Mac/i.test(navigator.platform)) return 'app-menu';
if (event.altKey && !mod && key === 'arrowleft') return 'back'; if (event.altKey && !mod && key === 'arrowleft') return 'back';
if (event.altKey && !mod && key === 'arrowright') return 'forward'; if (event.altKey && !mod && key === 'arrowright') return 'forward';
return null; return null;

View file

@ -151,7 +151,7 @@ const COMMANDS = {
*/ */
export const HOST_COMMANDS = { export const HOST_COMMANDS = {
shell: [ shell: [
'tabs_sync', 'tab_navigate', 'tab_action', 'open_external_url', 'shell_focus', 'shell_ready', 'tab_still_ready', 'shell_raise', 'window_system_menu', 'tabs_sync', 'tab_navigate', 'tab_action', 'tab_zoom', 'open_external_url', 'shell_focus', 'shell_ready', 'tab_still_ready', 'shell_raise', 'window_system_menu',
'profiles_get', 'profiles_set', 'i18n_get', 'i18n_set', 'profiles_get', 'profiles_set', 'i18n_get', 'i18n_set',
'plugins_list', 'plugin_set_enabled', 'plugins_open_folder', 'custom_css_get', 'custom_css_set', 'features_get', 'features_set', 'mode_get', 'mode_set', 'settings_get', 'settings_set', 'plugins_list', 'plugin_set_enabled', 'plugins_open_folder', 'custom_css_get', 'custom_css_set', 'features_get', 'features_set', 'mode_get', 'mode_set', 'settings_get', 'settings_set',
], ],
@ -938,8 +938,14 @@ function shellTs(o) {
' ],', ' ],',
' },', ' },',
' // Native features this app does not wire up yet (waybetter-host workspaces, sync, devmode,', ' // Native features this app does not wire up yet (waybetter-host workspaces, sync, devmode,',
' // plugins::commands): the frame shows nothing of them until they are switched on here.', ' // plugins::commands) and panel sections it does not have (the Store, the updater): the frame',
' features: { workspaces: false, sync: false, developer: false, pluginCommands: false },', ' // shows nothing of them until they are switched on here. Page zoom (`tab_zoom`) is the host\'s own.',
' features: { workspaces: false, sync: false, developer: false, pluginCommands: false, store: false, updates: false },',
' // The app menu (the titlebar\'s "Settings and more", Alt+F / F10) needs nothing: without `menu`',
' // the app gets every built-in row it can serve (new tab, splits, zoom, Plugins, the palette,',
' // About, Settings). `menu.groups` orders them and adds rows of your own, e.g.',
' // menu: { groups: menuWith(DEFAULT_MENU, "plugins", [{ id: "help", label: () => "Help", run: () => openHelp() }]) },',
' // and `menu.languages` / `menu.modes` (menuModes() from waybetter/shell/panel) add those submenus.',
' // The first-run screen (the modes of src/features.ts) is the panel\'s: open it when it is due.', ' // The first-run screen (the modes of src/features.ts) is the panel\'s: open it when it is due.',
' start() { openOnboardingWhenDue(); return undefined; },', ' start() { openOnboardingWhenDue(); return undefined; },',
'};', '};',

View file

@ -2,11 +2,13 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent,
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { DEFAULT_PROFILE, accountLabel as accountLabelCore, removeProfile, tabProfile, type Profile, type ProfileSettings } from '../profileModel.ts'; import { DEFAULT_PROFILE, accountLabel as accountLabelCore, removeProfile, tabProfile, type Profile, type ProfileSettings } from '../profileModel.ts';
import { accountAction, addAccountProfile, focusedAccount, moveFocus, setDefaultAccount, tabsByAccount } from './accountModel.ts'; import { accountAction, addAccountProfile, focusedAccount, moveFocus, setDefaultAccount, tabsByAccount } from './accountModel.ts';
import { APP_MENU_KEYS, openAppMenu } from './AppMenu.tsx';
import { invoke } from './bridge.ts'; import { invoke } from './bridge.ts';
import { decks, forgetColumn, saveDecks } from './deckStore.ts'; import { decks, forgetColumn, saveDecks } from './deckStore.ts';
import { usePaneTick, useProfiles, useT } from './hooks.ts'; import { usePaneTick, useProfiles, useT } from './hooks.ts';
import { t, tx } from './i18n.ts'; import { t, tx } from './i18n.ts';
import { Avatar, Icon } from './icons.tsx'; import { Avatar, Icon } from './icons.tsx';
import { menuText } from './menuModel.ts';
import { site } from './site.ts'; import { site } from './site.ts';
import { flushSync, paneInfos, setOverlay, setProfiles, shell } from './state.ts'; import { flushSync, paneInfos, setOverlay, setProfiles, shell } from './state.ts';
import { isTauri } from './windowBinding.ts'; import { isTauri } from './windowBinding.ts';
@ -183,7 +185,7 @@ export function AccountSwitcher() {
} }
if (event.key === 'Tab') { event.preventDefault(); close(true); return; } if (event.key === 'Tab') { event.preventDefault(); close(true); return; }
const scope = inSub ? target.closest('.bt-acct-sub, .bt-acct-confirm')! : menu.current!; const scope = inSub ? target.closest('.bt-acct-sub, .bt-acct-confirm')! : menu.current!;
const items = Array.from(scope.querySelectorAll<HTMLElement>(inSub ? 'button:not(:disabled)' : '.bt-acct-main, .bt-acct-add')); const items = Array.from(scope.querySelectorAll<HTMLElement>(inSub ? 'button:not(:disabled)' : '.bt-acct-main, .bt-acct-add, .bt-acct-settings'));
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
event.preventDefault(); event.preventDefault();
items[moveFocus(items.indexOf(target), event.key, items.length)]?.focus(); items[moveFocus(items.indexOf(target), event.key, items.length)]?.focus();
@ -268,6 +270,11 @@ export function AccountSwitcher() {
<span className="bt-acct-add-icon"><Icon name="plus"/></span> <span className="bt-acct-add-icon"><Icon name="plus"/></span>
<span className="bt-acct-text"><span className="bt-acct-name">{t('wb.acct.add')}</span><span className="bt-acct-meta">{t('wb.acct.addHint', { site: siteName })}</span></span> <span className="bt-acct-text"><span className="bt-acct-name">{t('wb.acct.add')}</span><span className="bt-acct-meta">{t('wb.acct.addHint', { site: siteName })}</span></span>
</button> </button>
<hr/>
{/* Settings are looked for where the account name is: the way to the app menu. */}
<button role="menuitem" className="bt-acct-settings" aria-haspopup="menu" onClick={event => { const keyboard = event.detail === 0; close(); openAppMenu({ keyboard }); }}>
<Icon name="settings"/><span className="bt-menu-label">{menuText('accountsRow')}</span><span className="bt-menu-keys">{APP_MENU_KEYS}</span>
</button>
{error && <div className="bt-acct-error" role="alert">{error}</div>} {error && <div className="bt-acct-error" role="alert">{error}</div>}
</div> </div>
</div>, document.body)} </div>, document.body)}

View file

@ -0,0 +1,469 @@
// The app menu: the titlebar's "Settings and more" button, at the far end of the titlebar cluster
// (before the caption buttons on Windows and Linux; macOS has none there). It opens on click, on
// Alt+F and F10 (Shell.tsx; a focused tab forwards the keys), from the command palette and from the
// last row of the account menu. Its rows are the app's (`ShellSite.menu`, menuModel.ts): built-in
// ones by name, each shown only when the app serves what it needs, and the app's own.
//
// Like every shell menu it is an overlay: while open the shell is raised above the tab webviews
// (`setOverlay`), panes stay visible and nothing is dimmed. Keyboard: arrows, Home/End, type-ahead,
// the arrow pointing along the reading direction opens a submenu and the other one closes it,
// Enter or Space chooses, Esc closes one level. Submenus fly out beside the menu and stay inside
// the window (in a narrow one they lie over it). Words: menuMessages.en.json (`menuText`).
import { useEffect, useLayoutEffect, useReducer, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { createPortal } from 'react-dom';
import { invoke } from './bridge.ts';
import { decks } from './deckStore.ts';
import { refreshDev } from './devHost.ts';
import { useDev, usePaneTick, useT } from './hooks.ts';
import { formatNumber } from './i18n.ts';
import { Icon, type IconName } from './icons.tsx';
import {
DEFAULT_MENU, ZOOM_MAX, ZOOM_MIN, findSpec, joinGroups, languageChoice, languageRows, menuText, moveStop, planMenu, shortcutText, specRows, submenuKeys, typeahead,
type BuiltinMenuItem, type MenuEnv, type MenuItemRow, type MenuRow,
} from './menuModel.ts';
import { hasFeature, site } from './site.ts';
import { isWeb, setOverlay, shell, type TabParams } from './state.ts';
import { openWorkspaceSave } from './TitlebarExtras.tsx';
import { os } from './windowBinding.ts';
import { loadWorkspaces, restoreWorkspace, workspaceList, workspaces } from './workspaces.ts';
import { readZoom, stepZoom, zoomOf, zoomTarget } from './zoomStore.ts';
import './appMenu.css';
/** How the menu is asked to open: at a submenu, with the first row focused (keyboard), or closing if it is open. */
export type AppMenuRequest = { submenu?: string; keyboard?: boolean; toggle?: boolean };
/** Opens the app menu (Alt+F / F10, the palette, the account menu's last row). */
export const openAppMenu = (request: AppMenuRequest = {}) => window.dispatchEvent(new CustomEvent('bt-app-menu', { detail: request }));
const mac = os === 'macos';
/** The keys that open the menu, as the button's tooltip and the account menu write them. */
export const APP_MENU_KEYS = mac ? 'F10' : 'Alt+F';
const getError = (error: unknown) => (error instanceof Error ? error.message : String(error)).replace(/^Error: /, '');
const percent = (factor: number) => formatNumber(factor, { style: 'percent', maximumFractionDigits: 0 });
type Focus = { level: number; id?: string; edge?: 'first' | 'last' | 'menu' };
/** The rows of one level a key can stop on: enabled items; the zoom row counts once. */
function stopsOf(menu: HTMLElement): HTMLElement[] {
const all = Array.from(menu.querySelectorAll<HTMLElement>('[data-item]:not(:disabled)'));
const active = document.activeElement;
const zoom = all.filter(el => 'zoom' in el.dataset);
const keep = zoom.find(el => el === active) ?? zoom.find(el => el.getAttribute('aria-disabled') !== 'true') ?? zoom[0];
return all.filter(el => !('zoom' in el.dataset) || el === keep);
}
export function AppMenu() {
const t = useT();
usePaneTick();
const dev = useDev();
const config = site();
const menu = config.menu ?? {};
const [open, setOpen] = useState(false);
const [path, setPath] = useState<string[]>([]);
const [anchor, setAnchor] = useState({ top: 50, right: 8 });
const [mode, setMode] = useState<string | null>(null);
const [asking, setAsking] = useState<{ to: string; changes: number } | null>(null);
const [version, setVersion] = useState('');
const [error, setError] = useState('');
const [, tick] = useReducer((n: number) => n + 1, 0);
const button = useRef<HTMLButtonElement>(null);
const layers = useRef<HTMLDivElement>(null);
const focusNext = useRef<Focus | null>(null);
const before = useRef<HTMLElement | null>(null);
const hover = useRef<{ el: Element | null; timer?: ReturnType<typeof setTimeout> }>({ el: null });
const typed = useRef({ text: '', at: 0 });
const live = useRef({ open, path });
live.current = { open, path };
// Whatever a row shows may change while the menu is open (a language or theme picked in it).
useEffect(() => {
const events = ['bt-zoom', 'bt-workspaces', 'bt-decks', 'bt-site'];
for (const event of events) window.addEventListener(event, tick);
const off = [config.themes?.subscribe(tick), menu.languages?.subscribe(tick)];
return () => {
for (const event of events) window.removeEventListener(event, tick);
for (const dispose of off) dispose?.();
};
}, []);
function show(request: AppMenuRequest = {}) {
const rect = button.current?.getBoundingClientRect();
if (rect) setAnchor({ top: Math.round(rect.bottom + 6), right: Math.max(8, Math.round(window.innerWidth - rect.right)) });
const active = document.activeElement as HTMLElement | null;
before.current = active && active !== document.body && document.hasFocus() ? active : null;
// Keys pressed in a tab open the menu: the shell takes the keyboard.
invoke('shell_focus').catch(() => {});
setAsking(null); setError('');
setPath(request.submenu ? [request.submenu] : []);
focusNext.current = request.submenu ? { level: 1, edge: 'first' } : { level: 0, edge: request.keyboard ? 'first' : 'menu' };
setOpen(true);
// What the rows show, read fresh.
void readZoom(zoomTarget());
if (hasFeature('developer')) void refreshDev();
if (hasFeature('workspaces') && !workspaces.loaded) void loadWorkspaces().catch(() => {});
void config.themes?.loadThemes();
menu.modes?.current().then(setMode, () => {});
if (!version) menu.version?.().then(setVersion, () => {});
}
/** `escape`: back to what had focus before. `action` and `away`: nothing focused, so typing returns to the active pane. */
function close(how: 'escape' | 'action' | 'away' = 'away') {
clearTimeout(hover.current.timer);
hover.current = { el: null };
focusNext.current = null;
setOpen(false); setPath([]); setAsking(null); setError('');
if (how === 'escape' && before.current?.isConnected) before.current.focus();
before.current = null;
}
useEffect(() => {
const onRequest = (event: Event) => {
const request = (event as CustomEvent<AppMenuRequest | undefined>).detail ?? {};
if (live.current.open && request.toggle) close('escape'); else show(request);
};
window.addEventListener('bt-app-menu', onRequest);
return () => window.removeEventListener('bt-app-menu', onRequest);
}, [version]);
useEffect(() => {
if (!open) return;
// Native tab webviews would cover the menu.
setOverlay(1);
const away = () => close();
// Keys while nothing in the menu has focus (a click on its padding): Esc closes, arrows enter it.
const onKey = (event: KeyboardEvent) => {
if (layers.current?.contains(event.target as Node)) return;
if (event.key === 'Escape') { event.preventDefault(); close('escape'); return; }
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
focusNext.current = { level: live.current.path.length, edge: event.key === 'ArrowDown' ? 'first' : 'last' };
tick();
}
};
window.addEventListener('blur', away);
window.addEventListener('resize', away);
window.addEventListener('keydown', onKey);
return () => { setOverlay(-1); window.removeEventListener('blur', away); window.removeEventListener('resize', away); window.removeEventListener('keydown', onKey); };
}, [open]);
const layer = (level: number) => layers.current?.querySelector<HTMLElement>(`[data-level="${level}"]`) ?? null;
// After every render: submenus sit beside their row, inside the window; then the focus that was asked for.
useLayoutEffect(() => {
if (!open) return;
for (let level = 1; level <= path.length; level += 1) {
const el = layer(level);
const parent = layer(level - 1);
const row = parent?.querySelector<HTMLElement>(`[data-item="${CSS.escape(path[level - 1]!)}"]`);
if (!el || !parent) continue;
const edge = 8;
const box = parent.getBoundingClientRect();
el.style.maxHeight = `${window.innerHeight - edge * 2}px`;
const { width, height } = el.getBoundingClientRect();
// The menu hangs at the window's end edge: a submenu opens on its other side, else beside it, else over it.
const left = box.left - width + 3 >= edge ? box.left - width + 3
: box.right + width - 3 <= window.innerWidth - edge ? box.right - 3
: Math.max(edge, Math.min(box.left - width + 3, window.innerWidth - width - edge));
const top = Math.max(edge, Math.min((row?.getBoundingClientRect().top ?? box.top) - 6, window.innerHeight - height - edge));
el.style.left = `${Math.round(left)}px`;
el.style.top = `${Math.round(top)}px`;
}
const want = focusNext.current;
const el = want ? layer(want.level) : null;
if (!want || !el) return;
focusNext.current = null;
const stops = stopsOf(el);
const target = want.id ? el.querySelector<HTMLElement>(`[data-item="${CSS.escape(want.id)}"]:not(:disabled)`)
: want.edge === 'menu' ? el : want.edge === 'last' ? stops[stops.length - 1] : stops[0];
(target ?? el).focus();
});
const openSub = (level: number, id: string, focus = false) => {
setPath(current => [...current.slice(0, level), id]);
setAsking(null); setError('');
if (focus) focusNext.current = { level: level + 1, edge: 'first' };
};
/** Closes the submenu at `level`; its row gets the focus back. */
const closeSub = (level: number) => {
focusNext.current = { level: level - 1, id: path[level - 1] };
setPath(current => current.slice(0, level - 1));
setAsking(null); setError('');
};
async function activate(row: MenuItemRow, level: number) {
if (row.disabled) return;
if (row.submenu) {
if (path[level] === row.id) { focusNext.current = { level: level + 1, edge: 'first' }; tick(); } else openSub(level, row.id, true);
return;
}
setError('');
if (!row.keepOpen) close('action');
try { await row.run?.(); }
catch (failure) { if (row.keepOpen) setError(getError(failure)); else console.error('[shell] menu', failure); }
}
// -------------------------------------------------------------------------------------------
// Rows
const item = (id: string, label: string, icon: IconName | undefined, rest: Partial<MenuItemRow> = {}): MenuItemRow => ({ kind: 'item', id, label, icon, ...rest });
/** "Plugins…": a row that opens a section of the app's panel. */
const section = (name: string) => menuText('section', { section: name });
const active = shell.api?.activePanel;
const activeParams = active?.params as TabParams | undefined;
const canSplit = !!active && (isWeb(active) || active.group.panels.length > 1);
const languageName = (code: string | undefined) => menu.languages?.list.find(language => language.code === code)?.name;
const themeState = config.themes?.current();
const themeId = config.themes && themeState ? config.themes.adapter.preset(themeState.choice) : '';
const modeList = menu.modes?.list() ?? [];
/** Settings → Language: the panel's Settings, scrolled to that group once it is there. */
function openSettingsAt(anchor: string) {
shell.openPanelSection('settings');
let tries = 0;
const find = () => {
const heading = document.getElementById(`${anchor}-h`);
if (heading) heading.scrollIntoView({ block: 'start' });
else if ((tries += 1) < 40) setTimeout(find, 50);
};
find();
}
function builtin(id: BuiltinMenuItem): MenuRow {
switch (id) {
case 'new-tab': return item(id, t('wb.shell.tab.new'), 'plus', { shortcut: 'Ctrl+T', run: () => shell.shortcut('new-tab') });
case 'split-right': return item(id, t('wb.shell.menu.splitRight'), 'split', { shortcut: 'Ctrl+\\', disabled: !canSplit, run: () => shell.shortcut('split-right') });
case 'split-down': return item(id, t('wb.shell.menu.splitDown'), 'splitDown', { shortcut: 'Ctrl+Shift+\\', disabled: !canSplit, run: () => shell.shortcut('split-down') });
case 'new-column': return item(id, menuText('newColumn'), 'columns', {
run: () => {
// The deck in front, else the first one; with none yet, the deck tab itself (it starts one).
const target = activeParams?.kind === 'deck' ? activeParams.deckId : decks.list[0]?.id;
shell.run(target ? { type: 'deck-add', deckId: target } : { type: 'deck' });
},
});
case 'language': return item(id, t('wb.settings.language.title'), 'globe', { submenu: true, hint: languageName(menu.languages?.state().locale) });
case 'theme': return item(id, menuText('theme'), 'palette', { submenu: true, hint: themeId ? config.themes!.themeName(themeId) : undefined });
case 'mode': return item(id, t('wb.modes.section'), 'layers', { submenu: true, hint: modeList.find(entry => entry.id === mode)?.label });
case 'zoom': return { kind: 'zoom', id: 'zoom' };
case 'plugins': return item(id, section(t('wb.panel.section.plugins')), 'plug', { run: () => shell.openPanelSection('plugins') });
case 'store': return item(id, section(t('wb.panel.section.store')), 'bag', { run: () => shell.openPanelSection('store') });
case 'workspaces': return item(id, t('wb.ws.title'), 'layout', { submenu: true, hint: workspaces.store.items.find(entry => entry.id === workspaces.active)?.name });
case 'shortcuts': return item(id, menuText('shortcuts'), 'keyboard', { run: () => (menu.shortcuts ? menu.shortcuts() : shell.shortcut('command-palette')) });
case 'palette': return item(id, menuText('palette'), 'command', { shortcut: 'Ctrl+K', run: () => shell.shortcut('command-palette') });
case 'developer': return item(id, menuText('developer'), 'code', { submenu: true });
case 'updates': return item(id, section(t('wb.panel.section.updates')), 'reload', { hint: version || undefined, run: () => shell.openPanelSection('updates') });
case 'about': return item(id, menuText('about', { product: config.product }), 'info', { run: () => shell.openPanelSection('home') });
case 'settings': return item(id, section(t('wb.panel.section.settings')), 'settings', { run: () => shell.openPanelSection('settings') });
}
}
function submenuRows(id: string): MenuRow[] {
switch (id) {
case 'language': {
const languages = menu.languages;
if (!languages) return [];
const rows = languageRows(languages.list, languages.state(), { site: t('wb.settings.language.followSite'), system: t('wb.settings.language.followSystem'), more: menuText('moreLanguages') });
return rows.map(row => {
if (row.kind !== 'item') return row;
const choice = languageChoice(row.id);
// Switching is live and the menu stays: what was picked can be read (or undone) on the spot.
return choice ? { ...row, keepOpen: true, run: () => languages.set(choice) } : { ...row, run: () => openSettingsAt('language') };
});
}
case 'theme': {
const themes = config.themes;
if (!themes || !themeState) return [];
const list: MenuRow[] = themes.allThemes(themeState.choice).map(theme => item(`theme:${theme.id}`, theme.name, undefined, {
check: 'radio', checked: theme.id === themeId, keepOpen: true, swatch: themes.adapter.dots(theme),
// As Appearance does: a theme starts without the customizations made on another one.
run: () => themes.setChoice(themes.adapter.choiceForId(themeState.choice, theme.id)),
}));
return joinGroups([list, [
item('theme:builder', section(t('wb.panel.section.builder')), 'edit', { run: () => shell.openPanelSection('builder') }),
item('theme:more', menuText('moreAppearance'), 'palette', { run: () => shell.openPanelSection('themes') }),
]]);
}
case 'mode': {
const modes = menu.modes;
if (!modes) return [];
const name = (modeId: string) => modeList.find(entry => entry.id === modeId)?.label ?? modeId;
const apply = async (to: string, keep: boolean) => { await modes.apply(to, keep); setMode(to); close('action'); };
const at = path.indexOf('mode') + 1;
if (asking) {
// The question Settings asks: what was changed by hand stays, or the mode is taken as it is.
return [
{ kind: 'note', id: 'mode:question', title: t('wb.modes.keepQuestion'), text: t('wb.modes.keepHint', { count: asking.changes }) },
item('mode:keep', t('wb.modes.keep'), 'check', { keepOpen: true, run: () => apply(asking.to, true) }),
item('mode:reset', t('wb.modes.reset', { mode: name(asking.to) }), 'reload', { keepOpen: true, run: () => apply(asking.to, false) }),
item('mode:cancel', t('wb.common.cancel'), 'close', { keepOpen: true, run: () => { focusNext.current = { level: at, id: `mode:${asking.to}` }; setAsking(null); } }),
];
}
return modeList.map(entry => item(`mode:${entry.id}`, entry.label, undefined, {
check: 'radio', checked: entry.id === mode, keepOpen: true,
run: async () => {
if (entry.id === mode) { close('action'); return; }
const changes = await modes.changes(entry.id);
if (changes > 0) { focusNext.current = { level: at, edge: 'first' }; setAsking({ to: entry.id, changes }); } else await apply(entry.id, false);
},
}));
}
case 'workspaces': {
const list = workspaceList();
const rows: MenuRow[] = list.length
? list.map(entry => item(`ws:${entry.id}`, entry.name, undefined, { check: 'radio', checked: entry.id === workspaces.active, hint: t('wb.ws.tabs', { count: entry.tabs }), run: () => restoreWorkspace(entry.id) }))
: [{ kind: 'note', id: 'ws:none', title: '', text: menuText('noWorkspaces') }];
return joinGroups([rows, [item('ws:save', menuText('saveLayout'), 'plus', { run: openWorkspaceSave })]]);
}
case 'developer': return [
item('dev:mode', t('wb.dev.mode'), undefined, { check: 'checkbox', checked: dev.enabled, keepOpen: true, run: async () => { await invoke('dev_set', { enabled: !dev.enabled }); await refreshDev(); } }),
item('dev:folder', menuText('pluginsFolder'), 'folder', { run: () => invoke('plugins_open_folder') }),
];
default: return specRows(findSpec(groups, id)?.submenu?.() ?? []);
}
}
const groups = menu.groups ?? DEFAULT_MENU;
const env: MenuEnv = {
decks: !!config.deck, languages: !!menu.languages, themes: !!config.themes, modes: modeList.length > 0,
zoom: hasFeature('zoom'), store: hasFeature('store'), workspaces: hasFeature('workspaces'), developer: hasFeature('developer'), updates: hasFeature('updates'),
};
const rootRows = () => joinGroups(planMenu(groups, env).map(group => group.flatMap(entry => (typeof entry === 'string' ? [builtin(entry)] : specRows([entry])))));
const labelOf = (id: string, rows: MenuRow[]) => rows.find((row): row is MenuItemRow => row.kind === 'item' && row.id === id)?.label ?? '';
// -------------------------------------------------------------------------------------------
// Keyboard and pointer
function onKey(event: ReactKeyboardEvent<HTMLDivElement>) {
const target = event.target as HTMLElement;
const menuEl = target.closest<HTMLElement>('[data-level]');
if (!menuEl) return;
const level = Number(menuEl.dataset.level);
const { key } = event;
// The keyboard leads now: the row under a resting pointer takes the focus back only when the pointer moves on.
clearTimeout(hover.current.timer);
hover.current = { el: null };
const keys = submenuKeys(getComputedStyle(menuEl).direction === 'rtl');
const done = () => { event.preventDefault(); event.stopPropagation(); };
if (key === 'Escape') { done(); if (level > 0) closeSub(level); else close('escape'); return; }
if (key === 'Tab') { done(); close('escape'); return; }
// The zoom row is one stop: left and right move through − + and reset (laid out left to right).
if ('zoom' in target.dataset && (key === 'ArrowLeft' || key === 'ArrowRight')) {
done();
const controls = Array.from(menuEl.querySelectorAll<HTMLElement>('[data-zoom]'));
controls[Math.max(0, Math.min(controls.length - 1, controls.indexOf(target) + (key === 'ArrowRight' ? 1 : -1)))]?.focus();
return;
}
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Home' || key === 'End') {
done();
const stops = stopsOf(menuEl);
stops[moveStop(stops.indexOf(target), key, stops.length)]?.focus();
return;
}
if (key === keys.open) {
if (target.getAttribute('aria-haspopup') === 'menu' && target.dataset.item) { done(); openSub(level, target.dataset.item, true); }
return;
}
if (key === keys.close) { if (level > 0) { done(); closeSub(level); } return; }
const now = Date.now();
const typing = !!typed.current.text && now - typed.current.at <= 700;
// Enter, and Space outside a typed word: the row's own click chooses it; on the menu itself nothing is chosen.
if (key === 'Enter' || (key === ' ' && !typing)) {
if (target === menuEl) done();
typed.current.text = '';
return;
}
if (key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
// Type-ahead: the next row whose label starts with what was typed.
done();
typed.current = { text: typing ? typed.current.text + key : key, at: now };
const stops = stopsOf(menuEl);
const found = typeahead(stops.map(el => el.dataset.label ?? ''), stops.indexOf(target), typed.current.text);
if (found >= 0) stops[found]!.focus();
}
/** The pointer moves the focus, like the arrows do: one highlight. A row with a submenu opens it after a beat. */
function onHover(row: MenuItemRow | null, level: number, el: HTMLElement) {
if (hover.current.el === el) return;
clearTimeout(hover.current.timer);
hover.current = { el };
if (document.activeElement !== el) el.focus({ preventScroll: true });
if (row?.submenu) {
if (path[level] !== row.id) hover.current.timer = setTimeout(() => openSub(level, row.id), 170);
} else if (path.length > level) {
hover.current.timer = setTimeout(() => { setPath(current => current.slice(0, level)); setAsking(null); }, 280);
}
}
// -------------------------------------------------------------------------------------------
function renderRow(row: MenuRow, level: number, index: number) {
if (row.kind === 'separator') return <hr key={`sep${index}`} role="separator"/>;
if (row.kind === 'note') {
return <div key={row.id} className={`bt-app-note${row.tone === 'error' ? ' is-error' : ''}`} role={row.tone === 'error' ? 'alert' : 'presentation'}>
{row.title && <strong>{row.title}</strong>}{row.text && <span>{row.text}</span>}
</div>;
}
if (row.kind === 'zoom') {
const tab = zoomTarget();
const factor = zoomOf(tab);
const control = (id: string, label: string, icon: IconName, direction: 1 | -1 | 0, off: boolean, className = '') =>
<button type="button" role="menuitem" tabIndex={-1} className={className} data-item={`zoom:${id}`} data-zoom="" data-label={menuText('zoom')}
aria-label={label} title={tab ? label : menuText('zoomNone')} aria-disabled={off}
onPointerMove={event => onHover(null, level, event.currentTarget)} onClick={() => { if (!off) void stepZoom(direction, tab); }}>
<Icon name={icon}/>
</button>;
return <div key="zoom" className={`bt-app-zoom${tab ? '' : ' is-off'}`} role="group" aria-label={menuText('zoom')} title={tab ? undefined : menuText('zoomNone')}>
<Icon name="zoom"/>
<span className="bt-menu-label">{menuText('zoom')}</span>
<span className="bt-app-zoom-step">
{control('out', menuText('zoomOut'), 'minus', -1, !tab || factor <= ZOOM_MIN)}
<output className="bt-app-zoom-value" aria-live="polite">{percent(factor)}</output>
{control('in', menuText('zoomIn'), 'plus', 1, !tab || factor >= ZOOM_MAX)}
</span>
{control('reset', menuText('zoomReset'), 'reload', 0, !tab || factor === 1, 'bt-app-zoom-reset')}
</div>;
}
const expanded = row.submenu ? path[level] === row.id : undefined;
return <button key={row.id} type="button" tabIndex={-1} disabled={row.disabled} data-item={row.id} data-label={row.label}
role={row.check === 'radio' ? 'menuitemradio' : row.check ? 'menuitemcheckbox' : 'menuitem'} aria-checked={row.check ? !!row.checked : undefined}
aria-haspopup={row.submenu ? 'menu' : undefined} aria-expanded={expanded}
onPointerMove={event => onHover(row, level, event.currentTarget)} onClick={() => void activate(row, level)}>
{row.check ? <span className="bt-app-check">{row.checked && <Icon name="check"/>}</span> : row.icon ? <Icon name={row.icon}/> : <span className="bt-app-check"/>}
{/* A language's own name keeps its own direction (<bdi>) but starts where every other label does. */}
<span className="bt-menu-label">{row.lang ? <bdi lang={row.lang} dir={row.dir}>{row.label}</bdi> : row.label}</span>
{row.swatch && <span className="wb-dots" aria-hidden="true" style={{ background: row.swatch.page }}>
<i style={{ background: row.swatch.bg, boxShadow: `inset 0 0 0 1px ${row.swatch.border}` }}/><i style={{ background: row.swatch.text }}/><i style={{ background: row.swatch.accent }}/>
</span>}
{row.hint && <span className="bt-app-hint">{row.hint}</span>}
{row.shortcut && <span className="bt-menu-keys">{shortcutText(row.shortcut, mac)}</span>}
{row.submenu && <Icon name="chevronRight" className="bt-app-chevron"/>}
</button>;
}
function renderMenus() {
const root = rootRows();
const levels: Array<{ rows: MenuRow[]; label: string }> = [{ rows: root, label: menuText('label') }];
path.forEach((id, index) => levels.push({ rows: submenuRows(id), label: labelOf(id, levels[index]!.rows) }));
// An error belongs to the level it happened on: the deepest one open.
if (error) levels[levels.length - 1]!.rows = [...levels[levels.length - 1]!.rows, { kind: 'note', id: 'error', title: '', text: error, tone: 'error' }];
return levels.map(({ rows, label }, level) => <div key={level === 0 ? 'root' : `${level}:${path[level - 1]}`} role="menu" aria-label={label} tabIndex={-1} data-level={level}
className={level === 0 ? 'bt-menu bt-app-menu bt-pop bt-pop--right' : 'bt-menu bt-app-menu bt-app-sub'}
style={level === 0 ? { top: anchor.top, right: anchor.right, maxHeight: `calc(100vh - ${anchor.top + 8}px)` } : undefined}>
{rows.map((row, index) => renderRow(row, level, index))}
</div>);
}
const label = menuText('label');
return <>
<button ref={button} type="button" className="bt-app-button" aria-haspopup="menu" aria-expanded={open} aria-label={label} title={`${label} (${APP_MENU_KEYS})`}
onClick={event => (open ? close('escape') : show({ keyboard: event.detail === 0 }))}>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="5.75" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="18.25" r="1.6"/></svg>
</button>
{open && createPortal(<div className="bt-menu-backdrop" onMouseDown={() => close()} onContextMenu={event => { event.preventDefault(); close(); }}>
<div ref={layers} className="bt-app-layers" onMouseDown={event => event.stopPropagation()} onContextMenu={event => { event.preventDefault(); event.stopPropagation(); }} onKeyDown={onKey}>
{renderMenus()}
</div>
</div>, document.body)}
</>;
}

View file

@ -16,6 +16,7 @@ import { besideGroup } from '../paneModel.ts';
import { tabProfile } from '../profileModel.ts'; import { tabProfile } from '../profileModel.ts';
import { accountAction } from './accountModel.ts'; import { accountAction } from './accountModel.ts';
import { accountLabel } from './AccountSwitcher.tsx'; import { accountLabel } from './AccountSwitcher.tsx';
import { openAppMenu } from './AppMenu.tsx';
import { invoke, listen } from './bridge.ts'; import { invoke, listen } from './bridge.ts';
import { CommandPalette } from './CommandPalette.tsx'; import { CommandPalette } from './CommandPalette.tsx';
import { DeckTabBadge, DeckView, focusColumn, runDeckCommand } from './DeckView.tsx'; import { DeckTabBadge, DeckView, focusColumn, runDeckCommand } from './DeckView.tsx';
@ -40,6 +41,7 @@ import { openWorkspaceSave } from './TitlebarExtras.tsx';
import { WebPanel } from './WebPanel.tsx'; import { WebPanel } from './WebPanel.tsx';
import { frameless, isTauri, onMaximizedChange, os } from './windowBinding.ts'; import { frameless, isTauri, onMaximizedChange, os } from './windowBinding.ts';
import { loadWorkspaces, restoreWorkspace, workspaceList, workspaces } from './workspaces.ts'; import { loadWorkspaces, restoreWorkspace, workspaceList, workspaces } from './workspaces.ts';
import { stepZoom, zoomTarget } from './zoomStore.ts';
import './shell.css'; import './shell.css';
type Placement = { referencePanel: string; direction: 'within' | 'right' | 'below' }; type Placement = { referencePanel: string; direction: 'within' | 'right' | 'below' };
@ -262,6 +264,7 @@ function runPalette(action: PaletteAction) {
case 'plugin-command': runPluginCommand(action.tab, action.command); break; case 'plugin-command': runPluginCommand(action.tab, action.command); break;
case 'workspace': void restoreWorkspace(action.id); break; case 'workspace': void restoreWorkspace(action.id); break;
case 'workspace-save': openWorkspaceSave(); break; case 'workspace-save': openWorkspaceSave(); break;
case 'menu': openAppMenu({ keyboard: true }); break;
} }
} }
@ -269,6 +272,13 @@ function runShortcut(shortcut: string, id?: string) {
const api = shell.api; const api = shell.api;
if (!api) return; if (!api) return;
if (shortcut === 'command-palette') { togglePalette(); return; } if (shortcut === 'command-palette') { togglePalette(); return; }
// Alt+F / F10: the app menu, opened for the keyboard (pressed again, it closes).
if (shortcut === 'app-menu') { openAppMenu({ keyboard: true, toggle: true }); return; }
// Page zoom of the tab the keys were pressed in (a deck column zooms itself), else the pane in front.
if (shortcut === 'zoom-in' || shortcut === 'zoom-out' || shortcut === 'zoom-reset') {
void stepZoom(shortcut === 'zoom-in' ? 1 : shortcut === 'zoom-out' ? -1 : 0, id ?? zoomTarget());
return;
}
if (shortcut === 'deck-toggle') { toggleDeck(); return; } if (shortcut === 'deck-toggle') { toggleDeck(); return; }
if (shortcut.startsWith('column-') || shortcut === 'deck-compose') { if (shortcut.startsWith('column-') || shortcut === 'deck-compose') {
const deckId = deckFor(id); const deckId = deckFor(id);
@ -333,7 +343,15 @@ function shortcutFor(event: KeyboardEvent): string | null {
if (key === 'tab') return event.shiftKey ? 'prev-tab' : 'next-tab'; if (key === 'tab') return event.shiftKey ? 'prev-tab' : 'next-tab';
if (key === 'r') return 'reload'; if (key === 'r') return 'reload';
if (event.code === 'Backslash' || event.code === 'IntlBackslash') return event.shiftKey ? 'split-down' : 'split-right'; if (event.code === 'Backslash' || event.code === 'IntlBackslash') return event.shiftKey ? 'split-down' : 'split-right';
if (hasFeature('zoom')) {
if (key === '=' || key === '+' || event.code === 'NumpadAdd') return 'zoom-in';
if (key === '-' || event.code === 'NumpadSubtract') return 'zoom-out';
if (key === '0' || event.code === 'Numpad0') return 'zoom-reset';
}
} }
// The app menu, like a browser's: F10, and Alt+F where Alt is not a typing key (not on macOS).
if (event.key === 'F10' && !mod && !event.altKey && !event.shiftKey) return 'app-menu';
if (event.altKey && !mod && !event.shiftKey && event.code === 'KeyF' && os !== 'macos') return 'app-menu';
if (event.altKey && !mod && key === 'arrowleft') return 'back'; if (event.altKey && !mod && key === 'arrowleft') return 'back';
if (event.altKey && !mod && key === 'arrowright') return 'forward'; if (event.altKey && !mod && key === 'arrowright') return 'forward';
return null; return null;
@ -542,6 +560,8 @@ export default function Shell() {
shell.openAccount = openAccount; shell.openAccount = openAccount;
shell.openUrl = (url, profile) => { addWeb(url, undefined, true, profile); }; shell.openUrl = (url, profile) => { addWeb(url, undefined, true, profile); };
shell.openPanelSection = section => openPanel(section, shell.api?.activePanel?.id); shell.openPanelSection = section => openPanel(section, shell.api?.activePanel?.id);
shell.shortcut = name => runShortcut(name);
shell.run = runPalette;
useEffect(() => { useEffect(() => {
document.documentElement.dataset.os = os; document.documentElement.dataset.os = os;

View file

@ -2,6 +2,7 @@ import { useEffect, type CSSProperties, type MouseEvent as ReactMouseEvent } fro
import { RESIZE_BORDER } from '../chrome.ts'; import { RESIZE_BORDER } from '../chrome.ts';
import { chipFor } from '../updateModel.ts'; import { chipFor } from '../updateModel.ts';
import { AccountSwitcher } from './AccountSwitcher.tsx'; import { AccountSwitcher } from './AccountSwitcher.tsx';
import { AppMenu } from './AppMenu.tsx';
import { invoke } from './bridge.ts'; import { invoke } from './bridge.ts';
import { useT, useUpdateStatus } from './hooks.ts'; import { useT, useUpdateStatus } from './hooks.ts';
import { tx } from './i18n.ts'; import { tx } from './i18n.ts';
@ -32,6 +33,7 @@ export function Titlebar({ maximized }: { maximized: boolean }) {
<TitlebarExtras/> <TitlebarExtras/>
<AccountSwitcher/> <AccountSwitcher/>
<UpdateChip/> <UpdateChip/>
<AppMenu/>
{os !== 'macos' && <WindowControls maximized={maximized}/>} {os !== 'macos' && <WindowControls maximized={maximized}/>}
</header>; </header>;
} }

View file

@ -6,15 +6,17 @@
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ago } from './backupModel.ts'; import { ago } from './backupModel.ts';
import { useDev, useSync, useT } from './hooks.ts'; import { useDev, usePaneTick, useSync, useT } from './hooks.ts';
import { t } from './i18n.ts'; import { formatNumber, t } from './i18n.ts';
import { Icon } from './icons.tsx'; import { Icon } from './icons.tsx';
import { menuText } from './menuModel.ts';
import { hasFeature } from './site.ts'; import { hasFeature } from './site.ts';
import { setOverlay, shell } from './state.ts'; import { setOverlay, shell } from './state.ts';
import { WORKSPACE_LIMITS } from './workspaceModel.ts'; import { WORKSPACE_LIMITS } from './workspaceModel.ts';
import { import {
deleteWorkspace, loadWorkspaces, renameSavedWorkspace, restoreWorkspace, saveCurrentWorkspace, setStartWorkspace, workspaceList, workspaces, deleteWorkspace, loadWorkspaces, renameSavedWorkspace, restoreWorkspace, saveCurrentWorkspace, setStartWorkspace, workspaceList, workspaces,
} from './workspaces.ts'; } from './workspaces.ts';
import { readZoom, stepZoom, zoomOf, zoomTarget } from './zoomStore.ts';
import './workspaces.css'; import './workspaces.css';
function useWorkspaces() { function useWorkspaces() {
@ -43,12 +45,36 @@ export function TitlebarExtras() {
return () => { observer.disconnect(); document.documentElement.style.removeProperty('--bt-tb-extra'); }; return () => { observer.disconnect(); document.documentElement.style.removeProperty('--bt-tb-extra'); };
}, []); }, []);
return <div className="bt-tb-extras" ref={root}> return <div className="bt-tb-extras" ref={root}>
{hasFeature('zoom') && <ZoomChip/>}
{hasFeature('sync') && <SyncChip/>} {hasFeature('sync') && <SyncChip/>}
{hasFeature('developer') && <DevChip/>} {hasFeature('developer') && <DevChip/>}
{hasFeature('workspaces') && <WorkspaceMenu/>} {hasFeature('workspaces') && <WorkspaceMenu/>}
</div>; </div>;
} }
/** The page zoom of the pane in front, only while it is not 100% (Ctrl with + and - say nothing else); a click resets it. */
function ZoomChip() {
useT();
usePaneTick();
const [, tick] = useState(0);
useEffect(() => {
const update = () => tick(value => value + 1);
window.addEventListener('bt-zoom', update);
window.addEventListener('bt-decks', update);
return () => { window.removeEventListener('bt-zoom', update); window.removeEventListener('bt-decks', update); };
}, []);
const tab = zoomTarget();
// A restored tab may come back zoomed: ask once it is the one in front.
useEffect(() => { void readZoom(tab); }, [tab]);
const factor = zoomOf(tab);
if (!tab || factor === 1) return null;
const percent = formatNumber(factor, { style: 'percent', maximumFractionDigits: 0 });
const label = menuText('zoomChip', { percent });
return <button className="bt-tb-chip bt-zoom-chip" title={label} aria-label={label} onClick={() => void stepZoom(0, tab)}>
<Icon name="zoom"/><span className="bt-tb-chip-text">{percent}</span>
</button>;
}
function DevChip() { function DevChip() {
const t = useT(); const t = useT();
const dev = useDev(); const dev = useDev();

View file

@ -0,0 +1,80 @@
/* The app menu (AppMenu.tsx): the titlebar's "Settings and more" button, its menu and submenus.
Tokens come from shell.css (:root --bt-*), so it follows the chrome theme (and its first-frame
cache) like every other shell menu. Its width in the titlebar is reserved in --bt-tb-menu. */
/* The button: the last thing before the caption buttons. */
.bt-app-button {
align-self: center; display: inline-grid; place-items: center; width: 30px; height: 30px; flex: none; margin-right: 8px; padding: 0; box-sizing: border-box; /* rtl-ok: titlebar */
border: 1px solid transparent; border-radius: 15px; background: transparent; color: var(--bt-text-2); cursor: pointer;
transition: background-color var(--bt-t1) var(--bt-ease), border-color var(--bt-t1) var(--bt-ease), color var(--bt-t1) var(--bt-ease);
}
.bt-app-button:hover { background: var(--bt-hover); border-color: var(--bt-line-soft); color: var(--bt-text); }
.bt-app-button[aria-expanded=true] { background: var(--bt-hover); border-color: rgb(var(--bt-accent-rgb) / .5); color: var(--bt-text); }
.bt-app-button:focus-visible { outline: 0; box-shadow: var(--bt-ring); }
.bt-shell .bt-app-button svg { width: 16px; height: 16px; fill: currentColor; stroke: none; }
/* The menu and its submenus. `display: contents`: the layers are positioned on their own. */
.bt-app-layers { display: contents; }
.bt-menu.bt-app-menu {
width: 304px; max-width: calc(100vw - 16px); padding: 6px; overflow-y: auto; overscroll-behavior: contain;
scrollbar-width: thin; scrollbar-color: var(--bt-line-strong) transparent;
}
.bt-menu.bt-app-menu:focus { outline: 0; }
.bt-menu.bt-app-sub {
width: max-content; min-width: 224px; max-width: min(320px, calc(100vw - 16px)); z-index: 1;
animation: bt-app-sub-in var(--bt-t1) var(--bt-ease-out);
}
@keyframes bt-app-sub-in { from { opacity: 0; transform: translateX(6px); } }
.bt-app-menu hr { flex: none; width: auto; align-self: stretch; }
/* Rows: one highlight, wherever it comes from (the pointer moves the focus, as the arrows do);
a row whose submenu is open keeps it; the keyboard's focus also gets a hairline in the accent. */
.bt-app-menu button { flex: none; width: 100%; min-width: 0; box-sizing: border-box; }
.bt-app-menu button:hover:not(:disabled) { background: transparent; }
.bt-app-menu button:focus:not(:disabled), .bt-app-menu button[aria-expanded=true] { background: var(--bt-hover); outline: 0; }
.bt-app-menu button:focus-visible:not(:disabled) { box-shadow: inset 0 0 0 1px rgb(var(--bt-accent-rgb) / .6); }
.bt-app-menu button:active:not(:disabled) { background: var(--bt-press); }
.bt-app-menu button:focus > .bt-glyph, .bt-app-menu button[aria-expanded=true] > .bt-glyph { color: var(--bt-text); }
/* The leading column: an icon, or the check of a choice (empty while it is not the one chosen). */
.bt-app-check { display: inline-grid; place-items: center; width: 16px; height: 16px; flex: none; }
.bt-app-check .bt-glyph { width: 15px; height: 15px; color: var(--bt-accent); stroke-width: 2.1; }
.bt-app-menu [aria-checked=true] .bt-menu-label { color: var(--bt-text); font-weight: 600; }
/* The trailing column: a quiet value (the language in use, a version), the keys, the submenu arrow. */
.bt-app-hint { flex: 0 1 auto; min-width: 0; max-width: 46%; margin-inline-start: auto; padding-inline-start: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--bt-muted); font: 400 12px/1.3 var(--bt-ui); font-variant-numeric: tabular-nums; }
.bt-app-menu .bt-menu-keys { flex: none; padding-inline-start: 12px; unicode-bidi: isolate; direction: ltr; }
.bt-app-hint + .bt-menu-keys, .bt-app-menu .wb-dots + .bt-app-hint { margin-inline-start: 0; }
.bt-app-menu .bt-app-chevron { width: 14px; height: 14px; margin-inline: 2px -4px; color: var(--bt-faint); }
.bt-app-menu .bt-app-hint + .bt-app-chevron { margin-inline-start: -4px; }
.bt-app-menu button:focus > .bt-app-chevron, .bt-app-menu button[aria-expanded=true] > .bt-app-chevron { color: var(--bt-text-2); }
[dir="rtl"] .bt-app-menu .bt-app-chevron { transform: scaleX(-1); }
.bt-app-menu .wb-dots { margin-inline-start: 8px; }
/* Zoom: one line, − 100% + and a reset. The stepper reads left to right in every language. */
.bt-app-zoom { display: flex; align-items: center; gap: 10px; height: 36px; flex: none; padding-inline: 10px 3px; color: var(--bt-text); }
.bt-app-zoom > .bt-glyph { color: var(--bt-muted); }
.bt-app-zoom.is-off > .bt-glyph, .bt-app-zoom.is-off .bt-menu-label, .bt-app-zoom.is-off .bt-app-zoom-value { color: var(--bt-faint); }
.bt-app-zoom-step { display: flex; align-items: center; flex: none; height: 28px; border-radius: 9px; direction: ltr; background: rgb(var(--bt-text-rgb) / .04); box-shadow: inset 0 0 0 1px var(--bt-line); }
.bt-app-menu .bt-app-zoom button { width: 30px; height: 28px; flex: none; padding: 0; justify-content: center; border-radius: 8px; color: var(--bt-text-2); }
.bt-app-menu .bt-app-zoom button > .bt-glyph { width: 14px; height: 14px; color: inherit; }
.bt-app-menu .bt-app-zoom button:focus { color: var(--bt-text); }
.bt-app-menu .bt-app-zoom button[aria-disabled=true] { color: var(--bt-faint); cursor: default; }
.bt-app-menu .bt-app-zoom button[aria-disabled=true]:active { background: var(--bt-hover); }
.bt-app-zoom-value { min-width: 50px; padding: 0 2px; text-align: center; color: var(--bt-text); font: 500 12px/1 var(--bt-mono); font-variant-numeric: tabular-nums; }
.bt-app-menu .bt-app-zoom .bt-app-zoom-reset { width: 28px; border-radius: 9px; }
/* Words that are not a choice: the mode question, an empty list, an error. */
.bt-app-note { display: flex; flex-direction: column; gap: 3px; max-width: 288px; padding: 8px 10px 7px; box-sizing: border-box; }
.bt-app-note strong { color: var(--bt-text); font: 600 13px/1.3 var(--bt-ui); }
.bt-app-note span { color: var(--bt-muted); font: 400 12px/1.4 var(--bt-ui); white-space: normal; }
.bt-app-note.is-error span { color: var(--bt-danger); }
/* The account menu's last row: the way here from where the account name is. */
.bt-menu .bt-acct-settings { flex: none; height: 34px; gap: 11px; padding: 0 8px; border-radius: 10px; color: var(--bt-text-2); }
.bt-menu .bt-acct-settings > .bt-glyph { width: 32px; height: 15px; }
.bt-menu .bt-acct-settings:focus-visible { box-shadow: var(--bt-ring); }
/* The titlebar's zoom chip: only while the pane in front is not at 100% (TitlebarExtras.tsx). */
.bt-zoom-chip { font-family: var(--bt-mono); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.bt-zoom-chip .bt-glyph { color: var(--bt-muted); }

View file

@ -37,6 +37,20 @@ export const ICONS = {
trash: 'M5 7h14M10 7V4h4v3M7 7l1 13h8l1-13', trash: 'M5 7h14M10 7V4h4v3M7 7l1 13h8l1-13',
edit: 'M4 20h4L19 9l-4-4L4 16zM14 6l4 4', edit: 'M4 20h4L19 9l-4-4L4 16zM14 6l4 4',
grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
// The app menu's rows (AppMenu.tsx).
chevronRight: 'M10 7l5 5-5 5',
minus: 'M5 12h14',
columns: 'M4 5h16v14H4zM9.5 5v14M14.5 5v14',
layers: 'M12 4l8 4-8 4-8-4zM4 12l8 4 8-4M4 16l8 4 8-4',
zoom: 'M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14zM20 20l-4-4M8.5 11h5M11 8.5v5',
plug: 'M9 3v5M15 3v5M6.5 8h11v3.5a5.5 5.5 0 0 1-11 0zM12 17v4',
bag: 'M5 8h14l-1 12H6zM9 8V7a3 3 0 0 1 6 0v1',
layout: 'M4 5h16v14H4zM10 5v14M10 12h10',
keyboard: 'M3 7h18v10H3zM7 10.5h.01M11 10.5h.01M15 10.5h.01M7.5 13.5h9',
command: 'M5 8l4 4-4 4M12 16h7',
code: 'M9 8l-4 4 4 4M15 8l4 4-4 4',
folder: 'M4 7h6l2 2h8v10H4z',
info: 'M12 21a9 9 0 1 1 0-18 9 9 0 0 1 0 18zM12 11v5M12 8h.01',
} as const; } as const;
export type IconName = keyof typeof ICONS; export type IconName = keyof typeof ICONS;

View file

@ -53,5 +53,10 @@ export { chromeVars, CHROME_VAR_NAMES, SHELL_UI_FONT, SHELL_MONO_FONT, type Chro
export { shell, setOverlay, scheduleSync, flushSync, isWeb, tabAction, setProfiles, type TabParams, type WebParams } from './state.ts'; export { shell, setOverlay, scheduleSync, flushSync, isWeb, tabAction, setProfiles, type TabParams, type WebParams } from './state.ts';
export type { PaletteItem, PaletteAction, PaletteContext } from './paletteModel.ts'; export type { PaletteItem, PaletteAction, PaletteContext } from './paletteModel.ts';
export { Icon, Avatar, ICONS, type IconName } from './icons.tsx'; export { Icon, Avatar, ICONS, type IconName } from './icons.tsx';
export {
DEFAULT_MENU, MENU_BUILTINS, MENU_MESSAGES, menuWith, menuWithout,
type BuiltinMenuItem, type MenuEntry, type MenuGroups, type MenuItemSpec, type MenuLanguages, type MenuModes, type ShellMenu,
} from './menuModel.ts';
export { openAppMenu, type AppMenuRequest } from './AppMenu.tsx';
export { createLanguage, resolveLocale, type Language, type LanguageOptions, type LanguagePreference } from './language.ts'; export { createLanguage, resolveLocale, type Language, type LanguageOptions, type LanguagePreference } from './language.ts';
export { createAppI18n, type AppI18n, type AppI18nOptions, type AppLanguage } from './appI18n.ts'; export { createAppI18n, type AppI18n, type AppI18nOptions, type AppLanguage } from './appI18n.ts';

View file

@ -0,0 +1,22 @@
{
"wb.menu.label": "Settings and more",
"wb.menu.accountsRow": "Settings and more…",
"wb.menu.newColumn": "New deck column",
"wb.menu.theme": "Theme",
"wb.menu.moreAppearance": "More in Appearance…",
"wb.menu.moreLanguages": "More languages…",
"wb.menu.zoom": "Zoom",
"wb.menu.zoomIn": "Zoom in",
"wb.menu.zoomOut": "Zoom out",
"wb.menu.zoomReset": "Reset zoom",
"wb.menu.zoomNone": "This tab has no page to zoom",
"wb.menu.zoomChip": "Zoom {percent}. Click to reset.",
"wb.menu.saveLayout": "Save layout…",
"wb.menu.noWorkspaces": "No saved workspaces yet",
"wb.menu.shortcuts": "Keyboard shortcuts…",
"wb.menu.palette": "Command palette",
"wb.menu.developer": "Developer",
"wb.menu.pluginsFolder": "Open plugins folder",
"wb.menu.about": "About {product}",
"wb.menu.section": "{section}…"
}

View file

@ -0,0 +1,369 @@
// Pure model of the app menu (the titlebar's "Settings and more": AppMenu.tsx), unit-tested under
// node: which rows an app gets (its `ShellSite.menu` groups, the built-in rows and what gates
// them), how the keyboard moves through them, the language list, the zoom steps and how a shortcut
// is written. No React, no Tauri.
//
// The menu's new words live in menuMessages.en.json (`wb.menu.*`) and are read through the
// framework i18n with that English as the fallback (`menuText`), so they work before the catalogs
// have them. Words the panel already has (section names, the mode question) are reused.
import { formatMessage } from '../../../runtime/src/i18n.ts';
import EN from './menuMessages.en.json' with { type: 'json' };
import { tx, type Params } from './i18n.ts';
import type { IconName } from './icons.tsx';
/** The menu's English (`wb.menu.*`), for the catalogs. */
export const MENU_MESSAGES: Readonly<Record<string, string>> = EN;
type MenuKey = keyof typeof EN;
/** A menu message's name: its key without the `wb.menu.` prefix. */
export type MenuWord = MenuKey extends `wb.menu.${infer Name}` ? Name : never;
/** A menu message in the active language; English from menuMessages.en.json until a catalog has it. */
export function menuText(name: MenuWord, params?: Params): string {
const key = `wb.menu.${name}`;
return tx(key, params, '') || formatMessage(MENU_MESSAGES[key] ?? key, params ?? {});
}
// ---------------------------------------------------------------------------------------------
// What the app configures (`ShellSite.menu`)
/** The rows the frame brings. Each shows only when the app serves what it needs (`builtinAvailable`). */
export const MENU_BUILTINS = [
'new-tab', 'split-right', 'split-down', 'new-column',
'language', 'theme', 'mode',
'zoom',
'plugins', 'store', 'workspaces',
'shortcuts', 'palette',
'developer', 'updates', 'about', 'settings',
] as const;
export type BuiltinMenuItem = typeof MENU_BUILTINS[number];
/** A row of the app's own. */
export type MenuItemSpec = {
/** Unique in the menu (never a built-in's name). */
id: string;
label(): string;
icon?: IconName;
/** Its keys, written like `Ctrl+Shift+D` (shown as the platform writes them; the app handles the keys). */
shortcut?: string;
/** Quiet text at the end of the row instead of a shortcut (a count, a version). */
hint?(): string;
/** Leave the row out while this is false. */
when?(): boolean;
/** A toggle: the row is a checkbox showing this state; `run` flips it. */
checked?(): boolean;
run?(): void | Promise<void>;
/** A submenu instead of an action (its rows may have submenus of their own). */
submenu?(): MenuItemSpec[];
/** Starts a new group (a rule above this row) inside a submenu. */
separated?: boolean;
/** The menu stays open after `run` (a toggle someone may flip back). */
keepOpen?: boolean;
};
export type MenuEntry = BuiltinMenuItem | MenuItemSpec;
/** Ordered groups; a rule is drawn between two groups. */
export type MenuGroups = ReadonlyArray<ReadonlyArray<MenuEntry>>;
/** One language of the app (a row of its `languages.json`). */
export type MenuLanguage = { code: string; name: string; englishName: string; dir: 'ltr' | 'rtl'; tier: 'supported' | 'community' };
/** The app's language state (frame/language.ts `createLanguage`), as the panel's picker takes it. */
export type MenuLanguages = {
list: readonly MenuLanguage[];
state(): { preference: string; siteLang: string | null; locale: string };
subscribe(listener: () => void): () => void;
set(preference: string): Promise<void>;
};
/** The app's modes (Basic / Advanced / Power User): the panel's `menuModes()` gives this. */
export type MenuModes = {
list(): ReadonlyArray<{ id: string; label: string }>;
/** The mode in use (null: none chosen yet). */
current(): Promise<string | null>;
/** How many of the owner's own changes choosing `to` would replace (0: nothing to ask). */
changes(to: string): Promise<number>;
/** Applies `to`, keeping the owner's own changes or not. */
apply(to: string, keep: boolean): Promise<void>;
};
/** `ShellSite.menu`: the app menu's rows and what the built-in ones need from the app. */
export type ShellMenu = {
/** The rows, in order. A string is a built-in row, an object one of the app's. Default: `DEFAULT_MENU`. */
groups?: MenuGroups;
/** The Language submenu; omit for an app in one language. */
languages?: MenuLanguages;
/** The Mode submenu; omit for an app without modes. */
modes?: MenuModes;
/** The app's version, shown on the Updates row. */
version?(): Promise<string>;
/** Opens the app's shortcuts sheet ("Keyboard shortcuts…"); without one the row opens the command palette. */
shortcuts?(): void;
};
/** The menu an app gets without saying anything: every built-in row it can serve. */
export const DEFAULT_MENU: MenuGroups = [
['new-tab', 'split-right', 'split-down', 'new-column'],
['language', 'theme', 'mode'],
['zoom'],
['plugins', 'store', 'workspaces'],
['shortcuts', 'palette'],
['developer', 'updates', 'about', 'settings'],
];
const entryId = (entry: MenuEntry) => (typeof entry === 'string' ? entry : entry.id);
/** `groups` with the app's `items` put next to the row `anchor` (after it, or before). Without that row they form a last group. */
export function menuWith(groups: MenuGroups, anchor: string, items: readonly MenuEntry[], where: 'after' | 'before' = 'after'): MenuGroups {
let placed = false;
const out = groups.map(group => {
const at = group.findIndex(entry => entryId(entry) === anchor);
if (at < 0 || placed) return group;
placed = true;
const index = where === 'after' ? at + 1 : at;
return [...group.slice(0, index), ...items, ...group.slice(index)];
});
return placed ? out : [...out, items];
}
/** `groups` without the rows named. */
export function menuWithout(groups: MenuGroups, ...ids: string[]): MenuGroups {
return groups.map(group => group.filter(entry => !ids.includes(entryId(entry))));
}
/** What the app serves, as far as the built-in rows care. */
export type MenuEnv = {
/** Decks of site columns (`site.deck`). */
decks: boolean;
/** `menu.languages`. */
languages: boolean;
/** `site.themes`. */
themes: boolean;
/** `menu.modes` with at least one mode. */
modes: boolean;
/** `features`: native page zoom (`tab_zoom`), the Store, workspaces, developer mode, the updater. */
zoom: boolean;
store: boolean;
workspaces: boolean;
developer: boolean;
updates: boolean;
};
/** Whether the app can serve a built-in row: a row that would do nothing is never shown. */
export function builtinAvailable(id: BuiltinMenuItem, env: MenuEnv): boolean {
switch (id) {
case 'new-column': return env.decks;
case 'language': return env.languages;
case 'theme': return env.themes;
case 'mode': return env.modes;
case 'zoom': return env.zoom;
case 'store': return env.store;
case 'workspaces': return env.workspaces;
case 'developer': return env.developer;
case 'updates': return env.updates;
default: return true;
}
}
const isBuiltin = (id: string): id is BuiltinMenuItem => (MENU_BUILTINS as readonly string[]).includes(id);
/**
* The entries the menu shows, group by group: built-in rows the app cannot serve and rows whose
* `when()` says no are left out, a row listed twice shows once, a group left empty goes (so no
* rule is ever doubled), and an unknown name is ignored.
*/
export function planMenu(groups: MenuGroups, env: MenuEnv): MenuEntry[][] {
const seen = new Set<string>();
const out: MenuEntry[][] = [];
for (const group of groups) {
const kept: MenuEntry[] = [];
for (const entry of group) {
const id = entryId(entry);
if (seen.has(id)) continue;
if (typeof entry === 'string') {
if (!isBuiltin(entry) || !builtinAvailable(entry, env)) continue;
} else if (isBuiltin(id) || (entry.when && !entry.when())) {
continue;
}
seen.add(id);
kept.push(entry);
}
if (kept.length) out.push(kept);
}
return out;
}
// ---------------------------------------------------------------------------------------------
// Rows as the menu draws them
export type MenuItemRow = {
kind: 'item';
id: string;
label: string;
icon?: IconName;
shortcut?: string;
hint?: string;
disabled?: boolean;
/** A choice among its neighbours (radio) or a toggle (checkbox), with `checked`. */
check?: 'radio' | 'checkbox';
checked?: boolean;
/** Opens a submenu (its rows come from the menu, by this row's id). */
submenu?: boolean;
keepOpen?: boolean;
/** The label's own language and direction (a language's native name). */
lang?: string;
dir?: 'ltr' | 'rtl';
/** A theme's colors, drawn as three dots (`ThemeAdapter.dots`). */
swatch?: { page: string; bg: string; border: string; text: string; accent: string };
run?(): unknown;
};
export type MenuRow =
| MenuItemRow
| { kind: 'separator' }
/** Words that are not a choice: a question above its answers, an empty list, an error. */
| { kind: 'note'; id: string; title: string; text?: string; tone?: 'error' }
/** The zoom row: − 100% + and a reset, in one line. */
| { kind: 'zoom'; id: 'zoom' };
/** Groups of rows as one list with a rule between two groups. */
export function joinGroups(groups: ReadonlyArray<readonly MenuRow[]>): MenuRow[] {
const out: MenuRow[] = [];
for (const group of groups) {
if (!group.length) continue;
if (out.length) out.push({ kind: 'separator' });
out.push(...group);
}
return out;
}
/** The app's own entries as rows (`submenu` rows are looked up again when they open). */
export function specRows(specs: readonly MenuItemSpec[]): MenuRow[] {
const out: MenuRow[] = [];
for (const spec of specs) {
if (spec.when && !spec.when()) continue;
if (spec.separated && out.length) out.push({ kind: 'separator' });
const checked = spec.checked?.();
out.push({
kind: 'item', id: spec.id, label: spec.label(), icon: spec.icon, shortcut: spec.shortcut, hint: spec.hint?.(),
check: checked === undefined ? undefined : 'checkbox', checked, submenu: !!spec.submenu, keepOpen: spec.keepOpen, run: spec.run,
});
}
return out;
}
/** The app's entry with this id, anywhere in its groups or their submenus. */
export function findSpec(groups: MenuGroups, id: string): MenuItemSpec | null {
const walk = (specs: readonly MenuItemSpec[], depth: number): MenuItemSpec | null => {
for (const spec of specs) {
if (spec.id === id) return spec;
const inner = depth < 3 && spec.submenu ? walk(spec.submenu(), depth + 1) : null;
if (inner) return inner;
}
return null;
};
return walk(groups.flat().filter((entry): entry is MenuItemSpec => typeof entry !== 'string'), 0);
}
// ---------------------------------------------------------------------------------------------
// Keyboard
/** Arrow keys, Home and End over `count` stops; wraps around. -1 (nothing focused) enters at an end. */
export function moveStop(index: number, key: string, count: number): number {
if (count <= 0) return -1;
if (key === 'Home') return 0;
if (key === 'End') return count - 1;
if (key === 'ArrowDown') return index < 0 ? 0 : (index + 1) % count;
if (key === 'ArrowUp') return index < 0 ? count - 1 : (index - 1 + count) % count;
return index;
}
const fold = (text: string) => text.normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase();
/**
* Type-ahead: the next stop after `from` whose label starts with what was typed (accents and case
* ignored). One letter typed again cycles through the labels that start with it. -1: none.
*/
export function typeahead(labels: readonly string[], from: number, typed: string): number {
const text = fold(typed);
if (!text || !labels.length) return -1;
const chars = [...text];
const repeat = chars.length > 1 && chars.every(char => char === chars[0]);
const needle = repeat ? chars[0]! : text;
// While a word is being typed the focused row may keep matching; a repeated letter moves on.
const start = chars.length > 1 && !repeat ? Math.max(0, from) : from + 1;
for (let step = 0; step < labels.length; step += 1) {
const index = (start + step + labels.length) % labels.length;
if (fold(labels[index]!).startsWith(needle)) return index;
}
return -1;
}
/** The keys that open and close a submenu: they follow the reading direction. */
export function submenuKeys(rtl: boolean): { open: string; close: string } {
return rtl ? { open: 'ArrowLeft', close: 'ArrowRight' } : { open: 'ArrowRight', close: 'ArrowLeft' };
}
/** A shortcut as the platform writes it: `Ctrl+Shift+T` on Windows and Linux, `⇧⌘T` on macOS. */
export function shortcutText(shortcut: string, mac: boolean): string {
const parts = shortcut.split('+').map(part => part.trim()).filter(Boolean);
if (!mac) return parts.join('+');
const symbols: Record<string, string> = { ctrl: '⌘', cmd: '⌘', shift: '⇧', alt: '⌥' };
const order = ['alt', 'shift', 'ctrl', 'cmd'];
const mods = parts.filter(part => part.toLowerCase() in symbols).sort((a, b) => order.indexOf(a.toLowerCase()) - order.indexOf(b.toLowerCase()));
const keys = parts.filter(part => !(part.toLowerCase() in symbols));
return `${mods.map(part => symbols[part.toLowerCase()]).join('')}${keys.join('')}`;
}
// ---------------------------------------------------------------------------------------------
// Language
/**
* The Language submenu: follow the site, follow the system, then the supported languages by their
* own names (the one chosen is checked; a community language in use is listed too, so the check is
* never missing), then the way to the full list in Settings.
*/
export function languageRows(
list: readonly MenuLanguage[],
state: { preference: string; siteLang: string | null; locale: string },
words: { site: string; system: string; more: string },
): MenuRow[] {
const named = (code: string | null) => (code ? list.find(language => language.code === code)?.name : undefined);
const automatic: MenuRow[] = [
{ kind: 'item', id: 'lang:site', label: words.site, check: 'radio', checked: state.preference === 'site', hint: state.preference === 'site' ? named(state.locale) : named(state.siteLang) },
{ kind: 'item', id: 'lang:system', label: words.system, check: 'radio', checked: state.preference === 'system', hint: state.preference === 'system' ? named(state.locale) : undefined },
];
const shown = list.filter(language => language.tier !== 'community' || language.code === state.preference);
const languages: MenuRow[] = shown.map(language => ({
kind: 'item', id: `lang:${language.code}`, label: language.name, check: 'radio', checked: state.preference === language.code,
hint: language.name === language.englishName ? undefined : language.englishName, lang: language.code, dir: language.dir,
}));
return joinGroups([automatic, languages, [{ kind: 'item', id: 'lang:more', label: words.more }]]);
}
/** The preference a Language row stands for (`lang:tr` → `tr`), or null for "More languages…". */
export function languageChoice(id: string): string | null {
const code = id.startsWith('lang:') ? id.slice(5) : '';
return code && code !== 'more' ? code : null;
}
// ---------------------------------------------------------------------------------------------
// Zoom
/** Page zoom limits, as native code clamps them (waybetter-host `zoom.rs`). */
export const ZOOM_MIN = 0.25;
export const ZOOM_MAX = 5;
/** The steps − and + move through (a browser's). */
export const ZOOM_STEPS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5] as const;
/** The next step up (`1`) or down (`-1`) from `factor`, or 100% (`0`). Stays inside the limits. */
export function zoomStep(factor: number, direction: 1 | -1 | 0): number {
if (direction === 0 || !Number.isFinite(factor)) return 1;
const near = 0.005;
if (direction > 0) return ZOOM_STEPS.find(step => step > factor + near) ?? ZOOM_MAX;
return [...ZOOM_STEPS].reverse().find(step => step < factor - near) ?? ZOOM_MIN;
}
/** Whole percent of a factor (1.1 → 110). */
export const zoomPercent = (factor: number) => Math.round(factor * 100);

View file

@ -6,6 +6,7 @@
import { rankItems } from '../palette.ts'; import { rankItems } from '../palette.ts';
import { t } from './i18n.ts'; import { t } from './i18n.ts';
import type { IconName } from './icons.tsx'; import type { IconName } from './icons.tsx';
import { menuText } from './menuModel.ts';
import type { PaletteSite } from './site.ts'; import type { PaletteSite } from './site.ts';
export { fuzzyScore, moveSelection } from '../palette.ts'; export { fuzzyScore, moveSelection } from '../palette.ts';
@ -27,7 +28,9 @@ export type PaletteAction =
| { type: 'plugin-command'; tab: string; command: string } | { type: 'plugin-command'; tab: string; command: string }
/** Workspaces: restore one, or save the current layout under a name. */ /** Workspaces: restore one, or save the current layout under a name. */
| { type: 'workspace'; id: string } | { type: 'workspace'; id: string }
| { type: 'workspace-save' }; | { type: 'workspace-save' }
/** Opens the app menu (the titlebar's "Settings and more"). */
| { type: 'menu' };
/** The frame's groups; the app's items are grouped under its product name, plugin commands under the plugin's. */ /** The frame's groups; the app's items are grouped under its product name, plugin commands under the plugin's. */
export type PaletteGroup = 'Tabs' | 'Deck' | 'Workspaces' | 'Profiles'; export type PaletteGroup = 'Tabs' | 'Deck' | 'Workspaces' | 'Profiles';
@ -143,6 +146,8 @@ export function buildItems(context: PaletteContext, query: string, setup: Palett
items.push({ id: `panel:${section.id}`, group: setup.product, label: section.label(), keywords: section.keywords, icon: section.icon, action: { type: 'panel', section: section.id } }); items.push({ id: `panel:${section.id}`, group: setup.product, label: section.label(), keywords: section.keywords, icon: section.icon, action: { type: 'panel', section: section.id } });
} }
if (setup.media) items.push({ id: 'media', group: setup.product, label: t('wb.palette.media'), hint: 'Ctrl+Shift+G', keywords: 'photos images screenshots attach paste media', icon: 'image', action: { type: 'media' } }); if (setup.media) items.push({ id: 'media', group: setup.product, label: t('wb.palette.media'), hint: 'Ctrl+Shift+G', keywords: 'photos images screenshots attach paste media', icon: 'image', action: { type: 'media' } });
// The app menu (the titlebar's "Settings and more"): language, theme, mode, zoom and the rest.
items.push({ id: 'app-menu', group: setup.product, label: menuText('label'), hint: 'Alt+F', keywords: 'menu more settings language theme mode zoom options preferences', icon: 'settings', action: { type: 'menu' } });
// Plugin commands, grouped under each plugin's name; they run in the focused site tab. // Plugin commands, grouped under each plugin's name; they run in the focused site tab.
if (context.commands) { if (context.commands) {
const { tab } = context.commands; const { tab } = context.commands;

View file

@ -32,9 +32,11 @@
--bt-t1: 120ms; --bt-t2: 180ms; --bt-t3: 320ms; --bt-t1: 120ms; --bt-t2: 180ms; --bt-t3: 320ms;
--bt-titlebar-h: 44px; --bt-toolbar-h: 48px; --bt-resize-border: 5px; --bt-titlebar-h: 44px; --bt-toolbar-h: 48px; --bt-resize-border: 5px;
/* Room the titlebar-hosted tab strip leaves for the logo (left) and caption buttons + drag spacer + account switcher (right). */ /* Room the titlebar-hosted tab strip leaves for the logo (left) and caption buttons + drag spacer + account switcher (right). */
--bt-tb-left: 50px; --bt-tb-right: calc(138px + 48px + 184px + var(--bt-tb-extra, 0px)); /* --bt-tb-menu: the app menu button (30px and its 8px margin). */
--bt-tb-menu: 38px;
--bt-tb-left: 50px; --bt-tb-right: calc(138px + 48px + 184px + var(--bt-tb-menu) + var(--bt-tb-extra, 0px));
} }
html[data-os=macos] { --bt-tb-left: calc(78px + 50px); --bt-tb-right: calc(48px + 184px + var(--bt-tb-extra, 0px)); } html[data-os=macos] { --bt-tb-left: calc(78px + 50px); --bt-tb-right: calc(48px + 184px + var(--bt-tb-menu) + var(--bt-tb-extra, 0px)); }
html, body, #root { height: 100%; margin: 0; overflow: hidden; background: var(--bt-ground); color: var(--bt-text); font: 400 13px/1.3 var(--bt-ui); -webkit-font-smoothing: antialiased; } html, body, #root { height: 100%; margin: 0; overflow: hidden; background: var(--bt-ground); color: var(--bt-text); font: 400 13px/1.3 var(--bt-ui); -webkit-font-smoothing: antialiased; }
.bt-shell { .bt-shell {
/* Legacy names used by the tab strip and menus. */ /* Legacy names used by the tab strip and menus. */
@ -106,8 +108,8 @@ html[data-os=macos] .bt-titlebar { padding-left: 78px; } /* rtl-ok: traffic ligh
.bt-grip { position: fixed; z-index: 50; } .bt-grip { position: fixed; z-index: 50; }
/* Update chip: a quiet brush-edged pill left of the account switcher, only while an update is actionable. */ /* Update chip: a quiet brush-edged pill left of the account switcher, only while an update is actionable. */
html[data-update] { --bt-tb-right: calc(138px + 48px + 184px + 184px + var(--bt-tb-extra, 0px)); } html[data-update] { --bt-tb-right: calc(138px + 48px + 184px + 184px + var(--bt-tb-menu) + var(--bt-tb-extra, 0px)); }
html[data-os=macos][data-update] { --bt-tb-right: calc(48px + 184px + 184px + var(--bt-tb-extra, 0px)); } html[data-os=macos][data-update] { --bt-tb-right: calc(48px + 184px + 184px + var(--bt-tb-menu) + var(--bt-tb-extra, 0px)); }
.bt-update-chip { .bt-update-chip {
align-self: center; display: inline-flex; align-items: center; gap: 8px; height: 28px; margin-right: 8px; padding: 0 12px 0 10px; /* rtl-ok: titlebar */ align-self: center; display: inline-flex; align-items: center; gap: 8px; height: 28px; margin-right: 8px; padding: 0 12px 0 10px; /* rtl-ok: titlebar */
border: 1px solid transparent; border-radius: 14px; color: var(--bt-text); font: 500 12px/1 var(--bt-ui); white-space: nowrap; cursor: pointer; border: 1px solid transparent; border-radius: 14px; color: var(--bt-text); font: 500 12px/1 var(--bt-ui); white-space: nowrap; cursor: pointer;

View file

@ -9,6 +9,7 @@ import type { AddressModel } from '../addressModel.ts';
import type { ColumnSource, DeckColumn } from '../deckModel.ts'; import type { ColumnSource, DeckColumn } from '../deckModel.ts';
import type { AccountRules } from '../profileModel.ts'; import type { AccountRules } from '../profileModel.ts';
import type { IconName } from './icons.tsx'; import type { IconName } from './icons.tsx';
import type { ShellMenu } from './menuModel.ts';
import type { PaletteItem } from './paletteModel.ts'; import type { PaletteItem } from './paletteModel.ts';
import type { ThemeStore } from './themeStore.ts'; import type { ThemeStore } from './themeStore.ts';
@ -145,10 +146,18 @@ export type ShellSite = {
themes?: ThemeStore<any, any>; themes?: ThemeStore<any, any>;
/** /**
* Native features the app serves (waybetter-host modules it wires up): workspaces (`workspaces_*`), * Native features the app serves (waybetter-host modules it wires up): workspaces (`workspaces_*`),
* encrypted folder sync (`sync_*`), developer mode (`dev_*`) and plugin palette commands * encrypted folder sync (`sync_*`), developer mode (`dev_*`), plugin palette commands
* (`tab_run_command`). Each is on unless set to false; off, the frame shows nothing of it. * (`tab_run_command`) and page zoom (`tab_zoom`, the host's own); and what its panel has that the
* app menu links to: the Store (`PanelSite.store`) and the updater (`PanelSite.updates`). Each is
* on unless set to false; off, the frame shows nothing of it.
*/ */
features?: { workspaces?: boolean; sync?: boolean; developer?: boolean; pluginCommands?: boolean }; features?: { workspaces?: boolean; sync?: boolean; developer?: boolean; pluginCommands?: boolean; zoom?: boolean; store?: boolean; updates?: boolean };
/**
* The app menu (the titlebar's "Settings and more", Alt+F / F10): its rows in order (built-in
* ones by name, the app's own as objects) and what the Language and Mode submenus need. Omit for
* the default menu: every built-in row this app can serve (menuModel.ts `DEFAULT_MENU`).
*/
menu?: ShellMenu;
/** Components rendered on top of the shell (they listen to their own events). */ /** Components rendered on top of the shell (they listen to their own events). */
overlays?: ReadonlyArray<ComponentType>; overlays?: ReadonlyArray<ComponentType>;
/** Called once the dock exists (read site settings, listen to site events). Returns a cleanup. */ /** Called once the dock exists (read site settings, listen to site events). Returns a cleanup. */

View file

@ -8,6 +8,7 @@ import { defaultProfiles, normalizeProfiles, tabProfile, type ProfileSettings }
import type { PaneInfo } from './accountModel.ts'; import type { PaneInfo } from './accountModel.ts';
import { currentWindow, invoke } from './bridge.ts'; import { currentWindow, invoke } from './bridge.ts';
import { columnSourceUrl, composeTabId, deckById, decks } from './deckStore.ts'; import { columnSourceUrl, composeTabId, deckById, decks } from './deckStore.ts';
import type { PaletteAction } from './paletteModel.ts';
import { site } from './site.ts'; import { site } from './site.ts';
import { latestOnly } from './syncGate.ts'; import { latestOnly } from './syncGate.ts';
@ -52,6 +53,10 @@ export const shell = {
openUrl: (_url: string, _profile: string) => {}, openUrl: (_url: string, _profile: string) => {},
/** Opens the app's panel at a section (e.g. the deck's settings button). */ /** Opens the app's panel at a section (e.g. the deck's settings button). */
openPanelSection: (_section: string) => {}, openPanelSection: (_section: string) => {},
/** Runs a shell shortcut by name as if its keys were pressed (`new-tab`, `split-right`, `command-palette`, ...). */
shortcut: (_name: string) => {},
/** Runs a palette action (navigation only: open a deck, add a column, restore a workspace, ...). */
run: (_action: PaletteAction) => {},
}; };
/** The app's panel tab id (`site.panel.id`, kept in saved layouts). */ /** The app's panel tab id (`site.panel.id`, kept in saved layouts). */

View file

@ -0,0 +1,54 @@
// Page zoom of the tab webviews, as the shell knows it. Native code owns it (`tab_zoom`, shell-only:
// the engine's own page zoom, clamped and remembered per tab; waybetter-host `zoom.rs`); the shell
// asks for a factor and keeps what native code answered, for the app menu's zoom row and the
// titlebar chip. Nothing here touches a page's CSS.
import { invoke } from './bridge.ts';
import { deckById, decks } from './deckStore.ts';
import { zoomStep } from './menuModel.ts';
import { hasFeature } from './site.ts';
import { isWeb, shell, type TabParams } from './state.ts';
const factors = new Map<string, number>();
/** The factor tab `id` shows, as last answered by native code (100% until asked). */
export const zoomOf = (id: string | null) => (id ? factors.get(id) ?? 1 : 1);
function note(id: string, factor: number) {
if (!Number.isFinite(factor) || zoomOf(id) === factor) return;
if (factor === 1) factors.delete(id); else factors.set(id, factor);
window.dispatchEvent(new CustomEvent('bt-zoom', { detail: id }));
}
/** The active pane's page: the tab in front, or the focused column of the deck in front. None for the app's own tabs. */
export function zoomTarget(): string | null {
const panel = shell.api?.activePanel;
if (!panel) return null;
if (isWeb(panel)) return panel.id;
const params = panel.params as TabParams | undefined;
if (params?.kind !== 'deck') return null;
const deck = deckById(params.deckId);
const focused = decks.focus.get(params.deckId);
return deck?.columns.find(column => column.id === focused)?.tabId ?? null;
}
/** Reads a tab's remembered zoom (a restored tab may come back zoomed). */
export async function readZoom(id: string | null): Promise<void> {
if (!id || !hasFeature('zoom')) return;
try { note(id, await invoke<number>('tab_zoom', { id })); } catch { /* No such webview yet: 100%. */ }
}
/** Sets a tab's zoom; native code answers the factor in effect. */
export async function setZoom(id: string, factor: number): Promise<number> {
const applied = await invoke<number>('tab_zoom', { id, factor });
note(id, applied);
return applied;
}
/** One step in (`1`), out (`-1`) or back to 100% (`0`) for tab `id` (default: the active pane's page). */
export async function stepZoom(direction: 1 | -1 | 0, id: string | null = zoomTarget()): Promise<void> {
if (!id || !hasFeature('zoom')) return;
const next = zoomStep(zoomOf(id), direction);
if (next === zoomOf(id)) return;
try { await setZoom(id, next); } catch (error) { console.error('[shell] tab_zoom', error); }
}

View file

@ -5,10 +5,11 @@
// the owner's own changes stay. A mode is a starting point: choosing one writes flag values // the owner's own changes stay. A mode is a starting point: choosing one writes flag values
// (`features_set`) and remembers the choice (`mode_get/set`); it never takes a switch away. // (`features_set`) and remembers the choice (`mode_get/set`); it never takes a switch away.
import { useEffect, useState, type ComponentType } from 'react'; import { useEffect, useState, type ComponentType } from 'react';
import { modeById, sparseFlags as sparse, type FeatureTable, type Flags } from '../../../runtime/src/featureTable.ts'; import { modeById, normalizeFlags, sparseFlags as sparse, type FeatureTable, type Flags } from '../../../runtime/src/featureTable.ts';
import { invoke } from '../frame/bridge.ts'; import { invoke } from '../frame/bridge.ts';
import { useT } from '../frame/hooks.ts'; import { useT } from '../frame/hooks.ts';
import { tx } from '../frame/i18n.ts'; import { tx } from '../frame/i18n.ts';
import type { MenuModes } from '../frame/menuModel.ts';
import { shell } from '../frame/state.ts'; import { shell } from '../frame/state.ts';
import { Badge, Button, Row, Section } from './controls.tsx'; import { Badge, Button, Row, Section } from './controls.tsx';
import { modePlan, type PluginFileState } from './featureModel.ts'; import { modePlan, type PluginFileState } from './featureModel.ts';
@ -169,3 +170,28 @@ export function openOnboardingWhenDue(): void {
}, 250); }, 250);
}).catch(() => {}); }).catch(() => {});
} }
/**
* The app menu's Mode submenu (the frame's `ShellSite.menu.modes`): the same modes, the same
* question (keep my changes or take the mode as it is) and the same steps as Settings.
*/
export function menuModes(): MenuModes {
const state = async () => (cached ??= await invoke<ModeState>('mode_get'));
const flagsNow = async (table: FeatureTable) => normalizeFlags(table, await invoke<unknown>('features_get'));
const plugins = () => invoke<PluginFileState[]>('plugins_list').catch(() => [] as PluginFileState[]);
return {
list: () => (featureTable()?.modes ?? []).map(mode => ({ id: mode.id, label: tx(mode.name, undefined, mode.id) })),
current: async () => (featureTable()?.modes.length ? (await state()).mode : null),
async changes(to) {
const table = featureTable();
return table ? modePlan(table, await flagsNow(table), await plugins(), (await state()).mode, to, false).changes : 0;
},
async apply(to, keep) {
const table = featureTable();
if (!table) return;
const save = async (next: Flags) => { await invoke('features_set', { features: sparse(table, next) }); return true; };
const next = await applyMode(table, await flagsNow(table), (await state()).mode, to, keep, save);
if (next) { cached = next; for (const listener of listeners) listener(next); }
},
};
}

View file

@ -40,6 +40,6 @@ export {
export { FeatureGroups, useFeatures } from './FeatureToggles.tsx'; export { FeatureGroups, useFeatures } from './FeatureToggles.tsx';
// Area plugins and modes (PanelSite.features): the cards, the Fixes list, the first-run screen. // Area plugins and modes (PanelSite.features): the cards, the Fixes list, the first-run screen.
export { AreaPlugins, featureTable, flagsChanged, useFlags } from './PluginFeatures.tsx'; export { AreaPlugins, featureTable, flagsChanged, useFlags } from './PluginFeatures.tsx';
export { ModeSettings, Onboarding, openOnboardingWhenDue, useModeState, type ModeState } from './Modes.tsx'; export { ModeSettings, Onboarding, menuModes, openOnboardingWhenDue, useModeState, type ModeState } from './Modes.tsx';
export { areaCards, featureFiles, fixRows, modePlan, type AreaCard, type FeatureRow, type ModePlan, type PluginFileState } from './featureModel.ts'; export { areaCards, featureFiles, fixRows, modePlan, type AreaCard, type FeatureRow, type ModePlan, type PluginFileState } from './featureModel.ts';
export { compareLines } from './compare.ts'; export { compareLines } from './compare.ts';

View file

@ -0,0 +1,227 @@
// The app menu's model (frame/menuModel.ts): which rows an app gets (its groups, the built-in rows
// and what gates them), how the keyboard moves through them, the language list, the zoom steps,
// how shortcuts are written and where the menu's words come from.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { setShellI18n, type ShellI18n } from '../src/frame/i18n.ts';
import {
DEFAULT_MENU, MENU_BUILTINS, MENU_MESSAGES, ZOOM_MAX, ZOOM_MIN, ZOOM_STEPS, builtinAvailable, findSpec, joinGroups, languageChoice, languageRows, menuText, menuWith,
menuWithout, moveStop, planMenu, shortcutText, specRows, submenuKeys, typeahead, zoomPercent, zoomStep,
type MenuEnv, type MenuGroups, type MenuItemRow, type MenuItemSpec, type MenuLanguage, type MenuRow,
} from '../src/frame/menuModel.ts';
import { buildItems } from '../src/frame/paletteModel.ts';
const here = dirname(fileURLToPath(import.meta.url));
const ALL: MenuEnv = { decks: true, languages: true, themes: true, modes: true, zoom: true, store: true, workspaces: true, developer: true, updates: true };
const NONE: MenuEnv = { decks: false, languages: false, themes: false, modes: false, zoom: false, store: false, workspaces: false, developer: false, updates: false };
const ids = (groups: ReturnType<typeof planMenu>) => groups.map(group => group.map(entry => (typeof entry === 'string' ? entry : entry.id)));
const items = (rows: MenuRow[]) => rows.filter((row): row is MenuItemRow => row.kind === 'item');
test('the default menu lists every built-in row once, and an app that serves everything gets them all', () => {
assert.deepEqual([...DEFAULT_MENU.flat()].sort(), [...MENU_BUILTINS].sort());
assert.deepEqual(ids(planMenu(DEFAULT_MENU, ALL)), DEFAULT_MENU.map(group => [...group]));
});
test('a row the app cannot serve is left out, and a group left empty takes its rule with it', () => {
// A new app (create-waybetter): no decks, languages, themes, zoom, Store, workspaces, developer mode or updater.
assert.deepEqual(ids(planMenu(DEFAULT_MENU, NONE)), [
['new-tab', 'split-right', 'split-down'],
['plugins'],
['shortcuts', 'palette'],
['about', 'settings'],
]);
// Each gate is its own.
const gates: Array<[keyof MenuEnv, string]> = [
['decks', 'new-column'], ['languages', 'language'], ['themes', 'theme'], ['modes', 'mode'], ['zoom', 'zoom'],
['store', 'store'], ['workspaces', 'workspaces'], ['developer', 'developer'], ['updates', 'updates'],
];
for (const [feature, row] of gates) {
const only = ids(planMenu(DEFAULT_MENU, { ...NONE, [feature]: true })).flat();
assert.ok(only.includes(row), `${feature} shows ${row}`);
assert.equal(ids(planMenu(DEFAULT_MENU, { ...ALL, [feature]: false })).flat().includes(row), false, `without ${feature} there is no ${row}`);
assert.equal(builtinAvailable(row as typeof MENU_BUILTINS[number], NONE), false);
}
for (const row of ['new-tab', 'split-right', 'split-down', 'plugins', 'shortcuts', 'palette', 'about', 'settings'] as const) assert.ok(builtinAvailable(row, NONE), row);
// The rows that remain are separated once, never twice.
const rows = joinGroups(planMenu(DEFAULT_MENU, NONE).map(group => group.map((entry): MenuRow => ({ kind: 'item', id: String(entry), label: String(entry) }))));
assert.equal(rows.filter(row => row.kind === 'separator').length, 3);
assert.ok(rows.every((row, index) => row.kind !== 'separator' || (index > 0 && index < rows.length - 1 && rows[index - 1]!.kind !== 'separator')));
});
test('an app orders, removes and adds rows; its own rows follow their `when`', () => {
let drafts = true;
const own: MenuItemSpec = { id: 'drafts', label: () => 'Drafts…', icon: 'draft', when: () => drafts, run() {} };
const groups = menuWith(DEFAULT_MENU, 'workspaces', [own]);
assert.deepEqual(ids(planMenu(groups, ALL))[3], ['plugins', 'store', 'workspaces', 'drafts']);
assert.deepEqual(ids(planMenu(menuWith(DEFAULT_MENU, 'plugins', [own], 'before'), ALL))[3], ['drafts', 'plugins', 'store', 'workspaces']);
// Without its anchor the app's rows form a last group.
assert.deepEqual(ids(planMenu(menuWith(DEFAULT_MENU, 'nothing', [own]), ALL)).at(-1), ['drafts']);
drafts = false;
assert.equal(ids(planMenu(groups, ALL)).flat().includes('drafts'), false);
drafts = true;
assert.deepEqual(ids(planMenu(menuWithout(groups, 'store', 'zoom', 'drafts'), ALL)), [
['new-tab', 'split-right', 'split-down', 'new-column'], ['language', 'theme', 'mode'], ['plugins', 'workspaces'], ['shortcuts', 'palette'], ['developer', 'updates', 'about', 'settings'],
]);
// Explicit order; a row listed twice shows once; an unknown name and an own row named like a built-in are ignored.
const custom: MenuGroups = [['settings', 'new-tab', 'settings', 'nope' as never], [{ id: 'zoom', label: () => 'Fake zoom' }, own]];
assert.deepEqual(ids(planMenu(custom, ALL)), [['settings', 'new-tab'], ['drafts']]);
});
test("the app's rows become menu rows: toggles, hints, submenus and groups inside a submenu", () => {
let on = false;
const specs: MenuItemSpec[] = [
{ id: 'a', label: () => 'Alpha', shortcut: 'Ctrl+Shift+A', run() {} },
{ id: 'b', label: () => 'Beta', checked: () => on, keepOpen: true, run() { on = !on; } },
{ id: 'c', label: () => 'Gamma', hint: () => '3', separated: true, submenu: () => [{ id: 'c1', label: () => 'Inner', submenu: () => [{ id: 'c2', label: () => 'Deep' }] }] },
{ id: 'd', label: () => 'Hidden', when: () => false },
];
const rows = specRows(specs);
assert.deepEqual(rows.map(row => (row.kind === 'item' ? row.id : row.kind)), ['a', 'b', 'separator', 'c']);
const [a, b, c] = items(rows);
assert.equal(a!.shortcut, 'Ctrl+Shift+A');
assert.deepEqual([b!.check, b!.checked, b!.keepOpen], ['checkbox', false, true]);
assert.deepEqual([c!.submenu, c!.hint, a!.submenu, a!.check], [true, '3', false, undefined]);
(b!.run as () => void)();
assert.equal(items(specRows(specs))[1]!.checked, true, 'a toggle shows its state when the menu is drawn again');
// Submenus are found by id at any depth.
assert.equal(findSpec([['new-tab', ...specs]], 'c2')?.label(), 'Deep');
assert.equal(findSpec([['new-tab', ...specs]], 'c')?.submenu?.().length, 1);
assert.equal(findSpec([['new-tab']], 'c'), null);
});
test('arrows wrap, Home and End jump, and with nothing focused an arrow enters at the near end', () => {
assert.equal(moveStop(0, 'ArrowDown', 4), 1);
assert.equal(moveStop(3, 'ArrowDown', 4), 0);
assert.equal(moveStop(0, 'ArrowUp', 4), 3);
assert.equal(moveStop(2, 'Home', 4), 0);
assert.equal(moveStop(1, 'End', 4), 3);
assert.equal(moveStop(-1, 'ArrowDown', 4), 0);
assert.equal(moveStop(-1, 'ArrowUp', 4), 3);
assert.equal(moveStop(2, 'a', 4), 2);
assert.equal(moveStop(0, 'ArrowDown', 0), -1);
// Submenus open along the reading direction and close against it.
assert.deepEqual(submenuKeys(false), { open: 'ArrowRight', close: 'ArrowLeft' });
assert.deepEqual(submenuKeys(true), { open: 'ArrowLeft', close: 'ArrowRight' });
});
test('type-ahead finds the next row that starts with what was typed', () => {
const labels = ['New tab', 'Split right', 'Split down', 'Language', 'Theme', 'Settings…', 'Écran', 'İstanbul'];
assert.equal(typeahead(labels, -1, 's'), 1);
assert.equal(typeahead(labels, 1, 's'), 2, 'the same letter again moves on');
assert.equal(typeahead(labels, 2, 'ss'), 5, 'a repeated letter cycles');
assert.equal(typeahead(labels, 5, 's'), 1, 'and wraps');
assert.equal(typeahead(labels, 1, 'se'), 5, 'a typed word narrows');
assert.equal(typeahead(labels, 5, 'set'), 5, 'and stays on the row that still matches');
assert.equal(typeahead(labels, 0, 'T'), 4, 'case is ignored');
assert.equal(typeahead(labels, 0, 'e'), 6, 'accents are ignored');
assert.equal(typeahead(labels, 0, 'i'), 7);
assert.equal(typeahead(labels, 0, 'x'), -1);
assert.equal(typeahead(labels, 0, ''), -1);
assert.equal(typeahead([], 0, 'a'), -1);
});
test('shortcuts are written the way the platform writes them', () => {
assert.equal(shortcutText('Ctrl+T', false), 'Ctrl+T');
assert.equal(shortcutText('Ctrl+Shift+\\', false), 'Ctrl+Shift+\\');
assert.equal(shortcutText('Ctrl+T', true), '⌘T');
assert.equal(shortcutText('Ctrl+Shift+\\', true), '⇧⌘\\');
assert.equal(shortcutText('Ctrl+Alt+N', true), '⌥⌘N');
assert.equal(shortcutText('F10', true), 'F10');
});
const LANGUAGES: MenuLanguage[] = [
{ code: 'en', name: 'English', englishName: 'English', dir: 'ltr', tier: 'supported' },
{ code: 'tr', name: 'Türkçe', englishName: 'Turkish', dir: 'ltr', tier: 'supported' },
{ code: 'ar', name: 'العربية', englishName: 'Arabic', dir: 'rtl', tier: 'supported' },
{ code: 'sv', name: 'Svenska', englishName: 'Swedish', dir: 'ltr', tier: 'community' },
];
const WORDS = { site: 'Follow X', system: 'Follow system', more: 'More languages…' };
test('the Language submenu: follow the site or the system, the supported languages by their own names, then the full list', () => {
const rows = languageRows(LANGUAGES, { preference: 'site', siteLang: 'tr', locale: 'tr' }, WORDS);
assert.deepEqual(rows.map(row => (row.kind === 'item' ? row.id : row.kind)), ['lang:site', 'lang:system', 'separator', 'lang:en', 'lang:tr', 'lang:ar', 'separator', 'lang:more']);
const [site, system, en, tr, ar, more] = items(rows);
assert.deepEqual([site!.label, site!.checked, site!.hint], ['Follow X', true, 'Türkçe'], 'following the site says which language that is');
assert.deepEqual([system!.checked, system!.hint], [false, undefined]);
assert.deepEqual([tr!.label, tr!.hint, tr!.checked, tr!.check], ['Türkçe', 'Turkish', false, 'radio'], 'a language reads in its own name, English beside it');
assert.equal(en!.hint, undefined, 'no English name beside English');
assert.deepEqual([ar!.lang, ar!.dir], ['ar', 'rtl']);
assert.deepEqual([more!.label, more!.check], ['More languages…', undefined]);
assert.equal(items(rows).filter(row => row.checked).length, 1, 'exactly one is checked');
});
test('the Language submenu checks what was picked, a community language included', () => {
const picked = items(languageRows(LANGUAGES, { preference: 'ar', siteLang: 'tr', locale: 'ar' }, WORDS));
assert.deepEqual(picked.filter(row => row.checked).map(row => row.id), ['lang:ar']);
assert.equal(picked[0]!.hint, 'Türkçe', 'the site row still says what following it would give');
const system = items(languageRows(LANGUAGES, { preference: 'system', siteLang: null, locale: 'en' }, WORDS));
assert.deepEqual([system[0]!.hint, system[1]!.hint, system[1]!.checked], [undefined, 'English', true]);
// Community languages are in Settings; one that is in use is listed, so the check is never missing.
assert.equal(picked.some(row => row.id === 'lang:sv'), false);
const community = items(languageRows(LANGUAGES, { preference: 'sv', siteLang: null, locale: 'sv' }, WORDS));
assert.deepEqual(community.filter(row => row.checked).map(row => row.id), ['lang:sv']);
// A row stands for the preference the language state takes.
assert.deepEqual(['lang:site', 'lang:system', 'lang:zh-Hant', 'lang:more', 'theme:x'].map(languageChoice), ['site', 'system', 'zh-Hant', null, null]);
});
test('zoom moves through a browser\'s steps and stays inside the limits native code enforces', () => {
assert.equal(zoomStep(1, 1), 1.1);
assert.equal(zoomStep(1, -1), 0.9);
assert.equal(zoomStep(1.1, 1), 1.25);
assert.equal(zoomStep(1.2, 1), 1.25, 'from between two steps to the next one');
assert.equal(zoomStep(1.2, -1), 1.1);
assert.equal(zoomStep(2.5, 0), 1);
assert.equal(zoomStep(ZOOM_MAX, 1), ZOOM_MAX);
assert.equal(zoomStep(ZOOM_MIN, -1), ZOOM_MIN);
assert.equal(zoomStep(Number.NaN, 1), 1);
assert.deepEqual([ZOOM_STEPS[0], ZOOM_STEPS.at(-1)], [ZOOM_MIN, ZOOM_MAX]);
assert.ok(ZOOM_STEPS.every((step, index) => index === 0 || step > ZOOM_STEPS[index - 1]!));
// Up through every step and down again: no step is skipped or repeated.
let factor: number = ZOOM_MIN;
const up: number[] = [];
while (factor < ZOOM_MAX) { factor = zoomStep(factor, 1); up.push(factor); }
assert.deepEqual(up, ZOOM_STEPS.slice(1));
assert.deepEqual([zoomPercent(1.1), zoomPercent(0.67), zoomPercent(0.33)], [110, 67, 33]);
// The limits are the host's (zoom.rs).
const rust = readFileSync(join(here, '../../../crates/waybetter-host/src/zoom.rs'), 'utf8');
assert.match(rust, new RegExp(`pub const MIN: f64 = ${ZOOM_MIN};`));
assert.match(rust, new RegExp(`pub const MAX: f64 = ${ZOOM_MAX.toFixed(1)};`));
});
test("the menu's words: English from menuMessages.en.json until a catalog has them", () => {
// Every key is a wb.menu.* key with plain text (no markup), as the catalogs require.
for (const [key, value] of Object.entries(MENU_MESSAGES)) {
assert.match(key, /^wb\.menu\.[A-Za-z]+$/, key);
assert.ok(value.trim() && !/[<>&]|javascript:/i.test(value), key);
}
// Unbound (no i18n instance) and bound to a catalog without the key: the English file speaks.
assert.equal(menuText('label'), 'Settings and more');
assert.equal(menuText('about', { product: 'wayBetter for X' }), 'About wayBetter for X');
assert.equal(menuText('section', { section: 'Plugins' }), 'Plugins…');
const catalog: Record<string, string> = { 'wb.menu.label': 'Ayarlar ve daha fazlası', 'wb.menu.about': '{product} hakkında' };
const bound: ShellI18n = {
locale: 'tr',
tx: (key, params, fallback) => (key in catalog ? catalog[key]!.replace('{product}', String(params?.product ?? '')) : fallback ?? key),
onChange: () => () => {}, number: String, relative: String, date: String,
};
setShellI18n(bound);
assert.equal(menuText('label'), 'Ayarlar ve daha fazlası', 'a catalog that has the key wins');
assert.equal(menuText('about', { product: 'wayBetter for X' }), 'wayBetter for X hakkında');
assert.equal(menuText('zoom'), 'Zoom', 'a key the catalog lacks still reads in English');
// The component and the palette name their words through menuText, never as raw keys.
const used = new Set<string>();
for (const file of ['AppMenu.tsx', 'TitlebarExtras.tsx', 'AccountSwitcher.tsx', 'paletteModel.ts']) {
for (const match of readFileSync(join(here, '../src/frame', file), 'utf8').matchAll(/menuText\('([A-Za-z]+)'/g)) used.add(`wb.menu.${match[1]}`);
}
for (const key of used) assert.ok(key in MENU_MESSAGES, `${key} has English`);
for (const key of Object.keys(MENU_MESSAGES)) assert.ok(used.has(key), `${key} is used`);
});
test('the palette opens the menu', () => {
const all = buildItems({ tabs: [], profiles: [], activeProfile: 'default' }, '', { site: { placeholder: () => '', sections: [] }, product: 'App', decks: false, media: false });
const entry = all.find(item => item.id === 'app-menu');
assert.deepEqual([entry?.action, entry?.group], [{ type: 'menu' }, 'App']);
});