diff --git a/.github/oversized.txt b/.github/oversized.txt index b3bda62..0ba4410 100644 --- a/.github/oversized.txt +++ b/.github/oversized.txt @@ -1,2 +1,2 @@ -2026 app/src-tauri/src/lib.rs -1969 crates/linkunbound-shell/src/main.rs +1893 app/src-tauri/src/lib.rs +1965 crates/linkunbound-shell/src/main.rs diff --git a/app/src-tauri/src/editing.rs b/app/src-tauri/src/editing.rs new file mode 100644 index 0000000..d8abd7f --- /dev/null +++ b/app/src-tauri/src/editing.rs @@ -0,0 +1,408 @@ +//! What the settings screen may change about a browser, checked before it is saved. + +use std::collections::BTreeMap; + +use linkunbound_core::{Browser, usable_key}; + +#[derive(serde::Serialize)] +pub struct BrowserView { + pub id: String, + pub name: String, + pub exe: String, + pub profiles: usize, + /// Named, not just counted: a rule can point at one profile of a browser, + /// so the screen has to be able to offer them apart. + pub profile_names: Vec<(String, String)>, + pub private: bool, + pub custom: bool, + pub hidden: bool, + pub icon: Option, + /// Quoted back the way it has to be typed, or a value with a space in it would be split + /// the next time the form was saved. + pub args: String, + pub private_flag: Option, + pub icon_path: Option, + /// The key chosen for each row, by profile id, or by "" for a browser with no profiles. + pub keys: BTreeMap, + /// The key each row shows in the picker, chosen or not, so an empty box can say which it got. + pub shown_keys: BTreeMap, +} + +pub fn seen(browser: &Browser, shown_keys: BTreeMap) -> BrowserView { + BrowserView { + profiles: browser.profiles.len(), + profile_names: browser + .profiles + .iter() + .map(|p| (p.id.clone(), p.name.clone())) + .collect(), + private: browser.private_flag.is_some(), + icon: crate::icon_data(browser.icon_source(), &browser.id), + id: browser.id.clone(), + name: browser.name.clone(), + exe: browser.exe.clone(), + custom: browser.custom, + hidden: browser.hidden, + args: linkunbound_core::join_args(&browser.extra_args), + private_flag: browser.private_flag.clone(), + icon_path: browser.icon_path.clone(), + keys: browser.keys.clone(), + shown_keys, + } +} + +pub fn views(all: &[Browser]) -> Vec { + let shown = linkunbound_core::picker_keys(all); + all.iter() + .zip(shown) + .map(|(b, keys)| seen(b, keys)) + .collect() +} + +/// What the settings screen can change about a browser it owns. +#[derive(serde::Deserialize)] +pub struct Edit { + pub name: String, + pub exe: String, + /// The line as it was typed, not the words in it: a value in quotes is one argument, and + /// deciding that in the form put the rule somewhere nothing could test it. + pub args: String, + pub private_flag: Option, + pub icon_path: Option, + /// What was typed for each row; an empty box leaves the row to the defaults. + #[serde(default)] + pub keys: BTreeMap, +} + +pub fn readable(path: &str) -> bool { + let path = std::path::Path::new(path); + path.is_file() || is_bundle(path) +} + +#[cfg(target_os = "macos")] +fn is_bundle(path: &std::path::Path) -> bool { + path.extension() + .is_some_and(|e| e.eq_ignore_ascii_case("app")) + && path.join("Contents").join("Info.plist").is_file() +} + +#[cfg(not(target_os = "macos"))] +fn is_bundle(_path: &std::path::Path) -> bool { + false +} + +pub fn hidden_in(mut all: Vec, id: &str, hidden: bool) -> Result, String> { + // Hidden, its keys were free to give away; back in the picker, it lets go of those now taken. + let taken: Vec = all + .iter() + .filter(|b| b.id != id && !b.hidden) + .flat_map(|b| { + b.keys + .values() + .filter_map(|k| linkunbound_core::usable_key(*k)) + }) + .collect(); + let Some(found) = all.iter_mut().find(|b| b.id == id) else { + return Err("browserGone".to_owned()); + }; + if found.hidden && !hidden { + found + .keys + .retain(|_, k| linkunbound_core::usable_key(*k).is_none_or(|k| !taken.contains(&k))); + } + found.hidden = hidden; + Ok(all) +} + +/// A detected browser is hidden, never removed: the next scan would bring it +/// back and the deletion would look like it failed. +pub fn without(mut all: Vec, id: &str) -> Result, String> { + if !all.iter().any(|b| b.id == id && b.custom) { + return Err("browserNotYours".to_owned()); + } + all.retain(|b| b.id != id); + Ok(all) +} + +/// Never reuses a number a live entry already holds, or editing one would +/// rename the other. +pub fn free_id(all: &[Browser]) -> String { + (1..) + .map(|n| format!("custom-{n}")) + .find(|id| !all.iter().any(|b| &b.id == id)) + .unwrap_or_else(|| "custom-1".to_owned()) +} + +pub fn added( + mut all: Vec, + edit: Edit, + exists: impl Fn(&str) -> bool, +) -> Result, String> { + if !exists(&edit.exe) { + return Err("browserNoProgram".to_owned()); + } + let id = free_id(&all); + let keys = keyed(&all, &id, &edit.keys)?; + let private_flag = private_flag(edit.private_flag, &edit.exe); + all.push(Browser { + id, + name: edit.name, + exe: edit.exe, + profiles: Vec::new(), + extra_args: linkunbound_core::split_args(&edit.args), + private_flag, + icon_path: edit.icon_path.filter(|p| !p.is_empty()), + custom: true, + hidden: false, + keys, + }); + Ok(all) +} + +pub fn edited( + mut all: Vec, + id: &str, + edit: Edit, + exists: impl Fn(&str) -> bool, +) -> Result, String> { + let keys = keyed(&all, id, &edit.keys)?; + let Some(found) = all.iter_mut().find(|b| b.id == id) else { + return Err("browserGone".to_owned()); + }; + found.keys = keys; + if found.custom { + if !exists(&edit.exe) { + return Err("browserNoProgram".to_owned()); + } + found.exe = edit.exe; + } + found.name = edit.name; + found.extra_args = linkunbound_core::split_args(&edit.args); + // Detection knows how a browser it found opens privately, and keeps that; where it found one + // it does not know — a family off its list — the flag is the person's to say, as for a + // browser they added themselves. + if found.custom || linkunbound_core::private_flag_for(&found.exe).is_none() { + found.private_flag = private_flag(edit.private_flag, &found.exe); + } + found.icon_path = edit.icon_path.filter(|p| !p.is_empty()); + Ok(all) +} + +/// The keys typed for one browser's rows. One that another row already holds is refused rather +/// than kept: the picker would give it to whichever row comes first, and the other would +/// silently lose it. +pub fn keyed( + all: &[Browser], + id: &str, + typed: &BTreeMap, +) -> Result, String> { + let rows: Vec = all.iter().find(|b| b.id == id).map_or_else( + || vec![String::new()], + |b| { + b.rows() + .iter() + .map(|p| p.map(|p| p.id.clone()).unwrap_or_default()) + .collect() + }, + ); + let mut keys = BTreeMap::new(); + for (row, said) in typed { + let said = said.trim(); + if said.is_empty() || !rows.contains(row) { + continue; + } + let mut chars = said.chars(); + let key = chars + .next() + .and_then(usable_key) + .filter(|_| chars.next().is_none()) + .ok_or_else(|| "keyUnusable".to_owned())?; + if keys.values().any(|k| *k == key) { + return Err("keyTwice".to_owned()); + } + if let Some(holder) = holder_of(all, id, key) { + return Err(format!("keyTaken:{holder}")); + } + keys.insert(row.clone(), key); + } + Ok(keys) +} + +/// The switch typed, or when the box was left empty the one the browser's family is known to use: +/// few people know `--incognito` exists, let alone that Edge spells it `-inprivate`. +fn private_flag(typed: Option, exe: &str) -> Option { + typed + .filter(|f| !f.trim().is_empty()) + .or_else(|| linkunbound_core::private_flag_for(exe).map(str::to_owned)) +} + +/// Who else holds a key: the browser's name, with the profile's when it is one of several. +fn holder_of(all: &[Browser], id: &str, key: char) -> Option { + all.iter() + .filter(|b| b.id != id && !b.hidden) + .find_map(|b| { + let held = b + .rows() + .into_iter() + .find(|p| b.key_for(p.map(|p| p.id.as_str())) == Some(key))?; + Some(held.map_or_else(|| b.name.clone(), |p| format!("{} · {}", b.name, p.name))) + }) +} + +#[cfg(test)] +mod tests { + use super::{Edit, added, hidden_in, keyed}; + use linkunbound_core::{Browser, Profile}; + use std::collections::BTreeMap; + + fn browser(id: &str, profiles: &[(&str, &str)]) -> Browser { + Browser { + id: id.to_owned(), + name: "Google Chrome".to_owned(), + exe: "chrome.exe".to_owned(), + profiles: profiles + .iter() + .map(|(id, name)| Profile { + id: (*id).to_owned(), + name: (*name).to_owned(), + args: Vec::new(), + }) + .collect(), + extra_args: Vec::new(), + private_flag: None, + icon_path: None, + custom: false, + hidden: false, + keys: BTreeMap::new(), + } + } + + fn typed(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(row, key)| ((*row).to_owned(), (*key).to_owned())) + .collect() + } + + /// Most people have never heard of `--incognito`: a browser added by hand whose family is + /// known opens privately without anyone typing it, and a switch typed on purpose still wins. + #[test] + fn a_known_browser_brings_its_own_private_switch() { + let add = |exe: &str, flag: &str| { + let edit = Edit { + name: "Mío".to_owned(), + exe: exe.to_owned(), + args: String::new(), + private_flag: Some(flag.to_owned()), + icon_path: None, + keys: BTreeMap::new(), + }; + added(Vec::new(), edit, |_| true).expect("there")[0] + .private_flag + .clone() + }; + let chrome = "C:/Program Files/Google/Chrome/Application/chrome.exe"; + assert_eq!(add(chrome, "").as_deref(), Some("--incognito")); + assert_eq!( + add("C:/Edge/msedge.exe", " ").as_deref(), + Some("-inprivate") + ); + assert_eq!(add(chrome, "--otra").as_deref(), Some("--otra")); + assert_eq!(add("C:/Apps/raro.exe", "").as_deref(), None); + } + + /// A hidden browser holds no key, so another may take its key meanwhile; shown again, it lets + /// go of that one rather than share it, and keeps the rest. + #[test] + fn a_browser_shown_again_lets_go_of_a_key_given_away() { + let mut edge = browser("edge", &[]); + edge.hidden = true; + edge.keys.insert(String::new(), 'H'); + let mut firefox = browser("firefox", &[]); + firefox.keys.insert(String::new(), 'H'); + let mut kept = browser("brave", &[]); + kept.hidden = true; + kept.keys.insert(String::new(), 'B'); + + let shown = hidden_in(vec![edge, firefox, kept], "edge", false).expect("there"); + assert!(shown[0].keys.is_empty(), "Firefox has H now"); + assert_eq!(shown[1].key_for(None), Some('H')); + let back = hidden_in(shown, "brave", false).expect("there"); + assert_eq!(back[2].key_for(None), Some('B'), "nobody took B"); + } + + /// A key is one digit from 1 or one letter, kept in capitals, and only while no other row holds + /// it: the second row would never be reached by it. + #[test] + fn a_key_is_kept_only_while_no_other_row_holds_it() { + let mut chrome = browser( + "chrome", + &[("Default", "Personal"), ("Profile 1", "Trabajo")], + ); + chrome.keys.insert("Profile 1".to_owned(), 'W'); + let all = vec![chrome, browser("custom-1", &[])]; + + let kept = keyed(&all, "custom-1", &typed(&[("", "p"), ("other", " ")])).expect("free"); + assert_eq!( + kept, + BTreeMap::from([(String::new(), 'P')]), + "a blank box is no key" + ); + assert_eq!( + keyed(&all, "custom-1", &typed(&[("", "w")])).unwrap_err(), + "keyTaken:Google Chrome · Trabajo" + ); + assert_eq!( + keyed(&all, "custom-1", &typed(&[("", "0")])).unwrap_err(), + "keyUnusable" + ); + assert_eq!( + keyed(&all, "custom-1", &typed(&[("", "ab")])).unwrap_err(), + "keyUnusable" + ); + assert_eq!( + keyed( + &all, + "chrome", + &typed(&[("Default", "Q"), ("Profile 1", "q")]) + ) + .unwrap_err(), + "keyTwice", + "two rows of one browser cannot share one either" + ); + assert_eq!( + keyed(&all, "chrome", &typed(&[("Profile 1", "w")])).expect("its own key"), + BTreeMap::from([("Profile 1".to_owned(), 'W')]) + ); + + let mut hidden = browser("edge", &[]); + hidden.hidden = true; + hidden.keys.insert(String::new(), 'H'); + let mut lower = browser("brave", &[]); + lower.keys.insert(String::new(), 'l'); + let others = [hidden, lower]; + assert!( + keyed(&others, "custom-1", &typed(&[("", "h")])).is_ok(), + "a hidden browser is not in the picker and holds nothing" + ); + assert!( + keyed(&others, "custom-1", &typed(&[("", "L")])).is_err(), + "a key written in lowercase by hand still holds" + ); + + assert_eq!( + keyed(&all, "custom-1", &typed(&[("Profile 9", "z")])).expect("ignored"), + BTreeMap::new(), + "a row the browser does not have keeps no key" + ); + + let mut plain = browser("firefox", &[]); + plain.name = "Firefox".to_owned(); + plain.keys.insert(String::new(), 'F'); + assert_eq!( + keyed(&[plain], "custom-1", &typed(&[("", "F")])).unwrap_err(), + "keyTaken:Firefox", + "a browser with no profiles is named alone" + ); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index d1c8544..aef3e3f 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod about; +mod editing; mod news; mod shell; mod shop; @@ -6,6 +7,7 @@ mod star; mod system; mod update; +use editing::{BrowserView, Edit, added, edited, free_id, hidden_in, readable, views, without}; use linkunbound_core::{ Browser, Language, Preferences, Rule, Scope, Store, Strings, Target, host_of, is_public_suffix, merge, normalise, same_name, site_of, @@ -327,79 +329,9 @@ fn rules_retarget( rules_list() } -#[derive(Serialize)] -struct BrowserView { - id: String, - name: String, - exe: String, - profiles: usize, - /// Named, not just counted: a rule can point at one profile of a browser, - /// so the screen has to be able to offer them apart. - profile_names: Vec<(String, String)>, - private: bool, - custom: bool, - hidden: bool, - icon: Option, - /// Quoted back the way it has to be typed, or a value with a space in it would be split - /// the next time the form was saved. - args: String, - private_flag: Option, - icon_path: Option, -} - -fn seen(browser: &Browser) -> BrowserView { - BrowserView { - profiles: browser.profiles.len(), - profile_names: browser - .profiles - .iter() - .map(|p| (p.id.clone(), p.name.clone())) - .collect(), - private: browser.private_flag.is_some(), - icon: icon_data(browser.icon_source(), &browser.id), - id: browser.id.clone(), - name: browser.name.clone(), - exe: browser.exe.clone(), - custom: browser.custom, - hidden: browser.hidden, - args: linkunbound_core::join_args(&browser.extra_args), - private_flag: browser.private_flag.clone(), - icon_path: browser.icon_path.clone(), - } -} - -/// What the settings screen can change about a browser it owns. -#[derive(serde::Deserialize)] -struct Edit { - name: String, - exe: String, - /// The line as it was typed, not the words in it: a value in quotes is one argument, and - /// deciding that in the form put the rule somewhere nothing could test it. - args: String, - private_flag: Option, - icon_path: Option, -} - -fn readable(path: &str) -> bool { - let path = std::path::Path::new(path); - path.is_file() || is_bundle(path) -} - -#[cfg(target_os = "macos")] -fn is_bundle(path: &std::path::Path) -> bool { - path.extension() - .is_some_and(|e| e.eq_ignore_ascii_case("app")) - && path.join("Contents").join("Info.plist").is_file() -} - -#[cfg(not(target_os = "macos"))] -fn is_bundle(_path: &std::path::Path) -> bool { - false -} - #[tauri::command] fn browsers_list() -> Vec { - catalogue().iter().map(seen).collect() + views(&catalogue()) } /// Saves the merged catalogue, which is what turns a detected browser into a @@ -422,65 +354,16 @@ fn browsers_set_hidden(id: String, hidden: bool) -> Result, Str keep(hidden_in(catalogue(), &id, hidden)?) } -fn hidden_in(mut all: Vec, id: &str, hidden: bool) -> Result, String> { - let Some(found) = all.iter_mut().find(|b| b.id == id) else { - return Err("browserGone".to_owned()); - }; - found.hidden = hidden; - Ok(all) -} - #[tauri::command] fn browsers_remove(id: String) -> Result, String> { keep(without(catalogue(), &id)?) } -/// A detected browser is hidden, never removed: the next scan would bring it -/// back and the deletion would look like it failed. -fn without(mut all: Vec, id: &str) -> Result, String> { - if !all.iter().any(|b| b.id == id && b.custom) { - return Err("browserNotYours".to_owned()); - } - all.retain(|b| b.id != id); - Ok(all) -} - -/// Never reuses a number a live entry already holds, or editing one would -/// rename the other. -fn free_id(all: &[Browser]) -> String { - (1..) - .map(|n| format!("custom-{n}")) - .find(|id| !all.iter().any(|b| &b.id == id)) - .unwrap_or_else(|| "custom-1".to_owned()) -} - #[tauri::command] fn browsers_add(edit: Edit) -> Result, String> { keep(added(catalogue(), edit, readable)?) } -fn added( - mut all: Vec, - edit: Edit, - exists: impl Fn(&str) -> bool, -) -> Result, String> { - if !exists(&edit.exe) { - return Err("browserNoProgram".to_owned()); - } - all.push(Browser { - id: free_id(&all), - name: edit.name, - exe: edit.exe, - profiles: Vec::new(), - extra_args: linkunbound_core::split_args(&edit.args), - private_flag: edit.private_flag.filter(|f| !f.is_empty()), - icon_path: edit.icon_path.filter(|p| !p.is_empty()), - custom: true, - hidden: false, - }); - Ok(all) -} - /// A detected browser can be renamed and given arguments or an icon, the way /// 1.x allowed, but not moved: the path is what detection found, and a rescan /// would overwrite anything typed over it. @@ -489,33 +372,6 @@ fn browsers_update(id: String, edit: Edit) -> Result, String> { keep(edited(catalogue(), &id, edit, readable)?) } -fn edited( - mut all: Vec, - id: &str, - edit: Edit, - exists: impl Fn(&str) -> bool, -) -> Result, String> { - let Some(found) = all.iter_mut().find(|b| b.id == id) else { - return Err("browserGone".to_owned()); - }; - if found.custom { - if !exists(&edit.exe) { - return Err("browserNoProgram".to_owned()); - } - found.exe = edit.exe; - } - found.name = edit.name; - found.extra_args = linkunbound_core::split_args(&edit.args); - // Detection knows how a browser it found opens privately, and keeps that; where it found one - // it does not know — a family off its list — the flag is the person's to say, as for a - // browser they added themselves. - if found.custom || linkunbound_core::private_flag_for(&found.exe).is_none() { - found.private_flag = edit.private_flag.filter(|f| !f.is_empty()); - } - found.icon_path = edit.icon_path.filter(|p| !p.is_empty()); - Ok(all) -} - /// What the bar shows. The plugin reports the size of each chunk rather than the total so far, /// and a whole it does not know yet is nought rather than everything. Capped, because a server /// that declares one size and sends another would otherwise put a number like 40000 on screen. @@ -581,7 +437,7 @@ fn maintenance_rescan() -> Result { // What was just detected and saved is the list; reading the catalogue again would detect twice. keep_in(&store(), found.browsers.clone())?; Ok(Rescanned { - browsers: found.browsers.iter().map(seen).collect(), + browsers: views(&found.browsers), added: found.added, removed: found.removed, }) @@ -1340,8 +1196,9 @@ pub fn run() { mod tests { use super::{ Edit, Errand, Store, added, describe, duplicated_in, edited, errand_in, far_along, free_id, - hidden_in, keep_in, ordered, readable, rescanned, seen, spoken, without, + hidden_in, keep_in, ordered, readable, rescanned, spoken, without, }; + use crate::editing::seen; use linkunbound_core::{Locale, Preferences, Profile, Rule, Scope, Target}; /// The resident's errands are told apart from a launch that wants the window, and from each @@ -1620,6 +1477,7 @@ mod tests { args: String::new(), private_flag: Some(String::new()), icon_path: Some(String::new()), + keys: [(String::new(), " ".to_owned())].into(), }, |_| true, ) @@ -1627,6 +1485,10 @@ mod tests { assert_eq!(all[0].private_flag, None); assert_eq!(all[0].icon_path, None); + assert!( + all[0].keys.is_empty(), + "an empty key box leaves the row to the defaults" + ); assert!(all[0].custom, "one somebody added is theirs to edit"); assert!(!all[0].hidden); } @@ -1643,6 +1505,7 @@ mod tests { args: String::new(), private_flag: None, icon_path: None, + keys: Default::default(), }, |_| false, ); @@ -1661,6 +1524,7 @@ mod tests { args: r#"--new-window --user-data-dir="C:\My Data""#.to_owned(), private_flag: Some("--incognito".to_owned()), icon_path: None, + keys: Default::default(), }, |_| true, ) @@ -1723,6 +1587,7 @@ mod tests { icon_path: None, custom: false, hidden: false, + keys: Default::default(), } } @@ -1765,7 +1630,7 @@ mod tests { #[test] fn a_detected_browser_is_not_reported_as_one_the_user_added() { - let view = seen(&detected("chrome")); + let view = seen(&detected("chrome"), Default::default()); assert!(!view.custom); assert_eq!(view.profiles, 1); assert_eq!( @@ -1775,7 +1640,7 @@ mod tests { ); assert!(view.private, "it declares a private switch"); - let mine = seen(&custom("custom-1")); + let mine = seen(&custom("custom-1"), Default::default()); assert!(mine.custom); assert!(!mine.private); } @@ -1896,6 +1761,7 @@ mod tests { args: "--new-window".to_owned(), private_flag: Some(String::new()), icon_path: None, + keys: Default::default(), } } @@ -2007,6 +1873,7 @@ mod tests { icon_path: None, custom: true, hidden: false, + keys: Default::default(), } } diff --git a/app/src/Settings.test.tsx b/app/src/Settings.test.tsx index f7e4021..3732dee 100644 --- a/app/src/Settings.test.tsx +++ b/app/src/Settings.test.tsx @@ -513,6 +513,18 @@ describe("settings", () => { expect(screen.queryByRole("button", { name: "Reparar" })).toBeNull(); }); + /// Browsers come before the rules that point at them, and both before the default-browser page. + it("lists the sections in the order they are set up", async () => { + answers(BASE, { browsers_list: () => Promise.resolve([]) }); + await opened(); + const order = ["Aplicación", "Navegadores", "Reglas", "Enlaces", "Mantenimiento"]; + const names = screen + .getAllByRole("button") + .map((b) => b.textContent?.trim() ?? "") + .filter((name) => order.includes(name)); + expect(names).toEqual(order); + }); + it("walks every section and marks the one it is on", async () => { answers(BASE, { browsers_list: () => Promise.resolve([]) }); await opened(); diff --git a/app/src/Settings.tsx b/app/src/Settings.tsx index 80b2c06..7d6746d 100644 --- a/app/src/Settings.tsx +++ b/app/src/Settings.tsx @@ -103,23 +103,23 @@ const PAGES: Entry[] = [ ), }, { - id: "links", - label: "navLinks", + id: "browsers", + label: "navBrowsers", icon: ( <> - - + + ), }, { id: "rules", label: "navRules", icon: }, { - id: "browsers", - label: "navBrowsers", + id: "links", + label: "navLinks", icon: ( <> - - + + ), }, diff --git a/app/src/i18n.ts b/app/src/i18n.ts index 64332d0..7bb71c1 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -105,10 +105,26 @@ const ES = { fieldArgsLabel: "Argumentos adicionales", fieldPrivate: "Argumento de ventana privada (--incognito, -private-window…)", fieldPrivateLabel: "Argumento de ventana privada", - fieldPrivateHint: "Sin este argumento, el navegador no podrá abrir en privado desde el selector.", + fieldPrivateHint: + "Déjalo vacío si es Chrome, Edge, Brave, Vivaldi, Opera o Firefox: se pone solo. Sin él, otro navegador no podrá abrir en privado.", + fieldAdvanced: "Opciones avanzadas", + helpTitle: "¿Cómo creo un perfil o un navegador personalizado?", + helpProfile: + "Un perfil de Chrome, Edge o Brave (su carpeta aparece en chrome://version, «Ruta del perfil»):", + helpProfileCode: '--profile-directory="Profile 1"', + helpSeparate: "Un navegador aparte, con su propio historial y sesiones:", + helpSeparateCode: '--user-data-dir="C:/Navegadores/Trabajo"', + helpFirefox: "Un perfil de Firefox (créalo antes en about:profiles):", + helpFirefoxCode: '-P "Trabajo"', + helpWindow: "Abrir siempre en una ventana nueva (en Firefox: -new-window):", + helpWindowCode: "--new-window", fieldIcon: "Ruta de icono personalizado (opcional)", fieldIconLabel: "Ruta de icono personalizado", fieldExeDetected: "La detección decide esta ruta; un nuevo escaneo la restauraría.", + fieldKey: "Tecla en el selector", + fieldKeyOf: "Tecla de {}", + fieldKeyHint: + "Si la dejas vacía usa la que le toca, en gris: 1–9, luego QWERTY, ASDFGH y ZXCVBN.", browserUp: "Subir {}", browserDown: "Bajar {}", browserDuplicate: "Duplicar {}", @@ -189,6 +205,9 @@ const ES = { tryAgain: "Reintentar", browserGone: "Ese navegador ya no está", browserNoProgram: "No hay ningún programa en esa ruta", + keyTaken: "Esa tecla ya la usa {}", + keyTwice: "Dos perfiles no pueden compartir tecla", + keyUnusable: "Usa un número del 1 al 9 o una letra", browserNotYours: "Solo puedes quitar un navegador que hayas añadido tú", noStartupTask: "Esta versión no trae tarea de inicio", notOnThisPlatform: "Todavía no está disponible en este sistema", @@ -387,10 +406,25 @@ const EN: Record = { fieldPrivate: "Private window argument (--incognito, -private-window…)", fieldPrivateLabel: "Private window argument", fieldPrivateHint: - "Without this argument, the browser will not be able to open privately from the picker.", + "Leave it empty for Chrome, Edge, Brave, Vivaldi, Opera or Firefox: it fills itself in. Without it, any other browser cannot open privately.", + fieldAdvanced: "Advanced options", + helpTitle: "How do I make a profile or a custom browser?", + helpProfile: + "A Chrome, Edge or Brave profile (its folder shows in chrome://version, “Profile Path”):", + helpProfileCode: '--profile-directory="Profile 1"', + helpSeparate: "A separate browser, with its own history and sign-ins:", + helpSeparateCode: '--user-data-dir="C:/Browsers/Work"', + helpFirefox: "A Firefox profile (create it first in about:profiles):", + helpFirefoxCode: '-P "Work"', + helpWindow: "Always open in a new window (in Firefox: -new-window):", + helpWindowCode: "--new-window", fieldIcon: "Custom icon path (optional)", fieldIconLabel: "Custom icon path", fieldExeDetected: "Detection owns this path; a rescan would put it back.", + fieldKey: "Key in the picker", + fieldKeyOf: "Key for {}", + fieldKeyHint: + "Left empty it uses the one in grey, next in line: 1–9, then QWERTY, ASDFGH and ZXCVBN.", browserUp: "Move {} up", browserDown: "Move {} down", browserDuplicate: "Duplicate {}", @@ -471,6 +505,9 @@ const EN: Record = { tryAgain: "Try again", browserGone: "That browser is no longer there", browserNoProgram: "There is no program at that path", + keyTaken: "That key is already used by {}", + keyTwice: "Two profiles cannot share a key", + keyUnusable: "Use a number from 1 to 9 or a letter", browserNotYours: "Only a browser you added can be removed", noStartupTask: "This build has no startup task", notOnThisPlatform: "Not supported on this platform yet", @@ -566,6 +603,7 @@ const EN: Record = { export const SPEECH: Record> = { es: ES, en: EN }; const MAC_ES: Partial> = { + helpSeparateCode: '--user-data-dir="/Users/Shared/Navegadores/Trabajo"', fieldExe: "Ruta de la aplicación (.app)", browserNoProgram: "No hay ninguna aplicación en esa ruta", healthBuildTree: @@ -586,6 +624,7 @@ const MAC_ES: Partial> = { }; const MAC_EN: Partial> = { + helpSeparateCode: '--user-data-dir="/Users/Shared/Browsers/Work"', fieldExe: "Application path (.app)", browserNoProgram: "There is no application at that path", healthBuildTree: diff --git a/app/src/news.json b/app/src/news.json index 9c70bd3..b5c1315 100644 --- a/app/src/news.json +++ b/app/src/news.json @@ -1,4 +1,55 @@ [ + { + "version": "2.7.1", + "headline": { + "es": "Tu tecla para cada navegador, y un Mosaico renovado", + "en": "Your own key for every browser, and a renewed Tiles look" + }, + "es": [ + { + "title": "Una tecla para cada navegador", + "said": "en Navegadores, al editar uno, le das la tecla que quieras a él o a cada uno de sus perfiles. Si esa tecla ya la usa otro, te avisa antes de guardar. Si no eliges ninguna, se ve en gris la que le toca." + }, + { + "title": "Más teclas", + "said": "después del 9 siguen las letras Q W E R T Y, A S D F G H y Z X C V B N, así que hasta 27 navegadores y perfiles se abren con una tecla. Con las flechas, la lista sigue al que eliges." + }, + { + "title": "Mosaico renovado", + "said": "el estilo Mosaico ahora tiene las mismas tarjetas de LinkBear, en tres columnas, pero sin el oso." + }, + { + "title": "Añadir un navegador es más fácil", + "said": "la ventana privada de Chrome, Edge, Brave, Vivaldi, Opera y Firefox se configura sola. Lo demás queda en Opciones avanzadas, con ejemplos para crear un perfil o un navegador aparte." + }, + { + "title": "Ajustes en orden", + "said": "Navegadores va justo después de Aplicación, seguido de Reglas y Enlaces." + } + ], + "en": [ + { + "title": "A key for every browser", + "said": "in Browsers, when you edit one, you give it, or each of its profiles, the key you want. If another one already uses that key, you are told before saving. If you choose none, the key it gets is shown in grey." + }, + { + "title": "More keys", + "said": "after 9 come the letters Q W E R T Y, A S D F G H and Z X C V B N, so up to 27 browsers and profiles open with a single key. With the arrows, the list follows the one you choose." + }, + { + "title": "Tiles, renewed", + "said": "the Tiles look now has the same cards as LinkBear, in three columns, without the bear." + }, + { + "title": "Adding a browser is easier", + "said": "the private window of Chrome, Edge, Brave, Vivaldi, Opera and Firefox sets itself up. Everything else sits under Advanced options, with examples for making a profile or a separate browser." + }, + { + "title": "Settings in order", + "said": "Browsers comes right after Application, followed by Rules and Links." + } + ] + }, { "version": "2.7.0", "headline": { diff --git a/app/src/settings/Browsers.test.tsx b/app/src/settings/Browsers.test.tsx index 932ec57..90cc781 100644 --- a/app/src/settings/Browsers.test.tsx +++ b/app/src/settings/Browsers.test.tsx @@ -18,6 +18,12 @@ const CHROME = { args: "", private_flag: "--incognito", icon_path: null, + profile_names: [ + ["Default", "Personal"], + ["Profile 1", "Trabajo"], + ] as [string, string][], + keys: { "Profile 1": "W" } as Record, + shown_keys: { Default: "1", "Profile 1": "W" } as Record, }; const EDGE = { @@ -26,6 +32,9 @@ const EDGE = { name: "Microsoft Edge", profiles: 1, hidden: true, + profile_names: [["Default", "Personal"]] as [string, string][], + keys: {}, + shown_keys: {}, }; const ODD = { @@ -36,6 +45,9 @@ const ODD = { private: false, private_flag: null, hidden: false, + profile_names: [], + keys: {}, + shown_keys: { "": "2" }, }; const MINE = { @@ -50,6 +62,9 @@ const MINE = { args: "--disable-extensions", private_flag: null, icon_path: null, + profile_names: [] as [string, string][], + keys: {} as Record, + shown_keys: { "": "3" } as Record, }; function answers(list: unknown[], overrides: Record Promise> = {}) { @@ -179,6 +194,7 @@ describe("browsers", () => { args: '--profile-directory="Profile 1"', private_flag: null, icon_path: null, + keys: { "": "" }, }, }); }); @@ -225,6 +241,71 @@ describe("browsers", () => { }); }); + /// A key another row holds is named before saving, and the form will not save it. + it("warns when the key is already another browser's", async () => { + render(); + await userEvent.click( + await screen.findByRole("button", { name: "Editar Chrome sin extensiones" }), + ); + await userEvent.type(screen.getByLabelText("Tecla en el selector"), "w"); + expect(screen.getByRole("alert")).toHaveTextContent( + "Esa tecla ya la usa Google Chrome · Trabajo", + ); + expect(screen.getByRole("button", { name: "Guardar" })).toBeDisabled(); + + await userEvent.type(screen.getByLabelText("Tecla en el selector"), "p"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Guardar" })); + expect(invoke).toHaveBeenCalledWith("browsers_update", { + id: "custom-1", + edit: expect.objectContaining({ keys: { "": "P" } }), + }); + }); + + /// Each profile is a row of its own in the picker, so each gets its own key. + it("offers a key for every profile and keeps the one already given", async () => { + render(); + await userEvent.click(await screen.findByRole("button", { name: "Editar Google Chrome" })); + expect(screen.getByLabelText("Tecla de Trabajo")).toHaveValue("W"); + expect(screen.getByLabelText("Tecla de Personal")).toHaveValue(""); + expect(screen.getByLabelText("Tecla de Personal")).toHaveAttribute("placeholder", "1"); + await userEvent.type(screen.getByLabelText("Tecla de Personal"), "w"); + for (const said of screen.getAllByRole("alert")) { + expect(said).toHaveTextContent("Dos perfiles no pueden compartir tecla"); + } + await userEvent.type(screen.getByLabelText("Tecla de Personal"), "1"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + /// The advanced options carry examples, since few people know these switches exist. + it("shows how to make a profile or a separate browser", async () => { + render(); + await userEvent.click(await screen.findByRole("button", { name: "Añadir un navegador" })); + await userEvent.click( + screen.getByRole("button", { name: "¿Cómo creo un perfil o un navegador personalizado?" }), + ); + expect(screen.getByText('--profile-directory="Profile 1"')).toBeVisible(); + expect(screen.getByText('--user-data-dir="C:/Navegadores/Trabajo"')).toBeVisible(); + }); + + /// The box holds one key, so the new one is whatever was typed, wherever the caret was; and a + /// hidden browser is not in the picker, so its key is free. + it("takes the key just typed and ignores the ones hidden browsers kept", async () => { + answers([CHROME, { ...EDGE, keys: { Default: "E" } }, ODD, MINE]); + render(); + await userEvent.click( + await screen.findByRole("button", { name: "Editar Chrome sin extensiones" }), + ); + const box = screen.getByLabelText("Tecla en el selector"); + await userEvent.type(box, "e"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + await userEvent.type(box, "k", { initialSelectionStart: 0, initialSelectionEnd: 0 }); + expect(box).toHaveValue("K"); + await userEvent.type(box, " "); + expect(box).toHaveValue(""); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + /// Duplicating a detected browser is how one binary becomes two entries with /// different arguments, so it cannot be limited to the custom ones. it("can duplicate a detected browser, not only a custom one", async () => { diff --git a/app/src/settings/Browsers.tsx b/app/src/settings/Browsers.tsx index a70f5cf..c541b4a 100644 --- a/app/src/settings/Browsers.tsx +++ b/app/src/settings/Browsers.tsx @@ -17,6 +17,9 @@ type BrowserView = { args: string; private_flag: string | null; icon_path: string | null; + profile_names: [string, string][]; + keys: Record; + shown_keys: Record; }; type Edit = { @@ -25,9 +28,63 @@ type Edit = { args: string; private_flag: string | null; icon_path: string | null; + keys: Record; }; -const BLANK: Edit = { name: "", exe: "", args: "", private_flag: null, icon_path: null }; +/// Examples for the advanced options: what to say, and what to type. +const HELP: [Key, Key][] = [ + ["helpProfile", "helpProfileCode"], + ["helpSeparate", "helpSeparateCode"], + ["helpFirefox", "helpFirefoxCode"], + ["helpWindow", "helpWindowCode"], +]; + +const BLANK: Edit = { name: "", exe: "", args: "", private_flag: null, icon_path: null, keys: {} }; + +/// Each row of a browser in the picker, by the id its key is saved under: one per profile, or the +/// browser itself under "" when it has none. +function rowsOf(browser?: BrowserView): [string, string][] { + return browser?.profile_names.length ? browser.profile_names : [["", ""]]; +} + +/// Who else holds a key, named the way the picker shows them. A hidden browser is not in the +/// picker and holds nothing. +function holderOf(key: string, others: BrowserView[]): string | null { + for (const b of others.filter((o) => !o.hidden)) { + for (const [row, profile] of rowsOf(b)) { + if (b.keys[row]?.toUpperCase() !== key) continue; + return profile ? `${b.name} · ${profile}` : b.name; + } + } + return null; +} + +/// The one key typed, wherever the caret was: the box already holds one, so the new one is +/// whatever is not it. +function typedKey(value: string, was: string): string { + const upper = value.toUpperCase(); + const fresh = was && upper.length > 1 ? upper.replace(was, "") : upper; + return fresh.slice(-1).trim(); +} + +/// What is wrong with the key typed for one row, said before saving rather than after. +function keyProblem( + key: string, + row: string, + typed: Record, + rows: string[], + others: BrowserView[], + t: (key: Key, ...values: string[]) => string, +): string | null { + if (key === "") return null; + if (!/^[1-9A-Z]$/.test(key)) return t("keyUnusable"); + if ( + Object.entries(typed).some(([other, k]) => other !== row && k === key && rows.includes(other)) + ) + return t("keyTwice"); + const holder = holderOf(key, others); + return holder ? t("keyTaken", holder) : null; +} function describe(browser: BrowserView, t: (key: Key, ...values: string[]) => string): string { const parts = [ @@ -64,11 +121,18 @@ const FIELD = function Form({ initial, + rows, + shown = {}, + others, detected, onSave, onCancel, }: { initial: Edit; + rows: [string, string][]; + /// The key each row has in the picker today, chosen or next in line, shown in an empty box. + shown?: Record; + others: BrowserView[]; /// Detection owns the path and the private flag: the path is shown and locked, since it says /// which program this is; the flag is not shown at all where detection knows it, and offered /// only for a family it could not place — the one case the person knows better. @@ -82,6 +146,11 @@ function Form({ const [args, setArgs] = useState(initial.args); const [priv, setPriv] = useState(initial.private_flag ?? ""); const [icon, setIcon] = useState(initial.icon_path ?? ""); + const [keys, setKeys] = useState>(initial.keys); + const advancedAtFirst = Boolean(initial.args || initial.icon_path); + const [helping, setHelping] = useState(false); + const ids = rows.map(([row]) => row); + const problems = ids.map((row) => keyProblem(keys[row] ?? "", row, keys, ids, others, t)); return (
[row, keys[row] ?? ""])), }); }} > @@ -119,38 +189,108 @@ function Form({ {t("fieldExeDetected")}

)} - setArgs(e.target.value)} - placeholder={t("fieldArgs")} - aria-label={t("fieldArgsLabel")} - className={FIELD} - /> - {(!detected || initial.private_flag === null) && ( - <> +
+ {t("fieldKey")} + {rows.map(([row, label], at) => ( +
+ {label && {label}} + { + const key = typedKey(e.target.value, keys[row] ?? ""); + setKeys((now) => ({ ...now, [row]: key })); + }} + aria-label={label ? t("fieldKeyOf", label) : t("fieldKey")} + aria-invalid={problems[at] !== null} + className={`${FIELD} w-10 text-center uppercase ${ + problems[at] ? "border-[#C0362F] dark:border-[#FF8A85]" : "" + }`} + /> + {problems[at] && ( + + {problems[at]} + + )} +
+ ))} +

{t("fieldKeyHint")}

+
+
+ + + {t("fieldAdvanced")} + +
+ setArgs(e.target.value)} + placeholder={t("fieldArgs")} + aria-label={t("fieldArgsLabel")} + className={FIELD} + /> + {(!detected || initial.private_flag === null) && ( + <> + setPriv(e.target.value)} + placeholder={t("fieldPrivate")} + aria-label={t("fieldPrivateLabel")} + className={FIELD} + /> +

+ {t("fieldPrivateHint")} +

+ + )} setPriv(e.target.value)} - placeholder={t("fieldPrivate")} - aria-label={t("fieldPrivateLabel")} + value={icon} + onChange={(e) => setIcon(e.target.value)} + placeholder={t("fieldIcon")} + aria-label={t("fieldIconLabel")} className={FIELD} /> -

