diff --git a/.github/oversized.txt b/.github/oversized.txt index 787b1dc..b3bda62 100644 --- a/.github/oversized.txt +++ b/.github/oversized.txt @@ -1,2 +1,2 @@ -2030 app/src-tauri/src/lib.rs -2032 crates/linkunbound-shell/src/main.rs +2026 app/src-tauri/src/lib.rs +1969 crates/linkunbound-shell/src/main.rs diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index fb5adfd..d1c8544 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -680,11 +680,6 @@ fn prefs_set(app: AppHandle, prefs: Preferences) -> Result { Ok(claim(&settled)) } -#[tauri::command] -fn system_open_default_apps() -> Result<(), String> { - system::open_default_apps() -} - /// Re-registers against this executable, which is the only repair there is: the /// command pointed somewhere this binary no longer lives. #[tauri::command(async)] @@ -1058,7 +1053,7 @@ fn relay_links(app: AppHandle) { hand_to_the_resident(url); } } - Event::Reopened => shell::open_settings(&app, store().prefs().theme), + Event::Reopened => shell::open_settings(&app, store().prefs().theme, None), Event::Launched { .. } => {} }); std::mem::forget(listening); @@ -1240,13 +1235,14 @@ pub fn run() { about::maintenance_report, prefs_get, prefs_set, + shell::picker_preview, system_state, system_legacy, system_retire_legacy, system_reveal_legacy, system_set_registered, system_set_startup, - system_open_default_apps, + system::system_open_default_apps, system_repair, system_register_anyway, system_open_elsewhere, @@ -1267,8 +1263,8 @@ pub fn run() { let builder = if errand.is_some() { builder } else { - builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - shell::open_settings(app, store().prefs().theme); + builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + shell::open_settings(app, store().prefs().theme, shell::page_in(&args)); })) }; @@ -1298,7 +1294,7 @@ pub fn run() { news::raise(app.handle()); #[cfg(target_os = "macos")] relay_links(app.handle().clone()); - shell::open_settings(app.handle(), store().prefs().theme); + shell::open_settings(app.handle(), store().prefs().theme, shell::page_in(&args)); Ok(()) }) .build(tauri::generate_context!()) diff --git a/app/src-tauri/src/shell.rs b/app/src-tauri/src/shell.rs index b08e335..82097a5 100644 --- a/app/src-tauri/src/shell.rs +++ b/app/src-tauri/src/shell.rs @@ -78,8 +78,19 @@ impl Drop for Building { /// Built on demand: an idle webview costs tens of megabytes and this window is /// opened rarely. The resident owns the tray, the picker and the notice. -pub fn open_settings(app: &AppHandle, theme: Theme) { +/// The page the command line asks settings to open on, when it asks for one. +#[must_use] +pub fn page_in(args: &[String]) -> Option<&'static str> { + args.iter() + .any(|a| a == linkunbound_core::BROWSERS) + .then_some("browsers") +} + +pub fn open_settings(app: &AppHandle, theme: Theme, page: Option<&str>) { if let Some(window) = app.get_webview_window(SETTINGS) { + if let Some(page) = page { + let _ = window.eval(format!("location.hash = '{page}'")); + } let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); @@ -89,13 +100,16 @@ pub fn open_settings(app: &AppHandle, theme: Theme) { return; } let _building = Building; - build_settings(app, theme); + build_settings(app, theme, page); } const LONG_ENOUGH_TO_PAINT: std::time::Duration = std::time::Duration::from_millis(1500); -fn build_settings(app: &AppHandle, theme: Theme) { - let mut builder = tauri::WebviewWindowBuilder::new(app, SETTINGS, tauri::WebviewUrl::default()) +fn build_settings(app: &AppHandle, theme: Theme, page: Option<&str>) { + let url = page.map_or_else(tauri::WebviewUrl::default, |page| { + tauri::WebviewUrl::App(format!("index.html#{page}").into()) + }); + let mut builder = tauri::WebviewWindowBuilder::new(app, SETTINGS, url) .title("LinkUnbound") .inner_size(780.0, 560.0) .min_inner_size(640.0, 480.0) @@ -127,7 +141,17 @@ pub fn reveal(window: &tauri::WebviewWindow) { #[cfg(test)] mod tests { - use super::{SETTINGS, dressed_as}; + use super::{SETTINGS, dressed_as, page_in}; + + #[test] + fn the_picker_can_send_settings_to_the_browsers() { + let said = |args: &[&str]| args.iter().map(|&a| a.to_owned()).collect::>(); + assert_eq!( + page_in(&said(&["settings.exe", "--browsers"])), + Some("browsers") + ); + assert_eq!(page_in(&said(&["settings.exe"])), None); + } use linkunbound_core::Theme; #[test] @@ -161,3 +185,19 @@ mod tests { assert!(!config.contains("\"label\":")); } } + +/// The picker over a sample link, so a look can be seen before it is kept. A resident already +/// running takes the word over its socket; with none, one is started with it. +#[tauri::command] +pub fn picker_preview() { + if linkunbound_shell::single::hand_over(linkunbound_core::PREVIEW) { + return; + } + let Ok(here) = std::env::current_exe() else { + return; + }; + let _ = linkunbound_core::spawn_and_forget( + std::process::Command::new(linkunbound_core::link_handler(&here)) + .arg(linkunbound_core::PREVIEW), + ); +} diff --git a/app/src-tauri/src/system.rs b/app/src-tauri/src/system.rs index 72e8bb0..be8ebca 100644 --- a/app/src-tauri/src/system.rs +++ b/app/src-tauri/src/system.rs @@ -615,3 +615,8 @@ pub use platform::{ browsers, legacy, open_default_apps, reconcile, register_anyway, register_for_the_installer, retire_legacy, reveal_legacy, set_registered, set_starts_with_system, state, }; + +#[tauri::command] +pub fn system_open_default_apps() -> Result<(), String> { + open_default_apps() +} diff --git a/app/src/Settings.test.tsx b/app/src/Settings.test.tsx index 40d0bc8..f7e4021 100644 --- a/app/src/Settings.test.tsx +++ b/app/src/Settings.test.tsx @@ -62,10 +62,32 @@ async function go(section: string) { await userEvent.click(await screen.findByRole("button", { name: section })); } +/// Settings opens on the application page; most of these are about the links one. +async function opened() { + render(); + await userEvent.click(await screen.findByRole("button", { name: /^(Enlaces|Links)$/ })); +} + describe("settings", () => { + /// The picker sends people here to add a browser, by naming the page after a hash. + it("opens on the browsers when the picker asks for them", async () => { + answers(BASE); + const otherwise = invoke.getMockImplementation(); + invoke.mockImplementation((cmd: string, ...rest: unknown[]) => + cmd === "browsers_list" ? Promise.resolve([]) : otherwise?.(cmd, ...rest), + ); + window.location.hash = "browsers"; + try { + render(); + expect(await screen.findByRole("heading", { name: "Navegadores" })).toBeInTheDocument(); + } finally { + window.location.hash = ""; + } + }); + it("asks for the window once it has painted", async () => { answers(BASE); - render(); + await opened(); await screen.findByRole("heading", { name: "Enlaces" }); await waitFor(() => expect(invoke).toHaveBeenCalledWith("settings_painted")); }); @@ -75,7 +97,7 @@ describe("settings", () => { /// The corner is gone and About carries the number, so the bar must stay quiet with nothing new. it("says nothing in the bar when there is no newer version", async () => { answers(BASE); - render(); + await opened(); await go("Acerca de"); expect(await screen.findByText("2.0.0")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /hay una versión nueva/ })).toBeNull(); @@ -86,7 +108,7 @@ describe("settings", () => { update_ready: () => Promise.resolve({ version: "2.1.0", route: "download", package: null, installs: true }), }); - render(); + await opened(); const go = await screen.findByRole("button", { name: "Acerca de · hay una versión nueva", @@ -105,7 +127,7 @@ describe("settings", () => { it("speaks whatever language the backend resolved", async () => { answers(BASE, { prefs_get: () => Promise.resolve({ ...PREFS, language: "en" }) }); - render(); + await opened(); expect(await screen.findByRole("button", { name: "Links" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Maintenance" })).toBeInTheDocument(); }); @@ -120,7 +142,7 @@ describe("settings", () => { language: "en", }), }); - render(); + await opened(); await go("Aplicación"); await userEvent.click(await screen.findByRole("radio", { name: "Inglés" })); expect(await screen.findByRole("button", { name: "Application" })).toBeInTheDocument(); @@ -130,7 +152,7 @@ describe("settings", () => { /// the language actually being shown. it("tells the document which language it is speaking", async () => { answers(BASE, { prefs_get: () => Promise.resolve({ ...PREFS, language: "en" }) }); - render(); + await opened(); await screen.findByRole("button", { name: "Links" }); await waitFor(() => expect(document.documentElement.lang).toBe("en")); }); @@ -141,7 +163,7 @@ describe("settings", () => { health: "wrong_binary", registered_path: String.raw`"C:\Program Files\LinkUnbound\linkunbound-settings.exe" "%1"`, }); - render(); + await opened(); expect(await screen.findByText(/no sabe abrir un enlace/)).toBeInTheDocument(); expect(screen.getByText(/Windows ejecutaría/).textContent).toContain( "linkunbound-settings.exe", @@ -154,14 +176,14 @@ describe("settings", () => { health: "build_tree", registered_path: String.raw`"D:\Code\LinkUnbound\target\debug\linkunbound-shell.exe" "%1"`, }); - render(); + await opened(); expect((await screen.findByText(/Windows ejecutaría/)).textContent).toContain( String.raw`target\debug`, ); }); it("says the links arrive here when they really do", async () => { - render(); + await opened(); expect(await screen.findByText(/recibe los enlaces/)).toBeInTheDocument(); }); @@ -170,7 +192,7 @@ describe("settings", () => { /// after the person had just made them arrive. it("asks again when it comes back from the Windows panel", async () => { answers({ ...BASE, is_default: false, associations: [] }); - render(); + await opened(); expect(await screen.findByText(/todavía no envía/)).toBeInTheDocument(); answers(BASE); @@ -181,13 +203,13 @@ describe("settings", () => { it("opens the Windows panel when another browser holds the links", async () => { answers({ ...BASE, is_default: false }); - render(); + await opened(); await userEvent.click(await screen.findByRole("button", { name: "Abrir Configuración" })); expect(invoke).toHaveBeenCalledWith("system_open_default_apps"); }); it("offers no Windows button once the links already arrive here", async () => { - render(); + await opened(); await screen.findByText(/recibe los enlaces/); expect(screen.queryByRole("button", { name: "Abrir Configuración" })).toBeNull(); }); @@ -196,7 +218,7 @@ describe("settings", () => { /// path this executable no longer occupies. it("warns and offers to repair a registration pointing somewhere else", async () => { answers({ ...BASE, health: "stale" }); - render(); + await opened(); expect(await screen.findByText(/no está recibiendo los enlaces/)).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Reparar" })); expect(invoke).toHaveBeenCalledWith("system_repair"); @@ -206,7 +228,7 @@ describe("settings", () => { /// says what it costs; «Reparar» would lie, since the next reconcile would not keep it. it("lets a build tree register itself on purpose, and says what that costs", async () => { answers({ ...BASE, health: "build_tree" }); - render(); + await opened(); expect(await screen.findByText(/recién compilada/)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Reparar" })).toBeNull(); expect(screen.getByText(/si mueves o limpias la carpeta/)).toBeInTheDocument(); @@ -219,7 +241,7 @@ describe("settings", () => { Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true }); try { answers({ ...BASE, health: "build_tree" }); - render(); + await opened(); expect(await screen.findByText(/fuera de un paquete/)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Registrar esta copia igualmente" })).toBeNull(); } finally { @@ -234,7 +256,7 @@ describe("settings", () => { health: "stale", registered_path: String.raw`C:\Users\Mario\AppData\Local\Programs\LinkUnbound\linkunbound-shell.exe`, }); - render(); + await opened(); const shown = await screen.findByText(/Windows ejecutaría/); expect(shown.textContent).toBe( String.raw`Windows ejecutaría: C:\Users\Mario\AppData\Local\Programs\LinkUnbound\linkunbound-shell.exe`, @@ -246,7 +268,7 @@ describe("settings", () => { }); it("says nothing about the handler when it is healthy", async () => { - render(); + await opened(); await screen.findByText(/recibe los enlaces/); expect(screen.queryByText(/no está recibiendo los enlaces/)).toBeNull(); }); @@ -255,7 +277,7 @@ describe("settings", () => { /// though intercepting the channel is not. it("explains the Edge channel only on a machine that has Edge", async () => { answers({ ...BASE, edge_installed: true }); - render(); + await opened(); expect(await screen.findByText(/saltándose el navegador/)).toBeInTheDocument(); }); @@ -263,7 +285,7 @@ describe("settings", () => { /// untouched, and the section does not come back on the next visit. it("stops explaining Edge once the person says they understood", async () => { answers({ ...BASE, edge_installed: true }); - render(); + await opened(); await userEvent.click(await screen.findByRole("button", { name: /Entendido/ })); expect(invoke).toHaveBeenCalledWith("prefs_set", { prefs: { ...PREFS.prefs, edge_warning_dismissed: true }, @@ -278,19 +300,19 @@ describe("settings", () => { Promise.resolve({ ...PREFS, prefs: { ...PREFS.prefs, edge_warning_dismissed: true } }), }, ); - render(); + await opened(); await screen.findByText(/recibe los enlaces/); expect(screen.queryByText(/saltándose el navegador/)).toBeNull(); }); it("says nothing about Edge when Edge is not installed", async () => { - render(); + await opened(); await screen.findByText(/recibe los enlaces/); expect(screen.queryByText(/saltándose el navegador/)).toBeNull(); }); it("lets the registration be turned off", async () => { - render(); + await opened(); await userEvent.click(await screen.findByRole("switch", { name: "Ofrecerse como navegador" })); expect(invoke).toHaveBeenCalledWith("system_set_registered", { enabled: false }); }); @@ -299,7 +321,7 @@ describe("settings", () => { /// rather than pretend the order was accepted. it("blocks startup when something outside disabled it, and explains why", async () => { answers({ ...BASE, starts_with_system: false, startup_is_ours: false }); - render(); + await opened(); await go("Aplicación"); expect(await screen.findByText(/Aplicaciones de inicio/)).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Iniciar con el sistema" })).toBeDisabled(); @@ -309,7 +331,7 @@ describe("settings", () => { answers(BASE, { system_set_registered: () => Promise.reject("the registry refused the write"), }); - render(); + await opened(); await userEvent.click(await screen.findByRole("switch", { name: "Ofrecerse como navegador" })); expect(await screen.findByText(/registry refused/)).toBeInTheDocument(); }); @@ -324,7 +346,7 @@ describe("settings", () => { it("offers to become the default, which macOS confirms with its own prompt", async () => { answers({ ...BASE, is_default: false }); - render(); + await opened(); expect(await screen.findByText(/macOS todavía no envía/)).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Establecer" })); expect(invoke).toHaveBeenCalledWith("system_set_registered", { enabled: true }); @@ -333,7 +355,7 @@ describe("settings", () => { it("sends to System Settings as the second way, never as the first", async () => { answers({ ...BASE, is_default: false }); - render(); + await opened(); await userEvent.click( await screen.findByRole("button", { name: "Abrir Ajustes del Sistema" }), ); @@ -341,7 +363,7 @@ describe("settings", () => { }); it("offers nothing to press once the links already arrive here", async () => { - render(); + await opened(); await screen.findByText(/recibe los enlaces/); expect(screen.queryByRole("button", { name: "Establecer" })).toBeNull(); expect(screen.queryByRole("button", { name: "Abrir Ajustes del Sistema" })).toBeNull(); @@ -354,14 +376,14 @@ describe("settings", () => { system_set_registered: () => Promise.reject("defaultDeclined"), }, ); - render(); + await opened(); await userEvent.click(await screen.findByRole("button", { name: "Establecer" })); expect(await screen.findByText(/Rechazaste el cambio/)).toBeInTheDocument(); }); it("names the login items pane rather than a Windows one", async () => { answers({ ...BASE, starts_with_system: false, startup_is_ours: false }); - render(); + await opened(); await go("Aplicación"); expect(await screen.findByText(/Ítems de inicio/)).toBeInTheDocument(); }); @@ -371,7 +393,7 @@ describe("settings", () => { system_legacy: () => Promise.resolve({ path: "/Applications/LinkUnbound.app", version: "1.4.0" }), }); - render(); + await opened(); expect(await screen.findByText(/LinkUnbound 1\.4\.0 instalado/)).toBeInTheDocument(); expect(screen.getByText(/Applications/)).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Quitar la versión anterior" })); @@ -385,7 +407,7 @@ describe("settings", () => { Promise.resolve({ path: "/Applications/LinkUnbound.app", version: "1.4.0" }), system_retire_legacy: () => Promise.reject("no permission"), }); - render(); + await opened(); await userEvent.click( await screen.findByRole("button", { name: "Quitar la versión anterior" }), ); @@ -395,7 +417,7 @@ describe("settings", () => { it("says nothing about 1.x when there is none installed", async () => { answers(BASE); - render(); + await opened(); await screen.findByText(/recibe los enlaces/); expect(screen.queryByText(/instalado/)).toBeNull(); }); @@ -405,7 +427,7 @@ describe("settings", () => { answers(BASE, { system_legacy: () => Promise.resolve({ path: "C:/x/LinkUnbound.exe", version: "1.4.0" }), }); - render(); + await opened(); await screen.findByRole("heading", { name: "Enlaces" }); expect(invoke).not.toHaveBeenCalledWith("system_legacy"); }); @@ -420,14 +442,14 @@ describe("settings", () => { { scheme: ".htm", held: false }, ], }); - render(); + await opened(); expect(await screen.findByText("1 de 3 asociaciones")).toBeInTheDocument(); expect(screen.getByText(/todavía no envía/)).toBeInTheDocument(); }); it("says nothing about the links while the backend has not answered", async () => { answers(BASE, { system_state: () => new Promise(() => {}) }); - render(); + await opened(); expect(await screen.findByRole("heading", { name: "Enlaces" })).toBeInTheDocument(); expect(screen.getByText("0 de 0 asociaciones")).toBeInTheDocument(); expect(screen.getByText(/todavía no envía/)).toBeInTheDocument(); @@ -439,7 +461,7 @@ describe("settings", () => { { ...BASE, is_default: false }, { system_open_default_apps: () => Promise.resolve(null) }, ); - render(); + await opened(); await userEvent.click(await screen.findByRole("button", { name: "Abrir Configuración" })); expect(screen.getByText(/todavía no envía/)).toBeInTheDocument(); expect(screen.queryByText(/Algo salió mal/)).toBeNull(); @@ -451,7 +473,7 @@ describe("settings", () => { system_set_registered: () => refuse ? Promise.reject("the registry refused the write") : Promise.resolve(BASE), }); - render(); + await opened(); await userEvent.click(await screen.findByRole("switch", { name: "Ofrecerse como navegador" })); expect(await screen.findByText(/registry refused/)).toBeInTheDocument(); refuse = false; @@ -465,7 +487,7 @@ describe("settings", () => { health: "stale", registered_path: String.raw`C:\Other\linkunbound-shell.exe`, }); - render(); + await opened(); await userEvent.click(await screen.findByRole("button", { name: "Reparar" })); expect(invoke).toHaveBeenCalledWith("system_repair"); }); @@ -474,7 +496,7 @@ describe("settings", () => { /// some other browser, since the default one is the resident that is missing. it("sends a copy without its resident to the download, not to a repair", async () => { answers({ ...BASE, health: "no_resident" }); - render(); + await opened(); expect(await screen.findByText(/Falta el programa/)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Reparar" })).toBeNull(); await userEvent.click(screen.getByRole("button", { name: "Descargar de nuevo" })); @@ -486,14 +508,14 @@ describe("settings", () => { it("warns a copy running from the disk image, and offers no repair", async () => { answers({ ...BASE, health: "mounted" }); - render(); + await opened(); expect(await screen.findByText(/imagen de disco/)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Reparar" })).toBeNull(); }); it("walks every section and marks the one it is on", async () => { answers(BASE, { browsers_list: () => Promise.resolve([]) }); - render(); + await opened(); for (const [name, heading] of [ ["Reglas", "Reglas"], ["Navegadores", "Navegadores"], @@ -511,7 +533,7 @@ describe("settings", () => { it("shows one section at a time", async () => { answers(BASE, { browsers_list: () => Promise.resolve([]) }); - render(); + await opened(); expect(await screen.findByText("Navegador predeterminado")).toBeInTheDocument(); expect(screen.queryByText(/Todavía no hay ninguna regla/)).toBeNull(); await go("Reglas"); @@ -524,14 +546,14 @@ describe("settings", () => { it("names no path when the registration names none", async () => { answers({ ...BASE, health: "stale", registered_path: null }); - render(); + await opened(); expect(await screen.findByText(/apunta a otra copia/)).toBeInTheDocument(); expect(screen.queryByText(/ejecutaría/)).toBeNull(); }); it("takes the startup toggle as off and ours while nothing is known", async () => { answers(BASE, { system_state: () => new Promise(() => {}) }); - render(); + await opened(); await go("Aplicación"); const toggle = await screen.findByRole("switch", { name: "Iniciar con el sistema" }); expect(toggle).toBeEnabled(); @@ -539,7 +561,7 @@ describe("settings", () => { }); it("opens on the links section, not on a blank pane", async () => { - render(); + await opened(); expect(await screen.findByRole("heading", { name: "Enlaces" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Enlaces" })).toHaveAttribute("aria-current", "page"); }); diff --git a/app/src/Settings.tsx b/app/src/Settings.tsx index db0afba..80b2c06 100644 --- a/app/src/Settings.tsx +++ b/app/src/Settings.tsx @@ -92,6 +92,16 @@ type Page = "links" | "rules" | "browsers" | "app" | "care" | "about"; type Entry = { id: Page; label: Key; icon: React.ReactNode }; const PAGES: Entry[] = [ + { + id: "app", + label: "navApp", + icon: ( + <> + + + + ), + }, { id: "links", label: "navLinks", @@ -113,16 +123,6 @@ const PAGES: Entry[] = [ ), }, - { - id: "app", - label: "navApp", - icon: ( - <> - - - - ), - }, { id: "care", label: "navCare", @@ -393,7 +393,15 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { const t = useWords(); const language = useSpoken(); const { ready, step, look, settled } = useUpdate(); - const [page, setPage] = useState("links"); + const [page, setPage] = useState(() => asked() ?? "app"); + useEffect(() => { + const follow = () => { + const next = asked(); + if (next) setPage(next); + }; + window.addEventListener("hashchange", follow); + return () => window.removeEventListener("hashchange", follow); + }, []); const [state, setState] = useState(null); const [problem, setProblem] = useState(null); const [starring, setStarring] = useState(false); @@ -500,6 +508,12 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { ); } +/// The resident opens settings on the browsers, with `#browsers`, to add one. +function asked(): Page | null { + const said = window.location.hash.slice(1); + return [...PAGES, ABOUT].some((p) => p.id === said) ? (said as Page) : null; +} + function Tab({ tab, on, onPick }: { tab: Entry; on: Page; onPick: (next: Page) => void }) { const t = useWords(); const here = tab.id === on; diff --git a/app/src/i18n.ts b/app/src/i18n.ts index c34bd31..f10a3b3 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -122,6 +122,9 @@ const ES = { appPicker: "Selector", pickerClassic: "Clásico", pickerSheet: "Mosaico", + pickerCompact: "Compacto", + pickerBear: "Oso", + pickerPreview: "Vista previa", themeAuto: "Automático", themeLight: "Claro", themeDark: "Oscuro", @@ -401,6 +404,9 @@ const EN: Record = { appPicker: "Picker", pickerClassic: "Classic", pickerSheet: "Tiles", + pickerCompact: "Compact", + pickerBear: "Bear", + pickerPreview: "Preview", themeAuto: "Automatic", themeLight: "Light", themeDark: "Dark", diff --git a/app/src/settings/Application.test.tsx b/app/src/settings/Application.test.tsx index a344eb3..ae12522 100644 --- a/app/src/settings/Application.test.tsx +++ b/app/src/settings/Application.test.tsx @@ -13,7 +13,7 @@ const PREFS = { locale: "system" as "system" | "spanish" | "english", shortcut: "Alt+Shift+L" as string | null, notify_on_rule: true, - picker_style: "classic" as "classic" | "sheet", + picker_style: "classic" as "bear" | "classic" | "sheet" | "compact", looks_for_updates: true, }; @@ -77,6 +77,31 @@ describe("application settings", () => { }); }); + /// Every look is offered, the bear among them, and each can be seen before it is kept. + it("offers the four looks and shows the picker over a sample link", async () => { + mount(); + const picker = within(await screen.findByRole("group", { name: "Selector" })); + for (const look of ["Oso", "Clásico", "Mosaico", "Compacto"]) { + expect(picker.getByRole("radio", { name: look })).toBeInTheDocument(); + } + await userEvent.click(screen.getByRole("button", { name: "Vista previa" })); + expect(invoke).toHaveBeenCalledWith("picker_preview"); + }); + + /// Once a preview is up, choosing another look redraws it rather than leaving the old one open. + it("redraws the preview when another look is chosen", async () => { + mount(); + const picker = within(await screen.findByRole("group", { name: "Selector" })); + await userEvent.click(picker.getByRole("radio", { name: "Mosaico" })); + expect(invoke.mock.calls.filter(([cmd]) => cmd === "picker_preview")).toHaveLength(0); + + await userEvent.click(screen.getByRole("button", { name: "Vista previa" })); + await userEvent.click(picker.getByRole("radio", { name: "Oso" })); + await waitFor(() => + expect(invoke.mock.calls.filter(([cmd]) => cmd === "picker_preview")).toHaveLength(2), + ); + }); + /// The resident asks the feed on its own; the switch is the one way to say not to, and it has /// to read back what is in force. it("lets the background look for releases be turned off", async () => { diff --git a/app/src/settings/Application.tsx b/app/src/settings/Application.tsx index 5edef38..009cd9f 100644 --- a/app/src/settings/Application.tsx +++ b/app/src/settings/Application.tsx @@ -7,7 +7,7 @@ import { Card, Line, Section, Switch } from "./parts"; type Theme = "system" | "light" | "dark"; type Locale = "system" | "spanish" | "english"; -type PickerStyle = "classic" | "sheet"; +type PickerStyle = "bear" | "classic" | "sheet" | "compact"; type Preferences = { schema_version: number; @@ -34,8 +34,10 @@ const LOCALES: { id: Locale; label: Key }[] = [ ]; const PICKERS: { id: PickerStyle; label: Key }[] = [ + { id: "bear", label: "pickerBear" }, { id: "classic", label: "pickerClassic" }, { id: "sheet", label: "pickerSheet" }, + { id: "compact", label: "pickerCompact" }, ]; const MODIFIERS = new Set(["Control", "Alt", "Shift", "Meta"]); @@ -116,7 +118,13 @@ export default function Application({ const [problem, setProblem] = useState(null); const [capturing, setCapturing] = useState(false); - const apply = (patch: Partial) => { + const [previewing, setPreviewing] = useState(false); + const preview = () => { + setPreviewing(true); + void invoke("picker_preview").catch(noop); + }; + + const apply = (patch: Partial, then?: () => void) => { if (!settings) return; void invoke("prefs_set", { prefs: { ...settings.prefs, ...patch } }) .then((next) => { @@ -125,6 +133,7 @@ export default function Application({ refresh(); setProblem(null); if ("shortcut" in patch) askAgainOnceTheResidentHasIt(); + then?.(); }) .catch((e: unknown) => setProblem(saidPlainly(language, e))) .finally(() => setCapturing(false)); @@ -171,12 +180,21 @@ export default function Application({ /> - apply({ picker_style })} - /> +
+ apply({ picker_style }, previewing ? preview : undefined)} + /> + +
diff --git a/crates/linkunbound-core/src/binaries.rs b/crates/linkunbound-core/src/binaries.rs index 133cd9f..1b7e32e 100644 --- a/crates/linkunbound-core/src/binaries.rs +++ b/crates/linkunbound-core/src/binaries.rs @@ -2,6 +2,10 @@ use std::path::{Path, PathBuf}; pub const RESIDENT: &str = "linkunbound-shell"; pub const SETTINGS: &str = "linkunbound-settings"; +/// What settings hands the resident to show the picker over a sample link, in place of a link. +pub const PREVIEW: &str = "--preview"; +/// What the resident hands settings to open it on the browsers, to add one. +pub const BROWSERS: &str = "--browsers"; fn named(beside: &Path, stem: &str) -> PathBuf { let file = if cfg!(windows) { diff --git a/crates/linkunbound-core/src/i18n.rs b/crates/linkunbound-core/src/i18n.rs index 7763b4b..128deb8 100644 --- a/crates/linkunbound-core/src/i18n.rs +++ b/crates/linkunbound-core/src/i18n.rs @@ -28,6 +28,20 @@ catalogue! { picker_remember: "Recordar esta elección" | "Remember this choice", picker_private: "Privada" | "Private", picker_copy: "Copiar la dirección" | "Copy the address", + picker_ask: "¿Dónde abro este enlace?" | "Where should this link open?", + picker_ask_private: "¿Dónde lo abro en privado?" | "Where should it open privately?", + picker_private_line: "Se abrirá en una ventana privada" | "It will open in a private window", + picker_remember_short: "Recordar" | "Remember", + picker_motto: "Cada enlace, donde tú decides." | "Every link, where you decide.", + picker_preview: "Vista previa: elegir no abre nada" | "Preview: choosing opens nothing", + picker_add: "Añadir navegador" | "Add a browser", + picker_add_more: "o un perfil" | "or a profile", + styles_headline: "El selector ahora es el oso" | "The picker is now the bear", + styles_reason: "¿Prefieres otro estilo? Elígelo aquí o en Ajustes" | "Prefer another look? Pick it here or in Settings", + style_classic: "Clásico" | "Classic", + style_sheet: "Mosaico" | "Tiles", + style_compact: "Compacto" | "Compact", + style_bear: "Oso" | "Bear", browser_copy_suffix: "{} (copia)" | "{} (copy)", reach_once: "Solo esta vez" | "Just this time", reach_url: "Esta URL" | "This URL", diff --git a/crates/linkunbound-core/src/lib.rs b/crates/linkunbound-core/src/lib.rs index 2749217..577c16f 100644 --- a/crates/linkunbound-core/src/lib.rs +++ b/crates/linkunbound-core/src/lib.rs @@ -20,7 +20,8 @@ mod url; pub use args::{join as join_args, split as split_args}; pub use binaries::{ - RESIDENT, Registered, SETTINGS, link_handler, settings_binary, what_is_registered, + BROWSERS, PREVIEW, RESIDENT, Registered, SETTINGS, link_handler, settings_binary, + what_is_registered, }; pub use browser::{Browser, LaunchRefused, Profile, merge}; pub use config::{ diff --git a/crates/linkunbound-core/src/prefs.rs b/crates/linkunbound-core/src/prefs.rs index 142efd0..5f850b1 100644 --- a/crates/linkunbound-core/src/prefs.rs +++ b/crates/linkunbound-core/src/prefs.rs @@ -18,13 +18,16 @@ pub enum Locale { English, } -/// The picker's look: rows under the link, the way 1.x users know it, or a sheet of tiles. +/// The picker's look: rows under the link, the way 1.x users know it, a sheet of tiles, a row of +/// icons, or the bear asking where the link goes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PickerStyle { - #[default] Classic, Sheet, + Compact, + #[default] + Bear, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -85,6 +88,18 @@ impl Default for Preferences { } impl Preferences { + /// The bear became everybody's picker once, whatever was chosen before; the move is written + /// down so a style picked afterwards stands. Returns the style left behind, when it was not + /// already the bear, so the person can be told how to get it back. + pub fn meets_the_bear(&mut self) -> Option { + if self.schema_version >= 1 { + return None; + } + self.schema_version = 1; + let before = std::mem::replace(&mut self.picker_style, PickerStyle::Bear); + (before != PickerStyle::Bear).then_some(before) + } + /// 1.x kept the theme in a file of its own holding nothing but the word. #[must_use] pub fn with_legacy_theme(mut self, raw: &str) -> Self { @@ -249,13 +264,12 @@ mod tests { assert!(!quiet.looks_for_updates); } - /// The rows are what a copy gets until somebody asks for the tiles, and a file from before - /// the choice existed must read the same way. The words in the file are the ones settings - /// shows, so a hand edit reads back. + /// The bear is what a copy gets until somebody asks for another look. The words in the file + /// are the ones settings shows, so a hand edit reads back. #[test] - fn the_picker_is_the_classic_one_unless_the_sheet_was_asked_for() { + fn the_picker_is_the_bear_unless_another_look_was_asked_for() { let fresh: Preferences = serde_json::from_str("{}").unwrap(); - assert_eq!(fresh.picker_style, PickerStyle::Classic); + assert_eq!(fresh.picker_style, PickerStyle::Bear); let asked: Preferences = serde_json::from_str(r#"{"picker_style": "sheet"}"#).unwrap(); assert_eq!(asked.picker_style, PickerStyle::Sheet); @@ -264,6 +278,22 @@ mod tests { assert!(written.contains(r#""picker_style":"sheet""#), "{written}"); } + /// Everybody moves to the bear once; after that, what they pick stays picked. + #[test] + fn the_bear_comes_once_and_a_later_choice_stands() { + let mut kept: Preferences = serde_json::from_str(r#"{"picker_style": "sheet"}"#).unwrap(); + assert_eq!(kept.meets_the_bear(), Some(PickerStyle::Sheet)); + assert_eq!(kept.picker_style, PickerStyle::Bear); + + kept.picker_style = PickerStyle::Classic; + assert_eq!(kept.meets_the_bear(), None); + assert_eq!(kept.picker_style, PickerStyle::Classic); + + let mut fresh = Preferences::default(); + assert_eq!(fresh.meets_the_bear(), None, "nothing to tell a new copy"); + assert_eq!(fresh.schema_version, 1); + } + #[test] fn turning_the_shortcut_off_is_kept_as_off() { let p: Preferences = serde_json::from_str(r#"{"shortcut": null}"#).unwrap(); diff --git a/crates/linkunbound-shell/examples/preview.rs b/crates/linkunbound-shell/examples/preview.rs index 603b45d..0861716 100644 --- a/crates/linkunbound-shell/examples/preview.rs +++ b/crates/linkunbound-shell/examples/preview.rs @@ -1,22 +1,32 @@ #![windows_subsystem = "windows"] -//! `cargo run -p linkunbound-shell --example preview -- [--sheet] [--light] [--en] [--six | --twelve] +//! `cargo run -p linkunbound-shell --example preview -- [--look=list|tiles|compact|bear] [--glass] +//! [--count=N] [--light] [--en] [--six | --twelve] //! [--scale=N] [--update | --store | --stage=starting|getting|installing|failed] [--notice]` use linkunbound_core::Language; -use linkunbound_shell::{HALO, ICON_SIDE, Listed, Picker, TILE_ICON_SIDE, dress, paint}; +use linkunbound_shell::{HALO, Listed, Look, Palette, Picker, dress, paint}; use slint::ComponentHandle; fn main() -> Result<(), slint::PlatformError> { let args: Vec = std::env::args().collect(); let flag = |name: &str| args.iter().any(|a| a == name); - let classic = !flag("--sheet"); + let look = match args.iter().find_map(|a| a.strip_prefix("--look=")) { + Some("list") => Look::List, + Some("tiles") => Look::Tiles, + Some("compact") => Look::Compact, + _ => Look::Bear, + }; let scale: u32 = args .iter() .find_map(|a| a.strip_prefix("--scale=")) .and_then(|v| v.parse().ok()) .unwrap_or(1); - let side = if classic { ICON_SIDE } else { TILE_ICON_SIDE } * scale; + let side = match look { + Look::List => 24, + Look::Tiles => 48, + Look::Compact | Look::Bear => 32, + } * scale; let icons = linkunbound_core::data_dir().join("icons"); // Real icons at the side the chosen style draws them, so what the preview shows is what // the resident will: a 24 px icon stretched to a tile is exactly the blur being checked for. @@ -44,7 +54,7 @@ fn main() -> Result<(), slint::PlatformError> { let english = flag("--en"); let named = |es: &str, en: &str| if english { en } else { es }.to_owned(); - let rows = vec![ + let rows = [ Listed { browser_id: "google-chrome".to_owned(), profile_id: Some("Default".to_owned()), @@ -71,17 +81,27 @@ fn main() -> Result<(), slint::PlatformError> { }, ]; - let mut rows = rows; - if flag("--six") || flag("--twelve") { - let mut more = rows.clone(); - more[0].profile = named("Cliente", "Client"); - more[2].profile = named("Personal", "Home"); - rows.extend(more); - } - if flag("--twelve") { - let more = rows.clone(); - rows.extend(more); - } + // Any number of rows, the three browsers taking turns with a profile each past the first round. + let count = args + .iter() + .find_map(|a| a.strip_prefix("--count=")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(if flag("--twelve") { + 12 + } else if flag("--six") { + 6 + } else { + rows.len() + }); + let rows: Vec = (0..count) + .map(|at| { + let mut row = rows[at % rows.len()].clone(); + if at >= rows.len() { + row.profile = format!("{} {}", named("Perfil", "Profile"), at / rows.len() + 1); + } + row + }) + .collect(); let words = if english { Language::English @@ -92,8 +112,14 @@ fn main() -> Result<(), slint::PlatformError> { let window = Picker::new()?; let notice = linkunbound_shell::Notice::new()?; paint(&window, ¬ice, flag("--light")); - window.set_classic(classic); - window.set_halo(if classic { 0.0 } else { HALO }); + window.set_look(look); + let glass = flag("--glass"); + window.global::().set_clear(glass); + window.set_halo(if look == Look::List || glass { + 0.0 + } else { + HALO + }); dress( &window, &words, @@ -173,5 +199,20 @@ fn main() -> Result<(), slint::PlatformError> { notice.show()?; } + #[cfg(windows)] + if glass { + let handle = window.as_weak(); + let dark = !flag("--light"); + slint::Timer::single_shot(std::time::Duration::from_millis(50), move || { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let Some(w) = handle.upgrade() else { return }; + if let Ok(found) = w.window().window_handle().window_handle() + && let RawWindowHandle::Win32(win32) = found.as_raw() + { + linkunbound_win::wear_acrylic(win32.hwnd.get(), dark); + } + }); + } + window.run() } diff --git a/crates/linkunbound-shell/src/held.rs b/crates/linkunbound-shell/src/held.rs new file mode 100644 index 0000000..f10c005 --- /dev/null +++ b/crates/linkunbound-shell/src/held.rs @@ -0,0 +1,50 @@ +//! Links that reach the socket before the event loop runs, kept until there is a window. + +/// Links the socket took before the loop could: the copy that handed them over believes they +/// were delivered, so they are kept here until there is a window to deliver them to. +static HELD_FOR_THE_LOOP: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +pub fn hold_for_the_loop(url: String) { + if let Ok(mut held) = HELD_FOR_THE_LOOP.lock() { + held.push(url); + } +} + +pub fn release_to_the_loop() { + let held: Vec = HELD_FOR_THE_LOOP + .lock() + .map(|mut held| held.drain(..).collect()) + .unwrap_or_default(); + for url in held { + let _ = slint::invoke_from_event_loop(move || crate::arrived(url)); + } +} + +pub fn handed_to_the_loop(url: String) { + let raw = url.clone(); + if slint::invoke_from_event_loop(move || crate::arrived(url)).is_err() { + hold_for_the_loop(raw); + } +} + +#[cfg(test)] +mod tests { + use super::{HELD_FOR_THE_LOOP, hold_for_the_loop, release_to_the_loop}; + + /// Held links leave the queue once handed on, so none is delivered twice. + #[test] + fn a_held_link_is_handed_on_once() { + hold_for_the_loop("https://a.test/".to_owned()); + assert!( + HELD_FOR_THE_LOOP + .lock() + .is_ok_and(|held| held.iter().any(|u| u == "https://a.test/")) + ); + release_to_the_loop(); + assert!( + HELD_FOR_THE_LOOP + .lock() + .is_ok_and(|held| !held.iter().any(|u| u == "https://a.test/")) + ); + } +} diff --git a/crates/linkunbound-shell/src/lib.rs b/crates/linkunbound-shell/src/lib.rs index 51e6c8e..57b35f6 100644 --- a/crates/linkunbound-shell/src/lib.rs +++ b/crates/linkunbound-shell/src/lib.rs @@ -189,10 +189,9 @@ impl Said { } } -/// The two the row shows. Everything else goes behind «More», because five labels never fit a -/// width the rows can also live at — and two of the five are dead half the time: a URL reach on -/// a wrapped link, a subdomain reach on a host that has none. -pub const IN_THE_ROW: [i32; 2] = [0, 3]; +/// The four the track shows, in the order it shows them. Only the app the link came from goes +/// behind «More»: its label carries a name no fixed width can be drawn for. +pub const IN_THE_ROW: [i32; 4] = [0, 3, 1, 2]; #[must_use] pub fn in_the_row(index: i32) -> bool { @@ -347,6 +346,18 @@ pub fn split(url: &str) -> (String, String) { } } +/// What a browser is called among friends: the maker's name in front and «Browser» at the end +/// say nothing a small card has room for. +#[must_use] +pub fn short_name(name: &str) -> &str { + let name = name.trim(); + let name = ["Google ", "Mozilla ", "Microsoft "] + .iter() + .find_map(|maker| name.strip_prefix(maker)) + .unwrap_or(name); + name.strip_suffix(" Browser").unwrap_or(name) +} + fn image_for(path: Option<&str>) -> slint::Image { path.and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok()) .unwrap_or_default() @@ -370,6 +381,15 @@ pub fn dress(window: &Picker, words: &Strings, url: &str, source: Option<&str>, ); window.set_remember_label(words.picker_remember.into()); window.set_private_label(words.picker_private.into()); + window.set_private_line(words.picker_private_line.into()); + window.set_remember_short(words.picker_remember_short.into()); + window.set_ask(words.picker_ask.into()); + window.set_ask_private(words.picker_ask_private.into()); + window.set_motto(words.picker_motto.into()); + window.set_add(words.picker_add.into()); + window.set_add_more(words.picker_add_more.into()); + window.set_chosen_at(-1); + window.set_preview(false); window.set_copy_label(words.picker_copy.into()); window.set_update_later(words.update_later.into()); window.set_private_on(false); @@ -398,6 +418,7 @@ pub fn dress(window: &Picker, words: &Strings, url: &str, source: Option<&str>, slint::SharedString::new() }, name: row.name.clone().into(), + short: short_name(&row.name).into(), profile: row.profile.clone().into(), icon: image_for(row.icon.as_deref()), can_private: row.can_private, @@ -437,9 +458,12 @@ pub fn dress(window: &Picker, words: &Strings, url: &str, source: Option<&str>, // What the row draws, so the window can be measured against the words it will actually show // rather than against every reach there is. + let row: Vec = IN_THE_ROW + .iter() + .filter_map(|&at| all.iter().find(|one| one.index == at).cloned()) + .collect(); window.set_reaches_line( - all.iter() - .filter(|one| in_the_row(one.index)) + row.iter() .map(|one| one.label.to_string()) .chain(std::iter::once(words.reach_more.to_owned())) .collect::>() @@ -447,12 +471,34 @@ pub fn dress(window: &Picker, words: &Strings, url: &str, source: Option<&str>, .into(), ); - let (row, rest): (Vec, Vec) = - all.into_iter().partition(|one| in_the_row(one.index)); + let rest: Vec = all + .into_iter() + .filter(|one| !in_the_row(one.index)) + .collect(); window.set_reaches(Rc::new(slint::VecModel::from(row)).into()); window.set_reaches_more(Rc::new(slint::VecModel::from(rest)).into()); } +#[cfg(test)] +mod short { + use super::short_name; + + #[test] + fn a_browser_goes_by_its_own_name() { + for (full, short) in [ + ("Google Chrome", "Chrome"), + ("Mozilla Firefox", "Firefox"), + ("Microsoft Edge", "Edge"), + ("Brave Browser", "Brave"), + ("Vivaldi", "Vivaldi"), + ("Google Chrome Beta", "Chrome Beta"), + ("Opera GX", "Opera GX"), + ] { + assert_eq!(short_name(full), short, "{full}"); + } + } +} + #[cfg(test)] mod tests { use super::dress; @@ -545,10 +591,11 @@ mod tests { let window = crate::Picker::new().expect("a window"); let words = Language::Spanish.strings(); let rows = dressed(); + window.set_look(crate::Look::List); dress(&window, &words, "https://docs.google.com/a", None, &rows); let alone = window.get_wanted_width(); - assert_eq!(alone, 348.0); + assert!(alone >= 380.0, "{alone}"); for from in ["ms-teams", "Microsoft Teams Classic"] { dress( @@ -573,7 +620,7 @@ mod tests { fn the_sheet_is_sized_by_its_tiles_and_its_halo() { headless(); let window = crate::Picker::new().expect("a window"); - window.set_classic(false); + window.set_look(crate::Look::Tiles); let words = Language::Spanish.strings(); let two = dressed(); @@ -584,25 +631,26 @@ mod tests { Some("ms-teams"), &two, ); - assert_eq!(window.get_wanted_width(), 348.0); + let two_across = window.get_wanted_width(); + assert!(two_across >= 372.0, "{two_across}"); let mut four = two.clone(); four.extend(two.iter().cloned()); dress(&window, &words, "https://docs.google.com/a", None, &four); - assert_eq!( - window.get_wanted_width(), - 392.0, + let four_across = window.get_wanted_width(); + assert!( + four_across >= 400.0 && four_across >= two_across, "four across need the wider sheet" ); window.set_halo(18.0); - assert_eq!(window.get_wanted_width(), 392.0 + 36.0); + assert_eq!(window.get_wanted_width(), four_across + 36.0); - window.set_classic(true); - assert_eq!( - window.get_wanted_width(), - 348.0, - "the classic look keeps its own width" + window.set_halo(0.0); + window.set_look(crate::Look::List); + assert!( + window.get_wanted_width() >= 380.0, + "the list keeps its own floor" ); } @@ -612,7 +660,7 @@ mod tests { fn the_classic_look_stops_growing_at_nine_rows_and_the_keys_stop_with_it() { headless(); let window = crate::Picker::new().expect("a window"); - window.set_classic(true); + window.set_look(crate::Look::List); let words = Language::Spanish.strings(); let two = dressed(); let mut many: Vec = Vec::new(); @@ -627,7 +675,7 @@ mod tests { nine.truncate(9); dress(&window, &words, "https://docs.google.com/a", None, &nine); let with_nine = window.get_wanted_height(); - assert_eq!(with_nine - with_two, 7.0 * 42.0); + assert_eq!(with_nine - with_two, 7.0 * 45.0); dress(&window, &words, "https://docs.google.com/a", None, &many); assert_eq!(window.get_wanted_height(), with_nine, "the rest scroll"); @@ -853,8 +901,13 @@ mod tests { &dressed(), ); - for classic in [false, true] { - window.set_classic(classic); + for look in [ + crate::Look::List, + crate::Look::Tiles, + crate::Look::Compact, + crate::Look::Bear, + ] { + window.set_look(look); let plain = window.get_wanted_height(); show_strip( &window, @@ -866,11 +919,7 @@ mod tests { far: 0, }), ); - assert_eq!( - window.get_wanted_height(), - plain + 46.0, - "classic: {classic}" - ); + assert_eq!(window.get_wanted_height(), plain + 46.0, "{look:?}"); show_strip(&window, None); assert_eq!(window.get_wanted_height(), plain); } @@ -927,8 +976,14 @@ mod tests { let row = window.get_reaches(); let menu = window.get_reaches_more(); - assert_eq!(row.row_count(), 2); - assert_eq!(menu.row_count(), 3); + assert_eq!(row.row_count(), 4); + assert_eq!(menu.row_count(), 1); + let order: Vec = row.iter().map(|one| one.index).collect(); + assert_eq!( + order, + vec![0, 3, 1, 2], + "once, the whole site, this URL, the subdomain" + ); let mut seen: Vec<(i32, String)> = row .iter() @@ -948,12 +1003,6 @@ mod tests { ], "every reach kept the number Reaches::at reads" ); - - assert_eq!( - row.iter().map(|one| one.index).collect::>(), - vec![0, 3], - "the row shows «just this time» and «the whole site»" - ); } /// With a reach the row does not show, the opener has to name it: left saying «More», the @@ -973,14 +1022,15 @@ mod tests { &rows, ); - for at in [0, 3] { + for at in [0, 1, 2, 3] { window.set_reach_index(at); assert!(!window.get_more_holds(), "{at} is drawn in the row"); } - for at in [1, 2, 4] { - window.set_reach_index(at); - assert!(window.get_more_holds(), "{at} is only behind the opener"); - } + window.set_reach_index(4); + assert!( + window.get_more_holds(), + "the origin is only behind the opener" + ); } /// Slint's testing backend, so the window needs neither a screen nor the main thread: winit @@ -1027,13 +1077,13 @@ mod tests { let offered = window.get_reaches(); assert_eq!( offered.row_count(), - 2, - "the row shows two and keeps the rest behind More" + 4, + "the row shows four and keeps the origin behind More" ); assert_eq!(offered.row_data(0).expect("first").label, words.reach_once); assert_eq!( window.get_reaches_more().row_count(), - 2, + 0, "no origin, so no origin reach" ); assert_eq!(window.get_problem(), "", "an ordinary link raises nothing"); diff --git a/crates/linkunbound-shell/src/look.rs b/crates/linkunbound-shell/src/look.rs index ed48c86..712a1a3 100644 --- a/crates/linkunbound-shell/src/look.rs +++ b/crates/linkunbound-shell/src/look.rs @@ -1,19 +1,29 @@ //! How the picker is drawn: list or tiles, and the material behind it where the system lends one. use linkunbound_core::PickerStyle; -use linkunbound_shell::{CLASSIC_CORNER, CORNER, HALO, Palette, Picker}; +use linkunbound_shell::{CLASSIC_CORNER, CORNER, HALO, Look, Palette, Picker, place}; use slint::ComponentHandle; pub fn style(picker: &Picker, chosen: PickerStyle) { - let classic = chosen != PickerStyle::Sheet; + let look = match chosen { + PickerStyle::Classic => Look::List, + PickerStyle::Sheet => Look::Tiles, + PickerStyle::Compact => Look::Compact, + PickerStyle::Bear => Look::Bear, + }; let clear = acrylic(); - picker.set_classic(classic); + picker.set_look(look); picker.global::().set_clear(clear); - picker.set_halo(if classic || clear { 0.0 } else { HALO }); + picker.global::().set_see_through(see_through()); + picker.set_halo(if look == Look::List || clear { + 0.0 + } else { + HALO + }); } pub fn corner_of(picker: &Picker) -> f64 { - if picker.get_classic() { + if picker.get_look() == Look::List { CLASSIC_CORNER } else { CORNER @@ -28,16 +38,102 @@ pub fn wear_backdrop(picker: &Picker, handle: isize) -> bool { return false; } palette.set_clear(false); - if picker.get_classic() { + if picker.get_look() == Look::List { return false; } picker.set_halo(HALO); true } +/// The picker belongs to the click that summoned it, not to the middle of a screen. +pub fn beside_the_pointer(picker: &Picker) { + let Some(pointer) = crate::host::cursor() else { + return; + }; + let Some(area) = crate::host::work_area_at(pointer.0, pointer.1) else { + return; + }; + place_near(picker, pointer, area); +} + +/// Where the picker goes for a pointer and the screen area around it, in the units the window is +/// placed in. Returns the corner it was put at. +fn place_near( + picker: &Picker, + (cx, cy): (i32, i32), + (x, y, width, height): (i32, i32, i32, i32), +) -> (i32, i32) { + let scale = if cfg!(target_os = "macos") { + 1.0 + } else { + f64::from(picker.window().scale_factor()) + }; + let size = |logical: f32| crate::physical(logical, scale); + + let halo = size(picker.get_halo()); + let (at_x, at_y) = place::beside( + (cx, cy), + ( + size(picker.get_wanted_width()), + size(picker.get_wanted_height()), + ), + // The bear rises over the sheet, so the screen it may use starts that much lower. + place::Bounds { + x, + y: y + size(crate::mascot::rise(picker)), + width, + height: height - size(crate::mascot::rise(picker)), + }, + ); + // The margin holds the shadow, not the sheet: the sheet's edge is what sits by the pointer, + // and the transparent rim may hang past the screen without anyone seeing it. + let (at_x, at_y) = (at_x - halo, at_y - halo); + if let Some(ui) = crate::ui() { + ui.mascot.anchor(at_x, at_y); + } + #[cfg(target_os = "macos")] + picker + .window() + .set_position(slint::LogicalPosition::new(at_x as f32, at_y as f32)); + #[cfg(not(target_os = "macos"))] + picker + .window() + .set_position(slint::PhysicalPosition::new(at_x, at_y)); + if picker.window().is_visible() { + follow(picker); + } + (at_x, at_y) +} + +/// The mascot comes and goes with the picker it leans on. +pub fn follow(picker: &Picker) { + if let Some(ui) = crate::ui() { + ui.mascot.follow(picker); + } +} + +pub fn put_away(picker: &Picker) { + let _ = picker.hide(); + if let Some(ui) = crate::ui() { + ui.mascot.hide(); + } +} + +#[cfg(windows)] +fn see_through() -> bool { + linkunbound_win::transparency_is_on() +} + +#[cfg(not(windows))] +fn see_through() -> bool { + true +} + #[cfg(windows)] fn acrylic() -> bool { - linkunbound_win::acrylic_is_available() + // A debug build can be told to paint, to see on Windows 11 what Windows 10 shows. + let painted = cfg!(debug_assertions) && std::env::var_os("LINKUNBOUND_NO_ACRYLIC").is_some(); + linkunbound_win::acrylic_is_available() && !painted } #[cfg(windows)] @@ -57,7 +153,7 @@ fn wear_acrylic(_window: isize, _dark: bool) -> bool { #[cfg(test)] mod tests { - use super::{acrylic, corner_of, style, wear_backdrop}; + use super::{acrylic, corner_of, place_near, style, wear_backdrop}; use linkunbound_core::PickerStyle; use linkunbound_shell::{CLASSIC_CORNER, CORNER, HALO, Palette, Picker}; use slint::ComponentHandle; @@ -84,6 +180,28 @@ mod tests { assert!((corner_of(&picker) - CLASSIC_CORNER).abs() < f64::EPSILON); } + /// The bear rises over the sheet, so a picker near the top of the screen is put that much + /// lower; a look without the bear goes right to the edge. + #[test] + fn a_picker_by_the_top_edge_keeps_room_for_the_bear() { + let picker = picker(); + let screen = (0, 0, 1920, 1080); + let near_the_top = (900, 2); + + style(&picker, PickerStyle::Classic); + let (_, list_y) = place_near(&picker, near_the_top, screen); + + style(&picker, PickerStyle::Bear); + picker.set_halo(0.0); + let (_, bear_y) = place_near(&picker, near_the_top, screen); + let rise = crate::mascot::rise(&picker); + assert!( + f64::from(bear_y) >= f64::from(rise) * f64::from(picker.window().scale_factor()) - 1.0, + "the bear's head stays on the screen: {bear_y} for a rise of {rise}" + ); + assert!(bear_y > list_y, "the list needs no room above it"); + } + /// A window the system will not dress gets the painted sheet back, and the tiles their /// shadow, which changes the size the window has to be placed at. #[test] diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index da8d2ec..a4f73b7 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -10,14 +10,15 @@ use linkunbound_core::{ }; use linkunbound_shell::hotkey::Hotkey; use linkunbound_shell::tray::{Asked, Tray}; -use linkunbound_shell::{ - ICON_SIDE, Listed, Notice, Picker, Reaches, TILE_ICON_SIDE, dress, paint, place, single, -}; +use linkunbound_shell::{Listed, Notice, Picker, Reaches, dress, paint, single}; use slint::{ComponentHandle, Model}; mod detected; +mod held; mod look; +mod mascot; mod notice; +mod preview; use detected::catalogue; fn store() -> Store { @@ -281,17 +282,12 @@ fn with_icons( listed } -/// The side the icon is drawn at, in the pixels of the screen it is drawn on: a row's 24 or a -/// tile's 40, times the scale. Extracted at that and drawn one to one, it has edges; extracted +/// The side the icon is drawn at, in the pixels of the screen it is drawn on: what the look +/// draws it at, times the scale. Extracted at that and drawn one to one, it has edges; extracted /// at anything else and scaled, it has none. fn physical_icon_side(picker: &Picker) -> u32 { - let logical = if picker.get_classic() { - ICON_SIDE - } else { - TILE_ICON_SIDE - }; let scale = f64::from(picker.window().scale_factor()).max(1.0); - (f64::from(logical) * scale).round() as u32 + (f64::from(picker.get_drawn_icon_side()) * scale).round() as u32 } fn rows(icon_side: u32) -> Vec { @@ -524,13 +520,14 @@ fn present(picker: &Picker, words: &Strings, shown: &Rc>, arrival held.rows = listed; held.source = source; } - beside_the_pointer(picker); + look::beside_the_pointer(picker); let _ = picker.show(); if let Some(handle) = native_handle(picker.window()) { host::keep_off_the_taskbar(handle, look::corner_of(picker)); if look::wear_backdrop(picker, handle) { - beside_the_pointer(picker); + look::beside_the_pointer(picker); } + look::follow(picker); host::take_the_keyboard(handle); } return true; @@ -545,7 +542,7 @@ fn present(picker: &Picker, words: &Strings, shown: &Rc>, arrival if let Some(ui) = ui() { ui.refresh_strip(); } - beside_the_pointer(picker); + look::beside_the_pointer(picker); let asked_at = physical_icon_side(picker); let _ = picker.show(); if let Some(ui) = ui() { @@ -554,8 +551,9 @@ fn present(picker: &Picker, words: &Strings, shown: &Rc>, arrival if let Some(handle) = native_handle(picker.window()) { host::keep_off_the_taskbar(handle, look::corner_of(picker)); if look::wear_backdrop(picker, handle) { - beside_the_pointer(picker); + look::beside_the_pointer(picker); } + look::follow(picker); host::take_the_keyboard(handle); } // The window reports a scale of one until it exists, which is only now: the first picker of @@ -563,7 +561,7 @@ fn present(picker: &Picker, words: &Strings, shown: &Rc>, arrival // asked for at the wrong side. let drawn_at = physical_icon_side(picker); if drawn_at != asked_at { - beside_the_pointer(picker); + look::beside_the_pointer(picker); let redrawn = rows(drawn_at); let held = shown.borrow(); if let Some(url) = held.url.as_deref() { @@ -626,54 +624,13 @@ fn physical(logical: f32, scale: f64) -> i32 { (f64::from(logical) * scale).round() as i32 } -/// The picker belongs to the click that summoned it, not to the middle of a screen. -fn beside_the_pointer(picker: &Picker) { - let Some((cx, cy)) = host::cursor() else { - return; - }; - let Some((x, y, width, height)) = host::work_area_at(cx, cy) else { - return; - }; - let scale = if cfg!(target_os = "macos") { - 1.0 - } else { - f64::from(picker.window().scale_factor()) - }; - let size = |logical: f32| physical(logical, scale); - - let halo = size(picker.get_halo()); - let (at_x, at_y) = place::beside( - (cx, cy), - ( - size(picker.get_wanted_width()), - size(picker.get_wanted_height()), - ), - place::Bounds { - x, - y, - width, - height, - }, - ); - // The margin holds the shadow, not the sheet: the sheet's edge is what sits by the pointer, - // and the transparent rim may hang past the screen without anyone seeing it. - let (at_x, at_y) = (at_x - halo, at_y - halo); - #[cfg(target_os = "macos")] - picker - .window() - .set_position(slint::LogicalPosition::new(at_x as f32, at_y as f32)); - #[cfg(not(target_os = "macos"))] - picker - .window() - .set_position(slint::PhysicalPosition::new(at_x, at_y)); -} - /// Everything the event loop owns. Reached from the listener thread by way of /// `invoke_from_event_loop`, which is why it lives here and not in a closure: /// the state is `Rc` and cannot cross a thread, but a wake-up can. struct Ui { picker: Picker, notice: Notice, + mascot: mascot::Mascot, shown: Rc>, words: Cell, firing: RefCell>, @@ -952,7 +909,7 @@ impl Ui { match ui.shown_over.get() { None => ui.shown_over.set(Some(front)), Some(over) if over != front && !host::front_is_ours() => { - let _ = ui.picker.hide(); + look::put_away(&ui.picker); host::let_whoever_opens_next_come_forward(); ui.watch.stop(); next_in_line(&ui.picker, &ui.words.get(), &ui.shown); @@ -974,7 +931,7 @@ impl Ui { host::take_the_keyboard(ours); return; } - let _ = ui.picker.hide(); + look::put_away(&ui.picker); host::let_whoever_opens_next_come_forward(); ui.watch.stop(); // Whatever queued behind this link is still a click the user @@ -1029,13 +986,16 @@ fn announce(ui: &Rc, fired: &Fired) { /// reads it: waiting for a poll turned four milliseconds of work into sixty. fn arrived(raw: String) { let Some(ui) = ui() else { - hold_for_the_loop(raw); + held::hold_for_the_loop(raw); return; }; + ui.catch_up(); + if preview::shown_for(&ui, &raw) { + return; + } // The socket takes a line from any process of this user; the command line is // normalised and this has to be too, or the scheme guard is walked around. let Some(url) = normalise(&raw) else { return }; - ui.catch_up(); let source = host::clicked_in(); if let Some(fired) = answered_by_rule(&url, source.as_ref()) { @@ -1054,33 +1014,6 @@ fn arrived(raw: String) { } } -/// Links the socket took before the loop could: the copy that handed them over believes they -/// were delivered, so they are kept here until there is a window to deliver them to. -static HELD_FOR_THE_LOOP: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); - -fn hold_for_the_loop(url: String) { - if let Ok(mut held) = HELD_FOR_THE_LOOP.lock() { - held.push(url); - } -} - -fn release_to_the_loop() { - let held: Vec = HELD_FOR_THE_LOOP - .lock() - .map(|mut held| held.drain(..).collect()) - .unwrap_or_default(); - for url in held { - let _ = slint::invoke_from_event_loop(move || arrived(url)); - } -} - -fn handed_to_the_loop(url: String) { - let raw = url.clone(); - if slint::invoke_from_event_loop(move || arrived(url)).is_err() { - hold_for_the_loop(raw); - } -} - #[cfg(target_os = "macos")] #[derive(Debug, PartialEq, Eq)] enum Reaction { @@ -1158,12 +1091,12 @@ fn main() -> Result<(), slint::PlatformError> { let args: Vec = std::env::args_os() .map(|a| a.to_string_lossy().into_owned()) .collect(); - let incoming = link_from(&args); + let incoming = link_from(&args).or_else(|| preview::asked(&args)); // Sign-in and a click on the icon both start this with no link, and only one of them wants a // window: the startup task passes this, the Start menu tile does not. let hushed = wants_no_window(&args, incoming.as_deref()); - let _server = match single::claim(handed_to_the_loop) { + let _server = match single::claim(held::handed_to_the_loop) { Some(server) => server, None => { let Some(url) = incoming.as_deref() else { @@ -1180,7 +1113,7 @@ fn main() -> Result<(), slint::PlatformError> { // A resident that will not take the link is one that is no longer // there: the socket it held is free again, and leaving here dropped // a click already made. - match single::claim(handed_to_the_loop) { + match single::claim(held::handed_to_the_loop) { Some(server) => server, None => return Ok(()), } @@ -1207,6 +1140,7 @@ fn main() -> Result<(), slint::PlatformError> { without_taking_the_front()?; let picker = Picker::new()?; picker.set_on_mac(cfg!(target_os = "macos")); + mascot::listen(&picker); let shown = Rc::new(RefCell::new(Shown::default())); // Alt+F4 reaches the window as a close request, which Slint answers by hiding it: the queue @@ -1227,7 +1161,7 @@ fn main() -> Result<(), slint::PlatformError> { let Some(window) = handle.upgrade() else { return; }; - let _ = window.hide(); + look::put_away(&window); host::let_whoever_opens_next_come_forward(); if let Some(ui) = ui() { next_in_line(&window, &ui.words.get(), &shown); @@ -1270,7 +1204,7 @@ fn main() -> Result<(), slint::PlatformError> { let Some(window) = handle.upgrade() else { return; }; - if ui().is_some_and(|ui| too_soon(ui.appeared.get())) { + if ui().is_some_and(|ui| too_soon(ui.appeared.get())) || preview::answered(&window) { return; } // A reach the keys landed on while it was dead is no reach at all. @@ -1303,7 +1237,7 @@ fn main() -> Result<(), slint::PlatformError> { } let words = ui().map(|ui| ui.words.get()); if remember(&url, chosen, private, reach, source) { - let _ = window.hide(); + look::put_away(&window); drop(held); if let Some(words) = words { next_in_line(&window, &words, &shown); @@ -1377,6 +1311,7 @@ fn main() -> Result<(), slint::PlatformError> { let state = Rc::new(Ui { picker, notice, + mascot: mascot::Mascot::new()?, shown, words: Cell::new(spoken), firing: RefCell::new(None), @@ -1401,6 +1336,7 @@ fn main() -> Result<(), slint::PlatformError> { // Applied here rather than only on a later change: the tray was built // visible and the theme never left settings at all. state.obey(&store().prefs()); + notice::after_the_bear(&state); state.keep_looking(); UI.with_borrow_mut(|slot| *slot = Some(Rc::clone(&state))); @@ -1409,7 +1345,7 @@ fn main() -> Result<(), slint::PlatformError> { if let Some(url) = incoming { let _ = slint::invoke_from_event_loop(move || arrived(url)); } - release_to_the_loop(); + held::release_to_the_loop(); #[cfg(target_os = "macos")] if plain_launch { @@ -1548,11 +1484,12 @@ mod tests { } } - fn headless_ui() -> Rc { + pub(crate) fn headless_ui() -> Rc { i_slint_backend_testing::init_no_event_loop(); let ui = Rc::new(Ui { picker: Picker::new().expect("a window"), notice: Notice::new().expect("a notice"), + mascot: super::mascot::Mascot::new().expect("a mascot"), shown: Rc::new(RefCell::new(Shown::default())), words: Cell::new(Language::English.strings()), firing: RefCell::new(None), @@ -1628,7 +1565,7 @@ mod tests { assert_eq!(ui.shown.borrow().url.as_deref(), Some("https://two.test/")); assert!(ui.shown.borrow().waiting.is_empty()); - let _ = ui.picker.hide(); + super::look::put_away(&ui.picker); ui.shown .borrow_mut() .waiting diff --git a/crates/linkunbound-shell/src/mascot.rs b/crates/linkunbound-shell/src/mascot.rs new file mode 100644 index 0000000..7c8cdab --- /dev/null +++ b/crates/linkunbound-shell/src/mascot.rs @@ -0,0 +1,199 @@ +//! The bear that peeks over the picker: its head under the glass, its paws on the edge. + +use linkunbound_shell::{BearHead, BearPaws, Look, Picker}; +use slint::ComponentHandle; + +/// Sizes in logical pixels: the head, how far it rises over the edge, a paw, the room between +/// the paws, and where across the picker the head is centred. +struct Pose { + head: f32, + rise: f32, + paw: f32, + gap: f32, +} + +const BEAR: Pose = Pose { + head: 92.0, + rise: 62.0, + paw: 30.0, + gap: 70.0, +}; +const COMPACT: Pose = Pose { + head: 66.0, + rise: 34.0, + paw: 20.0, + gap: 46.0, +}; + +/// The pose the look calls for, if the bear shows at all: only over the glass, since on a painted +/// The pose the look calls for, if the bear shows at all. Its head sits under the picker, so on +/// glass the hidden half shows blurred and on a painted sheet it is simply behind it. +fn pose(picker: &Picker) -> Option<&'static Pose> { + match picker.get_look() { + Look::Bear => Some(&BEAR), + Look::Compact => Some(&COMPACT), + _ => None, + } +} + +/// How far above the picker the bear reaches, in logical pixels. +pub fn rise(picker: &Picker) -> f32 { + pose(picker).map_or(0.0, |pose| pose.rise) +} + +pub struct Mascot { + head: BearHead, + paws: BearPaws, + /// Where the picker was last put, as asked: Slint moves the window later, so reading it back + /// right after gives the place it is leaving. + anchor: std::cell::Cell>, +} + +impl Mascot { + pub fn new() -> Result { + Ok(Self { + head: BearHead::new()?, + paws: BearPaws::new()?, + anchor: std::cell::Cell::new(None), + }) + } + + pub fn anchor(&self, x: i32, y: i32) { + self.anchor.set(Some((x, y))); + } + + pub fn follow(&self, picker: &Picker) { + let Some(pose) = pose(picker) else { + self.hide(); + return; + }; + self.head.set_side(pose.head); + self.paws.set_paw(pose.paw); + self.paws.set_gap(pose.gap); + // Measured from what the look asks for, not from the window: right after a change of + // look or place the window still reports the size and spot it had before. + let window = picker.window(); + let scale = window.scale_factor(); + let (x, y) = self + .anchor + .get() + .unwrap_or((window.position().x, window.position().y)); + let at = slint::PhysicalPosition::new(x, y); + let px = |logical: f32| (logical * scale).round() as i32; + let centre = at.x + px(picker.get_wanted_width() / 2.0); + // A painted sheet keeps a margin for its shadow; the bear leans on the sheet, not on it. + let top = at.y + px(picker.get_halo()); + self.head + .window() + .set_position(slint::PhysicalPosition::new( + centre - px(pose.head / 2.0), + top - px(pose.rise), + )); + self.paws + .window() + .set_position(slint::PhysicalPosition::new( + centre - px(pose.gap / 2.0 + pose.paw), + top - px(pose.paw * 0.3), + )); + self.mood(0.0, picker.get_private_on()); + let _ = self.head.show(); + let _ = self.paws.show(); + stack(&self.head, &self.paws, picker); + } + + pub fn mood(&self, aim: f32, masked: bool) { + self.head.set_look(aim); + self.head.set_masked(masked); + } + + pub fn hide(&self) { + let _ = self.head.hide(); + let _ = self.paws.hide(); + } +} + +/// The picker says where the bear should look and whether it wears the mask, and an empty slot +/// sends the person to settings to fill it. +pub fn listen(picker: &Picker) { + picker.on_mood(|aim, masked| { + if let Some(ui) = crate::ui() { + ui.mascot.mood(aim, masked); + } + }); + let handle = picker.as_weak(); + picker.on_add_browser(move || { + if let Some(picker) = handle.upgrade() { + crate::look::put_away(&picker); + } + crate::run_settings(&[linkunbound_core::BROWSERS]); + }); +} + +#[cfg(windows)] +fn stack(head: &BearHead, paws: &BearPaws, picker: &Picker) { + use crate::native_handle; + let (Some(head), Some(paws), Some(picker)) = ( + native_handle(head.window()), + native_handle(paws.window()), + native_handle(picker.window()), + ) else { + return; + }; + for window in [head, paws] { + linkunbound_win::keep_off_the_taskbar(window); + linkunbound_win::never_activates(window); + } + linkunbound_win::tuck_under(head, picker); +} + +#[cfg(not(windows))] +fn stack(_head: &BearHead, _paws: &BearPaws, _picker: &Picker) {} + +#[cfg(test)] +mod tests { + use super::{BEAR, COMPACT, Mascot, rise}; + use linkunbound_shell::{Look, Palette, Picker}; + use slint::ComponentHandle; + + fn picker(look: Look, clear: bool) -> Picker { + let picker = Picker::new().expect("a window"); + picker.set_look(look); + picker.global::().set_clear(clear); + picker + } + + /// The bear rises over the glass only, and only in the two looks drawn for it. + #[test] + fn the_bear_needs_room_only_where_it_shows() { + i_slint_backend_testing::init_no_event_loop(); + assert_eq!(rise(&picker(Look::Bear, true)), BEAR.rise); + assert_eq!(rise(&picker(Look::Compact, true)), COMPACT.rise); + assert_eq!(rise(&picker(Look::List, true)), 0.0); + assert_eq!( + rise(&picker(Look::Bear, false)), + BEAR.rise, + "a painted sheet has the bear behind it too" + ); + } + + #[test] + fn the_bear_comes_with_the_picker_and_follows_its_mood() { + i_slint_backend_testing::init_no_event_loop(); + let mascot = Mascot::new().expect("a bear"); + let over = picker(Look::Bear, true); + over.set_private_on(true); + mascot.follow(&over); + assert!(mascot.head.window().is_visible()); + assert!(mascot.paws.window().is_visible()); + assert!(mascot.head.get_masked()); + assert_eq!(mascot.head.get_side(), BEAR.head); + + mascot.mood(0.7, false); + assert_eq!(mascot.head.get_look(), 0.7); + assert!(!mascot.head.get_masked()); + + mascot.follow(&picker(Look::Tiles, true)); + assert!(!mascot.head.window().is_visible()); + assert!(!mascot.paws.window().is_visible()); + } +} diff --git a/crates/linkunbound-shell/src/notice.rs b/crates/linkunbound-shell/src/notice.rs index 2ae034a..c28fe82 100644 --- a/crates/linkunbound-shell/src/notice.rs +++ b/crates/linkunbound-shell/src/notice.rs @@ -1,8 +1,11 @@ -use linkunbound_core::Strings; +use std::rc::Rc; +use std::time::Duration; + +use linkunbound_core::{PickerStyle, Strings}; use linkunbound_shell::Notice; use slint::ComponentHandle; -use crate::{host, native_handle, physical, store, ui}; +use crate::{Ui, host, native_handle, physical, store, ui}; const NOTICE_SECONDS: i32 = 6; const NOTICE_MARGIN: f32 = 16.0; @@ -13,6 +16,7 @@ pub fn flash(notice: &Notice, headline: String, reason: String, undo: Option<&st notice.set_reason(reason.into()); notice.set_undo_label(undo.unwrap_or_default().into()); notice.set_undoable(undo.is_some()); + notice.set_choices(slint::ModelRc::default()); notice.set_left(NOTICE_SECONDS); let _ = notice.show(); if let Some(handle) = native_handle(notice.window()) { @@ -26,6 +30,77 @@ pub fn flash(notice: &Notice, headline: String, reason: String, undo: Option<&st } } +/// The looks the notice offers, in the order it draws them: the bear first, as the one in use. +const OFFERED: [PickerStyle; 4] = [ + PickerStyle::Bear, + PickerStyle::Classic, + PickerStyle::Sheet, + PickerStyle::Compact, +]; +const STYLES_SECONDS: i32 = 20; + +/// Once, after the update moved everybody to the bear: whatever look they had stays one press +/// away, in the corner, instead of three windows deep in settings. +pub fn after_the_bear(state: &Rc) { + let mut left_behind = None; + let _ = store().edit_prefs(|prefs| { + let before = prefs.schema_version; + left_behind = prefs.meets_the_bear(); + prefs.schema_version != before + }); + if left_behind.is_none() { + return; + } + state.obey(&store().prefs()); + state.notice.on_chose(|at| { + let Some(ui) = ui() else { return }; + let chosen = OFFERED[usize::try_from(at).unwrap_or(0).min(OFFERED.len() - 1)]; + let _ = store().edit_prefs(|prefs| { + prefs.picker_style = chosen; + true + }); + ui.obey(&store().prefs()); + ui.countdown.stop(); + let _ = ui.notice.hide(); + }); + // Shown from inside the loop: before it runs the window has no handle to keep it off the + // taskbar. + slint::Timer::single_shot(Duration::ZERO, || { + if let Some(ui) = ui() { + offer_styles(&ui); + } + }); +} + +fn offer_styles(ui: &Rc) { + { + let words = ui.words.get(); + flash( + &ui.notice, + words.styles_headline.to_owned(), + words.styles_reason.to_owned(), + None, + ); + let names: Vec = OFFERED + .iter() + .map(|style| { + match style { + PickerStyle::Bear => words.style_bear, + PickerStyle::Classic => words.style_classic, + PickerStyle::Sheet => words.style_sheet, + PickerStyle::Compact => words.style_compact, + } + .into() + }) + .collect(); + ui.notice + .set_choices(Rc::new(slint::VecModel::from(names)).into()); + ui.notice.set_left(STYLES_SECONDS); + in_the_corner(&ui.notice); + ui.count_down(); + } +} + pub fn noted(area: &'static str, what: &str) { linkunbound_core::note(store().dir(), area, what); } @@ -74,7 +149,33 @@ fn in_the_corner(notice: &Notice) { #[cfg(test)] mod tests { - use super::flash; + use super::{OFFERED, STYLES_SECONDS, flash, offer_styles}; + use slint::Model; + + /// The look in use comes first and every other one is a press away, for longer than a notice + /// about a rule stays. + #[test] + fn the_looks_are_offered_with_the_bear_first() { + let ui = crate::tests::headless_ui(); + offer_styles(&ui); + let choices = ui.notice.get_choices(); + assert_eq!(choices.row_count(), OFFERED.len()); + assert_eq!(choices.row_data(0).as_deref(), Some("Bear")); + assert_eq!(ui.notice.get_left(), STYLES_SECONDS); + assert!(!ui.notice.get_undoable()); + + flash( + &ui.notice, + "Opened".into(), + "by a rule".into(), + Some("Undo"), + ); + assert_eq!( + ui.notice.get_choices().row_count(), + 0, + "a later notice offers no looks" + ); + } use linkunbound_shell::Notice; #[test] diff --git a/crates/linkunbound-shell/src/preview.rs b/crates/linkunbound-shell/src/preview.rs new file mode 100644 index 0000000..bff4ed7 --- /dev/null +++ b/crates/linkunbound-shell/src/preview.rs @@ -0,0 +1,85 @@ +//! The picker settings shows so a look can be seen before it is kept: a sample link that no rule +//! answers and no choice opens. + +use std::rc::Rc; + +use linkunbound_core::PREVIEW; +use linkunbound_shell::Picker; +use slint::ComponentHandle; + +use crate::{Arrival, Ui, present}; + +const SAMPLE: &str = "https://www.linkunbound.app/"; + +pub fn asked(args: &[String]) -> Option { + args.iter() + .any(|a| a == PREVIEW) + .then(|| PREVIEW.to_owned()) +} + +/// Whether the line was the preview, shown or not: over a picker in use it waits for nobody, and +/// over a preview it starts again, so a look chosen meanwhile is drawn from scratch. Nothing +/// watches it lose the front: it stays while the person goes back to settings to compare, and +/// goes with Esc or a choice. +pub fn shown_for(ui: &Rc, raw: &str) -> bool { + if raw != PREVIEW { + return false; + } + if ui.picker.window().is_visible() && ui.picker.get_preview() { + crate::look::put_away(&ui.picker); + } + let sample = Arrival { + url: SAMPLE.to_owned(), + source: None, + }; + if !ui.picker.window().is_visible() && present(&ui.picker, &ui.words.get(), &ui.shown, sample) { + ui.picker.set_preview(true); + ui.picker + .set_source_line(ui.words.get().picker_preview.into()); + } + true +} + +/// A choice in the preview puts it away and opens nothing. +pub fn answered(picker: &Picker) -> bool { + if !picker.get_preview() { + return false; + } + crate::look::put_away(picker); + true +} + +#[cfg(test)] +mod tests { + use super::{SAMPLE, answered, asked, shown_for}; + use linkunbound_core::PREVIEW; + + fn args(said: &[&str]) -> Vec { + said.iter().map(|&a| a.to_owned()).collect() + } + + #[test] + fn only_the_word_settings_sends_asks_for_the_preview() { + assert_eq!( + asked(&args(&["shell.exe", PREVIEW])).as_deref(), + Some(PREVIEW) + ); + assert_eq!(asked(&args(&["shell.exe", "https://a.test/"])), None); + } + + /// The sample opens with nothing behind it: no rule, and a choice only puts it away. + #[test] + fn the_preview_shows_the_sample_and_a_choice_opens_nothing() { + let ui = crate::tests::headless_ui(); + assert!( + !shown_for(&ui, "https://a.test/"), + "a link is not the preview" + ); + assert!(!answered(&ui.picker), "a real picker is answered as usual"); + + assert!(shown_for(&ui, PREVIEW)); + assert!(ui.picker.get_preview()); + assert_eq!(ui.shown.borrow().url.as_deref(), Some(SAMPLE)); + assert!(answered(&ui.picker)); + } +} diff --git a/crates/linkunbound-shell/ui/bear.slint b/crates/linkunbound-shell/ui/bear.slint new file mode 100644 index 0000000..c806379 --- /dev/null +++ b/crates/linkunbound-shell/ui/bear.slint @@ -0,0 +1,146 @@ +// The mascot drawn from the same shapes as resources/mascot/linkunbound-head.svg. +component Shape inherits Path { + width: 100%; + height: 100%; + viewbox-x: 44; + viewbox-y: 60; + viewbox-width: 312; + viewbox-height: 256; +} + +component Head inherits Rectangle { + in property masked; + in property look; + property k: self.width / 312px; + + Shape { + commands: "M79 98 a29 29 0 1 0 58 0 a29 29 0 1 0 -58 0 Z M263 98 a29 29 0 1 0 58 0 a29 29 0 1 0 -58 0 Z"; + fill: #0C2A7A; + } + Shape { + commands: "M97 100 a11 11 0 1 0 22 0 a11 11 0 1 0 -22 0 Z M281 100 a11 11 0 1 0 22 0 a11 11 0 1 0 -22 0 Z"; + fill: #A9C8FF; + } + Shape { + commands: "M200 72 C262 72 310 96 328 148 L348 172 L326 186 L346 214 L320 224 C316 276 272 304 200 304 C128 304 84 276 80 224 L54 214 L74 186 L52 172 L72 148 C90 96 138 72 200 72 Z"; + fill: #0C2A7A; + stroke: #0C2A7A; + stroke-width: 8px * root.k; + } + Shape { + commands: "M130 100 Q200 84 270 100"; + stroke: #2D55C8; + stroke-width: 7px * root.k; + } + Shape { + commands: "M108 154 Q140 138 176 152 M292 154 Q260 138 224 152"; + stroke: #3E6BE0; + stroke-width: 11px * root.k; + } + if root.masked: Shape { + commands: "M96 160 Q200 140 304 160 L300 200 Q200 186 100 200 Z"; + fill: #6B4BD8; + } + Shape { + commands: "M132 178 a10 10 0 1 0 20 0 a10 10 0 1 0 -20 0 Z M248 178 a10 10 0 1 0 20 0 a10 10 0 1 0 -20 0 Z"; + fill: #FFFFFF; + } + Shape { + x: root.look * 6px * root.k; + commands: "M138.5 180 a5.5 5.5 0 1 0 11 0 a5.5 5.5 0 1 0 -11 0 Z M254.5 180 a5.5 5.5 0 1 0 11 0 a5.5 5.5 0 1 0 -11 0 Z"; + fill: #0C2A7A; + } + Shape { + commands: "M138 246 a62 50 0 1 0 124 0 a62 50 0 1 0 -124 0 Z"; + fill: #DCE9FF; + } + Shape { + commands: "M174 214 Q200 202 226 214 Q228 234 200 244 Q172 234 174 214 Z"; + fill: #0C2A7A; + stroke: #0C2A7A; + stroke-width: 6px * root.k; + } + Shape { + commands: "M200 244 L200 262 M176 270 Q200 286 224 270"; + stroke: #0C2A7A; + stroke-width: 5px * root.k; + } +} + +component PawShape inherits Path { + width: 100%; + height: 100%; + viewbox-width: 40; + viewbox-height: 31; +} + +// The renderer blurs nothing, so the shadow is the paw itself, offset twice and fading. +component Paw inherits Rectangle { + property k: self.width / 40px; + PawShape { + y: 3px * root.k; + commands: "M4 13 V6 Q4 0 10 0 H30 Q36 0 36 6 V13 Z M4.5 12.5 a3.5 3.5 0 0 1 7 0 V18.5 a3.5 3.5 0 0 1 -7 0 Z M12.5 12.5 a3.5 3.5 0 0 1 7 0 V21 a3.5 3.5 0 0 1 -7 0 Z M20.5 12.5 a3.5 3.5 0 0 1 7 0 V21 a3.5 3.5 0 0 1 -7 0 Z M28.5 12.25 a3.25 3.25 0 0 1 6.5 0 V18.25 a3.25 3.25 0 0 1 -6.5 0 Z"; + fill: #00000026; + } + PawShape { + y: 1.5px * root.k; + commands: "M4 13 V6 Q4 0 10 0 H30 Q36 0 36 6 V13 Z M4.5 12.5 a3.5 3.5 0 0 1 7 0 V18.5 a3.5 3.5 0 0 1 -7 0 Z M12.5 12.5 a3.5 3.5 0 0 1 7 0 V21 a3.5 3.5 0 0 1 -7 0 Z M20.5 12.5 a3.5 3.5 0 0 1 7 0 V21 a3.5 3.5 0 0 1 -7 0 Z M28.5 12.25 a3.25 3.25 0 0 1 6.5 0 V18.25 a3.25 3.25 0 0 1 -6.5 0 Z"; + fill: #00000059; + } + PawShape { + commands: "M4 13 V6 Q4 0 10 0 H30 Q36 0 36 6 V13 Z M4.5 12.5 a3.5 3.5 0 0 1 7 0 V18.5 a3.5 3.5 0 0 1 -7 0 Z M12.5 12.5 a3.5 3.5 0 0 1 7 0 V21 a3.5 3.5 0 0 1 -7 0 Z M20.5 12.5 a3.5 3.5 0 0 1 7 0 V21 a3.5 3.5 0 0 1 -7 0 Z M28.5 12.25 a3.25 3.25 0 0 1 6.5 0 V18.25 a3.25 3.25 0 0 1 -6.5 0 Z"; + fill: #0C2A7A; + } + PawShape { + commands: "M12 5.5 a8 3.2 0 1 0 16 0 a8 3.2 0 1 0 -16 0 Z"; + fill: #2D55C88C; + } + PawShape { + commands: "M5.7 12.5 a1 1 0 0 1 2 0 V18 a1 1 0 0 1 -2 0 Z M13.7 12.5 a1 1 0 0 1 2 0 V20.5 a1 1 0 0 1 -2 0 Z M21.7 12.5 a1 1 0 0 1 2 0 V20.5 a1 1 0 0 1 -2 0 Z M29.7 12.5 a1 1 0 0 1 2 0 V17.5 a1 1 0 0 1 -2 0 Z"; + fill: #3E6BE099; + } + PawShape { + commands: "M5.9 15 Q8 16.4 10.1 15 M13.9 15 Q16 16.4 18.1 15 M21.9 15 Q24 16.4 26.1 15 M29.9 15 Q31.75 16.4 33.6 15"; + stroke: #06184A; + stroke-width: 1px * root.k; + } +} + +// Below the picker, so the glass blurs the half it covers. +export component BearHead inherits Window { + in property masked; + in property look; + in property side: 92px; + title: "LinkUnbound"; + no-frame: true; + always-on-top: true; + background: transparent; + width: root.side; + height: root.side * 256 / 312; + + Head { + masked: root.masked; + look: root.look; + } +} + +// Above the picker: the fingers rest on its top edge. +export component BearPaws inherits Window { + in property paw: 30px; + in property gap: 70px; + title: "LinkUnbound"; + no-frame: true; + always-on-top: true; + background: transparent; + width: root.gap + 2 * root.paw; + height: root.paw * 31 / 40; + + Paw { + x: 0; + width: root.paw; + } + Paw { + x: root.gap + root.paw; + width: root.paw; + } +} diff --git a/crates/linkunbound-shell/ui/looks.slint b/crates/linkunbound-shell/ui/looks.slint new file mode 100644 index 0000000..c394b3c --- /dev/null +++ b/crates/linkunbound-shell/ui/looks.slint @@ -0,0 +1,469 @@ +import { Destination, Palette, KeyCap, Badge, Tools } from "parts.slint"; + +// The rows: key, icon, name, profile; nine on screen and the rest scroll under them. +export component ListLook inherits Rectangle { + in property <[Destination]> rows; + in property icon: 24px; + in property private-on; + in property chosen-at; + in property room; + callback open(int); + callback hovered(int); + + Flickable { + height: root.room; + content-height: root.rows.length * 45px + 12px; + VerticalLayout { + padding: 6px; + spacing: 1px; + for item[i] in root.rows: Rectangle { + property greyed: root.private-on && !item.can-private; + property lit: touch.has-hover || i == root.chosen-at; + height: 44px; + border-radius: 6px; + background: self.lit && !self.greyed ? Palette.hover : transparent; + opacity: self.greyed ? 0.45 : 1.0; + accessible-role: button; + accessible-label: item.profile != "" ? item.name + " · " + item.profile : item.name; + accessible-action-default => { root.open(i); } + touch := TouchArea { + enabled: !parent.greyed; + clicked => { root.open(i); } + changed has-hover => { root.hovered(self.has-hover ? i : -1); } + } + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + spacing: 11px; + Rectangle { + width: 22px; + KeyCap { + y: (parent.height - self.height) / 2; + key: item.key; + } + } + Badge { + y: (parent.height - self.height) / 2; + width: root.icon; + height: root.icon; + icon: item.icon; + secret: root.private-on && item.can-private; + } + Text { + text: item.name; + font-size: 15px; + color: Palette.ink; + vertical-alignment: center; + overflow: elide; + horizontal-stretch: 1; + } + if item.profile != "": Rectangle { + horizontal-stretch: 0; + y: (parent.height - self.height) / 2; + height: 20px; + width: profile.preferred-width + 16px; + border-radius: 10px; + background: Palette.chip; + profile := Text { + text: item.profile; + font-size: 12px; + color: Palette.dim; + horizontal-alignment: center; + vertical-alignment: center; + width: 100%; + height: 100%; + } + } + } + } + } + } +} + +// Tiles of one size, at most four to a row, the last row centred; the key in the corner. +export component TilesLook inherits Rectangle { + in property <[Destination]> rows; + in property private-on; + in property chosen-at; + in property cols; + in property tile-width; + callback open(int); + callback hovered(int); + + for item[i] in root.rows: Rectangle { + property row-at: Math.floor(i / root.cols); + property col-at: i - self.row-at * root.cols; + property in-row: min(root.cols, root.rows.length - self.row-at * root.cols); + property greyed: root.private-on && !item.can-private; + property lit: touch.has-hover || i == root.chosen-at; + x: (parent.width - (self.in-row * root.tile-width + (self.in-row - 1) * 6px)) / 2 + + self.col-at * (root.tile-width + 6px); + y: 8px + self.row-at * 106px; + width: root.tile-width; + height: 100px; + border-radius: 8px; + background: self.lit && !self.greyed ? Palette.hover : transparent; + opacity: self.greyed ? 0.4 : 1.0; + accessible-role: button; + accessible-label: item.profile != "" ? item.name + " · " + item.profile : item.name; + accessible-action-default => { root.open(i); } + touch := TouchArea { + enabled: !parent.greyed; + clicked => { root.open(i); } + changed has-hover => { root.hovered(self.has-hover ? i : -1); } + } + KeyCap { + x: 6px; + y: 6px; + key: item.key; + } + Badge { + x: (parent.width - self.width) / 2; + y: 12px; + width: 48px; + height: 48px; + icon: item.icon; + secret: root.private-on && item.can-private; + } + Text { + x: 4px; + y: 64px; + width: parent.width - 8px; + height: 17px; + text: item.name; + font-size: 13px; + color: Palette.ink; + horizontal-alignment: center; + vertical-alignment: center; + overflow: elide; + } + if item.profile != "": Rectangle { + x: (parent.width - self.width) / 2; + y: 82px; + height: 16px; + width: min(parent.width - 12px, tag.preferred-width + 14px); + border-radius: 8px; + background: Palette.chip; + tag := Text { + text: item.profile; + font-size: 11px; + color: Palette.dim; + horizontal-alignment: center; + vertical-alignment: center; + overflow: elide; + width: 100%; + height: 100%; + } + } + } +} + +// A border drawn in dashes: the renderer strokes no dashed line, so each dash is a sliver. +component Dashes inherits Rectangle { + in property ink; + property across: Math.floor(self.width / 9px); + property down: Math.floor(self.height / 9px); + for at in root.across: Rectangle { + x: at * 9px + 2px; + y: 0; + width: 5px; + height: 1px; + background: root.ink; + } + for at in root.across: Rectangle { + x: at * 9px + 2px; + y: root.height - 1px; + width: 5px; + height: 1px; + background: root.ink; + } + for at in root.down: Rectangle { + x: 0; + y: at * 9px + 2px; + width: 1px; + height: 5px; + background: root.ink; + } + for at in root.down: Rectangle { + x: root.width - 1px; + y: at * 9px + 2px; + width: 1px; + height: 5px; + background: root.ink; + } +} + +// The bear's cards: three columns of buttons, the profile under the name and the key at the end. +export component CardsLook inherits Rectangle { + in property <[Destination]> rows; + in property private-on; + in property chosen-at; + in property room; + // With one or two browsers the row is filled with a way to add another. + in property empty; + in property add; + in property add-more; + callback open(int); + callback hovered(int); + callback adding(); + property card-width: (self.width - 20px - 2 * 6px) / 3; + + Flickable { + height: root.room; + content-height: Math.ceil(root.rows.length / 3) * 58px + 10px; + for item[i] in root.rows: Rectangle { + property greyed: root.private-on && !item.can-private; + property lit: touch.has-hover || i == root.chosen-at; + x: 10px + Math.mod(i, 3) * (root.card-width + 6px); + y: 8px + Math.floor(i / 3) * 58px; + width: root.card-width; + height: 52px; + border-radius: 8px; + background: self.lit && !self.greyed ? Palette.brand-soft : Palette.chip; + border-width: self.lit && !self.greyed ? 1.5px : 0px; + border-color: Palette.brand; + opacity: self.greyed ? 0.4 : 1.0; + accessible-role: button; + accessible-label: item.profile != "" ? item.name + " · " + item.profile : item.name; + accessible-action-default => { root.open(i); } + touch := TouchArea { + enabled: !parent.greyed; + clicked => { root.open(i); } + changed has-hover => { root.hovered(self.has-hover ? i : -1); } + } + HorizontalLayout { + padding-left: 8px; + padding-right: 6px; + spacing: 8px; + Badge { + y: (parent.height - self.height) / 2; + width: 32px; + height: 32px; + icon: item.icon; + secret: root.private-on && item.can-private; + } + VerticalLayout { + alignment: center; + spacing: 1px; + horizontal-stretch: 1; + Text { + text: item.short; + font-size: 13px; + font-weight: 600; + color: Palette.ink; + overflow: elide; + } + if item.profile != "": Text { + text: item.profile; + font-size: 12px; + color: Palette.dim; + overflow: elide; + } + } + Rectangle { + width: 20px; + KeyCap { + x: parent.width - self.width; + y: 6px; + key: item.key; + lit: (touch.has-hover || i == root.chosen-at) && !(root.private-on && !item.can-private); + } + } + } + } + for slot in root.empty: Rectangle { + x: 10px + (root.rows.length + slot) * (root.card-width + 6px); + y: 8px; + width: root.card-width; + height: 52px; + border-radius: 8px; + background: add-touch.has-hover ? Palette.brand-soft : transparent; + accessible-role: button; + accessible-label: root.add + " " + root.add-more; + accessible-action-default => { root.adding(); } + Dashes { + ink: add-touch.has-hover ? Palette.brand : Palette.faint; + } + add-touch := TouchArea { + clicked => { root.adding(); } + } + HorizontalLayout { + padding-left: 12px; + padding-right: 8px; + spacing: 10px; + Text { + text: "+"; + font-size: 22px; + color: add-touch.has-hover ? Palette.brand : Palette.faint; + vertical-alignment: center; + } + VerticalLayout { + alignment: center; + spacing: 1px; + Text { + text: root.add; + font-size: 12.5px; + font-weight: 600; + color: add-touch.has-hover ? Palette.brand : Palette.dim; + overflow: elide; + } + Text { + text: root.add-more; + font-size: 11.5px; + color: Palette.faint; + overflow: elide; + } + } + } + } + } +} + +// Only icons: the address in a small line, the keys under the icons, the name of the one aimed +// at under them, and what to remember in a pill that Tab turns. +export component CompactLook inherits Rectangle { + in property <[Destination]> rows; + in property private-on; + in property chosen-at; + in property hovered-at; + in property host; + in property trail; + in property motto; + in property private-line; + in property private-label; + in property copied; + in property copy-label; + in property remember-label; + in property reach-said; + in property reach-keeps; + callback open(int); + callback hovered(int); + callback toggle-private(); + callback copy(); + callback turn-reach(); + property aimed: root.hovered-at >= 0 ? root.hovered-at : root.chosen-at; + + VerticalLayout { + padding-left: 10px; + padding-right: 10px; + padding-top: 12px; + padding-bottom: 10px; + spacing: 6px; + HorizontalLayout { + height: 28px; + spacing: 6px; + HorizontalLayout { + horizontal-stretch: 1; + alignment: start; + padding-left: 4px; + Text { + text: root.host; + font-size: 12.5px; + font-weight: 600; + color: Palette.ink; + vertical-alignment: center; + } + Text { + text: root.trail; + font-size: 12.5px; + color: Palette.dimmer; + vertical-alignment: center; + overflow: elide; + } + } + Tools { + private-on: root.private-on; + private-label: root.private-label; + copied: root.copied; + copy-label: root.copy-label; + toggle-private => { root.toggle-private(); } + copy => { root.copy(); } + } + } + HorizontalLayout { + alignment: center; + spacing: 4px; + height: 72px; + for item[i] in root.rows: Rectangle { + property greyed: root.private-on && !item.can-private; + property lit: touch.has-hover || i == root.chosen-at; + width: 50px; + border-radius: 8px; + background: i == root.chosen-at ? Palette.brand-soft : (touch.has-hover ? Palette.hover : transparent); + border-width: i == root.chosen-at ? 1.5px : 0px; + border-color: Palette.brand; + opacity: self.greyed ? 0.4 : 1.0; + accessible-role: button; + accessible-label: item.profile != "" ? item.name + " · " + item.profile : item.name; + accessible-action-default => { root.open(i); } + touch := TouchArea { + enabled: !parent.greyed; + clicked => { root.open(i); } + changed has-hover => { root.hovered(self.has-hover ? i : -1); } + } + Badge { + x: (parent.width - self.width) / 2; + y: 10px; + width: 32px; + height: 32px; + icon: item.icon; + secret: root.private-on && item.can-private; + } + KeyCap { + x: (parent.width - self.width) / 2; + y: 46px; + key: item.key; + lit: i == root.chosen-at; + } + } + } + Text { + height: 20px; + horizontal-alignment: center; + vertical-alignment: center; + overflow: elide; + font-size: 13px; + text: root.private-on ? root.private-line + : root.aimed >= 0 && root.aimed < root.rows.length + ? (root.rows[root.aimed].profile != "" ? root.rows[root.aimed].name + " · " + root.rows[root.aimed].profile : root.rows[root.aimed].name) + : root.motto; + color: root.private-on ? Palette.secret : (root.aimed >= 0 ? Palette.ink : Palette.dim); + font-italic: !root.private-on && root.aimed < 0; + } + HorizontalLayout { + alignment: center; + height: 26px; + Rectangle { + width: pill.preferred-width + 24px; + border-radius: 13px; + background: turn.has-hover ? Palette.hover : Palette.track; + pill := HorizontalLayout { + x: 12px; + spacing: 5px; + Text { + text: root.remember-label + ":"; + font-size: 12.5px; + color: Palette.dim; + vertical-alignment: center; + } + Text { + text: root.reach-said; + font-size: 12.5px; + font-weight: 600; + color: root.reach-keeps ? Palette.brand : Palette.ink; + vertical-alignment: center; + max-width: 180px; + overflow: elide; + } + KeyCap { + y: 3px; + key: "Tab"; + } + } + turn := TouchArea { + clicked => { root.turn-reach(); } + } + } + } + } +} diff --git a/crates/linkunbound-shell/ui/parts.slint b/crates/linkunbound-shell/ui/parts.slint new file mode 100644 index 0000000..93130f9 --- /dev/null +++ b/crates/linkunbound-shell/ui/parts.slint @@ -0,0 +1,608 @@ +export struct Destination { + key: string, + name: string, + short: string, + profile: string, + icon: image, + can-private: bool, +} + +export struct Reach { + label: string, + full: string, + keeps: bool, + dead: bool, + index: int, +} + +export global Palette { + in property light: false; + // Over Acrylic the system paints the material, the corners and the edge of the picker. + in property clear: false; + // Laid over Acrylic: the blur is the system's, the tone and the lit edge stay the picker's. + out property veil: light + ? @linear-gradient(180deg, #FFFFFFC4 0%, #F3F5F9CC 100%) + : @linear-gradient(180deg, #282B36C4 0%, #1A1C23CC 100%); + out property rim: light ? #FFFFFFE6 : #FFFFFF2E; + // Without a material: the same tone drawn by the picker, a touch see-through unless the system + // asked for no transparency, with the accent's glint coming in at the top corner. + in property see-through: true; + out property pane: light + ? (see-through ? @linear-gradient(180deg, #FFFFFFF8 0%, #F3F5F9FA 100%) : @linear-gradient(180deg, #FFFFFF 0%, #F3F5F9 100%)) + : (see-through ? @linear-gradient(180deg, #282B36F8 0%, #1A1C23FA 100%) : @linear-gradient(180deg, #282B36 0%, #1A1C23 100%)); + out property glint: light + ? @linear-gradient(135deg, #3E6BE017 0%, #3E6BE000 45%) + : @linear-gradient(135deg, #7FA2FF24 0%, #7FA2FF00 45%); + + out property ground: light ? #FFFFFF : #191A1F; + // Header and footer sit a shade apart from the list, which is what tells the zones apart. + out property band: light ? #F8F9FB : #1C1D23; + out property ink: light ? #16181D : #F0F1F4; + out property dim: light ? #555B6A : #AAB1C0; + out property dimmer: light ? #5F6575 : #9CA3B3; + out property line: light ? #00000014 : #ffffff14; + out property hover: light ? #0000000b : #ffffff12; + out property chip: light ? #0000000e : #ffffff10; + out property key-bg: light ? #00000010 : #ffffff14; + out property key-ink: light ? #5F6575 : #B3BAC9; + out property accent: light ? #2F62D8 : #6E9BFF; + // Literals here were picked for a dark ground and sat at 2.2:1 on a light + // one, which is where the launch failures are printed. + out property wrong: light ? #B3261E : #FF8A85; + out property caution: light ? #8A5200 : #E9A05C; + out property accent-soft: light ? #2F62D821 : #6E9BFF2b; + out property neutral: light ? #00000017 : #ffffff1c; + out property neutral-ink: light ? #3A3F4B : #D6DAE3; + out property edge: light ? #0000001f : #ffffff1a; + // The modern surface: one sheet, separated by fill rather than by lines. + out property surface: light ? #FFFFFF : #1E2027; + out property sheet-band: light ? #F8F9FB : #212329; + out property fill: light ? #0000000c : #ffffff0e; + out property fill-hover: light ? #00000014 : #ffffff1a; + out property faint: light ? #6B7280 : #8791A4; + out property glow: light ? #0000000f : #ffffff12; + out property shade: light ? #14192d : #000000; + out property accent-ink: light ? #FFFFFF : #12141B; + // The mascot's blue marks what is chosen; violet is the private window. + out property brand: light ? #3E6BE0 : #7FA2FF; + out property brand-soft: light ? #3E6BE01f : #7FA2FF24; + out property secret: light ? #7A4FE0 : #B79BFF; + out property secret-soft: light ? #7A4FE024 : #B79BFF29; + out property hair: light ? #00000012 : #ffffff17; + out property track: light ? #0000000d : #00000033; + out property thumb: light ? #FFFFFF : #ffffff1c; +} + +export enum Look { list, tiles, compact, bear } + +export // Two sheets, one over the other: rectangles, which the software renderer draws with edges, +// where a stroked path came out soft. Lit once the address was really copied. +component CopyMark inherits Rectangle { + in property done; + in property label; + callback pressed(); + + accessible-role: button; + accessible-label: root.label; + accessible-action-default => { root.pressed(); } + width: 28px; + height: 28px; + border-radius: 6px; + background: touch.has-hover ? Palette.hover : transparent; + property line: root.done ? Palette.accent : (touch.has-hover ? Palette.dim : Palette.faint); + + Rectangle { + x: 7px; + y: 7px; + width: 10px; + height: 10px; + border-radius: 2px; + border-width: 1.5px; + border-color: root.line; + } + Rectangle { + x: 11px; + y: 11px; + width: 10px; + height: 10px; + border-radius: 2px; + border-width: 1.5px; + border-color: root.line; + background: Palette.clear ? transparent : Palette.surface; + } + touch := TouchArea { + clicked => { root.pressed(); } + } +} + +export // The update, when one is owed: what it is, what pressing does, and how far along it is. The +// same strip sits atop both looks of the picker. +component UpdateStrip inherits Rectangle { + in property line; + in property detail; + in property button; + in property later; + in property far; + in property busy; + callback asked(); + callback dismissed(); + + height: 36px; + border-radius: 10px; + background: Palette.accent-soft; + + HorizontalLayout { + padding-left: 12px; + padding-right: 6px; + spacing: 10px; + + VerticalLayout { + alignment: center; + spacing: 3px; + horizontal-stretch: 1; + Text { + text: root.line; + font-size: 12.5px; + color: Palette.ink; + vertical-alignment: center; + overflow: elide; + } + if root.detail != "": Text { + text: root.detail; + font-size: 11.5px; + color: Palette.dim; + vertical-alignment: center; + overflow: elide; + } + if root.far > 0: Rectangle { + height: 3px; + border-radius: 2px; + background: Palette.neutral; + Rectangle { + x: 0; + width: parent.width * root.far / 100; + height: 100%; + border-radius: 2px; + background: Palette.accent; + } + } + } + + if root.button != "": Rectangle { + accessible-role: AccessibleRole.button; + accessible-label: root.button; + accessible-action-default => { root.asked(); } + horizontal-stretch: 0; + y: (parent.height - self.height) / 2; + height: 26px; + width: take-words.preferred-width + 24px; + border-radius: 13px; + background: Palette.accent; + opacity: take-touch.has-hover ? 0.9 : 1.0; + take-words := Text { + text: root.button; + font-size: 12.5px; + font-weight: 600; + color: Palette.accent-ink; + horizontal-alignment: center; + vertical-alignment: center; + width: 100%; + height: 100%; + } + take-touch := TouchArea { + clicked => { root.asked(); } + } + } + + if !root.busy: Rectangle { + accessible-role: AccessibleRole.button; + accessible-label: root.later; + accessible-action-default => { root.dismissed(); } + horizontal-stretch: 0; + y: (parent.height - self.height) / 2; + width: 22px; + height: 22px; + border-radius: 6px; + background: later-touch.has-hover ? Palette.fill-hover : transparent; + Path { + x: 7px; + y: 7px; + width: 8px; + height: 8px; + viewbox-width: 8; + viewbox-height: 8; + stroke: Palette.faint; + stroke-width: 1.5px; + commands: "M 1 1 L 7 7 M 7 1 L 1 7"; + } + later-touch := TouchArea { + clicked => { root.dismissed(); } + } + } + } +} + +export component Hair inherits Rectangle { + height: 1px; + Rectangle { + x: 12px; + width: parent.width - 24px; + height: 1px; + background: @linear-gradient(90deg, transparent 0%, Palette.hair 12%, Palette.hair 88%, transparent 100%); + } +} + +// A key on a keyboard: lit from above, pressed into the sheet. +export component KeyCap inherits Rectangle { + in property key; + in property lit; + width: max(20px, words.preferred-width + 10px); + height: 20px; + border-radius: 5px; + visible: root.key != ""; + background: root.lit ? Palette.brand : Palette.key-bg; + Rectangle { + x: 2px; + y: 1px; + width: parent.width - 4px; + height: 1px; + background: root.lit ? transparent : Palette.rim; + opacity: 0.6; + } + words := Text { + text: root.key; + font-size: 11.5px; + font-weight: 600; + color: root.lit ? #FFFFFF : Palette.key-ink; + horizontal-alignment: center; + vertical-alignment: center; + width: 100%; + height: 100%; + } +} + +// The browser's icon, with a violet dot while it is about to open privately. +export component Badge inherits Rectangle { + in property icon; + in property secret; + Image { + source: root.icon; + width: 100%; + height: 100%; + image-fit: contain; + image-rendering: smooth; + } + if root.secret: Rectangle { + x: parent.width - 9px; + y: parent.height - 9px; + width: 12px; + height: 12px; + border-radius: 6px; + background: Palette.secret; + border-width: 2px; + border-color: Palette.light ? #FFFFFF : #1E2027; + } +} + +// A mask: open privately. Lit while Shift is down or after a press, which pins it. +export component MaskButton inherits Rectangle { + in property on; + in property label; + callback pressed(); + + accessible-role: checkbox; + accessible-label: root.label; + accessible-checked: root.on; + accessible-action-default => { root.pressed(); } + width: 28px; + height: 28px; + border-radius: 6px; + background: root.on ? Palette.secret-soft : (touch.has-hover ? Palette.hover : transparent); + Path { + x: 6px; + y: 6px; + width: 16px; + height: 16px; + viewbox-width: 16; + viewbox-height: 16; + commands: "M2 6.5 C4 4.9 12 4.9 14 6.5 C13.8 9.7 12.2 11 10.5 11 C9.2 11 8.7 9.7 8 9.7 C7.3 9.7 6.8 11 5.5 11 C3.8 11 2.2 9.7 2 6.5 Z"; + stroke: root.on ? Palette.secret : (touch.has-hover ? Palette.dim : Palette.faint); + stroke-width: 1.3px; + } + Path { + x: 6px; + y: 6px; + width: 16px; + height: 16px; + viewbox-width: 16; + viewbox-height: 16; + commands: "M4.5 7.6 a0.9 0.9 0 1 0 1.8 0 a0.9 0.9 0 1 0 -1.8 0 Z M9.7 7.6 a0.9 0.9 0 1 0 1.8 0 a0.9 0.9 0 1 0 -1.8 0 Z"; + fill: root.on ? Palette.secret : (touch.has-hover ? Palette.dim : Palette.faint); + } + touch := TouchArea { + clicked => { root.pressed(); } + } +} + +export component Tools inherits HorizontalLayout { + in property private-on; + in property private-label; + in property copied; + in property copy-label; + callback toggle-private(); + callback copy(); + spacing: 2px; + MaskButton { + on: root.private-on; + label: root.private-label; + pressed => { root.toggle-private(); } + } + CopyMark { + label: root.copy-label; + done: root.copied; + pressed => { root.copy(); } + } +} + +// The address and, under it, where it came from, what is wrong, or that it opens privately. +export component AddressHead inherits Rectangle { + in property host; + in property trail; + in property line; + in property line-color; + in property private-on; + in property private-label; + in property copied; + in property copy-label; + callback toggle-private(); + callback copy(); + height: 60px; + + HorizontalLayout { + padding-left: 16px; + padding-right: 10px; + spacing: 8px; + VerticalLayout { + alignment: center; + spacing: 3px; + horizontal-stretch: 1; + HorizontalLayout { + alignment: start; + Text { + text: root.host; + font-size: 15px; + font-weight: 600; + color: Palette.ink; + } + Text { + text: root.trail; + font-size: 15px; + color: Palette.dimmer; + overflow: elide; + } + } + if root.line != "": Text { + text: root.line; + font-size: 12px; + color: root.line-color; + font-weight: root.private-on ? 600 : 400; + overflow: elide; + } + } + Tools { + y: (parent.height - 28px) / 2; + height: 28px; + private-on: root.private-on; + private-label: root.private-label; + copied: root.copied; + copy-label: root.copy-label; + toggle-private => { root.toggle-private(); } + copy => { root.copy(); } + } + } +} + +// The bear's header: it asks, and the address answers under the question. +export component AskHead inherits Rectangle { + in property ask; + in property host; + in property trail; + in property line; + in property line-color; + in property private-on; + in property private-label; + in property copied; + in property copy-label; + callback toggle-private(); + callback copy(); + height: 80px; + + VerticalLayout { + padding-top: 20px; + padding-left: 72px; + padding-right: 72px; + spacing: 3px; + alignment: start; + Text { + text: root.ask; + font-size: 17px; + font-weight: 700; + color: root.private-on ? Palette.secret : Palette.ink; + horizontal-alignment: center; + overflow: elide; + } + HorizontalLayout { + alignment: center; + Text { + text: root.host; + font-size: 13px; + font-weight: 600; + color: Palette.ink; + } + Text { + text: root.trail; + font-size: 13px; + color: Palette.dimmer; + max-width: root.width - 200px; + overflow: elide; + } + } + if root.line != "": Text { + text: root.line; + font-size: 12px; + color: root.line-color; + horizontal-alignment: center; + overflow: elide; + } + } + Tools { + x: root.width - 8px - self.width; + y: 16px; + width: 58px; + height: 28px; + private-on: root.private-on; + private-label: root.private-label; + copied: root.copied; + copy-label: root.copy-label; + toggle-private => { root.toggle-private(); } + copy => { root.copy(); } + } +} + +// «Recordar»: the reaches in a segmented track, the rest behind «Más». +export component RememberFoot inherits Rectangle { + in property label; + in property <[Reach]> reaches; + in property has-more; + in property more-said; + in property more-holds; + in property more-open; + in property reach-index; + callback picked(int); + callback more(); + height: 74px; + + VerticalLayout { + padding-left: 12px; + padding-right: 12px; + padding-top: 10px; + padding-bottom: 12px; + spacing: 7px; + Text { + text: root.label; + font-size: 11px; + font-weight: 600; + color: Palette.faint; + x: 4px; + } + Rectangle { + height: 32px; + border-radius: 7px; + background: Palette.track; + HorizontalLayout { + padding: 3px; + spacing: 2px; + for item in root.reaches: Rectangle { + property on: item.index == root.reach-index; + horizontal-stretch: 1; + border-radius: 5px; + opacity: item.dead ? 0.4 : 1.0; + background: self.on ? Palette.thumb : transparent; + drop-shadow-blur: self.on ? 2px : 0px; + drop-shadow-color: #0000002e; + drop-shadow-offset-y: 1px; + Text { + text: item.label; + font-size: 12.5px; + font-weight: parent.on ? 600 : 400; + color: parent.on ? (item.keeps ? Palette.brand : Palette.ink) : (seg.has-hover ? Palette.ink : Palette.dim); + horizontal-alignment: center; + vertical-alignment: center; + overflow: elide; + width: parent.width - 8px; + } + seg := TouchArea { + enabled: !item.dead; + clicked => { root.picked(item.index); } + } + } + if root.has-more: Rectangle { + horizontal-stretch: 1; + border-radius: 5px; + background: root.more-holds ? Palette.thumb : transparent; + HorizontalLayout { + alignment: center; + spacing: 5px; + Text { + text: root.more-said; + font-size: 12.5px; + font-weight: root.more-holds ? 600 : 400; + color: root.more-holds ? Palette.brand : (opener.has-hover ? Palette.ink : Palette.dim); + vertical-alignment: center; + overflow: elide; + max-width: 120px; + } + Rectangle { + width: 8px; + Path { + y: (parent.height - 5px) / 2; + width: 8px; + height: 5px; + viewbox-width: 8; + viewbox-height: 5; + stroke: root.more-holds ? Palette.brand : Palette.faint; + stroke-width: 1.5px; + commands: root.more-open ? "M 1 4 L 4 1 L 7 4" : "M 1 1 L 4 4 L 7 1"; + } + } + } + opener := TouchArea { + clicked => { root.more(); } + } + } + } + } + } +} + +// The reaches the track has no room for, hanging upwards from it. +export component ReachMenu inherits Rectangle { + in property <[Reach]> reaches; + in property reach-index; + callback picked(int); + width: 210px; + height: shelf.preferred-height; + border-radius: 8px; + background: Palette.clear ? Palette.ground : Palette.surface; + border-width: 1px; + border-color: Palette.edge; + drop-shadow-blur: 12px; + drop-shadow-color: #00000040; + drop-shadow-offset-y: 4px; + TouchArea {} + shelf := VerticalLayout { + padding: 4px; + for item in root.reaches: Rectangle { + height: 28px; + border-radius: 5px; + opacity: item.dead ? 0.4 : 1.0; + background: item.index == root.reach-index ? Palette.brand-soft : (option.has-hover ? Palette.hover : transparent); + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + Text { + text: item.label; + font-size: 12.5px; + color: item.index == root.reach-index ? Palette.brand : Palette.ink; + font-weight: item.index == root.reach-index ? 600 : 400; + vertical-alignment: center; + overflow: elide; + } + } + option := TouchArea { + enabled: !item.dead; + clicked => { root.picked(item.index); } + } + } + } +} diff --git a/crates/linkunbound-shell/ui/shell.slint b/crates/linkunbound-shell/ui/shell.slint index 6ddafb5..2a4bcbd 100644 --- a/crates/linkunbound-shell/ui/shell.slint +++ b/crates/linkunbound-shell/ui/shell.slint @@ -1,366 +1,8 @@ -struct Destination { - key: string, - name: string, - profile: string, - icon: image, - can-private: bool, -} - -struct Reach { - label: string, - full: string, - keeps: bool, - dead: bool, - index: int, -} - -export global Palette { - in property light: false; - // Over Acrylic the system paints the material, the corners and the edge of the picker. - in property clear: false; - // Laid over Acrylic: the blur is the system's, the tone and the lit edge stay the picker's. - out property veil: light - ? @linear-gradient(180deg, #FFFFFFC4 0%, #F3F5F9CC 100%) - : @linear-gradient(180deg, #282B36C4 0%, #1A1C23CC 100%); - out property rim: light ? #FFFFFFE6 : #FFFFFF2E; - - out property ground: light ? #FFFFFF : #191A1F; - // Header and footer sit a shade apart from the list, which is what tells the zones apart. - out property band: light ? #F8F9FB : #1C1D23; - out property ink: light ? #16181D : #F0F1F4; - out property dim: light ? #555B6A : #AAB1C0; - out property dimmer: light ? #5F6575 : #9CA3B3; - out property line: light ? #00000014 : #ffffff14; - out property hover: light ? #0000000b : #ffffff12; - out property chip: light ? #0000000e : #ffffff10; - out property key-bg: light ? #00000010 : #ffffff14; - out property key-ink: light ? #5F6575 : #B3BAC9; - out property accent: light ? #2F62D8 : #6E9BFF; - // Literals here were picked for a dark ground and sat at 2.2:1 on a light - // one, which is where the launch failures are printed. - out property wrong: light ? #B3261E : #FF8A85; - out property caution: light ? #8A5200 : #E9A05C; - out property accent-soft: light ? #2F62D821 : #6E9BFF2b; - out property neutral: light ? #00000017 : #ffffff1c; - out property neutral-ink: light ? #3A3F4B : #D6DAE3; - out property edge: light ? #0000001f : #ffffff1a; - // The modern surface: one sheet, separated by fill rather than by lines. - out property surface: light ? #FFFFFF : #1E2027; - out property sheet-band: light ? #F8F9FB : #212329; - out property fill: light ? #0000000c : #ffffff0e; - out property fill-hover: light ? #00000014 : #ffffff1a; - out property faint: light ? #6B7280 : #8791A4; - out property glow: light ? #0000000f : #ffffff12; - out property shade: light ? #14192d : #000000; - out property accent-ink: light ? #FFFFFF : #12141B; -} - -component Row inherits Rectangle { - in property entry; - in property private-wanted; - in property icon-side; - callback chosen(bool); - - height: 42px; - border-radius: 7px; - background: touch.has-hover ? Palette.hover : transparent; - opacity: root.private-wanted && !root.entry.can-private ? 0.45 : 1.0; - - touch := TouchArea { - enabled: !(root.private-wanted && !root.entry.can-private); - clicked => { root.chosen(root.private-wanted); } - } - - HorizontalLayout { - padding-left: 8px; - padding-right: 8px; - spacing: 11px; - - Rectangle { - width: 22px; - height: 22px; - y: (parent.height - self.height) / 2; - border-radius: 4px; - // No digit past the ninth row: the box keeps the column and shows nothing. - background: root.entry.key == "" ? transparent : Palette.key-bg; - Text { - text: root.entry.key; - font-size: 13px; - color: Palette.key-ink; - horizontal-alignment: center; - vertical-alignment: center; - width: 100%; - height: 100%; - } - } - - Image { - source: root.entry.icon; - width: root.icon-side * 1px; - height: root.icon-side * 1px; - y: (parent.height - self.height) / 2; - image-fit: contain; - image-rendering: smooth; - } - - Text { - text: root.entry.name; - font-size: 15px; - color: Palette.ink; - vertical-alignment: center; - overflow: elide; - horizontal-stretch: 1; - } - - if root.entry.profile != "": Text { - horizontal-stretch: 0; - text: root.entry.profile; - font-size: 13px; - color: Palette.dim; - vertical-alignment: center; - } - } -} - -component Segment inherits Rectangle { - in property reach; - in property active; - in property last; - callback picked(); - - height: 22px; - // A Rectangle contributes nothing to the layout around it unless it says what it wants, and - // the row's width is the sum of what its segments ask for. - preferred-width: body.preferred-width; - opacity: root.reach.dead ? 0.4 : 1.0; - - body := HorizontalLayout { - spacing: 7px; - - VerticalLayout { - spacing: 2px; - alignment: center; - - label := Text { - text: root.reach.label; - font-size: 12.5px; - color: root.active - ? (root.reach.keeps ? Palette.accent : Palette.neutral-ink) - : (touch.has-hover ? Palette.neutral-ink : Palette.dim); - font-weight: root.active ? 600 : 400; - vertical-alignment: center; - } - - // The mark sits under the word rather than around it, so a row of four reads as one - // line of text with one of them chosen. - Rectangle { - height: 2px; - border-radius: 1px; - background: root.active - ? (root.reach.keeps ? Palette.accent : Palette.neutral-ink) - : transparent; - } - } - - if !root.last: Text { - text: "·"; - font-size: 12.5px; - color: Palette.dimmer; - vertical-alignment: center; - } - } - - touch := TouchArea { - enabled: !root.reach.dead; - width: label.width; - x: 0; - clicked => { root.picked(); } - } -} - -// Two sheets, one over the other: rectangles, which the software renderer draws with edges, -// where a stroked path came out soft. Lit once the address was really copied. -component CopyMark inherits Rectangle { - in property done; - in property label; - callback pressed(); - - accessible-role: button; - accessible-label: root.label; - accessible-action-default => { root.pressed(); } - width: 24px; - height: 24px; - border-radius: 6px; - background: touch.has-hover ? Palette.hover : transparent; - property line: root.done ? Palette.accent : (touch.has-hover ? Palette.dim : Palette.faint); - - Rectangle { - x: 5px; - y: 5px; - width: 10px; - height: 10px; - border-radius: 2px; - border-width: 1.5px; - border-color: root.line; - } - Rectangle { - x: 9px; - y: 9px; - width: 10px; - height: 10px; - border-radius: 2px; - border-width: 1.5px; - border-color: root.line; - background: Palette.surface; - } - touch := TouchArea { - clicked => { root.pressed(); } - } -} - -// «⇧ Privada»: lit while Shift is down or after a press, which pins it. -component PrivateWords inherits Rectangle { - in property label; - in property on; - callback pressed(); +import { Destination, Reach, Palette, Look, UpdateStrip, Hair, AddressHead, AskHead, RememberFoot, ReachMenu } from "parts.slint"; +import { ListLook, TilesLook, CardsLook, CompactLook } from "looks.slint"; - accessible-role: checkbox; - accessible-label: root.label; - accessible-checked: root.on; - accessible-action-default => { root.pressed(); } - height: 22px; - width: body.preferred-width + 8px; - border-radius: 5px; - background: touch.has-hover ? Palette.hover : transparent; - - body := HorizontalLayout { - padding-left: 4px; - padding-right: 4px; - spacing: 5px; - Text { - text: "⇧"; - font-size: 14px; - color: root.on ? Palette.accent : Palette.dimmer; - vertical-alignment: center; - } - Text { - text: root.label; - font-size: 13px; - color: root.on ? Palette.accent : Palette.dimmer; - vertical-alignment: center; - } - } - touch := TouchArea { - clicked => { root.pressed(); } - } -} - -// The update, when one is owed: what it is, what pressing does, and how far along it is. The -// same strip sits atop both looks of the picker. -component UpdateStrip inherits Rectangle { - in property line; - in property detail; - in property button; - in property later; - in property far; - in property busy; - callback asked(); - callback dismissed(); - - height: 36px; - border-radius: 10px; - background: Palette.accent-soft; - - HorizontalLayout { - padding-left: 12px; - padding-right: 6px; - spacing: 10px; - - VerticalLayout { - alignment: center; - spacing: 3px; - horizontal-stretch: 1; - Text { - text: root.line; - font-size: 12.5px; - color: Palette.ink; - vertical-alignment: center; - overflow: elide; - } - if root.detail != "": Text { - text: root.detail; - font-size: 11.5px; - color: Palette.dim; - vertical-alignment: center; - overflow: elide; - } - if root.far > 0: Rectangle { - height: 3px; - border-radius: 2px; - background: Palette.neutral; - Rectangle { - x: 0; - width: parent.width * root.far / 100; - height: 100%; - border-radius: 2px; - background: Palette.accent; - } - } - } - - if root.button != "": Rectangle { - accessible-role: AccessibleRole.button; - accessible-label: root.button; - accessible-action-default => { root.asked(); } - horizontal-stretch: 0; - y: (parent.height - self.height) / 2; - height: 26px; - width: take-words.preferred-width + 24px; - border-radius: 13px; - background: Palette.accent; - opacity: take-touch.has-hover ? 0.9 : 1.0; - take-words := Text { - text: root.button; - font-size: 12.5px; - font-weight: 600; - color: Palette.accent-ink; - horizontal-alignment: center; - vertical-alignment: center; - width: 100%; - height: 100%; - } - take-touch := TouchArea { - clicked => { root.asked(); } - } - } - - if !root.busy: Rectangle { - accessible-role: AccessibleRole.button; - accessible-label: root.later; - accessible-action-default => { root.dismissed(); } - horizontal-stretch: 0; - y: (parent.height - self.height) / 2; - width: 22px; - height: 22px; - border-radius: 6px; - background: later-touch.has-hover ? Palette.fill-hover : transparent; - Path { - x: 7px; - y: 7px; - width: 8px; - height: 8px; - viewbox-width: 8; - viewbox-height: 8; - stroke: Palette.faint; - stroke-width: 1.5px; - commands: "M 1 1 L 7 7 M 7 1 L 1 7"; - } - later-touch := TouchArea { - clicked => { root.dismissed(); } - } - } - } -} +export { Palette, Look } from "parts.slint"; +export { BearHead, BearPaws } from "bear.slint"; export component Picker inherits Window { in property icon-side: 24; @@ -368,7 +10,14 @@ export component Picker inherits Window { in property trail; in property source-line; in property remember-label; + in property remember-short; in property private-label; + in property private-line; + in property ask; + in property ask-private; + in property motto; + in property add; + in property add-more; in property <[Destination]> rows; in property <[Reach]> reaches; in property <[Reach]> reaches-more; @@ -381,9 +30,12 @@ export component Picker inherits Window { in-out property reach-index: 0; in-out property private-on: false; in-out property more-open: false; + // The row the arrows rest on, opened by Enter; none until an arrow is pressed. + in-out property chosen-at: -1; + // Shown from settings over a sample link: a choice puts it away and opens nothing. + in-out property preview: false; - // Rows the way 1.x users know them, or the sheet of tiles behind a preference. - in property classic: true; + in property look: Look.bear; in property on-mac: false; // Room around the sheet for the shadow it draws itself. Zero where the system draws one. in property halo: 0px; @@ -417,6 +69,9 @@ export component Picker inherits Window { pure callback step-reach(int) -> int; callback dismissed(); callback copy(); + callback add-browser(); + // Where the bear looks, from -1 to 1, and whether it wears the mask. + callback mood(float, bool); title: "LinkUnbound"; icon: @image-url("../../../app/src-tauri/icons/128x128.png"); @@ -429,62 +84,110 @@ export component Picker inherits Window { default-font-family: root.family; forward-focus: keys; + property hovered-at: -1; + property aimed: root.hovered-at >= 0 ? root.hovered-at : root.chosen-at; + property count: root.rows.length; + property aim: + root.aimed < 0 ? 0 + : root.look == Look.bear ? (Math.mod(root.aimed, 3) - 1) * 0.7 + : ((root.aimed + 0.5) / max(1, root.count) - 0.5) * 2; + changed aim => { root.mood(root.aim, root.private-on); } + changed private-on => { root.mood(root.aim, root.private-on); } + + property line: root.problem != "" ? root.problem : (root.private-on ? root.private-line : root.source-line); + property line-color: root.problem != "" ? (root.alarming ? Palette.wrong : Palette.caution) : (root.private-on ? Palette.secret : Palette.dim); + out property reach-said: root.all-reaches[root.reach-index].full; + property more-said: root.more-holds ? root.reach-labels[root.reach-index] : root.reach-more-label; + + // Nine rows at most on screen, the keys stop at 9, and the rest scroll under them. + property rows-shown: min(root.count, 9); + property list-room: max(1, root.rows-shown) * 45px + 11px; // A fifth reach appears when the click came from a known app, and its label carries that - // app's name — which no fixed width can be drawn for. The reaches ask for the room they - // need; the floor is the width the rows were designed at and the ceiling keeps one long - // name from stretching the window across the screen. - property classic-width: min( - max(348px, ruler.preferred-width + 30px + (root.reaches.length - 1) * 8px), - 560px); - // Repeated elements reach the layout only once they are drawn, so the row cannot be asked - // how wide it wants to be before the window has a width. This measures the same words in one - // go instead, and the spacing the row puts around each separator is added on. + // app's name, which no fixed width can be drawn for: the track asks for the room it needs. + property track-need: ruler.preferred-width + 60px + root.reaches.length * 18px; + property list-width: min(max(380px, root.track-need), 560px); ruler := Text { text: root.reaches-line; font-size: 12.5px; visible: false; } - // Nine rows at most on screen — the keys stop at 9 — and the rest scroll under them. - property rows-shown: min(root.rows.length, 9); - property rows-room: root.rows-shown * 42px + 14px; - property classic-height: - root.strip-room - + 66px + 1px - + root.rows-room + 2px - + 1px + 38px + 22px + 12px - + (root.problem == "" ? 0px : 36px); - - // The sheet: tiles of one size, at most four to a row, the rows shared out evenly so six - // destinations make two rows of three rather than four and two. The last row is centred. - property count: root.rows.length; + // Tiles shared out evenly, so six make two rows of three rather than four and two. property tile-rows: max(1, Math.ceil(root.count / 4)); property tile-cols: max(1, Math.ceil(root.count / root.tile-rows)); - property sheet-width: root.tile-cols >= 4 ? 392px : 348px; - property tile-width: - Math.floor(((root.sheet-width - 28px - (root.tile-cols - 1) * 4px) / root.tile-cols) / 1px) * 1px; - property second-line: root.problem != "" || root.source-line != ""; - property head-height: 17px + (root.second-line ? 17px : 0px); - property tiles-top: 14px + root.strip-room + root.head-height + 10px; - property tiles-height: root.tile-rows * 88px + (root.tile-rows - 1) * 4px; - property sheet-height: root.tiles-top + root.tiles-height + 10px + 28px + 12px; - - out property wanted-width: root.classic ? root.classic-width : root.sheet-width + 2 * root.halo; - out property wanted-height: root.classic ? root.classic-height : root.sheet-height + 2 * root.halo; - out property reach-said: root.all-reaches[root.reach-index].full; + property tiles-width: min(max(root.tile-cols >= 4 ? 400px : 372px, root.track-need), 560px); + property tile-width: Math.floor(((root.tiles-width - 20px - (root.tile-cols - 1) * 6px) / root.tile-cols) / 1px) * 1px; + property tiles-room: root.tile-rows * 106px + 10px; + + // Three columns of cards; four rows on screen and the rest scroll. + property cards-room: min(4, max(1, Math.ceil(root.count / 3))) * 58px + 10px; + + property icons-shown: max(1, root.count); + property compact-width: max(280px, root.icons-shown * 54px - 4px + 20px); + property compact-height: 12px + 28px + 6px + 72px + 6px + 20px + 6px + 26px + 10px; + + property foot-room: 74px; + property sheet-width: + root.look == Look.list ? root.list-width + : root.look == Look.tiles ? root.tiles-width + : root.look == Look.bear ? max(500px, root.list-width) + : root.compact-width; + property head-room: root.look == Look.bear ? 80px : 60px; + property body-room: + root.look == Look.tiles ? root.tiles-room + : root.look == Look.bear ? root.cards-room + : root.list-room; + property sheet-height: root.strip-room + (root.look == Look.compact + ? root.compact-height + : root.head-room + 1px + root.body-room + 1px + root.foot-room); + + out property wanted-width: root.sheet-width + 2 * root.halo; + out property wanted-height: root.sheet-height + 2 * root.halo; + // The side the icons are drawn at, for the resident to extract them at in screen pixels. + out property drawn-icon-side: + root.look == Look.tiles ? 48 : root.look == Look.list ? 24 : 32; + + function toggle-private() { + root.pinned-private = !root.pinned-private; + root.private-on = root.pinned-private; + } + function step-chosen(by: int) { + if (root.count == 0) { + return; + } + root.chosen-at = root.chosen-at < 0 + ? (by > 0 ? 0 : root.count - 1) + : Math.mod(root.chosen-at + by + root.count * 4, root.count); + } keys := FocusScope { - // Shift is held, not toggled: the icon lights while it is down. + // Shift is held, not toggled: the mask lights while it is down. key-pressed(event) => { root.private-on = event.modifiers.shift || root.pinned-private; if (event.text == Key.Escape) { + if (root.more-open) { + root.more-open = false; + return accept; + } root.dismissed(); return accept; } - // Reach, copy and private are the rest of what this window does, and - // a TouchArea answers to nothing but a pointer: without these, a - // keyboard could open a link but never remember one. - // Every reach, not only the two the row draws: the keyboard reaches the ones behind + if (event.text == Key.Return && root.chosen-at >= 0) { + root.open(root.chosen-at, root.private-on); + return accept; + } + // The icons of the compact look lie in a row, so the arrows across walk them there; + // everywhere else they walk the reaches, as they always did. + if (root.look == Look.compact && (event.text == Key.LeftArrow || event.text == Key.RightArrow)) { + root.step-chosen(event.text == Key.RightArrow ? 1 : -1); + return accept; + } + if (event.text == Key.UpArrow || event.text == Key.DownArrow) { + root.step-chosen((event.text == Key.DownArrow ? 1 : -1) + * (root.look == Look.tiles ? root.tile-cols : root.look == Look.bear ? 3 : 1)); + return accept; + } + // Every reach, not only the ones the track draws: the keyboard reaches those behind // the opener without having to open it. if (event.text == Key.LeftArrow) { root.reach-index = root.step-reach(-1); @@ -501,8 +204,7 @@ export component Picker inherits Window { return accept; } if (event.text == "p" && (root.on-mac ? event.modifiers.meta : event.modifiers.control)) { - root.pinned-private = !root.pinned-private; - root.private-on = root.pinned-private; + root.toggle-private(); return accept; } // The digit is worked out in Rust: with Shift held the layout hands @@ -523,597 +225,168 @@ export component Picker inherits Window { clicked => { root.more-open = false; } } - if root.classic: Rectangle { - width: 100%; - height: 100%; - Rectangle { - border-radius: Palette.clear ? 8px : 10px; - background: Palette.clear ? Palette.veil : Palette.ground; - border-width: 1px; - border-color: Palette.clear ? transparent : Palette.edge; - - if Palette.clear: Rectangle { - x: parent.width * 0.15; - y: 1px; - width: parent.width * 0.7; - height: 1px; - background: @linear-gradient(90deg, transparent 0%, Palette.rim 50%, transparent 100%); - } - - // The renderer clips square, so each band is rounded like the window and the - // edge it turns towards the list is hidden under a strip of ground. - property head-band: root.strip-room + 66px; - property foot-band: 38px + 22px + 12px; - Rectangle { - x: 1px; - y: 1px; - width: parent.width - 2px; - height: parent.head-band + 10px; - border-radius: 9px; - background: Palette.clear ? transparent : Palette.band; - } - Rectangle { - x: 1px; - y: parent.head-band; - width: parent.width - 2px; - height: 11px; - background: Palette.clear ? transparent : Palette.ground; - } - Rectangle { - x: 1px; - y: parent.height - 1px - parent.foot-band - 10px; - width: parent.width - 2px; - height: parent.foot-band + 10px; - border-radius: 9px; - background: Palette.clear ? transparent : Palette.band; - } - Rectangle { - x: 1px; - y: parent.height - 1px - parent.foot-band - 11px; - width: parent.width - 2px; - height: 11px; - background: Palette.clear ? transparent : Palette.ground; - } - - VerticalLayout { - if root.update-shown: Rectangle { - height: 46px; - UpdateStrip { - x: 10px; - y: 10px; - width: parent.width - 20px; - line: root.update-line; - detail: root.update-detail; - button: root.update-button; - later: root.update-later; - far: root.update-far; - busy: root.update-busy; - asked => { root.update-asked(); } - dismissed => { root.update-dismissed(); } - } - } - - Rectangle { - height: 66px; - HorizontalLayout { - padding-left: 15px; - padding-right: 11px; - spacing: 8px; - alignment: stretch; - - VerticalLayout { - spacing: 4px; - alignment: center; - HorizontalLayout { - alignment: start; - Text { - text: root.host; - font-size: 15px; - font-weight: 600; - color: Palette.ink; - } - Text { - text: root.trail; - font-size: 15px; - color: Palette.dimmer; - overflow: elide; - } - } - if root.source-line != "": Text { - text: root.source-line; - font-size: 12.5px; - color: Palette.dim; - } - } - - CopyMark { - y: (parent.height - self.height) / 2; - label: root.copy-label; - done: root.copied; - pressed => { root.copy(); } - } - } - } - - Rectangle { height: 1px; background: Palette.line; } - - Rectangle { - height: root.rows-room + 2px; - Flickable { - y: 1px; - height: root.rows-room; - content-height: root.rows.length * 42px + 14px; - VerticalLayout { - padding-left: 6px; - padding-right: 6px; - padding-top: 7px; - padding-bottom: 7px; - for item[i] in root.rows: Row { - entry: item; - icon-side: root.icon-side; - private-wanted: root.private-on; - chosen(private) => { root.open(i, private); } - } - } - } - } - - if root.problem != "": Rectangle { - height: 36px; - HorizontalLayout { - padding-left: 15px; - padding-right: 15px; - Text { - text: root.problem; - font-size: 11.5px; - color: root.alarming ? Palette.wrong : Palette.caution; - vertical-alignment: center; - overflow: elide; - } - } - } - - Rectangle { height: 1px; background: Palette.line; } - - Rectangle { - VerticalLayout { - Rectangle { - height: 38px; - HorizontalLayout { - padding-left: 15px; - padding-right: 15px; - alignment: space-between; - Text { - text: root.remember-label; - font-size: 13px; - color: Palette.dimmer; - vertical-alignment: center; - } - PrivateWords { - y: (parent.height - self.height) / 2; - label: root.private-label; - on: root.private-on; - pressed => { root.pinned-private = !root.pinned-private; root.private-on = root.pinned-private; } - } - } - } - - scopes := HorizontalLayout { - padding-left: 15px; - padding-right: 15px; - padding-bottom: 12px; - spacing: 7px; - alignment: start; - - for item in root.reaches: Segment { - reach: item; - active: item.index == root.reach-index; - last: false; - picked => { - root.reach-index = item.index; - root.more-open = false; - } - } - - more := Rectangle { - height: 22px; - preferred-width: opener.preferred-width; - - opener := HorizontalLayout { - spacing: 4px; - VerticalLayout { - spacing: 2px; - alignment: center; - said := Text { - text: root.more-holds ? root.reach-labels[root.reach-index] - : root.reach-more-label; - font-size: 12.5px; - max-width: 150px; - overflow: elide; - color: root.more-holds - ? Palette.accent - : (more-touch.has-hover ? Palette.neutral-ink : Palette.dim); - font-weight: root.more-holds ? 600 : 400; - vertical-alignment: center; - } - Rectangle { - height: 2px; - border-radius: 1px; - background: root.more-holds ? Palette.accent : transparent; - } - } - Text { - text: root.more-open ? "▴" : "▾"; - font-size: 9px; - color: Palette.dimmer; - vertical-alignment: center; - } - } - - more-touch := TouchArea { - clicked => { root.more-open = !root.more-open; } - } - } - } - } - } - } + // The shadow is stacked, not blurred: six sheets fading outwards is what the + // software renderer can draw, and it reads as one soft shadow. + if root.halo > 0px: Rectangle { + for step in [1, 2, 3, 4, 5, 6]: Rectangle { + x: root.halo - step * 3px; + y: root.halo - step * 2px + step * 1.5px; + width: root.sheet-width + step * 6px; + height: root.sheet-height + step * 6px; + border-radius: 14px + step * 3px; + background: Palette.shade; + opacity: 0.05; } } - if !root.classic: Rectangle { - width: 100%; - height: 100%; - - // The shadow is stacked, not blurred: six sheets fading outwards is what the - // software renderer can draw, and it reads as one soft shadow. - if root.halo > 0px: Rectangle { - for step in [1, 2, 3, 4, 5, 6]: Rectangle { - x: root.halo - step * 3px; - y: root.halo - step * 2px + step * 1.5px; - width: root.sheet-width + step * 6px; - height: root.sheet-height + step * 6px; - border-radius: 14px + step * 3px; - background: Palette.shade; - opacity: 0.05; - } + sheet := Rectangle { + x: root.halo; + y: root.halo; + width: root.sheet-width; + height: root.sheet-height; + border-radius: Palette.clear ? 8px : (root.look == Look.list ? 10px : 14px); + background: Palette.clear ? Palette.veil : Palette.pane; + border-width: Palette.clear && !root.private-on ? 0px : 1px; + border-color: root.private-on ? Palette.secret : Palette.glow; + + if !Palette.clear: Rectangle { + border-radius: parent.border-radius; + background: Palette.glint; } - sheet := Rectangle { - x: root.halo; - y: root.halo; - width: root.sheet-width; - height: root.sheet-height; - border-radius: Palette.clear ? 8px : 14px; - background: Palette.clear ? Palette.veil : Palette.surface; - border-width: 1px; - border-color: Palette.clear ? transparent : Palette.glow; + Rectangle { + x: parent.width * 0.15; + y: 1px; + width: parent.width * 0.7; + height: 1px; + background: @linear-gradient(90deg, transparent 0%, Palette.rim 50%, transparent 100%); + } - if Palette.clear: Rectangle { - x: parent.width * 0.15; - y: 1px; - width: parent.width * 0.7; - height: 1px; - background: @linear-gradient(90deg, transparent 0%, Palette.rim 50%, transparent 100%); - } + if root.update-shown: UpdateStrip { + x: 12px; + y: 10px; + width: parent.width - 24px; + line: root.update-line; + detail: root.update-detail; + button: root.update-button; + later: root.update-later; + far: root.update-far; + busy: root.update-busy; + asked => { root.update-asked(); } + dismissed => { root.update-dismissed(); } + } - property head-band: root.tiles-top - 4px; - property foot-band: 12px + 28px + 8px; - Rectangle { - x: 1px; - y: 1px; - width: parent.width - 2px; - height: parent.head-band + 14px; - border-radius: 13px; - background: Palette.clear ? transparent : Palette.sheet-band; - } - Rectangle { - x: 1px; - y: parent.head-band; - width: parent.width - 2px; - height: 15px; - background: Palette.clear ? transparent : Palette.surface; - } - Rectangle { - x: 1px; - y: parent.height - 1px - parent.foot-band - 14px; - width: parent.width - 2px; - height: parent.foot-band + 14px; - border-radius: 13px; - background: Palette.clear ? transparent : Palette.sheet-band; - } - Rectangle { - x: 1px; - y: parent.height - 1px - parent.foot-band - 15px; - width: parent.width - 2px; - height: 15px; - background: Palette.clear ? transparent : Palette.surface; - } + if root.look == Look.compact: CompactLook { + y: root.strip-room; + height: root.compact-height; + rows: root.rows; + private-on: root.private-on; + chosen-at: root.chosen-at; + hovered-at: root.hovered-at; + host: root.host; + trail: root.trail; + motto: root.problem != "" ? root.problem : root.motto; + private-line: root.private-line; + private-label: root.private-label; + copied: root.copied; + copy-label: root.copy-label; + remember-label: root.remember-short; + reach-said: root.reach-said; + reach-keeps: root.all-reaches[root.reach-index].keeps; + open(i) => { root.open(i, root.private-on); } + hovered(i) => { root.hovered-at = i; } + toggle-private => { root.toggle-private(); } + copy => { root.copy(); } + turn-reach => { root.reach-index = root.step-reach(1); } + } - if root.update-shown: UpdateStrip { - x: 14px; - y: 14px; - width: root.sheet-width - 28px; - line: root.update-line; - detail: root.update-detail; - button: root.update-button; - later: root.update-later; - far: root.update-far; - busy: root.update-busy; - asked => { root.update-asked(); } - dismissed => { root.update-dismissed(); } + if root.look != Look.compact: VerticalLayout { + y: root.strip-room; + height: parent.height - root.strip-room; + if root.look == Look.bear: AskHead { + ask: root.private-on ? root.ask-private : root.ask; + host: root.host; + trail: root.trail; + line: root.problem != "" ? root.problem : root.source-line; + line-color: root.problem != "" ? root.line-color : Palette.dim; + private-on: root.private-on; + private-label: root.private-label; + copied: root.copied; + copy-label: root.copy-label; + toggle-private => { root.toggle-private(); } + copy => { root.copy(); } } - - HorizontalLayout { - x: 16px; - y: 14px + root.strip-room; - width: root.sheet-width - 32px - 30px; - height: 17px; - alignment: start; - Text { - text: root.host; - font-size: 13.5px; - font-weight: 600; - color: Palette.ink; - vertical-alignment: center; - } - Text { - text: root.trail; - font-size: 13.5px; - color: Palette.dim; - vertical-alignment: center; - overflow: elide; - } + if root.look != Look.bear: AddressHead { + host: root.host; + trail: root.trail; + line: root.line; + line-color: root.line-color; + private-on: root.private-on; + private-label: root.private-label; + copied: root.copied; + copy-label: root.copy-label; + toggle-private => { root.toggle-private(); } + copy => { root.copy(); } } - - if root.second-line: Text { - x: 16px; - y: 33px + root.strip-room; - width: root.sheet-width - 32px - 30px; - height: 15px; - text: root.problem != "" ? root.problem : root.source-line; - font-size: 12px; - color: root.problem != "" ? (root.alarming ? Palette.wrong : Palette.caution) : Palette.dim; - vertical-alignment: center; - overflow: elide; + Hair {} + if root.look == Look.bear: CardsLook { + height: root.cards-room; + room: root.cards-room; + empty: root.count > 0 && root.count < 3 ? 3 - root.count : 0; + add: root.add; + add-more: root.add-more; + adding => { root.add-browser(); } + rows: root.rows; + private-on: root.private-on; + chosen-at: root.chosen-at; + open(i) => { root.open(i, root.private-on); } + hovered(i) => { root.hovered-at = i; } } - - CopyMark { - x: root.sheet-width - 14px - 24px; - y: 10px + root.strip-room; - label: root.copy-label; - done: root.copied; - pressed => { root.copy(); } + if root.look == Look.list: ListLook { + height: root.list-room; + room: root.list-room; + rows: root.rows; + private-on: root.private-on; + chosen-at: root.chosen-at; + open(i) => { root.open(i, root.private-on); } + hovered(i) => { root.hovered-at = i; } } - - Rectangle { - x: 14px; - y: root.tiles-top; - width: root.sheet-width - 28px; - height: root.tiles-height; - - for item[i] in root.rows: Rectangle { - property row-at: Math.floor(i / root.tile-cols); - property col-at: i - self.row-at * root.tile-cols; - property in-row: min(root.tile-cols, root.count - self.row-at * root.tile-cols); - property greyed: root.private-on && !item.can-private; - x: (parent.width - (self.in-row * root.tile-width + (self.in-row - 1) * 4px)) / 2 - + self.col-at * (root.tile-width + 4px); - y: self.row-at * 92px; - width: root.tile-width; - height: 88px; - border-radius: 12px; - background: tile-touch.has-hover && !self.greyed ? Palette.fill-hover : transparent; - opacity: self.greyed ? 0.4 : 1.0; - - accessible-role: button; - accessible-label: item.profile != "" ? item.name + " · " + item.profile : item.name; - accessible-action-default => { root.open(i, root.private-on); } - tile-touch := TouchArea { - enabled: !parent.greyed; - clicked => { root.open(i, root.private-on); } - } - - Text { - x: parent.width - 9px - self.width; - y: 6px; - text: item.key; - font-size: 11.5px; - color: tile-touch.has-hover ? Palette.dim : Palette.faint; - } - - Image { - source: item.icon; - x: (parent.width - self.width) / 2; - y: 9px; - width: 40px; - height: 40px; - image-fit: contain; - image-rendering: smooth; - } - - Text { - x: 4px; - y: 55px; - width: parent.width - 8px; - height: 15px; - text: item.name; - font-size: 12px; - color: Palette.ink; - horizontal-alignment: center; - vertical-alignment: center; - overflow: elide; - } - - Text { - x: 4px; - y: 71px; - width: parent.width - 8px; - height: 13px; - text: item.profile; - font-size: 11px; - color: Palette.dim; - horizontal-alignment: center; - vertical-alignment: center; - overflow: elide; - } - } + if root.look == Look.tiles: TilesLook { + height: root.tiles-room; + rows: root.rows; + private-on: root.private-on; + chosen-at: root.chosen-at; + cols: root.tile-cols; + tile-width: root.tile-width; + open(i) => { root.open(i, root.private-on); } + hovered(i) => { root.hovered-at = i; } } - - // Hasta cuándo: one pill that says the consequence in full, blue when a click - // leaves a rule behind, and opens the menu of every reach. - reach-pill := Rectangle { - x: 14px; - y: root.sheet-height - 12px - 28px; - height: 28px; - width: reach-words.preferred-width + 20px + 6px + 8px; - // Words, the gap, the chevron, and the padding either side. - border-radius: 14px; - background: root.reach-index > 0 ? Palette.accent-soft : (reach-touch.has-hover ? Palette.fill-hover : Palette.fill); - opacity: root.problem != "" && !root.alarming ? 0.55 : 1.0; - HorizontalLayout { - padding-left: 10px; - padding-right: 10px; - spacing: 6px; - reach-words := Text { - text: root.reach-said; - font-size: 12.5px; - font-weight: root.reach-index > 0 ? 600 : 400; - color: root.reach-index > 0 ? Palette.accent : Palette.dim; - vertical-alignment: center; - max-width: 200px; - overflow: elide; - } - // Drawn: the triangle glyphs are not in every system font. - Rectangle { - width: 8px; - Path { - x: 0px; - y: (parent.height - 5px) / 2; - width: 8px; - height: 5px; - viewbox-width: 8; - viewbox-height: 5; - stroke: root.reach-index > 0 ? Palette.accent : Palette.faint; - stroke-width: 1.5px; - commands: root.more-open ? "M 1 4 L 4 1 L 7 4" : "M 1 1 L 4 4 L 7 1"; - } - } - } - reach-touch := TouchArea { - clicked => { root.more-open = !root.more-open; } + Hair {} + RememberFoot { + label: root.remember-label; + reaches: root.reaches; + has-more: root.reaches-more.length > 0; + more-said: root.more-said; + more-holds: root.more-holds; + more-open: root.more-open; + reach-index: root.reach-index; + picked(i) => { + root.reach-index = i; + root.more-open = false; } - } - - // Cómo: the private pill, lit while Shift is down or after Ctrl+P. - - PrivateWords { - x: root.sheet-width - 14px - self.width; - y: root.sheet-height - 12px - 28px + 3px; - label: root.private-label; - on: root.private-on; - pressed => { root.pinned-private = !root.pinned-private; root.private-on = root.pinned-private; } + more => { root.more-open = !root.more-open; } } } } - } - - if root.classic: Rectangle { - width: 100%; - height: 100%; - // Drawn over the rows rather than below the row that opens it: the reaches sit at the - // bottom edge, and a list growing downwards would fall outside the window. - if root.more-open: Rectangle { - // Hangs from the opener, which sits at the right edge with the row. - x: root.width - 15px - self.width; - y: root.height - 12px - 22px - self.height - 6px; - width: 190px; - height: shelf.preferred-height; - border-radius: 8px; - background: Palette.ground; - border-width: 1px; - border-color: Palette.edge; - - TouchArea {} - - shelf := VerticalLayout { - padding: 4px; - for item in root.reaches-more: Rectangle { - height: 26px; - border-radius: 5px; - opacity: item.dead ? 0.4 : 1.0; - background: item.index == root.reach-index - ? Palette.accent-soft - : (option.has-hover ? Palette.hover : transparent); - - HorizontalLayout { - padding-left: 8px; - padding-right: 8px; - Text { - text: item.label; - font-size: 11.5px; - color: item.index == root.reach-index ? Palette.accent : Palette.dim; - font-weight: item.index == root.reach-index ? 700 : 400; - vertical-alignment: center; - overflow: elide; - } - } - - option := TouchArea { - enabled: !item.dead; - clicked => { - root.reach-index = item.index; - root.more-open = false; - } - } - } - } - } - } - - // The sheet's menu hangs upwards from the pill, the way the classic one hangs from its opener. - if !root.classic && root.more-open: Rectangle { - x: root.halo + 14px; - y: root.halo + root.sheet-height - 12px - 28px - 6px - self.height; - width: 200px; - height: shelf-sheet.preferred-height; - border-radius: 10px; - background: Palette.surface; - border-width: 1px; - border-color: Palette.glow; - - TouchArea {} - - shelf-sheet := VerticalLayout { - padding: 4px; - for item in root.all-reaches: Rectangle { - height: 28px; - border-radius: 7px; - opacity: item.dead ? 0.4 : 1.0; - background: item.index == root.reach-index - ? Palette.accent-soft - : (option-sheet.has-hover ? Palette.fill-hover : transparent); - - HorizontalLayout { - padding-left: 10px; - padding-right: 10px; - Text { - text: item.label; - font-size: 12.5px; - color: item.index == root.reach-index ? Palette.accent : Palette.dim; - font-weight: item.index == root.reach-index ? 600 : 400; - vertical-alignment: center; - overflow: elide; - } - } - option-sheet := TouchArea { - enabled: !item.dead; - clicked => { - root.reach-index = item.index; - root.more-open = false; - } - } + if root.more-open && root.look != Look.compact: ReachMenu { + x: root.halo + root.sheet-width - 12px - self.width; + y: root.halo + root.sheet-height - 12px - 32px - 6px - self.height; + reaches: root.reaches-more; + reach-index: root.reach-index; + picked(i) => { + root.reach-index = i; + root.more-open = false; } } } @@ -1125,6 +398,9 @@ export component Notice inherits Window { in property undo-label; in property undoable: true; in-out property left: 6; + // Looks offered once after the update made the bear everybody's, the current one first. + in property <[string]> choices; + callback chose(int); callback undo(); callback dismissed(); @@ -1134,7 +410,7 @@ export component Notice inherits Window { no-frame: true; background: transparent; out property wanted-width: 356px; - out property wanted-height: 76px; + out property wanted-height: root.choices.length > 0 ? 120px : 76px; width: root.wanted-width; height: root.wanted-height; in property family; @@ -1146,7 +422,9 @@ export component Notice inherits Window { border-width: 1px; border-color: Palette.edge; + VerticalLayout { HorizontalLayout { + height: 76px; padding-left: 15px; padding-right: 12px; spacing: 10px; @@ -1209,5 +487,33 @@ export component Notice inherits Window { } } } + if root.choices.length > 0: HorizontalLayout { + height: 44px; + padding-left: 12px; + padding-right: 12px; + padding-bottom: 12px; + spacing: 6px; + for choice[i] in root.choices: Rectangle { + horizontal-stretch: 1; + border-radius: 6px; + background: i == 0 ? Palette.brand-soft : (pick.has-hover ? Palette.hover : Palette.chip); + border-width: i == 0 ? 1px : 0px; + border-color: Palette.brand; + Text { + text: choice; + font-size: 12.5px; + font-weight: i == 0 ? 600 : 400; + color: i == 0 ? Palette.brand : Palette.ink; + horizontal-alignment: center; + vertical-alignment: center; + width: 100%; + height: 100%; + } + pick := TouchArea { + clicked => { root.chose(i); } + } + } + } + } } } diff --git a/crates/linkunbound-win/src/icons.rs b/crates/linkunbound-win/src/icons.rs index 9a942fd..172ef48 100644 --- a/crates/linkunbound-win/src/icons.rs +++ b/crates/linkunbound-win/src/icons.rs @@ -24,7 +24,8 @@ pub fn cached_or_extract(exe: &str, id: &str, dir: &Path, side: u32) -> Option

