From 5b9424b0a0a5975e6d9ec8ff7bdeceabbfb0935f Mon Sep 17 00:00:00 2001 From: chinzu00 <13662542+chinzu00@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:51:38 -0500 Subject: [PATCH 1/5] feat(desktop): the keyboard shortcuts' list binds its keys again The canvas's shortcuts were a match in the core: which press does what was fixed, and the list in Help could only show it. They are a table now, and the list binds a row's keys when they are clicked: press the new key, Backspace takes them away, Esc gives up, Reset puts the app's back. - core (keys.rs): KeyAction names what a press can do; KeyBinding/KeyBindings carry the presses each action answers to, starting from the shortcuts' table itself so the list and the keys cannot drift apart. bind() takes a press from whatever had it, unbind() clears an action, reset() restores the start, and read_line()/kept_lines() write them into the app's settings file. - core: key_shortcut_bound() reads the presses as they are now, keeping the reading the canvas has always had: an exact press, then with Ctrl the same without Shift, then the key alone, then the presses that take a step (brush size, arrows). key_shortcut() and key_shortcut_list() keep their signatures for the Android bindings and mean "with the presses the app starts with"; key_shortcut_with()/key_shortcut_list_with() take bindings instead. - desktop: Prefs keeps the bindings in the interface file (key_undo ctrl+z,...), and the canvas's keys go through them. The Keyboard shortcuts panel paints a row's keys as a target: hovered they light, clicked they ask for a key, and the list shows the presses the bindings have ("Unbound" when there are none). Testing: cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings pass; cargo test --workspace passes (566; the two GPU filter tests in plugin_filter are the ones this machine fails outside CI, as DEV-SETUP notes). Tried by hand on Windows: bound Erase to F in the release app, saw the row change and interface gain key_erase e,f, and F then chose the eraser; a binding saved before a restart was shown again after it. --- crates/core/src/keys.rs | 612 +++++++++++++++++++++++++++++++++------ crates/core/src/lib.rs | 6 +- desktop/src/prefs.rs | 11 +- desktop/src/shortcuts.rs | 190 ++++++++++-- desktop/src/ui.rs | 16 +- 5 files changed, 720 insertions(+), 115 deletions(-) diff --git a/crates/core/src/keys.rs b/crates/core/src/keys.rs index 7bad139c..f9701ae4 100644 --- a/crates/core/src/keys.rs +++ b/crates/core/src/keys.rs @@ -64,10 +64,9 @@ pub enum KeyShortcut { }, } -/// The shortcut on `key`, if any: a letter or digit (either case), "[", "]", ";", "space", -/// "delete", "backspace", or an arrow ("left", "right", "up", "down"); `command` is Ctrl (⌘), and -/// the arrows nudge only while `transforming`. Ctrl and Alt together are AltGr typing a key, so -/// they count as neither. +/// The shortcut on `key` with the presses the app starts with (see [`KeyBindings`]): a letter or +/// digit (either case), "[", "]", ";", "space", "delete", "backspace", or an arrow ("left", +/// "right", "up", "down"); `command` is Ctrl (⌘), and the arrows nudge only while `transforming`. /// /// With the command key: Z undoes (with Shift redoes), C, X and V copy, cut and paste the layer, /// A copies the canvas, B opens Color Balance, U Hue, Saturation, Brightness, D deselects, J @@ -78,47 +77,48 @@ pub enum KeyShortcut { /// brush's size by 5% (with Shift 10%); U, D, J, K and 0 as with the command key. #[uniffi::export] pub fn key_shortcut(key: String, command: bool, shift: bool, alt: bool, transforming: bool) -> Option { + key_shortcut_with(KeyBindings::default(), key, command, shift, alt, transforming) +} + +/// The shortcut on `key` with `bindings` in place: a press a binding names exactly, or else the +/// key alone (so Shift with D deselects where only D is bound, and AltGr typing a bracket is the +/// bracket alone); then the presses that take a step from the modifiers: the brush's size and an +/// arrow nudging a transform. `command` is Ctrl (⌘), and Ctrl with Alt is AltGr typing a key. +#[uniffi::export] +pub fn key_shortcut_with( + bindings: KeyBindings, + key: String, + command: bool, + shift: bool, + alt: bool, + transforming: bool, +) -> Option { + key_shortcut_bound(&bindings, &key, command, shift, alt, transforming) +} + +/// [`key_shortcut_with`], with the bindings seen rather than taken. +pub fn key_shortcut_bound( + bindings: &KeyBindings, + key: &str, + command: bool, + shift: bool, + alt: bool, + transforming: bool, +) -> Option { use KeyShortcut::*; let key = key.to_lowercase(); - if command && !alt { - return match key.as_str() { - "z" if shift => Some(Redo), - "z" => Some(Undo), - "c" => Some(CopyLayer), - "x" => Some(CutLayer), - "v" => Some(Paste), - "a" => Some(CopyCanvas), - "b" => Some(ColourBalance), - "u" => Some(HueSaturationBrightness), - "d" => Some(Deselect), - "j" => Some(DuplicateLayer), - "k" => Some(Actions), - "0" => Some(Interface), - ";" => Some(PerspectiveGuide), - "delete" | "backspace" => Some(ClearLayer), - "g" => Some(GreyView), - "[" => Some(BrushSize { by: -0.01 }), - "]" => Some(BrushSize { by: 0.01 }), - _ => None, - }; + if let Some(action) = bindings.lookup(&key, command, shift, alt) { + return Some(action.shortcut()); } - let step = if shift { 0.10 } else { 0.05 }; + let step = if command && !alt { + 0.01 + } else if shift { + 0.10 + } else { + 0.05 + }; let nudge = |dx: f32, dy: f32| transforming.then_some(Nudge { dx, dy }); match key.as_str() { - "b" => Some(Paint), - "e" => Some(Erase), - "m" => Some(Eyedropper), - "s" => Some(Selection), - "v" => Some(Transform), - "u" => Some(HueSaturationBrightness), - "c" => Some(Colours), - "x" => Some(SwapColours), - "d" => Some(Deselect), - "j" => Some(DuplicateLayer), - "k" => Some(Actions), - "l" => Some(Layers), - "0" => Some(Interface), - "space" => Some(QuickMenu), "[" => Some(BrushSize { by: -step }), "]" => Some(BrushSize { by: step }), "left" => nudge(-1.0, 0.0), @@ -129,6 +129,240 @@ pub fn key_shortcut(key: String, command: bool, shift: bool, alt: bool, transfor } } +/// Something a key does that a press can be bound to: the shortcuts' list's commands. What takes a +/// step from the press itself (the brush's size, an arrow nudging a transform) is not among them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, uniffi::Enum)] +pub enum KeyAction { + Undo, + Redo, + CopyLayer, + CutLayer, + Paste, + Paint, + Erase, + Eyedropper, + Selection, + Transform, + HueSaturationBrightness, + Colours, + Actions, + Layers, + SwapColours, + Deselect, + DuplicateLayer, + Interface, + ClearLayer, + CopyCanvas, + ColourBalance, + PerspectiveGuide, + QuickMenu, + GreyView, +} + +impl KeyAction { + /// Every action, in the list's order. + pub const ALL: [KeyAction; 24] = [ + KeyAction::Undo, + KeyAction::Redo, + KeyAction::CopyLayer, + KeyAction::CutLayer, + KeyAction::Paste, + KeyAction::Paint, + KeyAction::Erase, + KeyAction::Eyedropper, + KeyAction::Selection, + KeyAction::Transform, + KeyAction::HueSaturationBrightness, + KeyAction::Colours, + KeyAction::Actions, + KeyAction::Layers, + KeyAction::SwapColours, + KeyAction::Deselect, + KeyAction::DuplicateLayer, + KeyAction::Interface, + KeyAction::ClearLayer, + KeyAction::CopyCanvas, + KeyAction::ColourBalance, + KeyAction::PerspectiveGuide, + KeyAction::QuickMenu, + KeyAction::GreyView, + ]; + + /// The name the settings file knows it by (`key_undo`'s "undo"). + pub fn key(self) -> &'static str { + match self { + KeyAction::Undo => "undo", + KeyAction::Redo => "redo", + KeyAction::CopyLayer => "copy_layer", + KeyAction::CutLayer => "cut_layer", + KeyAction::Paste => "paste", + KeyAction::Paint => "paint", + KeyAction::Erase => "erase", + KeyAction::Eyedropper => "eyedropper", + KeyAction::Selection => "selection", + KeyAction::Transform => "transform", + KeyAction::HueSaturationBrightness => "hue_saturation_brightness", + KeyAction::Colours => "colours", + KeyAction::Actions => "actions", + KeyAction::Layers => "layers", + KeyAction::SwapColours => "swap_colours", + KeyAction::Deselect => "deselect", + KeyAction::DuplicateLayer => "duplicate_layer", + KeyAction::Interface => "interface", + KeyAction::ClearLayer => "clear_layer", + KeyAction::CopyCanvas => "copy_canvas", + KeyAction::ColourBalance => "colour_balance", + KeyAction::PerspectiveGuide => "perspective_guide", + KeyAction::QuickMenu => "quick_menu", + KeyAction::GreyView => "grey_view", + } + } + + /// The action a settings file's name is, if it is one. + pub fn from_key(name: &str) -> Option { + KeyAction::ALL.into_iter().find(|action| action.key() == name) + } + + /// What pressing a key bound to it does. + pub fn shortcut(self) -> KeyShortcut { + match self { + KeyAction::Undo => KeyShortcut::Undo, + KeyAction::Redo => KeyShortcut::Redo, + KeyAction::CopyLayer => KeyShortcut::CopyLayer, + KeyAction::CutLayer => KeyShortcut::CutLayer, + KeyAction::Paste => KeyShortcut::Paste, + KeyAction::Paint => KeyShortcut::Paint, + KeyAction::Erase => KeyShortcut::Erase, + KeyAction::Eyedropper => KeyShortcut::Eyedropper, + KeyAction::Selection => KeyShortcut::Selection, + KeyAction::Transform => KeyShortcut::Transform, + KeyAction::HueSaturationBrightness => KeyShortcut::HueSaturationBrightness, + KeyAction::Colours => KeyShortcut::Colours, + KeyAction::Actions => KeyShortcut::Actions, + KeyAction::Layers => KeyShortcut::Layers, + KeyAction::SwapColours => KeyShortcut::SwapColours, + KeyAction::Deselect => KeyShortcut::Deselect, + KeyAction::DuplicateLayer => KeyShortcut::DuplicateLayer, + KeyAction::Interface => KeyShortcut::Interface, + KeyAction::ClearLayer => KeyShortcut::ClearLayer, + KeyAction::CopyCanvas => KeyShortcut::CopyCanvas, + KeyAction::ColourBalance => KeyShortcut::ColourBalance, + KeyAction::PerspectiveGuide => KeyShortcut::PerspectiveGuide, + KeyAction::QuickMenu => KeyShortcut::QuickMenu, + KeyAction::GreyView => KeyShortcut::GreyView, + } + } +} + +/// Whether a key can be bound: a letter, a digit, or one of the keys the list names around them. +pub fn bindable_key(key: &str) -> bool { + matches!(key, "space" | "delete" | "backspace" | "left" | "right" | "up" | "down" | "[" | "]" | ";" | "=" | "-") + || (key.len() == 1 && key.chars().all(|c| c.is_ascii_alphanumeric())) +} + +/// A press bound to an action: the key's name ([`KeyCombo`]) and the modifiers held with it. +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct KeyBinding { + pub action: KeyAction, + /// The presses that do it, any of them; none once it has been unbound. + pub combos: Vec, +} + +/// Every action's presses: what the shortcuts do now, as the list shows them and the keys do them. +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct KeyBindings { + pub bindings: Vec, +} + +impl Default for KeyBindings { + fn default() -> KeyBindings { + KeyBindings::starting() + } +} + +impl KeyBindings { + /// The presses the app starts with. + pub fn starting() -> KeyBindings { + let mut bindings: Vec = Vec::new(); + for entry in SHORTCUTS.iter().flat_map(|(_, entries)| *entries) { + let Some(action) = entry.action else { continue }; + if bindings.iter().any(|b| b.action == action) { + continue; + } + let combos = entry + .presses + .iter() + .map(|&(command, shift, alt, key)| KeyCombo { key: key.to_lowercase(), command, shift, alt }) + .collect(); + bindings.push(KeyBinding { action, combos }); + } + KeyBindings { bindings } + } + + /// The action a press does, as the keys have always been read: a binding that names it + /// exactly; with the command key, the same without Shift (so Ctrl+Shift+D deselects, as Ctrl+D + /// does, while Ctrl+Shift+Z redoes, which is bound); otherwise the key alone (so Shift with D + /// deselects too, and AltGr typing B paints). With the command key and no Alt, nothing falls + /// back to the key alone: Ctrl+Space has never opened the QuickMenu. + pub fn lookup(&self, key: &str, command: bool, shift: bool, alt: bool) -> Option { + let action = |command: bool, shift: bool, alt: bool| { + self.bindings.iter().find(|b| b.combos.iter().any(|c| c.is(key, command, shift, alt))).map(|b| b.action) + }; + action(command, shift, alt) + .or_else(|| if command && !alt { action(true, false, alt) } else { action(false, false, false) }) + } + + /// The presses that do `action`. + pub fn combos_for(&self, action: KeyAction) -> &[KeyCombo] { + self.bindings.iter().find(|b| b.action == action).map_or(&[], |b| b.combos.as_slice()) + } + + /// Binds `combo` to `action`, taking it from whatever did it before. + pub fn bind(&mut self, action: KeyAction, combo: KeyCombo) { + for binding in &mut self.bindings { + binding.combos.retain(|c| !c.same(&combo)); + } + if let Some(binding) = self.bindings.iter_mut().find(|b| b.action == action) { + binding.combos.push(combo); + } + } + + /// Takes every press that does `action` away from it. + pub fn unbind(&mut self, action: KeyAction) { + if let Some(binding) = self.bindings.iter_mut().find(|b| b.action == action) { + binding.combos.clear(); + } + } + + /// Back to the presses the app starts with. + pub fn reset(&mut self) { + *self = KeyBindings::starting(); + } + + /// A line of the settings file: "key_undo ctrl+z,ctrl+shift+z" (an action with no presses has + /// an empty value). True if `name` was one. + pub fn read_line(&mut self, name: &str, value: &str) -> bool { + let Some(action) = name.strip_prefix("key_").and_then(KeyAction::from_key) else { return false }; + let combos: Vec = + value.split(',').filter(|press| !press.is_empty()).filter_map(KeyCombo::parse).collect(); + if let Some(binding) = self.bindings.iter_mut().find(|b| b.action == action) { + binding.combos = combos; + } + true + } + + /// The settings file's lines, one an action. + pub fn kept_lines(&self) -> Vec { + KeyAction::ALL + .iter() + .map(|&action| { + let combos: Vec = self.combos_for(action).iter().map(KeyCombo::text).collect(); + format!("key_{} {}", action.key(), combos.join(",")) + }) + .collect() + } +} + /// How far off (window pixels) the point an arrow nudges towards is: far enough that the nudge /// goes straight that way. const FAR: f32 = 100_000.0; @@ -244,13 +478,60 @@ pub struct KeyCombo { pub alt: bool, } +impl KeyCombo { + /// Whether this is the press of `key` (either case) with exactly `command`, `shift` and `alt`. + pub fn is(&self, key: &str, command: bool, shift: bool, alt: bool) -> bool { + self.command == command && self.shift == shift && self.alt == alt && self.key.eq_ignore_ascii_case(key) + } + + /// Whether this is the same press as `other`, in whatever case its key was written. + pub fn same(&self, other: &KeyCombo) -> bool { + self.is(&other.key, other.command, other.shift, other.alt) + } + + /// The press for the settings file: "ctrl+shift+z", "space", "[", "ctrl+[". + pub fn text(&self) -> String { + let mut out = String::new(); + for (on, name) in [(self.command, "ctrl"), (self.alt, "alt"), (self.shift, "shift")] { + if on { + out.push_str(name); + out.push('+'); + } + } + out.push_str(&self.key); + out + } + + /// The press `text` names, if it names one: a key that can be bound, with "ctrl", "alt" and + /// "shift" in front of it. + pub fn parse(text: &str) -> Option { + let mut parts: Vec<&str> = text.split('+').collect(); + let key = parts.pop()?.to_lowercase(); + if !bindable_key(&key) { + return None; + } + let (mut command, mut shift, mut alt) = (false, false, false); + for modifier in parts { + match modifier { + "ctrl" | "command" => command = true, + "shift" => shift = true, + "alt" => alt = true, + _ => return None, + } + } + Some(KeyCombo { key, command, shift, alt }) + } +} + /// A row of the keyboard shortcuts' list: its keys written out for the platform (`keys`), the -/// presses that do it (`combos`, none for a drag), and what it does. +/// presses that do it (`combos`, none for a drag), what it does, and the action its keys can be +/// bound to again (`None` where the platform keeps them, as it does a drag). #[derive(Clone, Debug, PartialEq, uniffi::Record)] pub struct ShortcutRow { pub keys: String, pub combos: Vec, pub does: String, + pub action: Option, } /// A group of the keyboard shortcuts' list, under its heading. @@ -271,66 +552,75 @@ const fn command(key: &'static str) -> Press { (true, false, false, key) } -/// A row of the list's table: the presses (none for a drag, written as `drag`), what they do, and -/// whether only the desktop has them. +/// A row of the list's table: the presses it starts with (none for a drag, written as `drag`), +/// the action a press can be bound to (`None` where the platform keeps the presses), what it does, +/// and whether only the desktop has it. struct Entry { + action: Option, presses: &'static [Press], drag: &'static str, does: &'static str, desktop: bool, } +/// A row of a command a key can be bound to, with the presses it starts with. +const fn binding(action: KeyAction, presses: &'static [Press], does: &'static str) -> Entry { + Entry { action: Some(action), presses, drag: "", does, desktop: false } +} + +/// A row the platform keeps as it is. const fn entry(presses: &'static [Press], does: &'static str) -> Entry { - Entry { presses, drag: "", does, desktop: false } + Entry { action: None, presses, drag: "", does, desktop: false } } const fn desktop(presses: &'static [Press], does: &'static str) -> Entry { - Entry { presses, drag: "", does, desktop: true } + Entry { action: None, presses, drag: "", does, desktop: true } } const fn drag(keys: &'static str, does: &'static str) -> Entry { - Entry { presses: &[], drag: keys, does, desktop: true } + Entry { action: None, presses: &[], drag: keys, does, desktop: true } } -/// The list, by group ("{command}" in a drag's keys: the platform's command key). +/// The list, by group ("{command}" in a drag's keys: the platform's command key). A row's presses +/// are the ones it starts with; a row with an action is the ones it has now. const SHORTCUTS: &[(&str, &[Entry])] = &[ ( "Edit", &[ - entry(&[command("z")], "Undo"), - entry(&[(true, true, false, "z")], "Redo"), - entry(&[command("c")], "Copy the layer"), - entry(&[command("x")], "Cut the layer"), - entry(&[command("v")], "Paste"), - entry(&[command("a")], "Copy the canvas"), - entry(&[alone("j"), command("j")], "Duplicate the layer"), - entry(&[command("delete"), command("backspace")], "Clear the layer"), - entry(&[alone("d"), command("d")], "Deselect"), + binding(KeyAction::Undo, &[command("z")], "Undo"), + binding(KeyAction::Redo, &[(true, true, false, "z")], "Redo"), + binding(KeyAction::CopyLayer, &[command("c")], "Copy the layer"), + binding(KeyAction::CutLayer, &[command("x")], "Cut the layer"), + binding(KeyAction::Paste, &[command("v")], "Paste"), + binding(KeyAction::CopyCanvas, &[command("a")], "Copy the canvas"), + binding(KeyAction::DuplicateLayer, &[alone("j"), command("j")], "Duplicate the layer"), + binding(KeyAction::ClearLayer, &[command("delete"), command("backspace")], "Clear the layer"), + binding(KeyAction::Deselect, &[alone("d"), command("d")], "Deselect"), ], ), ( "Tools", &[ - entry(&[alone("b")], "Paint"), - entry(&[alone("e")], "Erase"), - entry(&[alone("m")], "Eyedropper: the next touch picks a colour"), - entry(&[alone("s")], "Selection"), - entry(&[alone("v")], "Transform"), - entry(&[alone("u"), command("u")], "Hue, Saturation, Brightness"), - entry(&[command("b")], "Color Balance"), - entry(&[command(";")], "Perspective guide on or off"), - entry(&[alone("x")], "Swap the colours"), + binding(KeyAction::Paint, &[alone("b")], "Paint"), + binding(KeyAction::Erase, &[alone("e")], "Erase"), + binding(KeyAction::Eyedropper, &[alone("m")], "Eyedropper: the next touch picks a colour"), + binding(KeyAction::Selection, &[alone("s")], "Selection"), + binding(KeyAction::Transform, &[alone("v")], "Transform"), + binding(KeyAction::HueSaturationBrightness, &[alone("u"), command("u")], "Hue, Saturation, Brightness"), + binding(KeyAction::ColourBalance, &[command("b")], "Color Balance"), + binding(KeyAction::PerspectiveGuide, &[command(";")], "Perspective guide on or off"), + binding(KeyAction::SwapColours, &[alone("x")], "Swap the colours"), entry(&[alone("left"), alone("right"), alone("up"), alone("down")], "Transforming: move it a pixel"), ], ), ( "Panels", &[ - entry(&[alone("c")], "Colours"), - entry(&[alone("k"), command("k")], "Actions"), - entry(&[alone("l")], "Layers"), - entry(&[alone("space")], "QuickMenu"), - entry(&[alone("0")], "Hide or show the interface"), + binding(KeyAction::Colours, &[alone("c")], "Colours"), + binding(KeyAction::Actions, &[alone("k"), command("k")], "Actions"), + binding(KeyAction::Layers, &[alone("l")], "Layers"), + binding(KeyAction::QuickMenu, &[alone("space")], "QuickMenu"), + binding(KeyAction::Interface, &[alone("0"), command("0")], "Hide or show the interface"), ], ), ( @@ -344,7 +634,7 @@ const SHORTCUTS: &[(&str, &[Entry])] = &[ ( "View", &[ - entry(&[command("g")], "Grey view, to check values"), + binding(KeyAction::GreyView, &[command("g")], "Grey view, to check values"), desktop(&[command("="), command("-")], "Zoom in or out"), desktop(&[command("0")], "Fit the canvas"), drag("{command}Space and drag", "Zoom"), @@ -385,7 +675,7 @@ fn key_label(key: &str, mac: bool) -> String { } /// A press written out: "Ctrl+Shift+Z", or on a Mac "⇧⌘Z" (its modifiers in its own order). -fn press_label(&(command, shift, alt, key): &Press, mac: bool) -> String { +fn press_label(command: bool, shift: bool, alt: bool, key: &str, mac: bool) -> String { let key = key_label(key, mac); if mac { let mods = [(alt, "⌥"), (shift, "⇧"), (command, "⌘")]; @@ -400,11 +690,23 @@ fn press_label(&(command, shift, alt, key): &Press, mac: bool) -> String { } } -/// The keyboard shortcuts, grouped, as a list to show: the canvas's (as [`key_shortcut`] has -/// them), with the desktop's own too (its files, zooming and turning the view) if `desktop`; the -/// keys written as a Mac's (⌘, ⇧, ⌥) if `mac`, else "Ctrl+", "Shift+", "Alt+". +/// The keyboard shortcuts, grouped, as a list to show with the presses the app starts with. #[uniffi::export] pub fn key_shortcut_list(desktop: bool, mac: bool) -> Vec { + key_shortcut_list_with(desktop, mac, KeyBindings::default()) +} + +/// The keyboard shortcuts, grouped, as a list to show: each row's presses as `bindings` has them +/// where a key can be bound to it (and "Unbound" where it has none); with the desktop's own too +/// (its files, zooming and turning the view) if `desktop`; the keys written as a Mac's (⌘, ⇧, ⌥) +/// if `mac`, else "Ctrl+", "Shift+", "Alt+". +#[uniffi::export] +pub fn key_shortcut_list_with(desktop: bool, mac: bool, bindings: KeyBindings) -> Vec { + key_shortcut_list_bound(desktop, mac, &bindings) +} + +/// [`key_shortcut_list_with`], with the bindings seen rather than taken. +pub fn key_shortcut_list_bound(desktop: bool, mac: bool, bindings: &KeyBindings) -> Vec { SHORTCUTS .iter() .filter_map(|(heading, entries)| { @@ -412,17 +714,32 @@ pub fn key_shortcut_list(desktop: bool, mac: bool) -> Vec { .iter() .filter(|e| desktop || !e.desktop) .map(|e| { - let keys = if e.presses.is_empty() { - e.drag.replace("{command}", if mac { "⌘" } else { "Ctrl+" }) + let presses: Vec<(bool, bool, bool, &str)> = match e.action { + Some(action) => bindings + .combos_for(action) + .iter() + .map(|c| (c.command, c.shift, c.alt, c.key.as_str())) + .collect(), + None => e.presses.to_vec(), + }; + let keys = if presses.is_empty() { + if e.drag.is_empty() { + "Unbound".to_string() + } else { + e.drag.replace("{command}", if mac { "⌘" } else { "Ctrl+" }) + } } else { - e.presses.iter().map(|p| press_label(p, mac)).collect::>().join(" ") + presses + .iter() + .map(|&(command, shift, alt, key)| press_label(command, shift, alt, key, mac)) + .collect::>() + .join(" ") }; - let combos = e - .presses + let combos = presses .iter() - .map(|&(command, shift, alt, key)| KeyCombo { key: key.into(), command, shift, alt }) + .map(|&(command, shift, alt, key)| KeyCombo { key: key.to_lowercase(), command, shift, alt }) .collect(); - ShortcutRow { keys, combos, does: e.does.into() } + ShortcutRow { keys, combos, does: e.does.into(), action: e.action } }) .collect(); (!rows.is_empty()).then(|| ShortcutGroup { heading: (*heading).into(), rows }) @@ -439,6 +756,137 @@ mod tests { key_shortcut(k.into(), command, shift, alt, false) } + /// The press `text` names, as the settings file writes it. + fn press(text: &str) -> KeyCombo { + KeyCombo::parse(text).unwrap_or_else(|| panic!("{text} is no press")) + } + + #[test] + fn the_presses_bound_do_what_they_did_before_they_could_be_bound() { + let bindings = KeyBindings::default(); + let at = |k: &str, command: bool, shift: bool, alt: bool| { + key_shortcut_bound(&bindings, k, command, shift, alt, false) + }; + // with the command key + for (k, does) in [ + ("z", Undo), + ("c", CopyLayer), + ("x", CutLayer), + ("v", Paste), + ("a", CopyCanvas), + ("b", ColourBalance), + ("u", HueSaturationBrightness), + ("d", Deselect), + ("j", DuplicateLayer), + ("k", Actions), + ("0", Interface), + (";", PerspectiveGuide), + ("g", GreyView), + ] { + assert_eq!(at(k, true, false, false), Some(does), "{k}"); + } + assert_eq!(at("z", true, true, false), Some(Redo)); + for k in ["delete", "backspace"] { + assert_eq!(at(k, true, false, false), Some(ClearLayer), "{k}"); + } + // alone + for (k, does) in [ + ("b", Paint), + ("e", Erase), + ("m", Eyedropper), + ("s", Selection), + ("v", Transform), + ("c", Colours), + ("x", SwapColours), + ("l", Layers), + ("space", QuickMenu), + ("0", Interface), + ] { + assert_eq!(at(k, false, false, false), Some(does), "{k}"); + } + // a modifier no binding names narrows nothing: Shift with D still deselects, and AltGr + // typing B still paints + assert_eq!(at("d", false, true, false), Some(Deselect)); + assert_eq!(at("d", true, true, false), Some(Deselect)); + assert_eq!(at("b", true, false, true), Some(Paint)); + // and with the command key nothing falls back to the key alone + assert_eq!(at("space", true, false, false), None); + assert_eq!(at("e", true, false, false), None); + assert_eq!(at("q", false, false, false), None); + } + + #[test] + fn a_key_bound_again_does_what_it_was_bound_to_and_the_list_says_so() { + let mut bindings = KeyBindings::default(); + bindings.bind(KeyAction::Paint, press("p")); + assert_eq!(key_shortcut_bound(&bindings, "p", false, false, false, false), Some(Paint)); + // P was nothing before: it is Paint's now, and B is still the one it started as + assert_eq!(key_shortcut_bound(&bindings, "b", false, false, false, false), Some(Paint)); + let row = key_shortcut_list_with(true, false, bindings) + .iter() + .flat_map(|g| &g.rows) + .find(|r| r.does == "Paint") + .cloned() + .unwrap(); + assert_eq!(row.keys, "B P"); + assert_eq!(row.combos, vec![press("b"), press("p")]); + assert_eq!(row.action, Some(KeyAction::Paint)); + } + + #[test] + fn binding_a_press_takes_it_from_what_did_it_before() { + let mut bindings = KeyBindings::default(); + bindings.bind(KeyAction::Erase, press("ctrl+z")); + assert!(bindings.combos_for(KeyAction::Undo).is_empty(), "Ctrl+Z is Erase's now"); + assert_eq!(bindings.lookup("z", true, false, false), Some(KeyAction::Erase)); + // given back, Undo has it again and Erase has not + bindings.reset(); + assert_eq!(bindings.lookup("z", true, false, false), Some(KeyAction::Undo)); + assert_eq!(bindings.lookup("e", false, false, false), Some(KeyAction::Erase)); + } + + #[test] + fn a_command_unbound_has_no_keys_and_does_nothing() { + let mut bindings = KeyBindings::default(); + bindings.unbind(KeyAction::Layers); + assert_eq!(key_shortcut_bound(&bindings, "l", false, false, false, false), None); + let row = key_shortcut_list_with(false, false, bindings) + .iter() + .flat_map(|g| &g.rows) + .find(|r| r.does == "Layers") + .cloned() + .unwrap(); + assert_eq!(row.keys, "Unbound"); + assert!(row.combos.is_empty()); + } + + #[test] + fn the_bindings_keep_and_come_back_as_they_were() { + let mut bindings = KeyBindings::default(); + bindings.bind(KeyAction::Erase, press("ctrl+shift+e")); + bindings.unbind(KeyAction::QuickMenu); + let mut read = KeyBindings::default(); + for line in bindings.kept_lines() { + let (name, value) = line.split_once(' ').unwrap(); + assert!(read.read_line(name, value), "{name}"); + } + assert_eq!(read, bindings); + // what is not a binding is left for the plugins, as before + assert!(!read.read_line("something_else", "x")); + assert!(read.read_line("key_undo", "")); + assert!(read.combos_for(KeyAction::Undo).is_empty()); + } + + #[test] + fn a_press_is_written_and_read_back_as_it_is() { + for text in ["ctrl+shift+z", "space", "[", "ctrl+[", "alt+m", "ctrl+alt+0", "left"] { + assert_eq!(press(text).text(), text, "{text}"); + } + for text in ["", "ctrl+", "hyper+z", "ctrl+,", "z z"] { + assert_eq!(KeyCombo::parse(text), None, "{text:?}"); + } + } + #[test] fn the_command_key_undoes_and_moves_layers_and_alone_the_letters_choose() { assert_eq!(key("Z", true, false, false), Some(Undo)); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index fcace8b2..b04ce92e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -120,7 +120,11 @@ pub use gradients::{GradientInfo, GradientStop, Gradients}; pub use guide::{GuideInfo, GuideType, GuideUnit, SymmetryType}; pub use history::ColourHistory; pub use hues::{HueControl, RING_HUES, SLIDER_HUES, hue_at, hue_position, hue_stops}; -pub use keys::{KeyCombo, KeyDrag, KeyShortcut, ShortcutGroup, ShortcutRow, key_shortcut, key_shortcut_list}; +pub use keys::{ + KeyAction, KeyBinding, KeyBindings, KeyCombo, KeyDrag, KeyShortcut, ShortcutGroup, ShortcutRow, bindable_key, + key_shortcut, key_shortcut_bound, key_shortcut_list, key_shortcut_list_bound, key_shortcut_list_with, + key_shortcut_with, +}; pub use layers::{ BlendModeInfo, Blocked, LayerInfo, LayerOption, LayerOptionRow, LayerType, ThumbnailImage, blend_modes, brush_blend_modes, combine_blend_modes, grain_blend_modes, layers_for, set_resident_budget, diff --git a/desktop/src/prefs.rs b/desktop/src/prefs.rs index e8af00d3..5aa43ea1 100644 --- a/desktop/src/prefs.rs +++ b/desktop/src/prefs.rs @@ -4,7 +4,7 @@ use std::sync::Arc; -use procraft_core::{CanvasSession, Plugins}; +use procraft_core::{CanvasSession, KeyBindings, Plugins}; /// When the brush cursor shows: while the pointer hovers, while painting, or both. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -88,6 +88,8 @@ pub struct Prefs { pub drag_image_format: usize, /// Recolor's Flood, kept from one time to the next. pub recolor_threshold: f32, + /// The keys the canvas's shortcuts answer to: as they start, until one is bound again. + pub keys: KeyBindings, } impl Default for Prefs { @@ -126,6 +128,7 @@ impl Default for Prefs { drag_format: 0, drag_image_format: 0, recolor_threshold: 0.0, + keys: KeyBindings::default(), } } } @@ -196,6 +199,9 @@ impl Prefs { p.curve = points; } } + name if name.starts_with("key_") => { + p.keys.read_line(name, value); + } _ => p.plugins.read_kept(name.to_owned(), value.to_owned()), } } @@ -240,7 +246,8 @@ impl Prefs { format!("drag_image_format {}", self.drag_image_format), format!("recolor_threshold {}", self.recolor_threshold), ]; - let lines: Vec = lines.into_iter().chain(self.plugins.kept_lines()).collect(); + let lines: Vec = + lines.into_iter().chain(self.keys.kept_lines()).chain(self.plugins.kept_lines()).collect(); crate::files::keep_text("interface", &(lines.join("\n") + "\n")); } diff --git a/desktop/src/shortcuts.rs b/desktop/src/shortcuts.rs index 1c8e1dd5..372ac240 100644 --- a/desktop/src/shortcuts.rs +++ b/desktop/src/shortcuts.rs @@ -1,14 +1,19 @@ //! The keyboard shortcuts' list (#31), the core's (keys.rs) with the desktop's own: Ctrl (⌘) held //! alone a moment shows it for as long as it is held, as a tablet's system shows an app's //! shortcuts while its command key is held; Help's Keyboard shortcuts opens it until Done, Esc or -//! a click beside it. +//! a click beside it. A shortcut's keys clicked there are bound again: the next key pressed takes +//! them, Backspace takes them away, Esc gives up; the list keeps them between runs (prefs.rs). +use std::sync::Arc; use std::time::{Duration, Instant}; -use egui::{Align2, Color32, Galley, Id, Order, Rect, ScrollArea, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2}; -use procraft_core::ShortcutGroup; +use egui::{ + Align2, Color32, Galley, Id, Key, Order, Rect, ScrollArea, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2, +}; +use procraft_core::{KeyAction, KeyBindings, KeyCombo, ShortcutGroup}; use crate::look::{Weight, argb, pc, text}; +use crate::prefs::Prefs; /// How long Ctrl (⌘) is held alone before the list shows. const HOLD: Duration = Duration::from_millis(1000); @@ -26,6 +31,8 @@ const ROW_GAP: f32 = 6.0; const HEADING_HEIGHT: f32 = 28.0; const GROUP_GAP: f32 = 14.0; const DONE: f32 = 40.0; +/// The keys a shortcut's row is bound again from, lit while the pointer is over them. +const OVER: u32 = 0x1FFFFFFF; /// The list, opened from Help or shown while Ctrl (⌘) is held. #[derive(Default)] @@ -36,12 +43,20 @@ pub struct Shortcuts { /// the hold until it is let go. held_since: Option, spoiled: bool, + /// The shortcut whose keys are being chosen: the next key pressed is bound to it. + capturing: Option, } impl Shortcuts { + /// Whether the list is showing and taking the keys, so the canvas's shortcuts wait. + pub fn takes_keys(&self) -> bool { + self.open || self.capturing.is_some() + } + /// Shows the list if it is open, or Ctrl (⌘) has been held alone long enough while `keys` go - /// to the canvas (no text being typed). - pub fn show(&mut self, ctx: &egui::Context, keys: bool) { + /// to the canvas (no text being typed). A shortcut's keys clicked are bound again, and the + /// bindings kept: true if one changed. + pub fn show(&mut self, ctx: &egui::Context, keys: bool, prefs: &mut Prefs) -> bool { let (alone, other) = ctx.input(|i| { let m = i.modifiers; let alone = m.command && !m.shift && !m.alt && (!m.ctrl || m.mac_cmd || !cfg!(target_os = "macos")); @@ -49,7 +64,10 @@ impl Shortcuts { (alone, other) }); let mut held = false; - if !alone || !keys { + if self.capturing.is_some() { + // the list stays, however Ctrl is held, while a key is being chosen + self.held_since = None; + } else if !alone || !keys { self.held_since = None; self.spoiled = false; } else if other || self.spoiled { @@ -63,38 +81,56 @@ impl Shortcuts { ctx.request_repaint_after(HOLD - for_now); } } + let mut changed = false; if self.open { - self.open = !panel(ctx, true); + let (close, bound) = panel(ctx, true, &mut self.capturing, &mut prefs.keys); + self.open = !close; + if close { + // nothing is left waiting for a key once the list is put away + self.capturing = None; + } + changed |= bound; } else if held { - panel(ctx, false); + let (_, bound) = panel(ctx, false, &mut self.capturing, &mut prefs.keys); + changed |= bound; } + changed } } -/// The groups laid out: each row's keys and what it does as galleys, a column `width` wide. +/// One row of the list laid out: its keys and what it does as galleys, and the action the keys can +/// be bound to again (none where the platform keeps them). +struct LaidRow { + keys: Arc, + does: Arc, + action: Option, +} + +/// The rows of a group laid out: its heading, its rows, and how tall it is. struct Laid { heading: String, - rows: Vec<(std::sync::Arc, std::sync::Arc)>, + rows: Vec, height: f32, } +/// The groups laid out: each row's keys and what it does as galleys, a column `width` wide. fn lay_out(painter: &egui::Painter, groups: Vec) -> Vec { let galley = |s: String, width: f32, colour: u32, weight: Weight| painter.layout(s, weight.font(14.0), argb(colour), width); groups .into_iter() .map(|g| { - let rows: Vec<_> = g + let rows: Vec = g .rows .into_iter() .map(|r| { let keys = galley(r.keys, KEYS_WIDTH - 12.0, TITLE, Weight::Medium); let does = galley(r.does, COLUMN - KEYS_WIDTH, INK, Weight::Regular); - (keys, does) + LaidRow { keys, does, action: r.action } }) .collect(); let height = HEADING_HEIGHT - + rows.iter().map(|(k, d)| k.size().y.max(d.size().y) + ROW_GAP).sum::() + + rows.iter().map(|row| row.keys.size().y.max(row.does.size().y) + ROW_GAP).sum::() + GROUP_GAP; Laid { heading: g.heading, rows, height } }) @@ -123,11 +159,62 @@ fn columns(laid: &[Laid], columns: usize) -> Vec> { out } +/// Takes the key pressed while `capturing`, if one was: it becomes `action`'s press; Backspace +/// (alone) takes the action's presses away; Escape gives up. True when the bindings changed. +fn catch( + ctx: &egui::Context, + capturing: &mut Option, + bindings: &mut KeyBindings, + action: KeyAction, +) -> bool { + let pressed = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)), + _ => None, + }) + }); + let Some((key, modifiers)) = pressed else { + // keys come one at a time; look again next frame + ctx.request_repaint(); + return false; + }; + ctx.input_mut(|i| i.consume_key(modifiers, key)); + if key == Key::Escape { + *capturing = None; + return false; + } + if key == Key::Backspace && !modifiers.command && !modifiers.shift && !modifiers.alt { + bindings.unbind(action); + *capturing = None; + return true; + } + let Some(name) = crate::ui::key_name(key) else { return false }; + // Ctrl with Alt is AltGr typing a key, not a press to bind; nor is a key that cannot be bound + if !procraft_core::bindable_key(name) || (modifiers.command && modifiers.alt) { + return false; + } + let combo = + KeyCombo { key: name.to_lowercase(), command: modifiers.command, shift: modifiers.shift, alt: modifiers.alt }; + bindings.bind(action, combo); + *capturing = None; + true +} + /// The list's panel in the middle of the window, with Done and a dimmed window around it if -/// `closable`; true once closed. -fn panel(ctx: &egui::Context, closable: bool) -> bool { - let screen = ctx.content_rect(); +/// `closable`; its shortcuts' keys can be bound again while it is. (True once closed, true if a +/// binding changed.) +fn panel( + ctx: &egui::Context, + closable: bool, + capturing: &mut Option, + bindings: &mut KeyBindings, +) -> (bool, bool) { let mut close = false; + let mut changed = false; + if closable && let Some(action) = *capturing { + changed = catch(ctx, capturing, bindings, action); + } + let screen = ctx.content_rect(); if closable { egui::Area::new(Id::unique("shortcuts scrim")).fixed_pos(screen.min).order(Order::Foreground).show(ctx, |ui| { let beside = ui.allocate_rect(screen, Sense::click()); @@ -137,7 +224,7 @@ fn panel(ctx: &egui::Context, closable: bool) -> bool { } let mac = cfg!(target_os = "macos"); let layer = egui::LayerId::new(Order::Tooltip, Id::unique("shortcuts")); - let laid = lay_out(&ctx.layer_painter(layer), procraft_core::key_shortcut_list(true, mac)); + let laid = lay_out(&ctx.layer_painter(layer), procraft_core::key_shortcut_list_with(true, mac, bindings.clone())); let fit = ((screen.width() - 40.0 - 2.0 * PAD + COLUMN_GAP) / (COLUMN + COLUMN_GAP)).floor(); let cols = columns(&laid, (fit as usize).clamp(1, 3)); let body_height = cols.iter().map(|c| c.iter().map(|g| g.height).sum::()).fold(0.0, f32::max) - GROUP_GAP; @@ -158,17 +245,56 @@ fn panel(ctx: &egui::Context, closable: bool) -> bool { ui.scope_builder(UiBuilder::new().max_rect(body), |ui| { ScrollArea::vertical().id_salt("shortcuts list").show(ui, |ui| { let (rect, _) = ui.allocate_exact_size(vec2(body.width(), body_height), Sense::hover()); - let painter = ui.painter(); for (c, column) in cols.iter().enumerate() { let x = rect.left() + c as f32 * (COLUMN + COLUMN_GAP); let mut y = rect.top(); for group in column { - text(painter, pos2(x, y), Align2::LEFT_TOP, &group.heading, 13.0, Weight::Bold, argb(HEADING)); + text( + ui.painter(), + pos2(x, y), + Align2::LEFT_TOP, + &group.heading, + 13.0, + Weight::Bold, + argb(HEADING), + ); y += HEADING_HEIGHT; - for (keys, does) in &group.rows { - painter.galley(pos2(x, y), keys.clone(), argb(TITLE)); - painter.galley(pos2(x + KEYS_WIDTH, y), does.clone(), argb(INK)); - y += keys.size().y.max(does.size().y) + ROW_GAP; + for row in &group.rows { + let row_height = row.keys.size().y.max(row.does.size().y); + // the whole slot the row sits in, gap and all, so a click between + // two rows' lines still chooses one of them + let keys = Rect::from_min_size(pos2(x, y), vec2(KEYS_WIDTH, row_height + ROW_GAP)); + let mut over = false; + if closable && let Some(action) = row.action { + let response = + ui.interact(keys, ui.scope_id().with(("shortcut", action.key())), Sense::click()); + over = response.hovered(); + if over { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + if response.clicked() { + *capturing = Some(action); + } + } + if row.action.is_some() && *capturing == row.action { + text( + ui.painter(), + pos2(x, y), + Align2::LEFT_TOP, + "Press a key…", + 15.0, + Weight::Medium, + argb(pc::ACCENT), + ); + } else { + if over { + let lit = keys.shrink2(vec2(3.0, ROW_GAP * 0.5)); + ui.painter().rect_filled(lit, 6, argb(OVER)); + } + ui.painter().galley(pos2(x, y), row.keys.clone(), argb(TITLE)); + } + ui.painter().galley(pos2(x + KEYS_WIDTH, y), row.does.clone(), argb(INK)); + y += row_height + ROW_GAP; } y += GROUP_GAP; } @@ -182,9 +308,25 @@ fn panel(ctx: &egui::Context, closable: bool) -> bool { ); text(ui.painter(), done.center(), Align2::CENTER_CENTER, "Done", 16.0, Weight::Medium, argb(pc::ACCENT)); close |= ui.interact(done, ui.scope_id().with("done"), Sense::click()).clicked(); + let reset = Rect::from_min_size(pos2(done.left() - 88.0, done.top()), vec2(72.0, DONE)); + text(ui.painter(), reset.center(), Align2::CENTER_CENTER, "Reset", 15.0, Weight::Medium, argb(HEADING)); + if ui.interact(reset, ui.scope_id().with("reset"), Sense::click()).clicked() { + bindings.reset(); + *capturing = None; + changed = true; + } + text( + ui.painter(), + pos2(panel.left() + PAD, done.center().y), + Align2::LEFT_CENTER, + "Click a shortcut to change it · Esc cancels · Backspace clears it", + 13.0, + Weight::Regular, + argb(HEADING), + ); } }); - close + (close, changed) } #[cfg(test)] diff --git a/desktop/src/ui.rs b/desktop/src/ui.rs index 53515b9d..b192e5ca 100644 --- a/desktop/src/ui.rs +++ b/desktop/src/ui.rs @@ -808,7 +808,9 @@ impl Interface { self.licences = false; } let keys_to_canvas = self.canvas_shown() && !ctx.egui_wants_keyboard_input() && !self.keys_taken(); - self.shortcut_list.show(&ctx, keys_to_canvas); + if self.shortcut_list.show(&ctx, keys_to_canvas, &mut self.prefs) { + self.prefs.keep(); + } self.name_colour(&ctx); self.place_canvas(session, ctx.pixels_per_point()); session.set_glass(backdrops, windows); @@ -2328,8 +2330,9 @@ impl Interface { /// A hardware keyboard's shortcuts: the core's (keys.rs), and the desktop's files: Ctrl (⌘) /// with N, O, S (and Shift). fn shortcuts(&mut self, ctx: &egui::Context, session: &CanvasSession, info: &CanvasInfo) { - // a text field, or the hex keypad, takes the keys; text being edited takes them all - if ctx.egui_wants_keyboard_input() || self.keys_taken() { + // a text field, or the hex keypad, takes the keys; text being edited takes them all, and + // the shortcuts' list (open, or waiting for a key to bind) takes them first of all + if ctx.egui_wants_keyboard_input() || self.keys_taken() || self.shortcut_list.takes_keys() { return; } let pressed = |modifiers: Modifiers, key: Key| { @@ -2378,8 +2381,9 @@ impl Interface { } let Some(name) = key_name(key) else { continue }; let transforming = self.transform.on; - let Some(shortcut) = procraft_core::key_shortcut( - name.into(), + let Some(shortcut) = procraft_core::key_shortcut_bound( + &self.prefs.keys, + name, modifiers.command, modifiers.shift, modifiers.alt, @@ -2459,7 +2463,7 @@ fn escape_id() -> egui::Id { } /// The name the core's shortcuts know `key` by (keys.rs), if it has one. -fn key_name(key: Key) -> Option<&'static str> { +pub(crate) fn key_name(key: Key) -> Option<&'static str> { Some(match key { Key::OpenBracket | Key::OpenCurlyBracket => "[", Key::CloseBracket | Key::CloseCurlyBracket => "]", From d0c80c42dba61e50f0158fba394f9a4f2458364c Mon Sep 17 00:00:00 2001 From: chinzu00 <13662542+chinzu00@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:36:00 -0500 Subject: [PATCH 2/5] =?UTF-8?q?fix(desktop):=20Ctrl=20(=E2=8C=98)=20held?= =?UTF-8?q?=20alone=20shows=20the=20shortcuts'=20list=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit egui puts the modifier keys themselves in keys_down (Key::ControlLeft, Key::ShiftLeft and their kind), so the test for another key being down was true on the hold's first frame and the list never came up. - shortcuts.rs: what spoils the hold is a key that is not a modifier (Shift, Ctrl, Alt or Super, either side); a click or a scroll still does. - Prefs: "Shortcut list on Ctrl held" (shortcuts_on_ctrl, on by default). Off, the list is asked for from Help alone; the setting is kept with the others. Testing: fmt, clippy and the tests pass as before. Held Ctrl with no other key on Windows: the list came up after a second and went on release. Turned the switch off (shortcuts_on_ctrl false in the interface file) and held Ctrl again: nothing came up. --- desktop/src/actions.rs | 7 +++++ desktop/src/prefs.rs | 5 ++++ desktop/src/shortcuts.rs | 60 ++++++++++++++++++++++++++++++++++++++-- desktop/src/ui.rs | 1 + 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/desktop/src/actions.rs b/desktop/src/actions.rs index ff09bf80..08c2f7a4 100644 --- a/desktop/src/actions.rs +++ b/desktop/src/actions.rs @@ -114,6 +114,8 @@ pub enum Act { ScalingMenu, LightInterface, RightHanded, + /// Ctrl (⌘) held alone shows the keyboard shortcuts' list, or doesn't. + HoldShortcuts, BrushCursor, ProjectCanvas, ResetInput, @@ -348,6 +350,7 @@ fn groups(tab: usize, showing: &Showing, prefs: &Prefs) -> Vec> { vec![ Switch("Light interface", prefs.light_interface, Some(Act::LightInterface)), Switch("Right-hand interface", prefs.right_handed, Some(Act::RightHanded)), + Switch("Shortcut list on Ctrl held", prefs.shortcuts_on_ctrl, Some(Act::HoldShortcuts)), Button( "Brush scaling", Some(if prefs.brush_on_screen { "Screen" } else { "Canvas" }), @@ -624,6 +627,10 @@ impl Actions { prefs.right_handed = !prefs.right_handed; Some(Act::Prefs) } + Some(Tapped::Act(Act::HoldShortcuts)) => { + prefs.shortcuts_on_ctrl = !prefs.shortcuts_on_ctrl; + Some(Act::Prefs) + } Some(Tapped::Act(Act::BrushCursor)) => { prefs.brush_cursor = !prefs.brush_cursor; Some(Act::Prefs) diff --git a/desktop/src/prefs.rs b/desktop/src/prefs.rs index 5aa43ea1..f88abc4f 100644 --- a/desktop/src/prefs.rs +++ b/desktop/src/prefs.rs @@ -90,6 +90,8 @@ pub struct Prefs { pub recolor_threshold: f32, /// The keys the canvas's shortcuts answer to: as they start, until one is bound again. pub keys: KeyBindings, + /// Ctrl (⌘) held alone a moment shows the keyboard shortcuts' list. + pub shortcuts_on_ctrl: bool, } impl Default for Prefs { @@ -129,6 +131,7 @@ impl Default for Prefs { drag_image_format: 0, recolor_threshold: 0.0, keys: KeyBindings::default(), + shortcuts_on_ctrl: true, } } } @@ -191,6 +194,7 @@ impl Prefs { p.drag_image_format = value.parse::().map_or(0, |t| t.min(DRAG_IMAGE_FORMATS.len() - 1)) } "recolor_threshold" => p.recolor_threshold = unit.unwrap_or(0.0), + "shortcuts_on_ctrl" => p.shortcuts_on_ctrl = on, "actions_tab" => p.actions_tab = value.parse::().map_or(0, |t| t.min(5)), "curve" => { let numbers: Vec = value.split(',').filter_map(|v| v.parse().ok()).collect(); @@ -245,6 +249,7 @@ impl Prefs { format!("drag_format {}", self.drag_format), format!("drag_image_format {}", self.drag_image_format), format!("recolor_threshold {}", self.recolor_threshold), + format!("shortcuts_on_ctrl {}", self.shortcuts_on_ctrl), ]; let lines: Vec = lines.into_iter().chain(self.keys.kept_lines()).chain(self.plugins.kept_lines()).collect(); diff --git a/desktop/src/shortcuts.rs b/desktop/src/shortcuts.rs index 372ac240..b3ba7729 100644 --- a/desktop/src/shortcuts.rs +++ b/desktop/src/shortcuts.rs @@ -54,19 +54,24 @@ impl Shortcuts { } /// Shows the list if it is open, or Ctrl (⌘) has been held alone long enough while `keys` go - /// to the canvas (no text being typed). A shortcut's keys clicked are bound again, and the - /// bindings kept: true if one changed. + /// to the canvas (no text being typed) and the setting for it is on. A shortcut's keys clicked + /// are bound again, and the bindings kept: true if one changed. pub fn show(&mut self, ctx: &egui::Context, keys: bool, prefs: &mut Prefs) -> bool { let (alone, other) = ctx.input(|i| { let m = i.modifiers; let alone = m.command && !m.shift && !m.alt && (!m.ctrl || m.mac_cmd || !cfg!(target_os = "macos")); - let other = !i.keys_down.is_empty() || i.pointer.any_down() || i.smooth_scroll_delta != egui::Vec2::ZERO; + // the modifiers held are not presses that spoil the hold: Ctrl itself is the ask + let other = spoiled(&i.keys_down, i.pointer.any_down(), i.smooth_scroll_delta != egui::Vec2::ZERO); (alone, other) }); let mut held = false; if self.capturing.is_some() { // the list stays, however Ctrl is held, while a key is being chosen self.held_since = None; + } else if !prefs.shortcuts_on_ctrl { + // the list is asked for from Help alone + self.held_since = None; + self.spoiled = false; } else if !alone || !keys { self.held_since = None; self.spoiled = false; @@ -98,6 +103,28 @@ impl Shortcuts { } } +/// Whether `key` is a modifier itself (Shift, Ctrl, Alt, Super: either side). Holding Ctrl is how +/// the list is asked for, so it must not count as another key pressed. +fn is_modifier(key: egui::Key) -> bool { + matches!( + key, + egui::Key::ShiftLeft + | egui::Key::ShiftRight + | egui::Key::ControlLeft + | egui::Key::ControlRight + | egui::Key::AltLeft + | egui::Key::AltRight + | egui::Key::SuperLeft + | egui::Key::SuperRight + ) +} + +/// Whether something else was pressed, scrolled or clicked since: the list is only for Ctrl (⌘) +/// held on its own, so a key (not a modifier), a pointer press or a scroll spoils it. +fn spoiled(keys: &std::collections::HashSet, pointer_down: bool, scrolled: bool) -> bool { + pointer_down || scrolled || keys.iter().any(|key| !is_modifier(*key)) +} + /// One row of the list laid out: its keys and what it does as galleys, and the action the keys can /// be bound to again (none where the platform keeps them). struct LaidRow { @@ -347,4 +374,31 @@ mod tests { assert_eq!(heights(2), vec![vec![200.0, 220.0], vec![100.0, 80.0, 180.0, 110.0]]); assert_eq!(heights(3), vec![vec![200.0], vec![220.0, 100.0], vec![80.0, 180.0, 110.0]]); } + + #[test] + fn ctrl_held_alone_is_not_spoiled_by_the_modifier_itself() { + let mut keys = std::collections::HashSet::new(); + keys.insert(Key::ControlLeft); + assert!(!spoiled(&keys, false, false), "Ctrl is how the list is asked for"); + // either Ctrl, Shift, Alt or Super, either side + for modifier in [ + Key::ShiftLeft, + Key::ShiftRight, + Key::ControlLeft, + Key::ControlRight, + Key::AltLeft, + Key::AltRight, + Key::SuperLeft, + Key::SuperRight, + ] { + let one: std::collections::HashSet = [modifier].into_iter().collect(); + assert!(!spoiled(&one, false, false), "{modifier:?}"); + } + // another key, a click or a scroll spoils it + keys.insert(Key::Z); + assert!(spoiled(&keys, false, false), "Z is another key"); + keys.remove(&Key::Z); + assert!(spoiled(&keys, true, false), "a pointer press"); + assert!(spoiled(&keys, false, true), "a scroll"); + } } diff --git a/desktop/src/ui.rs b/desktop/src/ui.rs index b192e5ca..562af7e2 100644 --- a/desktop/src/ui.rs +++ b/desktop/src/ui.rs @@ -2269,6 +2269,7 @@ impl Interface { | Act::TipAttachment | Act::LightInterface | Act::RightHanded + | Act::HoldShortcuts | Act::BrushCursor | Act::ProjectCanvas | Act::ResetInput, From 039c50ddd6c53d7e6251eb37bbff6ac5270e9f1c Mon Sep 17 00:00:00 2001 From: chinzu00 <13662542+chinzu00@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:32:43 -0500 Subject: [PATCH 3/5] feat(desktop): the shortcuts' list rises into place, holds for 0.9 s, and lights whole rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The list fades and rises 16 points into place over 150 ms, easing out, however it was asked for (Help or Ctrl held); the scrim fades with it. It used to appear in one frame. - Ctrl (⌘) held alone shows it after 900 ms rather than a second. - A shortcut's row is one target: its keys and its name light together while the pointer is over either, and a click anywhere on the row asks for the new key. Testing: fmt, clippy and the desktop tests pass. On Windows, holding Ctrl gave a half-faded panel mid-rise and the settled one 16 points higher; hovering the name of a row lit its keys too. --- desktop/src/shortcuts.rs | 72 +++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/desktop/src/shortcuts.rs b/desktop/src/shortcuts.rs index b3ba7729..a3147031 100644 --- a/desktop/src/shortcuts.rs +++ b/desktop/src/shortcuts.rs @@ -1,8 +1,9 @@ //! The keyboard shortcuts' list (#31), the core's (keys.rs) with the desktop's own: Ctrl (⌘) held //! alone a moment shows it for as long as it is held, as a tablet's system shows an app's //! shortcuts while its command key is held; Help's Keyboard shortcuts opens it until Done, Esc or -//! a click beside it. A shortcut's keys clicked there are bound again: the next key pressed takes -//! them, Backspace takes them away, Esc gives up; the list keeps them between runs (prefs.rs). +//! a click beside it. A shortcut's row clicked there is bound again: the next key pressed takes +//! them, Backspace takes them away, Esc gives up; the list keeps them between runs (prefs.rs). It +//! rises into place quickly, and Ctrl (⌘) held shows it only if the setting says so (prefs.rs). use std::sync::Arc; use std::time::{Duration, Instant}; @@ -16,7 +17,11 @@ use crate::look::{Weight, argb, pc, text}; use crate::prefs::Prefs; /// How long Ctrl (⌘) is held alone before the list shows. -const HOLD: Duration = Duration::from_millis(1000); +const HOLD: Duration = Duration::from_millis(900); +/// How long the list takes to rise into place: fast, then easing out. +const RISE: Duration = Duration::from_millis(150); +/// How far up (points) the list rises into place. +const RISE_BY: f32 = 16.0; const FILL: u32 = 0xF21E1E1E; const EDGE: u32 = 0x24FFFFFF; const TITLE: u32 = 0xFFE5E5E5; @@ -31,7 +36,7 @@ const ROW_GAP: f32 = 6.0; const HEADING_HEIGHT: f32 = 28.0; const GROUP_GAP: f32 = 14.0; const DONE: f32 = 40.0; -/// The keys a shortcut's row is bound again from, lit while the pointer is over them. +/// A shortcut's row lit while the pointer is over it: its keys and what it does. const OVER: u32 = 0x1FFFFFFF; /// The list, opened from Help or shown while Ctrl (⌘) is held. @@ -43,6 +48,8 @@ pub struct Shortcuts { /// the hold until it is let go. held_since: Option, spoiled: bool, + /// Since when the list has been showing: it rises into place from here. + shown_at: Option, /// The shortcut whose keys are being chosen: the next key pressed is bound to it. capturing: Option, } @@ -54,8 +61,8 @@ impl Shortcuts { } /// Shows the list if it is open, or Ctrl (⌘) has been held alone long enough while `keys` go - /// to the canvas (no text being typed) and the setting for it is on. A shortcut's keys clicked - /// are bound again, and the bindings kept: true if one changed. + /// to the canvas (no text being typed) and the setting for it is on. A shortcut's row clicked + /// is bound again, and the bindings kept: true if one changed. pub fn show(&mut self, ctx: &egui::Context, keys: bool, prefs: &mut Prefs) -> bool { let (alone, other) = ctx.input(|i| { let m = i.modifiers; @@ -87,17 +94,29 @@ impl Shortcuts { } } let mut changed = false; - if self.open { - let (close, bound) = panel(ctx, true, &mut self.capturing, &mut prefs.keys); - self.open = !close; - if close { - // nothing is left waiting for a key once the list is put away - self.capturing = None; + let showing = self.open || held; + if !showing { + self.shown_at = None; + } else { + // it rises into place, fast and easing out, however it was asked for + let risen = self.shown_at.get_or_insert_with(Instant::now).elapsed().as_secs_f32() / RISE.as_secs_f32(); + let rise = risen.clamp(0.0, 1.0); + if rise < 1.0 { + ctx.request_repaint_after(Duration::from_millis(8)); + } + let rise = 1.0 - (1.0 - rise).powi(3); + if self.open { + let (close, bound) = panel(ctx, true, &mut self.capturing, &mut prefs.keys, rise); + self.open = !close; + if close { + // nothing is left waiting for a key once the list is put away + self.capturing = None; + } + changed |= bound; + } else { + let (_, bound) = panel(ctx, false, &mut self.capturing, &mut prefs.keys, rise); + changed |= bound; } - changed |= bound; - } else if held { - let (_, bound) = panel(ctx, false, &mut self.capturing, &mut prefs.keys); - changed |= bound; } changed } @@ -235,6 +254,7 @@ fn panel( closable: bool, capturing: &mut Option, bindings: &mut KeyBindings, + rise: f32, ) -> (bool, bool) { let mut close = false; let mut changed = false; @@ -245,7 +265,7 @@ fn panel( if closable { egui::Area::new(Id::unique("shortcuts scrim")).fixed_pos(screen.min).order(Order::Foreground).show(ctx, |ui| { let beside = ui.allocate_rect(screen, Sense::click()); - ui.painter().rect_filled(screen, 0, Color32::from_black_alpha(0x99)); + ui.painter().rect_filled(screen, 0, Color32::from_black_alpha((0x99 as f32 * rise) as u8)); close |= crate::ui::escape(ui.ctx()) || beside.clicked(); }); } @@ -258,8 +278,10 @@ fn panel( let width = 2.0 * PAD + cols.len() as f32 * (COLUMN + COLUMN_GAP) - COLUMN_GAP; let done_height = if closable { 16.0 + DONE } else { 0.0 }; let height = PAD + TITLE_HEIGHT + 16.0 + body_height + done_height + PAD; - let panel = Rect::from_center_size(screen.center(), vec2(width, height.min(screen.height() - 40.0))); + let panel = Rect::from_center_size(screen.center(), vec2(width, height.min(screen.height() - 40.0))) + .translate(vec2(0.0, (1.0 - rise) * RISE_BY)); egui::Area::new(Id::unique("shortcuts")).fixed_pos(panel.min).order(Order::Tooltip).show(ctx, |ui| { + ui.set_opacity(rise); ui.allocate_rect(panel, Sense::click()); ui.painter().rect_filled(panel, 28, argb(FILL)); ui.painter().rect_stroke(panel, 28, Stroke::new(1.0, argb(EDGE)), StrokeKind::Inside); @@ -288,13 +310,13 @@ fn panel( y += HEADING_HEIGHT; for row in &group.rows { let row_height = row.keys.size().y.max(row.does.size().y); - // the whole slot the row sits in, gap and all, so a click between - // two rows' lines still chooses one of them - let keys = Rect::from_min_size(pos2(x, y), vec2(KEYS_WIDTH, row_height + ROW_GAP)); + // the whole row, keys and what it does, gap and all, so a click + // between two rows' lines still chooses one of them + let slot = Rect::from_min_size(pos2(x, y), vec2(COLUMN, row_height + ROW_GAP)); let mut over = false; if closable && let Some(action) = row.action { let response = - ui.interact(keys, ui.scope_id().with(("shortcut", action.key())), Sense::click()); + ui.interact(slot, ui.scope_id().with(("shortcut", action.key())), Sense::click()); over = response.hovered(); if over { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); @@ -315,7 +337,11 @@ fn panel( ); } else { if over { - let lit = keys.shrink2(vec2(3.0, ROW_GAP * 0.5)); + // the row lit as one: its keys and its name + let lit = Rect::from_min_max( + pos2(slot.left() - 6.0, slot.top() + ROW_GAP * 0.5), + pos2(slot.right(), slot.bottom() - ROW_GAP * 0.5), + ); ui.painter().rect_filled(lit, 6, argb(OVER)); } ui.painter().galley(pos2(x, y), row.keys.clone(), argb(TITLE)); From ed5ad98c529ae2903973e9e1eb2f9cee850deb8c Mon Sep 17 00:00:00 2001 From: chinzu00 <13662542+chinzu00@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:20:19 -0500 Subject: [PATCH 4/5] fix(desktop): every row of the shortcuts' list lights when the pointer is over it The rows the platform keeps (the arrows, the brush size steps, and the desktop's zoom, turn and files) have no action to bind again, and the hover target was gated on having one, so they never lit. Every row is a pointer target now, so the whole list reads as one table as the pointer moves; only the rows that can be bound again take a key on a click, and only they show the pointing hand. Each row is numbered as it is laid out for its target (a test checks no two share one). --- desktop/src/shortcuts.rs | 48 +++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/desktop/src/shortcuts.rs b/desktop/src/shortcuts.rs index a3147031..b599f7ff 100644 --- a/desktop/src/shortcuts.rs +++ b/desktop/src/shortcuts.rs @@ -144,12 +144,14 @@ fn spoiled(keys: &std::collections::HashSet, pointer_down: bool, scro pointer_down || scrolled || keys.iter().any(|key| !is_modifier(*key)) } -/// One row of the list laid out: its keys and what it does as galleys, and the action the keys can -/// be bound to again (none where the platform keeps them). +/// One row of the list laid out: its keys and what it does as galleys, the action the keys can be +/// bound to again (none where the platform keeps them), and where it sits in the list, for its +/// pointer target. struct LaidRow { keys: Arc, does: Arc, action: Option, + index: usize, } /// The rows of a group laid out: its heading, its rows, and how tall it is. @@ -163,6 +165,7 @@ struct Laid { fn lay_out(painter: &egui::Painter, groups: Vec) -> Vec { let galley = |s: String, width: f32, colour: u32, weight: Weight| painter.layout(s, weight.font(14.0), argb(colour), width); + let mut index = 0; groups .into_iter() .map(|g| { @@ -172,7 +175,9 @@ fn lay_out(painter: &egui::Painter, groups: Vec) -> Vec { .map(|r| { let keys = galley(r.keys, KEYS_WIDTH - 12.0, TITLE, Weight::Medium); let does = galley(r.does, COLUMN - KEYS_WIDTH, INK, Weight::Regular); - LaidRow { keys, does, action: r.action } + let row = LaidRow { keys, does, action: r.action, index }; + index += 1; + row }) .collect(); let height = HEADING_HEIGHT @@ -314,15 +319,19 @@ fn panel( // between two rows' lines still chooses one of them let slot = Rect::from_min_size(pos2(x, y), vec2(COLUMN, row_height + ROW_GAP)); let mut over = false; - if closable && let Some(action) = row.action { + if closable { + // every row lights as one, to follow it across the columns; only + // the ones the platform doesn't keep take a new key on a click let response = - ui.interact(slot, ui.scope_id().with(("shortcut", action.key())), Sense::click()); + ui.interact(slot, ui.scope_id().with(("shortcut", row.index)), Sense::click()); over = response.hovered(); - if over { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - if response.clicked() { - *capturing = Some(action); + if let Some(action) = row.action { + if over { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + if response.clicked() { + *capturing = Some(action); + } } } if row.action.is_some() && *capturing == row.action { @@ -401,6 +410,25 @@ mod tests { assert_eq!(heights(3), vec![vec![200.0], vec![220.0, 100.0], vec![80.0, 180.0, 110.0]]); } + #[test] + fn every_row_gets_its_own_pointer_target() { + // rows are hovered by index (they have no action to name them by), so two rows sharing one + // would light and click together + let ctx = egui::Context::default(); + // the fonts arrive with the first frame; the rows cannot be laid out before them (and a + // frame's texture deltas must be taken or dropped on purpose) + let mut first = ctx.run_pass(egui::RawInput::default(), |_| {}); + first.textures_delta.clear(); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, egui::Id::unique("lay out"))); + let laid = lay_out(&painter, procraft_core::key_shortcut_list(true, false)); + let mut targets: Vec = laid.iter().flat_map(|group| &group.rows).map(|row| row.index).collect(); + assert!(!targets.is_empty()); + let rows = targets.len(); + targets.sort_unstable(); + targets.dedup(); + assert_eq!(targets.len(), rows, "two rows share a pointer target"); + } + #[test] fn ctrl_held_alone_is_not_spoiled_by_the_modifier_itself() { let mut keys = std::collections::HashSet::new(); From 2e85ab811daf9424d6c438f6e65a8ac37874202c Mon Sep 17 00:00:00 2001 From: chinzu00 <13662542+chinzu00@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:55:42 -0500 Subject: [PATCH 5/5] feat(desktop): a row that can be bound again shows an accent capsule on hover Every row lights as one when the pointer is over it; the rows with a key to bind now go further: the accent sits behind their keys in a rounded capsule, the keys brighten towards it, and the row's name comes up to the title's white. The rows the platform keeps (the arrows, the brush size steps, the desktop's zoom, turn and files) keep the plain grey wash, so the list says at a glance which of its rows can be changed, and which only read. --- desktop/src/shortcuts.rs | 48 ++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/desktop/src/shortcuts.rs b/desktop/src/shortcuts.rs index b599f7ff..49e7f4ae 100644 --- a/desktop/src/shortcuts.rs +++ b/desktop/src/shortcuts.rs @@ -38,6 +38,12 @@ const GROUP_GAP: f32 = 14.0; const DONE: f32 = 40.0; /// A shortcut's row lit while the pointer is over it: its keys and what it does. const OVER: u32 = 0x1FFFFFFF; +/// The capsule behind the keys of a row that can be bound again, while the pointer is over it (or +/// it waits for a key): the accent, faint enough to read through. +const CAPSULE: u32 = 0x330076FD; +/// Those keys' text, brightened from the list's own white towards the accent, so a row that can be +/// changed reads as one from a row that cannot. +const KEYS_LIT: u32 = 0xFF6FB2FF; /// The list, opened from Help or shown while Ctrl (⌘) is held. #[derive(Default)] @@ -334,7 +340,25 @@ fn panel( } } } - if row.action.is_some() && *capturing == row.action { + let binds = row.action.is_some(); + let lit = over && binds; + let waiting = binds && *capturing == row.action; + // the row lit as one, keys and name, whatever the row does + if over { + let lit_row = Rect::from_min_max( + pos2(slot.left() - 6.0, slot.top() + ROW_GAP * 0.5), + pos2(slot.right(), slot.bottom() - ROW_GAP * 0.5), + ); + ui.painter().rect_filled(lit_row, 6, argb(OVER)); + } + // one whose keys can be bound again says so: a capsule of the accent + // behind them, where the pointer would click, and brighter text + if lit || waiting { + let wide = row.keys.size().x.min(KEYS_WIDTH - 12.0) + 12.0; + let capsule = Rect::from_min_size(pos2(x - 6.0, y - 1.0), vec2(wide, row_height + 2.0)); + ui.painter().rect_filled(capsule, 6, argb(CAPSULE)); + } + if waiting { text( ui.painter(), pos2(x, y), @@ -342,20 +366,20 @@ fn panel( "Press a key…", 15.0, Weight::Medium, - argb(pc::ACCENT), + argb(KEYS_LIT), ); } else { - if over { - // the row lit as one: its keys and its name - let lit = Rect::from_min_max( - pos2(slot.left() - 6.0, slot.top() + ROW_GAP * 0.5), - pos2(slot.right(), slot.bottom() - ROW_GAP * 0.5), - ); - ui.painter().rect_filled(lit, 6, argb(OVER)); - } - ui.painter().galley(pos2(x, y), row.keys.clone(), argb(TITLE)); + ui.painter().galley( + pos2(x, y), + row.keys.clone(), + argb(if lit { KEYS_LIT } else { TITLE }), + ); } - ui.painter().galley(pos2(x + KEYS_WIDTH, y), row.does.clone(), argb(INK)); + ui.painter().galley( + pos2(x + KEYS_WIDTH, y), + row.does.clone(), + argb(if lit || waiting { TITLE } else { INK }), + ); y += row_height + ROW_GAP; } y += GROUP_GAP;