From d2d5aa35b23c724d72acdca6324b85ec3fa6fd0c Mon Sep 17 00:00:00 2001
From: rgdevment
Date: Thu, 15 Oct 2026 14:41:03 -0300
Subject: [PATCH 1/8] feat(picker): tiles draw the LinkBear cards without the
bear
---
crates/linkunbound-shell/examples/preview.rs | 3 +-
crates/linkunbound-shell/src/lib.rs | 50 +++++++------
crates/linkunbound-shell/ui/looks.slint | 78 --------------------
crates/linkunbound-shell/ui/shell.slint | 41 +++-------
4 files changed, 39 insertions(+), 133 deletions(-)
diff --git a/crates/linkunbound-shell/examples/preview.rs b/crates/linkunbound-shell/examples/preview.rs
index 7466411..b96686d 100644
--- a/crates/linkunbound-shell/examples/preview.rs
+++ b/crates/linkunbound-shell/examples/preview.rs
@@ -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
diff --git a/crates/linkunbound-shell/src/lib.rs b/crates/linkunbound-shell/src/lib.rs
index 57b35f6..9dafc34 100644
--- a/crates/linkunbound-shell/src/lib.rs
+++ b/crates/linkunbound-shell/src/lib.rs
@@ -613,38 +613,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);
diff --git a/crates/linkunbound-shell/ui/looks.slint b/crates/linkunbound-shell/ui/looks.slint
index c394b3c..ad6e9eb 100644
--- a/crates/linkunbound-shell/ui/looks.slint
+++ b/crates/linkunbound-shell/ui/looks.slint
@@ -80,84 +80,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;
diff --git a/crates/linkunbound-shell/ui/shell.slint b/crates/linkunbound-shell/ui/shell.slint
index 704ede6..dfc571f 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";
@@ -87,9 +87,11 @@ 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;
+ // Tiles are the bear's cards with nobody leaning on them.
+ 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); }
@@ -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
@@ -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;
From 0c353dfbda5c6386f49533e05c391a2f7bdac141 Mon Sep 17 00:00:00 2001
From: rgdevment
Date: Thu, 15 Oct 2026 14:44:46 -0300
Subject: [PATCH 2/8] refactor(picker): drop the unused tile icon side and
describe tiles as the cards without the bear
---
crates/linkunbound-core/src/prefs.rs | 4 ++--
crates/linkunbound-shell/src/lib.rs | 3 +--
crates/linkunbound-shell/src/look.rs | 2 +-
crates/linkunbound-shell/ui/shell.slint | 1 -
4 files changed, 4 insertions(+), 6 deletions(-)
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-shell/src/lib.rs b/crates/linkunbound-shell/src/lib.rs
index 9dafc34..920b234 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 {
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/ui/shell.slint b/crates/linkunbound-shell/ui/shell.slint
index dfc571f..57c209e 100644
--- a/crates/linkunbound-shell/ui/shell.slint
+++ b/crates/linkunbound-shell/ui/shell.slint
@@ -87,7 +87,6 @@ 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;
- // Tiles are the bear's cards with nobody leaning on them.
property cards: root.look == Look.bear || root.look == Look.tiles;
property aim:
root.aimed < 0 ? 0
From b263f9765f56c6e3da4ff47843c08e7d8859c2a6 Mon Sep 17 00:00:00 2001
From: rgdevment
Date: Thu, 15 Oct 2026 15:15:08 -0300
Subject: [PATCH 3/8] =?UTF-8?q?feat(picker):=20keys=20past=209=20from=20th?=
=?UTF-8?q?e=20QWERTY=20rows=20or=20A=E2=80=93Z,=20chosen=20in=20settings,?=
=?UTF-8?q?=20and=20the=20chosen=20row=20kept=20in=20sight?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/oversized.txt | 2 +-
app/src/i18n.ts | 10 ++
app/src/settings/Application.test.tsx | 12 +++
app/src/settings/Application.tsx | 16 +++
crates/linkunbound-core/src/lib.rs | 2 +-
crates/linkunbound-core/src/prefs.rs | 46 ++++++++-
crates/linkunbound-shell/examples/preview.rs | 13 ++-
crates/linkunbound-shell/src/lib.rs | 102 +++++++++++++++++--
crates/linkunbound-shell/src/main.rs | 17 ++--
crates/linkunbound-shell/ui/looks.slint | 12 ++-
crates/linkunbound-shell/ui/shell.slint | 8 +-
11 files changed, 206 insertions(+), 34 deletions(-)
diff --git a/.github/oversized.txt b/.github/oversized.txt
index b3bda62..ba16c30 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
+1966 crates/linkunbound-shell/src/main.rs
diff --git a/app/src/i18n.ts b/app/src/i18n.ts
index 64332d0..c1815ca 100644
--- a/app/src/i18n.ts
+++ b/app/src/i18n.ts
@@ -125,6 +125,11 @@ const ES = {
pickerCompact: "Compacto",
pickerBear: "LinkBear",
pickerPreview: "Vista previa",
+ appKeys: "Teclas del selector",
+ keysNote: "Cada tecla abre un navegador; Mayús lo abre en privado.",
+ keysDigits: "1–9",
+ keysQwerty: "1–9 y QWERTY",
+ keysAlphabet: "1–9 y A–Z",
themeAuto: "Automático",
themeLight: "Claro",
themeDark: "Oscuro",
@@ -407,6 +412,11 @@ const EN: Record = {
pickerCompact: "Compact",
pickerBear: "LinkBear",
pickerPreview: "Preview",
+ appKeys: "Picker keys",
+ keysNote: "Each key opens a browser; Shift opens it privately.",
+ keysDigits: "1–9",
+ keysQwerty: "1–9 and QWERTY",
+ keysAlphabet: "1–9 and A–Z",
themeAuto: "Automatic",
themeLight: "Light",
themeDark: "Dark",
diff --git a/app/src/settings/Application.test.tsx b/app/src/settings/Application.test.tsx
index 1a0b2e1..81382ae 100644
--- a/app/src/settings/Application.test.tsx
+++ b/app/src/settings/Application.test.tsx
@@ -14,6 +14,7 @@ const PREFS = {
shortcut: "Alt+Shift+L" as string | null,
notify_on_rule: true,
picker_style: "classic" as "bear" | "classic" | "sheet" | "compact",
+ picker_keys: "qwerty" as "digits" | "qwerty" | "alphabet",
looks_for_updates: true,
};
@@ -77,6 +78,17 @@ describe("application settings", () => {
});
});
+ /// The letters after the 9 are a choice: the QWERTY rows until another is picked.
+ it("offers the keys that open the browsers", async () => {
+ mount();
+ const keys = within(await screen.findByRole("group", { name: "Teclas del selector" }));
+ expect(keys.getByRole("radio", { name: "1–9 y QWERTY" })).toBeChecked();
+ await userEvent.click(keys.getByRole("radio", { name: "1–9 y A–Z" }));
+ expect(invoke).toHaveBeenCalledWith("prefs_set", {
+ prefs: { ...PREFS, picker_keys: "alphabet" },
+ });
+ });
+
/// 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();
diff --git a/app/src/settings/Application.tsx b/app/src/settings/Application.tsx
index 009cd9f..6df0757 100644
--- a/app/src/settings/Application.tsx
+++ b/app/src/settings/Application.tsx
@@ -8,6 +8,7 @@ import { Card, Line, Section, Switch } from "./parts";
type Theme = "system" | "light" | "dark";
type Locale = "system" | "spanish" | "english";
type PickerStyle = "bear" | "classic" | "sheet" | "compact";
+type PickerKeys = "digits" | "qwerty" | "alphabet";
type Preferences = {
schema_version: number;
@@ -16,6 +17,7 @@ type Preferences = {
shortcut: string | null;
notify_on_rule: boolean;
picker_style: PickerStyle;
+ picker_keys: PickerKeys;
looks_for_updates: boolean;
};
@@ -40,6 +42,12 @@ const PICKERS: { id: PickerStyle; label: Key }[] = [
{ id: "compact", label: "pickerCompact" },
];
+const KEYS: { id: PickerKeys; label: Key }[] = [
+ { id: "digits", label: "keysDigits" },
+ { id: "qwerty", label: "keysQwerty" },
+ { id: "alphabet", label: "keysAlphabet" },
+];
+
const MODIFIERS = new Set(["Control", "Alt", "Shift", "Meta"]);
const RESIDENT_ANSWERS_WITHIN = 1400;
@@ -196,6 +204,14 @@ export default function Application({
+
+ apply({ picker_keys }, previewing ? preview : undefined)}
+ />
+
diff --git a/crates/linkunbound-core/src/lib.rs b/crates/linkunbound-core/src/lib.rs
index 577c16f..56ff99d 100644
--- a/crates/linkunbound-core/src/lib.rs
+++ b/crates/linkunbound-core/src/lib.rs
@@ -35,7 +35,7 @@ pub use icons::{cached_icon, prune as prune_icons};
pub use journal::{JOURNAL, journal, note, scrubbed};
pub use launch::{LaunchError, open as launch, spawn_and_forget};
pub use news::{behind as news_behind, untold as untold_news};
-pub use prefs::{Locale, PickerStyle, Preferences, Theme};
+pub use prefs::{Locale, PickerKeys, PickerStyle, Preferences, Theme};
pub use private::private_flag_for;
pub use report::{diagnostics, redact};
pub use rule::{
diff --git a/crates/linkunbound-core/src/prefs.rs b/crates/linkunbound-core/src/prefs.rs
index b01f4a2..c8da0f7 100644
--- a/crates/linkunbound-core/src/prefs.rs
+++ b/crates/linkunbound-core/src/prefs.rs
@@ -30,6 +30,29 @@ pub enum PickerStyle {
Bear,
}
+/// The keys that open the picker's rows: the digits always, and after the 9 the three letter rows
+/// under them on a QWERTY keyboard, the whole alphabet, or nothing more.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PickerKeys {
+ Digits,
+ #[default]
+ Qwerty,
+ Alphabet,
+}
+
+impl PickerKeys {
+ /// Every key in the order of the rows it opens.
+ #[must_use]
+ pub const fn keys(self) -> &'static str {
+ match self {
+ Self::Digits => "123456789",
+ Self::Qwerty => "123456789QWERTYASDFGHZXCVBN",
+ Self::Alphabet => "123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ",
+ }
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Preferences {
#[serde(default)]
@@ -49,6 +72,8 @@ pub struct Preferences {
pub edge_warning_dismissed: bool,
#[serde(default)]
pub picker_style: PickerStyle,
+ #[serde(default)]
+ pub picker_keys: PickerKeys,
/// Whether the resident asks about releases on its own, every few hours; off, only the
/// settings window ever asks, and only while it is open.
#[serde(default = "yes")]
@@ -79,6 +104,7 @@ impl Default for Preferences {
notify_on_rule: true,
edge_warning_dismissed: false,
picker_style: PickerStyle::default(),
+ picker_keys: PickerKeys::default(),
looks_for_updates: true,
here_since: None,
asked_for_a_star: false,
@@ -159,7 +185,7 @@ fn shortcut_of(said: &str) -> Option {
#[cfg(test)]
mod tests {
- use super::{Locale, PickerStyle, Preferences, Theme};
+ use super::{Locale, PickerKeys, PickerStyle, Preferences, Theme};
#[test]
fn a_fresh_install_follows_the_system_and_claims_the_default_shortcut() {
@@ -278,6 +304,24 @@ mod tests {
assert!(written.contains(r#""picker_style":"sheet""#), "{written}");
}
+ /// A copy that never chose keeps the letters after the digits; each choice reads back.
+ #[test]
+ fn the_letters_follow_the_digits_unless_asked_otherwise() {
+ let fresh: Preferences = serde_json::from_str("{}").unwrap();
+ assert_eq!(fresh.picker_keys, PickerKeys::Qwerty);
+ for (said, keys, count) in [
+ ("digits", PickerKeys::Digits, 9),
+ ("qwerty", PickerKeys::Qwerty, 27),
+ ("alphabet", PickerKeys::Alphabet, 35),
+ ] {
+ let asked: Preferences =
+ serde_json::from_str(&format!(r#"{{"picker_keys": "{said}"}}"#)).unwrap();
+ assert_eq!(asked.picker_keys, keys);
+ assert_eq!(keys.keys().len(), count, "{said}");
+ assert!(keys.keys().starts_with("123456789"), "{said}");
+ }
+ }
+
/// Everybody moves to the bear once; after that, what they pick stays picked.
#[test]
fn the_bear_comes_once_and_a_later_choice_stands() {
diff --git a/crates/linkunbound-shell/examples/preview.rs b/crates/linkunbound-shell/examples/preview.rs
index b96686d..5c1ed26 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> {
@@ -171,17 +171,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 rows = w.get_rows().row_count();
+ let Some(index) = row_typed(&w.get_keys(), &typed, chorded, rows, 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 920b234..0543647 100644
--- a/crates/linkunbound-shell/src/lib.rs
+++ b/crates/linkunbound-shell/src/lib.rs
@@ -345,6 +345,50 @@ pub fn split(url: &str) -> (String, String) {
}
}
+/// The key a row shows, none past the last one: those are opened with the pointer or the arrows.
+#[must_use]
+pub fn key_of(keys: &str, at: usize) -> slint::SharedString {
+ keys.get(at..=at).unwrap_or_default().into()
+}
+
+/// New keys for a picker that may be on screen: its rows were labelled with the old ones.
+pub fn relabel(window: &Picker, keys: &str) {
+ use slint::Model;
+ window.set_keys(keys.into());
+ let rows = window.get_rows();
+ for at in 0..rows.row_count() {
+ if let Some(mut row) = rows.row_data(at) {
+ row.key = key_of(keys, at);
+ rows.set_row_data(at, row);
+ }
+ }
+}
+
+/// The row a typed letter opens, with or without Shift held for a private window. The digits are
+/// read from the key pressed, not the glyph, so they are left to the host.
+#[must_use]
+pub fn row_of_letter(keys: &str, typed: char) -> Option {
+ let letters = keys.get(9..)?;
+ letters.find(typed.to_ascii_uppercase()).map(|at| at + 9)
+}
+
+/// The row a key opens, if one of those shown does: the digit under the key the host read, or a
+/// letter past the digits unless a modifier makes it a shortcut.
+#[must_use]
+pub fn row_typed(
+ keys: &str,
+ typed: &str,
+ chorded: bool,
+ rows: usize,
+ digit_behind: impl Fn(char) -> Option,
+) -> Option {
+ let typed = typed.chars().next()?;
+ let at = digit_behind(typed)
+ .and_then(|digit| usize::try_from(digit).ok()?.checked_sub(1))
+ .or_else(|| row_of_letter(keys, typed).filter(|_| !chorded))?;
+ (at < rows).then(|| i32::try_from(at).ok()).flatten()
+}
+
/// 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]
@@ -405,17 +449,12 @@ pub fn dress(window: &Picker, words: &Strings, url: &str, source: Option<&str>,
slint::SharedString::new()
});
+ let keys = window.get_keys();
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()
- },
+ key: key_of(&keys, i),
name: row.name.clone().into(),
short: short_name(&row.name).into(),
profile: row.profile.clone().into(),
@@ -502,7 +541,9 @@ mod short {
mod tests {
use super::dress;
use super::{Listed, Palette, Reaches, destinations, image_for, paint, reaches, split};
- use linkunbound_core::{Browser, Language, Profile, Scope, Strings, looks_unresolved};
+ use linkunbound_core::{
+ Browser, Language, PickerKeys, Profile, Scope, Strings, looks_unresolved,
+ };
use slint::ComponentHandle;
const SPOKEN: Strings = Language::Spanish.strings();
@@ -683,13 +724,56 @@ 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");
+
+ super::relabel(&window, PickerKeys::Digits.keys());
+ assert_eq!(window.get_keys(), "123456789");
+ assert_eq!(rows.row_data(8).expect("ninth").key, "9");
assert_eq!(
rows.row_data(9).expect("tenth").key,
"",
- "no digit opens the tenth"
+ "an open picker drops the letters"
);
}
+ /// With the QWERTY rows, twenty-seven rows have a key each and the next one none, and a letter
+ /// opens the same row whether Shift is held or not. With the digits alone no letter opens one.
+ #[test]
+ fn the_letters_carry_on_where_the_digits_stop() {
+ use super::{key_of, row_of_letter};
+ let keys = PickerKeys::Qwerty.keys();
+ assert_eq!(key_of(keys, 0), "1");
+ assert_eq!(key_of(keys, 9), "Q");
+ assert_eq!(key_of(keys, 15), "A");
+ assert_eq!(key_of(keys, 26), "N");
+ assert_eq!(key_of(keys, 27), "");
+ assert_eq!(key_of(PickerKeys::Digits.keys(), 9), "");
+ assert_eq!(row_of_letter(PickerKeys::Digits.keys(), 'q'), None);
+ assert_eq!(row_of_letter(PickerKeys::Alphabet.keys(), 'm'), Some(21));
+ let digit = |c: char| c.to_digit(10).filter(|d| (1..=9).contains(d));
+ let typed = |text: &str, chorded: bool| super::row_typed(keys, text, chorded, 12, digit);
+ assert_eq!(typed("3", false), Some(2));
+ assert_eq!(typed("3", true), Some(2), "Ctrl+3 still opens");
+ assert_eq!(typed("E", false), Some(11));
+ assert_eq!(typed("e", true), None, "Alt+E is a shortcut");
+ assert_eq!(typed("r", false), None, "past the rows shown");
+ assert_eq!(typed("", false), None);
+ for (typed, row) in [
+ ('q', Some(9)),
+ ('Q', Some(9)),
+ ('a', Some(15)),
+ ('z', Some(21)),
+ ('N', Some(26)),
+ ('p', None),
+ ('m', None),
+ ('1', None),
+ ('é', None),
+ ] {
+ assert_eq!(row_of_letter(keys, typed), row, "{typed}");
+ }
+ }
+
/// The pill says the consequence in full once a reach is chosen: «Todo el sitio» in a menu is
/// an option, «Recordar todo el sitio» on its own says what the click is about to do.
#[test]
diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs
index a4f73b7..7a422f1 100644
--- a/crates/linkunbound-shell/src/main.rs
+++ b/crates/linkunbound-shell/src/main.rs
@@ -854,6 +854,7 @@ impl Ui {
self.light_seen.set(light);
paint(&self.picker, &self.notice, light);
look::style(&self.picker, prefs.picker_style);
+ linkunbound_shell::relabel(&self.picker, prefs.picker_keys.keys());
if let Some(tray) = self.tray.as_ref() {
tray.show(true);
tray.relabel(&self.words.get());
@@ -1170,20 +1171,16 @@ 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;
+ let rows = window.get_rows().row_count();
+ let keys = window.get_keys();
+ match linkunbound_shell::row_typed(&keys, &typed, chorded, rows, host::digit_behind) {
+ Some(index) => window.invoke_open(index, private),
+ None => return false,
}
- window.invoke_open(index, private);
true
});
}
diff --git a/crates/linkunbound-shell/ui/looks.slint b/crates/linkunbound-shell/ui/looks.slint
index ad6e9eb..eae3b4a 100644
--- a/crates/linkunbound-shell/ui/looks.slint
+++ b/crates/linkunbound-shell/ui/looks.slint
@@ -10,7 +10,12 @@ 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 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 {
@@ -130,7 +135,10 @@ export component CardsLook inherits Rectangle {
callback adding();
property card-width: (self.width - 20px - 2 * 6px) / 3;
- Flickable {
+ 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 57c209e..f4f413b 100644
--- a/crates/linkunbound-shell/ui/shell.slint
+++ b/crates/linkunbound-shell/ui/shell.slint
@@ -36,6 +36,7 @@ export component Picker inherits Window {
in-out property preview: false;
in property look: Look.bear;
+ in property keys: "123456789QWERTYASDFGHZXCVBN";
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;
@@ -64,7 +65,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();
@@ -100,7 +102,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
@@ -202,7 +204,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;
From 31424d769c569cdbcf27824d6c047a7cc503aa5d Mon Sep 17 00:00:00 2001
From: rgdevment
Date: Thu, 15 Oct 2026 15:54:51 -0300
Subject: [PATCH 4/8] feat(browsers): a key per browser and profile in
settings, the private switch filled in for known browsers, and examples
---
.github/oversized.txt | 4 +-
app/src-tauri/src/editing.rs | 353 +++++++++++++++++++
app/src-tauri/src/lib.rs | 169 +--------
app/src/i18n.ts | 53 ++-
app/src/settings/Application.test.tsx | 12 -
app/src/settings/Application.tsx | 16 -
app/src/settings/Browsers.test.tsx | 78 ++++
app/src/settings/Browsers.tsx | 188 ++++++++--
crates/linkunbound-core/src/browser.rs | 168 ++++++++-
crates/linkunbound-core/src/config.rs | 2 +
crates/linkunbound-core/src/hostile.rs | 1 +
crates/linkunbound-core/src/launch.rs | 1 +
crates/linkunbound-core/src/lib.rs | 6 +-
crates/linkunbound-core/src/prefs.rs | 46 +--
crates/linkunbound-core/src/store.rs | 1 +
crates/linkunbound-mac/src/detect.rs | 2 +
crates/linkunbound-shell/examples/preview.rs | 7 +-
crates/linkunbound-shell/src/lib.rs | 170 +++++----
crates/linkunbound-shell/src/main.rs | 15 +-
crates/linkunbound-shell/ui/shell.slint | 1 -
crates/linkunbound-win/src/detect.rs | 2 +
21 files changed, 930 insertions(+), 365 deletions(-)
create mode 100644 app/src-tauri/src/editing.rs
diff --git a/.github/oversized.txt b/.github/oversized.txt
index ba16c30..0ba4410 100644
--- a/.github/oversized.txt
+++ b/.github/oversized.txt
@@ -1,2 +1,2 @@
-2026 app/src-tauri/src/lib.rs
-1966 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..d9b98e8
--- /dev/null
+++ b/app/src-tauri/src/editing.rs
@@ -0,0 +1,353 @@
+//! 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> {
+ let Some(found) = all.iter_mut().find(|b| b.id == id) else {
+ return Err("browserGone".to_owned());
+ };
+ 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 mut keys = BTreeMap::new();
+ for (row, said) in typed {
+ let said = said.trim();
+ if said.is_empty() {
+ 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, 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 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, "custom-1", &typed(&[("a", "Q"), ("b", "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"
+ );
+
+ 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/i18n.ts b/app/src/i18n.ts
index c1815ca..b8406e1 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: "Ejemplos: perfiles y navegadores personalizados",
+ 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 {}",
@@ -125,11 +141,6 @@ const ES = {
pickerCompact: "Compacto",
pickerBear: "LinkBear",
pickerPreview: "Vista previa",
- appKeys: "Teclas del selector",
- keysNote: "Cada tecla abre un navegador; Mayús lo abre en privado.",
- keysDigits: "1–9",
- keysQwerty: "1–9 y QWERTY",
- keysAlphabet: "1–9 y A–Z",
themeAuto: "Automático",
themeLight: "Claro",
themeDark: "Oscuro",
@@ -194,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",
@@ -392,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: "Examples: profiles and custom browsers",
+ 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 {}",
@@ -412,11 +441,6 @@ const EN: Record = {
pickerCompact: "Compact",
pickerBear: "LinkBear",
pickerPreview: "Preview",
- appKeys: "Picker keys",
- keysNote: "Each key opens a browser; Shift opens it privately.",
- keysDigits: "1–9",
- keysQwerty: "1–9 and QWERTY",
- keysAlphabet: "1–9 and A–Z",
themeAuto: "Automatic",
themeLight: "Light",
themeDark: "Dark",
@@ -481,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",
@@ -576,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:
@@ -596,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/settings/Application.test.tsx b/app/src/settings/Application.test.tsx
index 81382ae..1a0b2e1 100644
--- a/app/src/settings/Application.test.tsx
+++ b/app/src/settings/Application.test.tsx
@@ -14,7 +14,6 @@ const PREFS = {
shortcut: "Alt+Shift+L" as string | null,
notify_on_rule: true,
picker_style: "classic" as "bear" | "classic" | "sheet" | "compact",
- picker_keys: "qwerty" as "digits" | "qwerty" | "alphabet",
looks_for_updates: true,
};
@@ -78,17 +77,6 @@ describe("application settings", () => {
});
});
- /// The letters after the 9 are a choice: the QWERTY rows until another is picked.
- it("offers the keys that open the browsers", async () => {
- mount();
- const keys = within(await screen.findByRole("group", { name: "Teclas del selector" }));
- expect(keys.getByRole("radio", { name: "1–9 y QWERTY" })).toBeChecked();
- await userEvent.click(keys.getByRole("radio", { name: "1–9 y A–Z" }));
- expect(invoke).toHaveBeenCalledWith("prefs_set", {
- prefs: { ...PREFS, picker_keys: "alphabet" },
- });
- });
-
/// 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();
diff --git a/app/src/settings/Application.tsx b/app/src/settings/Application.tsx
index 6df0757..009cd9f 100644
--- a/app/src/settings/Application.tsx
+++ b/app/src/settings/Application.tsx
@@ -8,7 +8,6 @@ import { Card, Line, Section, Switch } from "./parts";
type Theme = "system" | "light" | "dark";
type Locale = "system" | "spanish" | "english";
type PickerStyle = "bear" | "classic" | "sheet" | "compact";
-type PickerKeys = "digits" | "qwerty" | "alphabet";
type Preferences = {
schema_version: number;
@@ -17,7 +16,6 @@ type Preferences = {
shortcut: string | null;
notify_on_rule: boolean;
picker_style: PickerStyle;
- picker_keys: PickerKeys;
looks_for_updates: boolean;
};
@@ -42,12 +40,6 @@ const PICKERS: { id: PickerStyle; label: Key }[] = [
{ id: "compact", label: "pickerCompact" },
];
-const KEYS: { id: PickerKeys; label: Key }[] = [
- { id: "digits", label: "keysDigits" },
- { id: "qwerty", label: "keysQwerty" },
- { id: "alphabet", label: "keysAlphabet" },
-];
-
const MODIFIERS = new Set(["Control", "Alt", "Shift", "Meta"]);
const RESIDENT_ANSWERS_WITHIN = 1400;
@@ -204,14 +196,6 @@ export default function Application({
-
- apply({ picker_keys }, previewing ? preview : undefined)}
- />
-
diff --git a/app/src/settings/Browsers.test.tsx b/app/src/settings/Browsers.test.tsx
index 932ec57..5de3a03 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,68 @@ 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: "Ejemplos: perfiles y navegadores personalizados" }),
+ );
+ 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");
+ });
+
/// 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..de6b2e0 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,59 @@ 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);
+}
+
+/// What is wrong with the key typed for one row, said before saving rather than after.
+function keyProblem(
+ key: string,
+ row: string,
+ typed: Record,
+ 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)) 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 +117,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 +142,10 @@ 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 problems = rows.map(([row]) => keyProblem(keys[row] ?? "", row, keys, others, t));
return (
)}
- setArgs(e.target.value)}
- placeholder={t("fieldArgs")}
- aria-label={t("fieldArgsLabel")}
- className={FIELD}
- />
- {(!detected || initial.private_flag === null) && (
- <>
+
+
+
+ {t("fieldAdvanced")}
+
+
+
+
+ {helping && (
+
+
{t("helpTitle")}
+
+ {HELP.map(([said, code]) => (
+
+
- {t(said)}
+ -
+
{t(code)}
+
+
+ ))}
+
+
)}
- setIcon(e.target.value)}
- placeholder={t("fieldIcon")}
- aria-label={t("fieldIconLabel")}
- className={FIELD}
- />
@@ -273,6 +401,9 @@ export default function Browsers() {