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/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 e8af00d3..f88abc4f 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,10 @@ 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, + /// Ctrl (⌘) held alone a moment shows the keyboard shortcuts' list. + pub shortcuts_on_ctrl: bool, } impl Default for Prefs { @@ -126,6 +130,8 @@ impl Default for Prefs { drag_format: 0, drag_image_format: 0, recolor_threshold: 0.0, + keys: KeyBindings::default(), + shortcuts_on_ctrl: true, } } } @@ -188,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(); @@ -196,6 +203,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()), } } @@ -239,8 +249,10 @@ 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.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..49e7f4ae 100644 --- a/desktop/src/shortcuts.rs +++ b/desktop/src/shortcuts.rs @@ -1,17 +1,27 @@ //! 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 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}; -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); +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; @@ -26,6 +36,14 @@ const ROW_GAP: f32 = 6.0; const HEADING_HEIGHT: f32 = 28.0; 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)] @@ -36,20 +54,38 @@ 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, } 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) 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; 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 !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 !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; } else if other || self.spoiled { @@ -63,38 +99,95 @@ impl Shortcuts { ctx.request_repaint_after(HOLD - for_now); } } - if self.open { - self.open = !panel(ctx, true); - } else if held { - panel(ctx, false); + let mut changed = false; + 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 } } -/// The groups laid out: each row's keys and what it does as galleys, a column `width` wide. +/// 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, 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. 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); + let mut index = 0; 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) + let row = LaidRow { keys, does, action: r.action, index }; + index += 1; + row }) .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,29 +216,83 @@ 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, + rise: f32, +) -> (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()); - 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(); }); } 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; 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); @@ -158,17 +305,82 @@ 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 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 { + // 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", row.index)), Sense::click()); + over = response.hovered(); + if let Some(action) = row.action { + if over { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + if response.clicked() { + *capturing = Some(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), + Align2::LEFT_TOP, + "Press a key…", + 15.0, + Weight::Medium, + argb(KEYS_LIT), + ); + } else { + 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(if lit || waiting { TITLE } else { INK }), + ); + y += row_height + ROW_GAP; } y += GROUP_GAP; } @@ -182,9 +394,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)] @@ -205,4 +433,50 @@ 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 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(); + 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 53515b9d..562af7e2 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); @@ -2267,6 +2269,7 @@ impl Interface { | Act::TipAttachment | Act::LightInterface | Act::RightHanded + | Act::HoldShortcuts | Act::BrushCursor | Act::ProjectCanvas | Act::ResetInput, @@ -2328,8 +2331,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 +2382,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 +2464,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 => "]",