= built => return Some(target), @@ -33,6 +34,7 @@ pub fn cached_or_extract(exe: &str, id: &str, dir: &Path, side: u32) -> Option

Option

(u32, u32, Vec) { + if side == 0 || (width <= side && height <= side) { + return (width, height, pixels.to_vec()); + } + let scale = f64::from(width.max(height)) / f64::from(side); + let fit = |n: u32| ((f64::from(n) / scale).round() as u32).max(1); + let (w, h) = (fit(width), fit(height)); + let mut out = vec![0u8; (w * h * 4) as usize]; + for y in 0..h { + let (y0, y1) = ( + f64::from(y) * scale, + (f64::from(y + 1) * scale).min(f64::from(height)), + ); + for x in 0..w { + let (x0, x1) = ( + f64::from(x) * scale, + (f64::from(x + 1) * scale).min(f64::from(width)), + ); + let mut sum = [0f64; 4]; + let mut area = 0f64; + for sy in y0.floor() as u32..y1.ceil() as u32 { + let dy = (f64::from(sy + 1).min(y1) - f64::from(sy).max(y0)).max(0.0); + for sx in x0.floor() as u32..x1.ceil() as u32 { + let dx = (f64::from(sx + 1).min(x1) - f64::from(sx).max(x0)).max(0.0); + let at = ((sy * width + sx) * 4) as usize; + for (c, total) in sum.iter_mut().enumerate() { + *total += f64::from(pixels[at + c]) * dx * dy; + } + area += dx * dy; + } + } + let at = ((y * w + x) * 4) as usize; + for (c, total) in sum.iter().enumerate() { + out[at + c] = (total / area.max(f64::EPSILON)).round().clamp(0.0, 255.0) as u8; + } + } + } + (w, h, out) +} + #[cfg(test)] mod tests { use super::*; @@ -142,7 +188,7 @@ mod tests { #[cfg(test)] mod probe { - use super::cached_or_extract; + use super::{cached_or_extract, shrunk_to}; use crate::installed_browsers; /// `cargo test -p linkunbound-win -- --ignored --nocapture` @@ -162,4 +208,25 @@ mod probe { } println!("en {}", dir.display()); } + + /// Four pixels become one that is their average, not whichever one a sample landed on. + #[test] + fn a_larger_icon_is_averaged_down_to_the_side_asked_for() { + let pixels: Vec = [ + [0, 0, 0, 0], + [200, 100, 40, 255], + [200, 100, 40, 255], + [0, 0, 0, 0], + ] + .into_iter() + .flatten() + .collect(); + let (w, h, out) = shrunk_to(1, 2, 2, &pixels); + assert_eq!((w, h), (1, 1)); + assert_eq!(out, vec![100, 50, 20, 128]); + + let (w, h, same) = shrunk_to(4, 2, 2, &pixels); + assert_eq!((w, h), (2, 2)); + assert_eq!(same, pixels, "a smaller icon is left alone"); + } } diff --git a/crates/linkunbound-win/src/lib.rs b/crates/linkunbound-win/src/lib.rs index baf8299..9bde191 100644 --- a/crates/linkunbound-win/src/lib.rs +++ b/crates/linkunbound-win/src/lib.rs @@ -18,13 +18,14 @@ pub use native::{ attach_parent_console, copy_text, current_session, current_user_sid, cursor, digit_behind, front_is_ours, front_window, is_in_front, keep_off_the_taskbar, let_whoever_opens_next_come_forward, never_activates, notify_associations_changed, - shift_is_down, source_app, strip_title_icon, take_the_keyboard, wear_acrylic, work_area_at, - write_line_to_pipe, + shift_is_down, source_app, strip_title_icon, take_the_keyboard, tuck_under, wear_acrylic, + work_area_at, write_line_to_pipe, }; #[cfg(windows)] pub use registration::{ Registration, acrylic_is_available, association_report, default_apps_page, is_build_tree, - is_default_browser, sweep_legacy_edge_capture, taskbar_is_light, windows_are_light, + is_default_browser, sweep_legacy_edge_capture, taskbar_is_light, transparency_is_on, + windows_are_light, }; #[cfg(windows)] pub use startup::{ diff --git a/crates/linkunbound-win/src/native.rs b/crates/linkunbound-win/src/native.rs index dd59fb4..8995680 100644 --- a/crates/linkunbound-win/src/native.rs +++ b/crates/linkunbound-win/src/native.rs @@ -175,6 +175,21 @@ pub fn wear_acrylic(window: isize, dark: bool) -> bool { } } +/// Slides `window` just below `above` in the topmost band, so what `above` draws covers it. +pub fn tuck_under(window: isize, above: isize) { + let _ = unsafe { + SetWindowPos( + HWND(window as *mut std::ffi::c_void), + Some(HWND(above as *mut std::ffi::c_void)), + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ) + }; +} + /// Windows draws the window's own icon beside the title, and this one has nothing to say there /// that the title does not. Clearing both icons alone still leaves the frame reserving the space, /// so the window is given a dialog's frame, which draws none. diff --git a/crates/linkunbound-win/src/registration.rs b/crates/linkunbound-win/src/registration.rs index cdc8953..26c9bbd 100644 --- a/crates/linkunbound-win/src/registration.rs +++ b/crates/linkunbound-win/src/registration.rs @@ -280,6 +280,15 @@ pub fn windows_are_light() -> bool { .is_ok_and(|v| v == 1) } +/// The switch in Settings › Personalisation › Colours; read as on when it cannot be read. +#[must_use] +pub fn transparency_is_on() -> bool { + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") + .and_then(|k| k.get_value::("EnableTransparency")) + .map_or(true, |v| v != 0) +} + /// Acrylic as a window backdrop arrived with Windows 11 22H2, build 22621; earlier builds /// refuse the attribute or draw it black. #[must_use]