- {t("fieldPrivateHint")} -

- +
+
+ + {helping && ( +
+
+ {HELP.map(([said, code]) => ( +
+
{t(said)}
+
+ {t(code)} +
+
+ ))} +
+
)} - setIcon(e.target.value)} - placeholder={t("fieldIcon")} - aria-label={t("fieldIconLabel")} - className={FIELD} - />
@@ -273,6 +413,9 @@ export default function Browsers() { o.id !== b.id)} detected onCancel={() => setEditing(null)} onSave={(edit) => run("browsers_update", { id: b.id, edit })} @@ -357,6 +500,9 @@ export default function Browsers() { o.id !== b.id)} onCancel={() => setEditing(null)} onSave={(edit) => run("browsers_update", { id: b.id, edit })} /> @@ -430,6 +576,8 @@ export default function Browsers() { {editing === "new" ? ( setEditing(null)} onSave={(edit) => run("browsers_add", { edit })} /> diff --git a/crates/linkunbound-core/src/browser.rs b/crates/linkunbound-core/src/browser.rs index c2c706e..af1d6a1 100644 --- a/crates/linkunbound-core/src/browser.rs +++ b/crates/linkunbound-core/src/browser.rs @@ -36,6 +36,67 @@ pub struct Browser { /// Kept out of the picker without being forgotten. #[serde(default)] pub hidden: bool, + /// The key the person gave each row in the picker, by profile id, or by "" for a browser with + /// none. Kept here rather than on the profile, which every scan rebuilds. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub keys: std::collections::BTreeMap, +} + +/// A key a person may give a row of the picker: a digit from 1 or a letter, in either case. +#[must_use] +pub fn usable_key(key: char) -> Option { + let key = key.to_ascii_uppercase(); + (key.is_ascii_uppercase() || ('1'..='9').contains(&key)).then_some(key) +} + +/// The keys the rows answer to unless the person chose one: the digits, then the three rows of +/// letters under them. +pub const KEYS: &str = "123456789QWERTYASDFGHZXCVBN"; + +/// The key each row shows, from the ones chosen in order of the rows: a chosen key stands unless +/// an earlier row holds it already, the others take the first of the defaults nobody took, and +/// past the defaults a row has none and opens with the pointer. +#[must_use] +pub fn assigned_keys(chosen: impl IntoIterator>) -> Vec> { + let mut given: Vec> = Vec::new(); + for key in chosen { + let key = key.and_then(usable_key); + given.push(key.filter(|k| !given.contains(&Some(*k)))); + } + let spare: Vec = KEYS + .chars() + .filter(|k| !given.contains(&Some(*k))) + .collect(); + let mut spare = spare.into_iter(); + given + .into_iter() + .map(|key| key.or_else(|| spare.next())) + .collect() +} + +/// The keys the picker gives each browser's rows, by profile id or "" as in [`Browser::keys`]: the +/// hidden ones are not in the picker and have none. +#[must_use] +pub fn picker_keys(browsers: &[Browser]) -> Vec> { + let rows: Vec<(usize, String, Option)> = browsers + .iter() + .enumerate() + .filter(|(_, b)| !b.hidden) + .flat_map(|(at, b)| { + b.rows().into_iter().map(move |profile| { + let id = profile.map(|p| p.id.as_str()); + (at, id.unwrap_or_default().to_owned(), b.key_for(id)) + }) + }) + .collect(); + let keys = assigned_keys(rows.iter().map(|(_, _, key)| *key)); + let mut out = vec![std::collections::BTreeMap::new(); browsers.len()]; + for ((at, row, _), key) in rows.into_iter().zip(keys) { + if let Some(key) = key { + out[at].insert(row, key); + } + } + out } /// What the registry finds is the truth about paths and profiles; what the user @@ -56,8 +117,12 @@ pub fn merge(detected: Vec, saved: &[Browser]) -> Vec { extra_args: kept.extra_args.clone(), private_flag: kept.private_flag.clone(), icon_path: kept.icon_path.clone(), + keys: kept.keys.clone(), ..found.clone() }); + if let Some(last) = out.last_mut() { + last.forget_gone_keys(); + } } } for found in detected { @@ -84,11 +149,42 @@ impl Browser { name: named.to_owned(), custom: true, hidden: false, + keys: Default::default(), profiles: Vec::new(), ..self.clone() } } + /// The rows it puts in the picker: one per profile, or the browser itself when it has none. + #[must_use] + pub fn rows(&self) -> Vec> { + if self.profiles.is_empty() { + vec![None] + } else { + self.profiles.iter().map(Some).collect() + } + } + + /// The key the person gave the row of this profile, or of the browser when it has none. + #[must_use] + pub fn key_for(&self, profile: Option<&str>) -> Option { + self.keys + .get(profile.unwrap_or_default()) + .copied() + .and_then(usable_key) + } + + /// A scan can take a profile away, or give profiles to a browser that had none: a key left on + /// a row nobody sees would still count as taken, with no box in settings to clear it. + fn forget_gone_keys(&mut self) { + let live: Vec = self + .rows() + .iter() + .map(|p| p.map(|p| p.id.clone()).unwrap_or_default()) + .collect(); + self.keys.retain(|row, _| live.contains(row)); + } + pub fn profile(&self, id: &str) -> Option<&Profile> { self.profiles.iter().find(|p| p.id == id) } @@ -147,6 +243,7 @@ mod tests { icon_path: None, custom: false, hidden: true, + keys: Default::default(), }; assert!( @@ -171,6 +268,7 @@ mod tests { icon_path: None, custom: false, hidden: false, + keys: Default::default(), }; assert!(browser.supports_private()); @@ -200,6 +298,7 @@ mod tests { icon_path: None, custom: false, hidden: false, + keys: Default::default(), } } @@ -214,6 +313,7 @@ mod tests { icon_path: None, custom: false, hidden: false, + keys: Default::default(), } } @@ -259,7 +359,7 @@ mod tests { #[cfg(test)] mod merging { - use super::{Browser, merge}; + use super::{Browser, merge, usable_key}; fn browser(id: &str, exe: &str) -> Browser { Browser { @@ -272,6 +372,7 @@ mod merging { icon_path: None, custom: false, hidden: false, + keys: Default::default(), } } @@ -281,6 +382,7 @@ mod merging { let detected = vec![browser("chrome", "C:/New/chrome.exe")]; let saved = vec![Browser { hidden: true, + keys: Default::default(), extra_args: vec!["--foo".to_owned()], ..browser("chrome", "C:/Old/chrome.exe") }]; @@ -359,6 +461,70 @@ mod merging { ); } + /// The keys are the person's, like the name: a rescan rebuilds the profiles but not them, and + /// a copy starts with none, or it would fight its original for the same key. + #[test] + fn the_keys_survive_a_scan_and_stay_off_a_copy() { + let detected = vec![browser("chrome", "C:/real/chrome.exe")]; + let mut mine = browser("chrome", "C:/real/chrome.exe"); + mine.keys.insert("Default".to_owned(), 'W'); + mine.keys.insert(String::new(), 'F'); + mine.keys.insert("Gone".to_owned(), 'G'); + let mut detected = detected; + detected[0].profiles = vec![super::Profile { + id: "Default".to_owned(), + name: "Personal".to_owned(), + args: Vec::new(), + }]; + let merged = merge(detected, &[mine]); + assert_eq!(merged[0].key_for(Some("Default")), Some('W')); + assert_eq!( + merged[0].keys.len(), + 1, + "the rows the scan took away let go of their keys" + ); + assert_eq!(merged[0].key_for(None), None); + assert!( + merged[0] + .duplicated("custom-1".to_owned(), "x") + .keys + .is_empty() + ); + } + + /// Settings shows the key each row gets on its own, so it has to be the picker's: the chosen + /// ones stand, the rest take the free defaults in order, and a hidden browser gets none. + #[test] + fn every_row_shown_gets_the_key_the_picker_gives_it() { + let mut chrome = browser("chrome", "chrome.exe"); + chrome.profiles = ["Default", "Work"] + .map(|id| super::Profile { + id: id.to_owned(), + name: id.to_owned(), + args: Vec::new(), + }) + .to_vec(); + chrome.keys.insert("Work".to_owned(), '1'); + let mut hidden = browser("edge", "edge.exe"); + hidden.hidden = true; + let firefox = browser("firefox", "firefox.exe"); + + let keys = super::picker_keys(&[chrome, hidden, firefox]); + assert_eq!(keys[0].get("Default"), Some(&'2')); + assert_eq!(keys[0].get("Work"), Some(&'1')); + assert!(keys[1].is_empty(), "hidden, so not in the picker"); + assert_eq!(keys[2].get(""), Some(&'3')); + } + + #[test] + fn only_a_digit_from_one_or_a_letter_is_a_key() { + assert_eq!(usable_key('w'), Some('W')); + assert_eq!(usable_key('7'), Some('7')); + assert_eq!(usable_key('0'), None); + assert_eq!(usable_key('ñ'), None); + assert_eq!(usable_key(' '), None); + } + /// Settings lets a detected browser be renamed and given a private switch. /// Handing those fields back to detection on read undoes the edit in the /// same render that saved it. diff --git a/crates/linkunbound-core/src/config.rs b/crates/linkunbound-core/src/config.rs index d424213..a7f09e0 100644 --- a/crates/linkunbound-core/src/config.rs +++ b/crates/linkunbound-core/src/config.rs @@ -154,6 +154,7 @@ pub fn read_browsers(raw: &str) -> Result { icon_path: None, custom: b.is_custom, hidden: false, + keys: Default::default(), extra_args: b.extra_args, }) .collect(), @@ -200,6 +201,7 @@ mod tests { icon_path: None, custom: true, hidden: true, + keys: Default::default(), }); let body = write_browsers(&config).expect("written"); diff --git a/crates/linkunbound-core/src/hostile.rs b/crates/linkunbound-core/src/hostile.rs index f98d501..32126c2 100644 --- a/crates/linkunbound-core/src/hostile.rs +++ b/crates/linkunbound-core/src/hostile.rs @@ -147,6 +147,7 @@ mod tests { icon_path: None, custom: false, hidden: false, + keys: Default::default(), } } diff --git a/crates/linkunbound-core/src/launch.rs b/crates/linkunbound-core/src/launch.rs index 9863f33..44ccaec 100644 --- a/crates/linkunbound-core/src/launch.rs +++ b/crates/linkunbound-core/src/launch.rs @@ -136,6 +136,7 @@ mod tests { icon_path: None, custom: false, hidden: false, + keys: Default::default(), }] } diff --git a/crates/linkunbound-core/src/lib.rs b/crates/linkunbound-core/src/lib.rs index 577c16f..44d9192 100644 --- a/crates/linkunbound-core/src/lib.rs +++ b/crates/linkunbound-core/src/lib.rs @@ -23,7 +23,9 @@ pub use binaries::{ BROWSERS, PREVIEW, RESIDENT, Registered, SETTINGS, link_handler, settings_binary, what_is_registered, }; -pub use browser::{Browser, LaunchRefused, Profile, merge}; +pub use browser::{ + Browser, KEYS, LaunchRefused, Profile, assigned_keys, merge, picker_keys, usable_key, +}; pub use config::{ BrowserConfig, ConfigError, SCHEMA_VERSION, read_browsers, read_rules, write_browsers, write_rules, diff --git a/crates/linkunbound-core/src/prefs.rs b/crates/linkunbound-core/src/prefs.rs index 5f850b1..b01f4a2 100644 --- a/crates/linkunbound-core/src/prefs.rs +++ b/crates/linkunbound-core/src/prefs.rs @@ -18,8 +18,8 @@ pub enum Locale { English, } -/// 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. +/// The picker's look: rows under the link, the way 1.x users know it, the bear's cards without +/// the bear, 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 { diff --git a/crates/linkunbound-core/src/store.rs b/crates/linkunbound-core/src/store.rs index f646c75..ad5696f 100644 --- a/crates/linkunbound-core/src/store.rs +++ b/crates/linkunbound-core/src/store.rs @@ -553,6 +553,7 @@ mod tests { icon_path: None, custom: true, hidden: false, + keys: Default::default(), }); store.save_browsers(&browsers).expect("saved"); let read = Store::at(&dir).browsers().expect("read back"); diff --git a/crates/linkunbound-mac/src/detect.rs b/crates/linkunbound-mac/src/detect.rs index 82f5617..f4fc769 100644 --- a/crates/linkunbound-mac/src/detect.rs +++ b/crates/linkunbound-mac/src/detect.rs @@ -238,6 +238,7 @@ fn read_bundle(found: &NSURL) -> Option { icon_path: None, custom: false, hidden: false, + keys: Default::default(), }) } @@ -289,6 +290,7 @@ mod tests { icon_path: None, custom: false, hidden: false, + keys: Default::default(), } } diff --git a/crates/linkunbound-shell/examples/preview.rs b/crates/linkunbound-shell/examples/preview.rs index 7466411..bc327ae 100644 --- a/crates/linkunbound-shell/examples/preview.rs +++ b/crates/linkunbound-shell/examples/preview.rs @@ -5,7 +5,7 @@ //! [--scale=N] [--update | --store | --stage=starting|getting|installing|failed] [--notice]` use linkunbound_core::Language; -use linkunbound_shell::{HALO, Listed, Look, Palette, Picker, dress, paint}; +use linkunbound_shell::{HALO, Listed, Look, Palette, Picker, dress, paint, row_typed}; use slint::ComponentHandle; fn main() -> Result<(), slint::PlatformError> { @@ -24,8 +24,7 @@ fn main() -> Result<(), slint::PlatformError> { .unwrap_or(1); let side = match look { Look::List => 24, - Look::Tiles => 48, - Look::Compact | Look::Bear => 32, + Look::Tiles | Look::Compact | Look::Bear => 32, } * scale; let icons = linkunbound_core::data_dir().join("icons"); // Real icons at the side the chosen style draws them, so what the preview shows is what @@ -62,6 +61,7 @@ fn main() -> Result<(), slint::PlatformError> { profile: named("Tu Chrome", "Personal"), icon: icon("google-chrome"), can_private: true, + key: None, }, Listed { browser_id: "firefox".to_owned(), @@ -70,6 +70,7 @@ fn main() -> Result<(), slint::PlatformError> { profile: String::new(), icon: icon("firefox-308046b0af4a39cb"), can_private: true, + key: None, }, Listed { browser_id: "vivaldi".to_owned(), @@ -78,6 +79,7 @@ fn main() -> Result<(), slint::PlatformError> { profile: named("Trabajo", "Work"), icon: icon("vivaldi-xzttkvpr7ou6s6fgtka6s5fohm"), can_private: false, + key: None, }, ]; @@ -172,17 +174,16 @@ fn main() -> Result<(), slint::PlatformError> { // The keys as the resident reads them: a digit opens its row, the arrows walk the reaches. { let handle = window.as_weak(); - window.on_typed(move |typed, private| { + window.on_typed(move |typed, private, chorded| { + use slint::Model; let Some(w) = handle.upgrade() else { return false; }; - let Some(digit) = typed.chars().next().and_then(|c| c.to_digit(10)) else { + let digit = |c: char| c.to_digit(10); + let shown: Vec<_> = w.get_rows().iter().map(|row| row.key).collect(); + let Some(index) = row_typed(&shown, &typed, chorded, digit) else { return false; }; - let index = i32::try_from(digit).unwrap_or(0) - 1; - if index < 0 { - return false; - } w.invoke_open(index, private); true }); diff --git a/crates/linkunbound-shell/src/lib.rs b/crates/linkunbound-shell/src/lib.rs index 57b35f6..083ace0 100644 --- a/crates/linkunbound-shell/src/lib.rs +++ b/crates/linkunbound-shell/src/lib.rs @@ -40,9 +40,8 @@ pub const CLASSIC_CORNER: f64 = 10.0; /// The sheet draws its own shadow where the system draws none for a frameless window. pub const HALO: f32 = if cfg!(target_os = "macos") { 0.0 } else { 18.0 }; -/// Logical sides the two looks draw the icon at; the resident asks for them in screen pixels. +/// Logical side the list draws the icon at; the resident asks for it in screen pixels. pub const ICON_SIDE: u32 = 24; -pub const TILE_ICON_SIDE: u32 = 40; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Reaches { @@ -94,6 +93,8 @@ pub struct Listed { pub profile: String, pub icon: Option, pub can_private: bool, + /// The key the person gave this row, if any. + pub key: Option, } #[must_use] @@ -109,19 +110,21 @@ pub fn destinations(browsers: &[Browser]) -> Vec { profile: String::new(), icon: None, can_private: browser.supports_private(), + key: browser.key_for(None), }; - if browser.profiles.is_empty() { - return vec![base]; - } browser - .profiles - .iter() - .map(|profile| Listed { - profile_id: Some(profile.id.clone()), - profile: profile.name.clone(), - ..base.clone() + .rows() + .into_iter() + .map(|profile| match profile { + None => base.clone(), + Some(profile) => Listed { + profile_id: Some(profile.id.clone()), + profile: profile.name.clone(), + key: browser.key_for(Some(&profile.id)), + ..base.clone() + }, }) - .collect() + .collect::>() }) .collect() } @@ -346,6 +349,31 @@ pub fn split(url: &str) -> (String, String) { } } +/// The key each row of the picker shows. +#[must_use] +pub fn keys_for(rows: &[Listed]) -> Vec> { + linkunbound_core::assigned_keys(rows.iter().map(|row| row.key)) +} + +/// The row a key opens, if one shows it: the digit under the key the host read, or a letter unless +/// a modifier makes it a shortcut. +#[must_use] +pub fn row_typed( + shown: &[slint::SharedString], + typed: &str, + chorded: bool, + digit_behind: impl Fn(char) -> Option, +) -> Option { + let typed = typed.chars().next()?; + let wanted = digit_behind(typed) + .and_then(|digit| char::from_digit(digit, 10)) + .or_else(|| { + linkunbound_core::usable_key(typed).filter(|k| !chorded && k.is_ascii_uppercase()) + })?; + let at = shown.iter().position(|key| key.chars().eq([wanted]))?; + i32::try_from(at).ok() +} + /// What a browser is called among friends: the maker's name in front and «Browser» at the end /// say nothing a small card has room for. #[must_use] @@ -406,17 +434,12 @@ pub fn dress(window: &Picker, words: &Strings, url: &str, source: Option<&str>, slint::SharedString::new() }); + let keys = keys_for(rows); let listed: Vec = rows .iter() - .enumerate() - .map(|(i, row)| Destination { - // The digits stop at 9; a tenth row is opened with the pointer and says so by - // showing no key. - key: if i < 9 { - (i + 1).to_string().into() - } else { - slint::SharedString::new() - }, + .zip(keys) + .map(|(row, key)| Destination { + key: key.map(String::from).unwrap_or_default().into(), name: row.name.clone().into(), short: short_name(&row.name).into(), profile: row.profile.clone().into(), @@ -526,6 +549,7 @@ mod tests { icon_path: None, custom: false, hidden: false, + keys: Default::default(), } } @@ -548,6 +572,7 @@ mod tests { fn a_hidden_browser_never_reaches_the_picker() { let hidden = Browser { hidden: true, + keys: Default::default(), ..browser("edge", &[], true) }; assert!(destinations(&[hidden]).is_empty()); @@ -570,6 +595,7 @@ mod tests { profile: String::new(), icon: None, can_private: true, + key: None, }, Listed { browser_id: "chrome".to_owned(), @@ -578,6 +604,7 @@ mod tests { profile: "Personal".to_owned(), icon: None, can_private: false, + key: None, }, ] } @@ -613,38 +640,40 @@ mod tests { } } - /// The sheet's width comes from how many tiles share a row, never from the words: four across - /// need the wider sheet and the origin's name changes nothing. The halo is room for the - /// shadow and is counted, since the window is what gets placed beside the pointer. + /// Tiles are the bear's cards without the bear: the same sheet for any number of browsers. + /// The halo is room for the shadow and is counted, since the window is what gets placed + /// beside the pointer. #[test] - fn the_sheet_is_sized_by_its_tiles_and_its_halo() { + fn tiles_draw_the_bear_sheet_and_count_their_halo() { headless(); let window = crate::Picker::new().expect("a window"); - window.set_look(crate::Look::Tiles); let words = Language::Spanish.strings(); let two = dressed(); + let mut eight = two.clone(); + for _ in 0..3 { + eight.extend(two.iter().cloned()); + } - dress( - &window, - &words, - "https://docs.google.com/a", - Some("ms-teams"), - &two, - ); - let two_across = window.get_wanted_width(); - assert!(two_across >= 372.0, "{two_across}"); - - let mut four = two.clone(); - four.extend(two.iter().cloned()); - dress(&window, &words, "https://docs.google.com/a", None, &four); - let four_across = window.get_wanted_width(); - assert!( - four_across >= 400.0 && four_across >= two_across, - "four across need the wider sheet" - ); + for rows in [&two, &eight] { + dress( + &window, + &words, + "https://docs.google.com/a", + Some("ms-teams"), + rows, + ); + window.set_look(crate::Look::Bear); + let bear = (window.get_wanted_width(), window.get_wanted_height()); + window.set_look(crate::Look::Tiles); + let tiles = (window.get_wanted_width(), window.get_wanted_height()); + assert_eq!(tiles, bear, "{} browsers", rows.len()); + assert!(tiles.0 >= 500.0, "{}", tiles.0); + assert_eq!(window.get_drawn_icon_side(), 32.0); + } + let plain = window.get_wanted_width(); window.set_halo(18.0); - assert_eq!(window.get_wanted_width(), four_across + 36.0); + assert_eq!(window.get_wanted_width(), plain + 36.0); window.set_halo(0.0); window.set_look(crate::Look::List); @@ -682,11 +711,63 @@ mod tests { use slint::Model; let rows = window.get_rows(); assert_eq!(rows.row_data(8).expect("ninth").key, "9"); + assert_eq!(rows.row_data(9).expect("tenth").key, "Q"); + assert_eq!(rows.row_data(11).expect("twelfth").key, "E"); + } + + fn chose(key: Option) -> Listed { + Listed { + browser_id: "chrome".to_owned(), + profile_id: None, + name: "Chrome".to_owned(), + profile: String::new(), + icon: None, + can_private: true, + key, + } + } + + /// A key chosen in settings is kept, the first row to claim it wins, and the rest take the + /// defaults nobody holds, in order; past the twenty-seven defaults a row has none. + #[test] + fn a_chosen_key_stands_and_the_others_take_what_is_left() { + use super::keys_for; + let rows = [ + chose(None), + chose(Some('1')), + chose(Some('p')), + chose(Some('P')), + chose(Some('0')), + ]; assert_eq!( - rows.row_data(9).expect("tenth").key, - "", - "no digit opens the tenth" + keys_for(&rows), + [Some('2'), Some('1'), Some('P'), Some('3'), Some('4')], + "a second P and a zero fall back to the defaults" ); + + let many: Vec = (0..28).map(|_| chose(None)).collect(); + let given = keys_for(&many); + assert_eq!(given[9], Some('Q')); + assert_eq!(given[26], Some('N')); + assert_eq!(given[27], None); + } + + /// A letter opens the row that shows it whether Shift is held or not; a digit is the one the + /// host read under the key; Alt, Ctrl or Cmd with a letter is a shortcut, with a digit it still + /// opens. + #[test] + fn a_key_opens_the_row_that_shows_it() { + let shown: Vec = ["2", "1", "P", "Q", ""].map(Into::into).to_vec(); + let digit = |c: char| c.to_digit(10).filter(|d| (1..=9).contains(d)); + let typed = |text: &str, chorded: bool| super::row_typed(&shown, text, chorded, digit); + assert_eq!(typed("1", false), Some(1)); + assert_eq!(typed("2", true), Some(0), "Ctrl+2 still opens"); + assert_eq!(typed("p", false), Some(2)); + assert_eq!(typed("Q", false), Some(3)); + assert_eq!(typed("q", true), None, "Alt+Q is a shortcut"); + assert_eq!(typed("w", false), None, "no row shows it"); + assert_eq!(typed("é", false), None); + assert_eq!(typed("", false), None); } /// The pill says the consequence in full once a reach is chosen: «Todo el sitio» in a menu is diff --git a/crates/linkunbound-shell/src/look.rs b/crates/linkunbound-shell/src/look.rs index 0f407a1..79c692d 100644 --- a/crates/linkunbound-shell/src/look.rs +++ b/crates/linkunbound-shell/src/look.rs @@ -1,4 +1,4 @@ -//! How the picker is drawn: list or tiles, and the material behind it where the system lends one. +//! How the picker is drawn: which look, and the material behind it where the system lends one. use linkunbound_core::PickerStyle; use linkunbound_shell::{CLASSIC_CORNER, CORNER, HALO, Look, Palette, Picker, place}; diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index a4f73b7..97826ab 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -1170,21 +1170,14 @@ fn main() -> Result<(), slint::PlatformError> { } { let handle = picker.as_weak(); - picker.on_typed(move |typed, private| { + picker.on_typed(move |typed, private, chorded| { let Some(window) = handle.upgrade() else { return false; }; - let Some(digit) = typed.chars().next().and_then(host::digit_behind) else { - return false; - }; - let Some(index) = i32::try_from(digit).ok().map(|d| d - 1) else { - return false; - }; - if index >= window.get_rows().row_count().try_into().unwrap_or(i32::MAX) { - return false; - } - window.invoke_open(index, private); - true + let shown: Vec<_> = window.get_rows().iter().map(|row| row.key).collect(); + linkunbound_shell::row_typed(&shown, &typed, chorded, host::digit_behind) + .map(|index| window.invoke_open(index, private)) + .is_some() }); } { @@ -1657,6 +1650,7 @@ mod tests { profile: String::new(), icon: None, can_private: false, + key: None, } } fn browser_named(id: &str) -> linkunbound_core::Browser { @@ -1670,6 +1664,7 @@ mod tests { icon_path: None, custom: false, hidden: false, + keys: Default::default(), } } @@ -1815,6 +1810,7 @@ mod tests { profile: "Personal".to_owned(), icon: None, can_private: true, + key: None, } } diff --git a/crates/linkunbound-shell/ui/looks.slint b/crates/linkunbound-shell/ui/looks.slint index c394b3c..2e93685 100644 --- a/crates/linkunbound-shell/ui/looks.slint +++ b/crates/linkunbound-shell/ui/looks.slint @@ -10,7 +10,15 @@ export component ListLook inherits Rectangle { callback open(int); callback hovered(int); - Flickable { + // The arrows choose rows the window may not show: the chosen one is brought into sight, and a + // new link starts from the top. + changed rows => { + list.content-y = 0; + } + changed chosen-at => { + list.content-y = root.chosen-at < 0 ? 0 : clamp(list.content-y, -(root.chosen-at * 45px), root.room - (root.chosen-at * 45px + 56px)); + } + list := Flickable { height: root.room; content-height: root.rows.length * 45px + 12px; VerticalLayout { @@ -80,84 +88,6 @@ export component ListLook inherits Rectangle { } } -// Tiles of one size, at most four to a row, the last row centred; the key in the corner. -export component TilesLook inherits Rectangle { - in property <[Destination]> rows; - in property private-on; - in property chosen-at; - in property cols; - in property tile-width; - callback open(int); - callback hovered(int); - - for item[i] in root.rows: Rectangle { - property row-at: Math.floor(i / root.cols); - property col-at: i - self.row-at * root.cols; - property in-row: min(root.cols, root.rows.length - self.row-at * root.cols); - property greyed: root.private-on && !item.can-private; - property lit: touch.has-hover || i == root.chosen-at; - x: (parent.width - (self.in-row * root.tile-width + (self.in-row - 1) * 6px)) / 2 - + self.col-at * (root.tile-width + 6px); - y: 8px + self.row-at * 106px; - width: root.tile-width; - height: 100px; - border-radius: 8px; - background: self.lit && !self.greyed ? Palette.hover : transparent; - opacity: self.greyed ? 0.4 : 1.0; - accessible-role: button; - accessible-label: item.profile != "" ? item.name + " · " + item.profile : item.name; - accessible-action-default => { root.open(i); } - touch := TouchArea { - enabled: !parent.greyed; - clicked => { root.open(i); } - changed has-hover => { root.hovered(self.has-hover ? i : -1); } - } - KeyCap { - x: 6px; - y: 6px; - key: item.key; - } - Badge { - x: (parent.width - self.width) / 2; - y: 12px; - width: 48px; - height: 48px; - icon: item.icon; - secret: root.private-on && item.can-private; - } - Text { - x: 4px; - y: 64px; - width: parent.width - 8px; - height: 17px; - text: item.name; - font-size: 13px; - color: Palette.ink; - horizontal-alignment: center; - vertical-alignment: center; - overflow: elide; - } - if item.profile != "": Rectangle { - x: (parent.width - self.width) / 2; - y: 82px; - height: 16px; - width: min(parent.width - 12px, tag.preferred-width + 14px); - border-radius: 8px; - background: Palette.chip; - tag := Text { - text: item.profile; - font-size: 11px; - color: Palette.dim; - horizontal-alignment: center; - vertical-alignment: center; - overflow: elide; - width: 100%; - height: 100%; - } - } - } -} - // A border drawn in dashes: the renderer strokes no dashed line, so each dash is a sliver. component Dashes inherits Rectangle { in property ink; @@ -208,7 +138,13 @@ export component CardsLook inherits Rectangle { callback adding(); property card-width: (self.width - 20px - 2 * 6px) / 3; - Flickable { + changed rows => { + cards.content-y = 0; + } + changed chosen-at => { + cards.content-y = root.chosen-at < 0 ? 0 : clamp(cards.content-y, -(Math.floor(root.chosen-at / 3) * 58px), root.room - (Math.floor(root.chosen-at / 3) * 58px + 68px)); + } + cards := Flickable { height: root.room; content-height: Math.ceil(root.rows.length / 3) * 58px + 10px; for item[i] in root.rows: Rectangle { diff --git a/crates/linkunbound-shell/ui/shell.slint b/crates/linkunbound-shell/ui/shell.slint index 704ede6..1f4bb24 100644 --- a/crates/linkunbound-shell/ui/shell.slint +++ b/crates/linkunbound-shell/ui/shell.slint @@ -1,5 +1,5 @@ import { Destination, Reach, Palette, Look, UpdateStrip, Hair, AddressHead, AskHead, RememberFoot, ReachMenu } from "parts.slint"; -import { ListLook, TilesLook, CardsLook, CompactLook } from "looks.slint"; +import { ListLook, CardsLook, CompactLook } from "looks.slint"; export { Palette, Look } from "parts.slint"; export { BearHead, BearPaws } from "bear.slint"; @@ -64,7 +64,8 @@ export component Picker inherits Window { callback open(int, bool); /// Returns whether the key was a destination. See `key-pressed`. - callback typed(string, bool) -> bool; + // The third says Ctrl, Cmd or Alt was held: a letter then belongs to a shortcut, not a row. + callback typed(string, bool, bool) -> bool; /// The reach the keys land on next, one step either way; the dead ones are stepped over. pure callback step-reach(int) -> int; callback dismissed(); @@ -87,9 +88,10 @@ export component Picker inherits Window { property hovered-at: -1; property aimed: root.hovered-at >= 0 ? root.hovered-at : root.chosen-at; property count: root.rows.length; + property cards: root.look == Look.bear || root.look == Look.tiles; property aim: root.aimed < 0 ? 0 - : root.look == Look.bear ? (Math.mod(root.aimed, 3) - 1) * 0.7 + : root.cards ? (Math.mod(root.aimed, 3) - 1) * 0.7 : ((root.aimed + 0.5) / max(1, root.count) - 0.5) * 2; changed aim => { root.mood(root.aim, root.private-on); } changed private-on => { root.mood(root.aim, root.private-on); } @@ -99,7 +101,7 @@ export component Picker inherits Window { 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. + // Nine rows at most on screen 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 @@ -112,13 +114,6 @@ export component Picker inherits Window { visible: false; } - // 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 tiles-width: min(max(root.tile-cols >= 4 ? 400px : 372px, root.track-need), 560px); - property tile-width: Math.floor(((root.tiles-width - 20px - (root.tile-cols - 1) * 6px) / root.tile-cols) / 1px) * 1px; - property tiles-room: root.tile-rows * 106px + 10px; - // Three columns of cards; four rows on screen and the rest scroll. property cards-room: min(4, max(1, Math.ceil(root.count / 3))) * 58px + 10px; @@ -129,13 +124,11 @@ export component Picker inherits Window { property foot-room: 74px; property sheet-width: root.look == Look.list ? root.list-width - : root.look == Look.tiles ? root.tiles-width - : root.look == Look.bear ? max(500px, root.list-width) + : root.cards ? max(500px, root.list-width) : root.compact-width; - property head-room: root.look == Look.bear ? 80px : 60px; + property head-room: root.cards ? 80px : 60px; property body-room: - root.look == Look.tiles ? root.tiles-room - : root.look == Look.bear ? root.cards-room + root.cards ? root.cards-room : root.list-room; property sheet-height: root.strip-room + (root.look == Look.compact ? root.compact-height @@ -145,7 +138,7 @@ export component Picker inherits Window { out property wanted-height: root.sheet-height + 2 * root.halo; // The side the icons are drawn at, for the resident to extract them at in screen pixels. out property drawn-icon-side: - root.look == Look.tiles ? 48 : root.look == Look.list ? 24 : 32; + root.look == Look.list ? 24 : 32; function toggle-private() { root.pinned-private = !root.pinned-private; @@ -184,7 +177,7 @@ export component Picker inherits Window { } if (event.text == Key.UpArrow || event.text == Key.DownArrow) { root.step-chosen((event.text == Key.DownArrow ? 1 : -1) - * (root.look == Look.tiles ? root.tile-cols : root.look == Look.bear ? 3 : 1)); + * (root.cards ? 3 : 1)); return accept; } // Every reach, not only the ones the track draws: the keyboard reaches those behind @@ -210,7 +203,7 @@ export component Picker inherits Window { // The digit is worked out in Rust: with Shift held the layout hands // over "!" or "@" instead of "1", so comparing characters here made // Shift-to-open-privately break the very key it modifies. - if (root.typed(event.text, root.private-on)) { + if (root.typed(event.text, root.private-on, event.modifiers.control || event.modifiers.meta || event.modifiers.alt)) { return accept; } return reject; @@ -303,7 +296,7 @@ export component Picker inherits Window { if root.look != Look.compact: VerticalLayout { y: root.strip-room; height: parent.height - root.strip-room; - if root.look == Look.bear: AskHead { + if root.cards: AskHead { ask: root.private-on ? root.ask-private : root.ask; host: root.host; trail: root.trail; @@ -316,7 +309,7 @@ export component Picker inherits Window { toggle-private => { root.toggle-private(); } copy => { root.copy(); } } - if root.look != Look.bear: AddressHead { + if !root.cards: AddressHead { host: root.host; trail: root.trail; line: root.line; @@ -329,7 +322,7 @@ export component Picker inherits Window { copy => { root.copy(); } } Hair {} - if root.look == Look.bear: CardsLook { + if root.cards: CardsLook { height: root.cards-room; room: root.cards-room; empty: root.count > 0 && root.count < 3 ? 3 - root.count : 0; @@ -351,16 +344,6 @@ export component Picker inherits Window { open(i) => { root.open(i, root.private-on); } hovered(i) => { root.hovered-at = i; } } - 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; } - } Hair {} RememberFoot { label: root.remember-label; diff --git a/crates/linkunbound-win/src/detect.rs b/crates/linkunbound-win/src/detect.rs index 5a6dd76..fb3b582 100644 --- a/crates/linkunbound-win/src/detect.rs +++ b/crates/linkunbound-win/src/detect.rs @@ -69,6 +69,7 @@ fn read_entry(root: &RegKey, key_name: &str) -> Option { icon_path: None, custom: false, hidden: false, + keys: Default::default(), }) } @@ -221,6 +222,7 @@ mod tests { icon_path: None, custom: false, hidden: false, + keys: Default::default(), } }