From e73ba31bdd9ad647206f0301c2ff73bfb98bb2c8 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 15 Oct 2026 01:41:13 -0300 Subject: [PATCH 01/10] feat(picker): bear mascot peeking behind the glass with its paws on the edge --- .github/oversized.txt | 2 +- crates/linkunbound-shell/src/look.rs | 61 +++++++++- crates/linkunbound-shell/src/main.rs | 70 +++--------- crates/linkunbound-shell/src/mascot.rs | 76 ++++++++++++ crates/linkunbound-shell/ui/bear.slint | 146 ++++++++++++++++++++++++ crates/linkunbound-shell/ui/shell.slint | 2 + crates/linkunbound-win/src/lib.rs | 4 +- crates/linkunbound-win/src/native.rs | 15 +++ 8 files changed, 319 insertions(+), 57 deletions(-) create mode 100644 crates/linkunbound-shell/src/mascot.rs create mode 100644 crates/linkunbound-shell/ui/bear.slint diff --git a/.github/oversized.txt b/.github/oversized.txt index 787b1dc..898376c 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 +1996 crates/linkunbound-shell/src/main.rs diff --git a/crates/linkunbound-shell/src/look.rs b/crates/linkunbound-shell/src/look.rs index ed48c86..79f1ed7 100644 --- a/crates/linkunbound-shell/src/look.rs +++ b/crates/linkunbound-shell/src/look.rs @@ -1,7 +1,7 @@ //! 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, Palette, Picker, place}; use slint::ComponentHandle; pub fn style(picker: &Picker, chosen: PickerStyle) { @@ -35,6 +35,65 @@ pub fn wear_backdrop(picker: &Picker, handle: isize) -> bool { 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((cx, cy)) = crate::host::cursor() else { + return; + }; + let Some((x, y, width, height)) = crate::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| 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()), + ), + 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)); + if picker.window().is_visible() { + follow(picker); + } +} + +/// 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 acrylic() -> bool { linkunbound_win::acrylic_is_available() diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index da8d2ec..247c379 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -11,12 +11,13 @@ 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, + ICON_SIDE, Listed, Notice, Picker, Reaches, TILE_ICON_SIDE, dress, paint, single, }; use slint::{ComponentHandle, Model}; mod detected; mod look; +mod mascot; mod notice; use detected::catalogue; @@ -524,13 +525,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 +547,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 +556,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 +566,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 +629,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 +914,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 +936,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 @@ -1227,7 +1189,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); @@ -1303,7 +1265,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 +1339,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), @@ -1553,6 +1516,7 @@ mod tests { 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 +1592,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..adc1398 --- /dev/null +++ b/crates/linkunbound-shell/src/mascot.rs @@ -0,0 +1,76 @@ +//! The bear that peeks over the picker: its head under the glass, its paws on the edge. + +use linkunbound_shell::{BearHead, BearPaws, Palette, Picker}; +use slint::ComponentHandle; + +use crate::native_handle; + +const HEAD: f32 = 92.0; +const HEAD_RISE: f32 = 46.0; +const PAW: f32 = 30.0; +const PAW_GAP: f32 = 70.0; + +pub struct Mascot { + head: BearHead, + paws: BearPaws, +} + +impl Mascot { + pub fn new() -> Result { + Ok(Self { + head: BearHead::new()?, + paws: BearPaws::new()?, + }) + } + + pub fn follow(&self, picker: &Picker) { + if !picker.global::().get_clear() { + self.hide(); + return; + } + let window = picker.window(); + let scale = window.scale_factor(); + let at = window.position(); + let mid = at.x + window.size().width as i32 / 2; + let px = |logical: f32| (logical * scale).round() as i32; + self.head + .window() + .set_position(slint::PhysicalPosition::new( + mid - px(HEAD / 2.0), + at.y - px(HEAD_RISE), + )); + self.paws + .window() + .set_position(slint::PhysicalPosition::new( + mid - px(PAW_GAP / 2.0 + PAW), + at.y - px(PAW * 0.3), + )); + let _ = self.head.show(); + let _ = self.paws.show(); + stack(&self.head, &self.paws, picker); + } + + pub fn hide(&self) { + let _ = self.head.hide(); + let _ = self.paws.hide(); + } +} + +#[cfg(windows)] +fn stack(head: &BearHead, paws: &BearPaws, picker: &Picker) { + 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) {} 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/shell.slint b/crates/linkunbound-shell/ui/shell.slint index 6ddafb5..5db818e 100644 --- a/crates/linkunbound-shell/ui/shell.slint +++ b/crates/linkunbound-shell/ui/shell.slint @@ -1,3 +1,5 @@ +export { BearHead, BearPaws } from "bear.slint"; + struct Destination { key: string, name: string, diff --git a/crates/linkunbound-win/src/lib.rs b/crates/linkunbound-win/src/lib.rs index baf8299..ec55f6f 100644 --- a/crates/linkunbound-win/src/lib.rs +++ b/crates/linkunbound-win/src/lib.rs @@ -18,8 +18,8 @@ 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::{ 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. From 681113f748d426099419b965bd86fcf7ab898a01 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 15 Oct 2026 01:55:19 -0300 Subject: [PATCH 02/10] feat(picker): four looks on the glass with the bear as default and a one-time choice after the update --- .github/oversized.txt | 2 +- app/src/i18n.ts | 4 + app/src/settings/Application.tsx | 4 +- crates/linkunbound-core/src/i18n.rs | 11 + crates/linkunbound-core/src/prefs.rs | 44 +- crates/linkunbound-shell/examples/preview.rs | 22 +- crates/linkunbound-shell/src/lib.rs | 43 +- crates/linkunbound-shell/src/look.rs | 21 +- crates/linkunbound-shell/src/main.rs | 17 +- crates/linkunbound-shell/src/mascot.rs | 68 +- crates/linkunbound-shell/src/notice.rs | 72 +- crates/linkunbound-shell/ui/looks.slint | 382 ++++++ crates/linkunbound-shell/ui/parts.slint | 597 +++++++++ crates/linkunbound-shell/ui/shell.slint | 1252 ++++-------------- 14 files changed, 1497 insertions(+), 1042 deletions(-) create mode 100644 crates/linkunbound-shell/ui/looks.slint create mode 100644 crates/linkunbound-shell/ui/parts.slint diff --git a/.github/oversized.txt b/.github/oversized.txt index 898376c..5424a2a 100644 --- a/.github/oversized.txt +++ b/.github/oversized.txt @@ -1,2 +1,2 @@ 2030 app/src-tauri/src/lib.rs -1996 crates/linkunbound-shell/src/main.rs +1991 crates/linkunbound-shell/src/main.rs diff --git a/app/src/i18n.ts b/app/src/i18n.ts index c34bd31..8e4287f 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -122,6 +122,8 @@ const ES = { appPicker: "Selector", pickerClassic: "Clásico", pickerSheet: "Mosaico", + pickerCompact: "Compacto", + pickerBear: "Oso", themeAuto: "Automático", themeLight: "Claro", themeDark: "Oscuro", @@ -401,6 +403,8 @@ const EN: Record = { appPicker: "Picker", pickerClassic: "Classic", pickerSheet: "Tiles", + pickerCompact: "Compact", + pickerBear: "Bear", themeAuto: "Automatic", themeLight: "Light", themeDark: "Dark", diff --git a/app/src/settings/Application.tsx b/app/src/settings/Application.tsx index 5edef38..c4e6f9d 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"]); diff --git a/crates/linkunbound-core/src/i18n.rs b/crates/linkunbound-core/src/i18n.rs index 7763b4b..bcbc1dc 100644 --- a/crates/linkunbound-core/src/i18n.rs +++ b/crates/linkunbound-core/src/i18n.rs @@ -28,6 +28,17 @@ 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.", + 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/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..aa21f6d 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] [--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, 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 => 28, + Look::Tiles => 44, + Look::Compact => 38, + Look::Bear => 34, + } * 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. @@ -92,8 +102,8 @@ 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); + window.set_halo(if look == Look::List { 0.0 } else { HALO }); dress( &window, &words, diff --git a/crates/linkunbound-shell/src/lib.rs b/crates/linkunbound-shell/src/lib.rs index 51e6c8e..24e4224 100644 --- a/crates/linkunbound-shell/src/lib.rs +++ b/crates/linkunbound-shell/src/lib.rs @@ -370,6 +370,12 @@ 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_chosen_at(-1); window.set_copy_label(words.picker_copy.into()); window.set_update_later(words.update_later.into()); window.set_private_on(false); @@ -545,10 +551,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_eq!(alone, 380.0); for from in ["ms-teams", "Microsoft Teams Classic"] { dress( @@ -573,7 +580,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 +591,26 @@ mod tests { Some("ms-teams"), &two, ); - assert_eq!(window.get_wanted_width(), 348.0); + assert_eq!(window.get_wanted_width(), 372.0); 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, + 400.0, "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(), 400.0 + 36.0); - window.set_classic(true); + window.set_halo(0.0); + window.set_look(crate::Look::List); assert_eq!( window.get_wanted_width(), - 348.0, - "the classic look keeps its own width" + 380.0, + "the list keeps its own width" ); } @@ -612,7 +620,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 +635,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 +861,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 +879,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); } diff --git a/crates/linkunbound-shell/src/look.rs b/crates/linkunbound-shell/src/look.rs index 79f1ed7..55821b8 100644 --- a/crates/linkunbound-shell/src/look.rs +++ b/crates/linkunbound-shell/src/look.rs @@ -1,19 +1,28 @@ //! 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, place}; +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.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,7 +37,7 @@ 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); diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index 247c379..c00e04f 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -10,9 +10,7 @@ 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, single, -}; +use linkunbound_shell::{Listed, Notice, Picker, Reaches, dress, paint, single}; use slint::{ComponentHandle, Model}; mod detected; @@ -282,17 +280,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 { @@ -1169,6 +1162,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 @@ -1364,6 +1358,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))); diff --git a/crates/linkunbound-shell/src/mascot.rs b/crates/linkunbound-shell/src/mascot.rs index adc1398..a929b51 100644 --- a/crates/linkunbound-shell/src/mascot.rs +++ b/crates/linkunbound-shell/src/mascot.rs @@ -1,14 +1,31 @@ //! The bear that peeks over the picker: its head under the glass, its paws on the edge. -use linkunbound_shell::{BearHead, BearPaws, Palette, Picker}; +use linkunbound_shell::{BearHead, BearPaws, Look, Palette, Picker}; use slint::ComponentHandle; use crate::native_handle; -const HEAD: f32 = 92.0; -const HEAD_RISE: f32 = 46.0; -const PAW: f32 = 30.0; -const PAW_GAP: f32 = 70.0; +/// 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: 46.0, + paw: 30.0, + gap: 70.0, +}; +const COMPACT: Pose = Pose { + head: 66.0, + rise: 34.0, + paw: 20.0, + gap: 46.0, +}; pub struct Mascot { head: BearHead, @@ -23,39 +40,70 @@ impl Mascot { }) } + /// Only over the glass: on a painted sheet the head would show whole above it, unblurred. pub fn follow(&self, picker: &Picker) { + let pose = match picker.get_look() { + Look::Bear => &BEAR, + Look::Compact => &COMPACT, + _ => { + self.hide(); + return; + } + }; if !picker.global::().get_clear() { self.hide(); return; } + self.head.set_side(pose.head); + self.paws.set_paw(pose.paw); + self.paws.set_gap(pose.gap); let window = picker.window(); let scale = window.scale_factor(); let at = window.position(); - let mid = at.x + window.size().width as i32 / 2; let px = |logical: f32| (logical * scale).round() as i32; + let centre = if picker.get_look() == Look::Bear { + at.x + window.size().width as i32 / 2 + } else { + at.x + px(18.0 + pose.head / 2.0) + }; self.head .window() .set_position(slint::PhysicalPosition::new( - mid - px(HEAD / 2.0), - at.y - px(HEAD_RISE), + centre - px(pose.head / 2.0), + at.y - px(pose.rise), )); self.paws .window() .set_position(slint::PhysicalPosition::new( - mid - px(PAW_GAP / 2.0 + PAW), - at.y - px(PAW * 0.3), + centre - px(pose.gap / 2.0 + pose.paw), + at.y - 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. +pub fn listen(picker: &Picker) { + picker.on_mood(|aim, masked| { + if let Some(ui) = crate::ui() { + ui.mascot.mood(aim, masked); + } + }); +} + #[cfg(windows)] fn stack(head: &BearHead, paws: &BearPaws, picker: &Picker) { let (Some(head), Some(paws), Some(picker)) = ( diff --git a/crates/linkunbound-shell/src/notice.rs b/crates/linkunbound-shell/src/notice.rs index 2ae034a..be0ec29 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,70 @@ 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, || { + let Some(ui) = ui() else { return }; + 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); } diff --git a/crates/linkunbound-shell/ui/looks.slint b/crates/linkunbound-shell/ui/looks.slint new file mode 100644 index 0000000..2603cee --- /dev/null +++ b/crates/linkunbound-shell/ui/looks.slint @@ -0,0 +1,382 @@ +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 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: 28px; + height: 28px; + 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 * 104px; + width: root.tile-width; + height: 98px; + 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: 14px; + width: 44px; + height: 44px; + icon: item.icon; + secret: root.private-on && item.can-private; + } + Text { + x: 4px; + y: 62px; + 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: 80px; + 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%; + } + } + } +} + +// The bear's cards: two columns, 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; + callback open(int); + callback hovered(int); + property card-width: (self.width - 20px - 6px) / 2; + + Flickable { + height: root.room; + content-height: Math.ceil(root.rows.length / 2) * 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, 2) * (root.card-width + 6px); + y: 8px + Math.floor(i / 2) * 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: 10px; + padding-right: 8px; + spacing: 10px; + Badge { + y: (parent.height - self.height) / 2; + width: 34px; + height: 34px; + icon: item.icon; + secret: root.private-on && item.can-private; + } + VerticalLayout { + alignment: center; + spacing: 1px; + horizontal-stretch: 1; + Text { + text: item.name; + font-size: 14px; + 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: 22px; + KeyCap { + y: 8px; + key: item.key; + lit: (touch.has-hover || i == root.chosen-at) && !(root.private-on && !item.can-private); + } + } + } + } + } +} + +// 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: 76px; + 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: 8px; + width: 38px; + height: 38px; + icon: item.icon; + secret: root.private-on && item.can-private; + } + KeyCap { + x: (parent.width - self.width) / 2; + y: 51px; + 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..224e405 --- /dev/null +++ b/crates/linkunbound-shell/ui/parts.slint @@ -0,0 +1,597 @@ +export struct Destination { + key: string, + name: 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; + + 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: 104px; + + VerticalLayout { + padding-top: 38px; + padding-left: 16px; + padding-right: 16px; + 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: 8px; + 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 5db818e..ed1e509 100644 --- a/crates/linkunbound-shell/ui/shell.slint +++ b/crates/linkunbound-shell/ui/shell.slint @@ -1,368 +1,8 @@ -export { BearHead, BearPaws } from "bear.slint"; - -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(); - - 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(); } - } - } +import { Destination, Reach, Palette, Look, UpdateStrip, Hair, AddressHead, AskHead, RememberFoot, ReachMenu } from "parts.slint"; +import { ListLook, TilesLook, CardsLook, CompactLook } from "looks.slint"; - 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; @@ -370,7 +10,12 @@ 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 <[Destination]> rows; in property <[Reach]> reaches; in property <[Reach]> reaches-more; @@ -383,9 +28,10 @@ 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; - // 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; @@ -419,6 +65,8 @@ export component Picker inherits Window { pure callback step-reach(int) -> int; callback dismissed(); callback copy(); + // 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"); @@ -431,62 +79,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, 2) == 0 ? -0.7 : 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 list-width: min(max(380px, ruler.preferred-width + 60px + root.reaches.length * 18px), 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: root.tile-cols >= 4 ? 400px : 372px; + 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 * 104px + 10px; + + // Two columns of cards; five rows on screen and the rest scroll. + property card-rows: min(5, max(1, Math.ceil(root.count / 2))); + property cards-room: root.card-rows * 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 + 76px + 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 ? 420px + : root.compact-width; + property head-room: root.look == Look.bear ? 104px : 60px; + property body-room: + root.look == Look.list ? root.list-room + : root.look == Look.tiles ? root.tiles-room + : root.cards-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.list ? 28 : root.look == Look.tiles ? 44 : root.look == Look.bear ? 34 : 38; + + 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.bear ? 2 : root.look == Look.tiles ? root.tile-cols : 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); @@ -503,8 +199,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 @@ -525,597 +220,159 @@ 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%; + 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.surface; + border-width: Palette.clear && !root.private-on ? 0px : 1px; + border-color: root.private-on ? Palette.secret : Palette.glow; - // 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; - } + 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%); } - 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; + 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(); } + } - 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.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); } + } - 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: 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(); } } - - 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.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(); } } - - 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; - } + Hair {} + 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; } } - - 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; + 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; } } - - 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.bear: CardsLook { + height: root.cards-room; + room: root.cards-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; - } - } - } - - // 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; } } } @@ -1127,6 +384,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(); @@ -1136,7 +396,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; @@ -1148,7 +408,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; @@ -1211,5 +473,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); } + } + } + } + } } } From 9be9f3e193cde831959822ca82d644c59ff4350f Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 15 Oct 2026 02:01:47 -0300 Subject: [PATCH 03/10] fix(picker): keep the bear header tools in the top right corner --- crates/linkunbound-shell/ui/parts.slint | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/linkunbound-shell/ui/parts.slint b/crates/linkunbound-shell/ui/parts.slint index 224e405..bb589f1 100644 --- a/crates/linkunbound-shell/ui/parts.slint +++ b/crates/linkunbound-shell/ui/parts.slint @@ -448,6 +448,7 @@ export component AskHead inherits Rectangle { Tools { x: root.width - 8px - self.width; y: 8px; + width: 58px; height: 28px; private-on: root.private-on; private-label: root.private-label; From 685bdf8e90a7a8b2a3c94bfa01349ec2e8b07d88 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 15 Oct 2026 02:13:54 -0300 Subject: [PATCH 04/10] feat(picker): preview the chosen look from settings and keep room above the picker for the bear --- .github/oversized.txt | 4 +- app/src-tauri/src/lib.rs | 8 +-- app/src-tauri/src/shell.rs | 16 +++++ app/src-tauri/src/system.rs | 5 ++ app/src/i18n.ts | 2 + app/src/settings/Application.test.tsx | 13 +++- app/src/settings/Application.tsx | 21 +++++-- crates/linkunbound-core/src/binaries.rs | 2 + crates/linkunbound-core/src/i18n.rs | 1 + crates/linkunbound-core/src/lib.rs | 2 +- crates/linkunbound-shell/src/held.rs | 50 ++++++++++++++++ crates/linkunbound-shell/src/lib.rs | 1 + crates/linkunbound-shell/src/look.rs | 5 +- crates/linkunbound-shell/src/main.rs | 46 ++++---------- crates/linkunbound-shell/src/mascot.rs | 76 +++++++++++++++++++---- crates/linkunbound-shell/src/notice.rs | 39 +++++++++++- crates/linkunbound-shell/src/preview.rs | 80 +++++++++++++++++++++++++ crates/linkunbound-shell/ui/shell.slint | 2 + 18 files changed, 307 insertions(+), 66 deletions(-) create mode 100644 crates/linkunbound-shell/src/held.rs create mode 100644 crates/linkunbound-shell/src/preview.rs diff --git a/.github/oversized.txt b/.github/oversized.txt index 5424a2a..b3bda62 100644 --- a/.github/oversized.txt +++ b/.github/oversized.txt @@ -1,2 +1,2 @@ -2030 app/src-tauri/src/lib.rs -1991 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..fcb7cb0 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)] @@ -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, diff --git a/app/src-tauri/src/shell.rs b/app/src-tauri/src/shell.rs index b08e335..8478345 100644 --- a/app/src-tauri/src/shell.rs +++ b/app/src-tauri/src/shell.rs @@ -161,3 +161,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/i18n.ts b/app/src/i18n.ts index 8e4287f..f10a3b3 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -124,6 +124,7 @@ const ES = { pickerSheet: "Mosaico", pickerCompact: "Compacto", pickerBear: "Oso", + pickerPreview: "Vista previa", themeAuto: "Automático", themeLight: "Claro", themeDark: "Oscuro", @@ -405,6 +406,7 @@ const EN: Record = { 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..28df37f 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,17 @@ 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"); + }); + /// 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 c4e6f9d..a466aa8 100644 --- a/app/src/settings/Application.tsx +++ b/app/src/settings/Application.tsx @@ -173,12 +173,21 @@ export default function Application({ /> - apply({ picker_style })} - /> +
+ apply({ picker_style })} + /> + +
diff --git a/crates/linkunbound-core/src/binaries.rs b/crates/linkunbound-core/src/binaries.rs index 133cd9f..208df86 100644 --- a/crates/linkunbound-core/src/binaries.rs +++ b/crates/linkunbound-core/src/binaries.rs @@ -2,6 +2,8 @@ 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"; 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 bcbc1dc..03aad92 100644 --- a/crates/linkunbound-core/src/i18n.rs +++ b/crates/linkunbound-core/src/i18n.rs @@ -33,6 +33,7 @@ catalogue! { 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", 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", diff --git a/crates/linkunbound-core/src/lib.rs b/crates/linkunbound-core/src/lib.rs index 2749217..905dc10 100644 --- a/crates/linkunbound-core/src/lib.rs +++ b/crates/linkunbound-core/src/lib.rs @@ -20,7 +20,7 @@ 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, + 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-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 24e4224..2620640 100644 --- a/crates/linkunbound-shell/src/lib.rs +++ b/crates/linkunbound-shell/src/lib.rs @@ -376,6 +376,7 @@ pub fn dress(window: &Picker, words: &Strings, url: &str, source: Option<&str>, window.set_ask_private(words.picker_ask_private.into()); window.set_motto(words.picker_motto.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); diff --git a/crates/linkunbound-shell/src/look.rs b/crates/linkunbound-shell/src/look.rs index 55821b8..93f756f 100644 --- a/crates/linkunbound-shell/src/look.rs +++ b/crates/linkunbound-shell/src/look.rs @@ -66,11 +66,12 @@ pub fn beside_the_pointer(picker: &Picker) { 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: y + size(crate::mascot::rise(picker)), width, - height, + 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, diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index c00e04f..e5e9a37 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -14,9 +14,11 @@ 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 { @@ -984,9 +986,12 @@ 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; }; + 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 }; @@ -1009,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 { @@ -1113,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 { @@ -1135,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(()), } @@ -1226,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. @@ -1367,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 { @@ -1506,7 +1484,7 @@ 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"), diff --git a/crates/linkunbound-shell/src/mascot.rs b/crates/linkunbound-shell/src/mascot.rs index a929b51..c89d2d8 100644 --- a/crates/linkunbound-shell/src/mascot.rs +++ b/crates/linkunbound-shell/src/mascot.rs @@ -27,6 +27,24 @@ const COMPACT: Pose = Pose { gap: 46.0, }; +/// The pose the look calls for, if the bear shows at all: only over the glass, since on a painted +/// sheet the head would show whole above it, unblurred. +fn pose(picker: &Picker) -> Option<&'static Pose> { + if !picker.global::().get_clear() { + return None; + } + 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, @@ -40,20 +58,11 @@ impl Mascot { }) } - /// Only over the glass: on a painted sheet the head would show whole above it, unblurred. pub fn follow(&self, picker: &Picker) { - let pose = match picker.get_look() { - Look::Bear => &BEAR, - Look::Compact => &COMPACT, - _ => { - self.hide(); - return; - } - }; - if !picker.global::().get_clear() { + 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); @@ -122,3 +131,48 @@ fn stack(head: &BearHead, paws: &BearPaws, picker: &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)), 0.0, "no glass, no bear"); + } + + #[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 be0ec29..c28fe82 100644 --- a/crates/linkunbound-shell/src/notice.rs +++ b/crates/linkunbound-shell/src/notice.rs @@ -66,7 +66,14 @@ pub fn after_the_bear(state: &Rc) { // 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, || { - let Some(ui) = ui() else { return }; + if let Some(ui) = ui() { + offer_styles(&ui); + } + }); +} + +fn offer_styles(ui: &Rc) { + { let words = ui.words.get(); flash( &ui.notice, @@ -91,7 +98,7 @@ pub fn after_the_bear(state: &Rc) { ui.notice.set_left(STYLES_SECONDS); in_the_corner(&ui.notice); ui.count_down(); - }); + } } pub fn noted(area: &'static str, what: &str) { @@ -142,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..be21279 --- /dev/null +++ b/crates/linkunbound-shell/src/preview.rs @@ -0,0 +1,80 @@ +//! 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. +pub fn shown_for(ui: &Rc, raw: &str) -> bool { + if raw != PREVIEW { + return false; + } + 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()); + ui.watch_focus(); + } + 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/shell.slint b/crates/linkunbound-shell/ui/shell.slint index ed1e509..24a75ec 100644 --- a/crates/linkunbound-shell/ui/shell.slint +++ b/crates/linkunbound-shell/ui/shell.slint @@ -30,6 +30,8 @@ export component Picker inherits Window { 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; in property look: Look.bear; in property on-mac: false; From 5d9a52fbbd162c8e5d0d27a7ef3c883aca1739ea Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 15 Oct 2026 02:44:15 -0300 Subject: [PATCH 05/10] feat(picker): bear cards in three columns with short names, four reaches on the track, settings opens on app --- app/src/Settings.test.tsx | 96 +++++++++-------- app/src/Settings.tsx | 22 ++-- app/src/settings/Application.test.tsx | 14 +++ app/src/settings/Application.tsx | 13 ++- crates/linkunbound-shell/examples/preview.rs | 7 +- crates/linkunbound-shell/src/lib.rs | 106 +++++++++++++------ crates/linkunbound-shell/src/look.rs | 3 + crates/linkunbound-shell/src/main.rs | 2 +- crates/linkunbound-shell/src/mascot.rs | 24 +++-- crates/linkunbound-shell/src/preview.rs | 6 +- crates/linkunbound-shell/ui/looks.slint | 58 +++++----- crates/linkunbound-shell/ui/parts.slint | 11 +- crates/linkunbound-shell/ui/shell.slint | 48 ++++----- crates/linkunbound-win/src/icons.rs | 71 ++++++++++++- 14 files changed, 316 insertions(+), 165 deletions(-) diff --git a/app/src/Settings.test.tsx b/app/src/Settings.test.tsx index 40d0bc8..bc11bc2 100644 --- a/app/src/Settings.test.tsx +++ b/app/src/Settings.test.tsx @@ -62,10 +62,16 @@ 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", () => { 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 +81,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 +92,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 +111,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 +126,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 +136,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 +147,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 +160,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 +176,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 +187,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 +202,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 +212,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 +225,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 +240,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 +252,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 +261,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 +269,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 +284,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 +305,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 +315,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 +330,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 +339,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 +347,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 +360,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 +377,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 +391,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 +401,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 +411,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 +426,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 +445,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 +457,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 +471,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 +480,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 +492,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 +517,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 +530,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 +545,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..c9e894d 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,7 @@ 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("app"); const [state, setState] = useState(null); const [problem, setProblem] = useState(null); const [starring, setStarring] = useState(false); diff --git a/app/src/settings/Application.test.tsx b/app/src/settings/Application.test.tsx index 28df37f..ae12522 100644 --- a/app/src/settings/Application.test.tsx +++ b/app/src/settings/Application.test.tsx @@ -88,6 +88,20 @@ describe("application settings", () => { 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 a466aa8..009cd9f 100644 --- a/app/src/settings/Application.tsx +++ b/app/src/settings/Application.tsx @@ -118,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) => { @@ -127,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)); @@ -178,11 +185,11 @@ export default function Application({ options={PICKERS} value={prefs.picker_style} label={t("appPicker")} - onPick={(picker_style) => apply({ picker_style })} + onPick={(picker_style) => apply({ picker_style }, previewing ? preview : undefined)} />