From aadb3d874f7e96238fa421a7e57dbf9950e358a1 Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Sat, 16 May 2026 03:36:02 +0200 Subject: [PATCH] feat!: generic key bindings See issue #450. Changes syntax for [keybinds] section in config file to "key" = "tool-or-command" Also adds: - bindings for all toolbar buttons - commands for size selection - automatic hint generation for all buttons Removes: - layout independent key bindings - instead bind what you want - single letter binding restriction Old style is still supported but will generate deprecation warnings. The old code always sent key events into the IME handling before they were processed as key event. Thus there were two places for bindings, bindings with a modifier and single letter bindings. But IME is for text input, thus it is now only activated for the text tool. --- README.md | 86 ++++++++-- config.toml | 65 ++++++-- src/configuration.rs | 106 +------------ src/keybindings.rs | 364 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 32 ++++ src/sketch_board.rs | 312 ++++++++++++++++++++----------------- src/tools/mod.rs | 70 +++++---- src/ui/toolbars.rs | 200 ++++++++++++++++-------- 8 files changed, 873 insertions(+), 362 deletions(-) create mode 100644 src/keybindings.rs diff --git a/README.md b/README.md index cff5ae2e..44cbeb69 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,10 @@ All configuration is done either at the config file in `XDG_CONFIG_DIR/.config/s ### Shortcuts +#### General - Enter: as configured (see below), default: copy-to-clipboard (may be masked by active tool) - Esc: as configured (see below), default: exit (may be masked by active tool) -- Delete reset (clear) experimental 0.20.1 +- Shift+Delete reset (clear) experimental NEXTRELEASE - Ctrl+C: Save to clipboard (may be masked by active tool) - Ctrl+Shift+D or Ctrl+Shift+I: Open GTK inspector if not already opened - Ctrl+S: Save to specified output file @@ -80,6 +81,7 @@ The bindings are: - Holding Ctrl will switch to 1.0 step size - Mouse middle-button and page up/page down step size is 1.0 - Mouse right-button jumps to minimum/maximum +- s focuses the annotation size factor input field #### Tool Selection Shortcuts (configurable) 0.20.0 Default single-key shortcuts: @@ -131,6 +133,22 @@ Highlight: - Hold Ctrl to switch between block and freehand mode (default configurable, see below). - Hold Shift in freehand mode for a straight 15° aligned line. Stop at some position and release and hold Shift again to achieve perfectly aligned turns. +#### Overwriting Keybindings (since NEXTRELEASE) + +Shortcuts can be overwritten in the config by +```toml +[keybinds] +"BINDING" = "TOOL-OR-COMMAND" +``` + +Where `BINDING` follows the GTK syntax. This means modifiers are enclosed in angle brackets (e.g., ``) and keys are specified by name (for example, `-` must be written as `minus`). + +Pressing any unbound key will print its name to the console. + +Setting a binding to `"none"` will unbind it. + +The defaults are listed in the `config.toml`. + ### Configuration File ```toml @@ -214,19 +232,60 @@ title = "Satty" # experimental feature (0.21.0): set app_id, note this has to match D-Bus well-known name format, otherwise GTK does not accept it. app-id = "org.satty.satty" -# Tool selection keyboard shortcuts (since 0.20.0) +# Generic keyboard shortcuts (NEXTRELEASE) [keybinds] -pointer = "p" -crop = "c" -brush = "b" -line = "i" -arrow = "z" -rectangle = "r" -ellipse = "e" -text = "t" -marker = "m" -blur = "u" -highlight = "g" +# "q" = "run-actions-on-escape" # additionally to Escape +# "i" = "none" # unbind "i" default for line +# "l" = "line" + +# Global +"d" = "open-gtk-inspector" +"i" = "open-gtk-inspector" +"Left" = "pan-left" +"Right" = "pan-right" +"Up" = "pan-up" +"Down" = "pan-down" +"Delete" = "delete-selection" +"Escape" = "run-actions-on-escape" +"Return" = "run-actions-on-enter" +"t" = "toggle-toolbars" + +# top toolbar +"1" = "original-scale" +"2" = "fit-to-window" +"Delete" = "reset-all" +"z" = "undo" +"y" = "redo" +"p" = "pointer" +"c" = "crop" +"b" = "brush" +"i" = "line" +"z" = "arrow" +"r" = "rectangle" +"e" = "ellipse" +"t" = "text" +"m" = "marker" +"u" = "blur" +"g" = "highlight" +"s" = "save-to-file" +"s" = "save-to-file-as" +"c" = "save-to-clipboard" +"c" = "copy-filepath-to-clipboard" + +# bottom toolbar +"1" = "select-color-index:1" +"2" = "select-color-index:2" +"3" = "select-color-index:3" +"4" = "select-color-index:4" +"5" = "select-color-index:5" +"6" = "select-color-index:6" +"7" = "select-color-index:7" +"8" = "select-color-index:8" +"9" = "select-color-index:9" +"minus" = "cycle-size" +#"..." = "select-size:(small|medium|large)" +"s" = "focus-annotation-size-factor" +"f" = "toggle-fill" # Font to use for text annotations [font] @@ -272,6 +331,7 @@ custom = [ ] ``` + ### Command Line ``` diff --git a/config.toml b/config.toml index 25360c9d..0561e45c 100644 --- a/config.toml +++ b/config.toml @@ -77,19 +77,60 @@ title = "Satty" # experimental feature (0.21.0): set app_id, note this has to match D-Bus well-known name format, otherwise GTK does not accept it. app-id = "org.satty.satty" -# Tool selection keyboard shortcuts (since 0.20.0) +# Generic keyboard shortcuts (NEXTRELEASE) [keybinds] -pointer = "p" -crop = "c" -brush = "b" -line = "i" -arrow = "z" -rectangle = "r" -ellipse = "e" -text = "t" -marker = "m" -blur = "u" -highlight = "g" +# "q" = "run-actions-on-escape" # additionally to Escape +# "i" = "none" # unbind "i" default for line +# "l" = "line" + +# Global +"d" = "open-gtk-inspector" +"i" = "open-gtk-inspector" +"Left" = "pan-left" +"Right" = "pan-right" +"Up" = "pan-up" +"Down" = "pan-down" +"Delete" = "delete-selection" +"Escape" = "run-actions-on-escape" +"Return" = "run-actions-on-enter" +"t" = "toggle-toolbars" + +# top toolbar +"1" = "original-scale" +"2" = "fit-to-window" +"Delete" = "reset-all" +"z" = "undo" +"y" = "redo" +"p" = "pointer" +"c" = "crop" +"b" = "brush" +"i" = "line" +"z" = "arrow" +"r" = "rectangle" +"e" = "ellipse" +"t" = "text" +"m" = "marker" +"u" = "blur" +"g" = "highlight" +"s" = "save-to-file" +"s" = "save-to-file-as" +"c" = "save-to-clipboard" +"c" = "copy-filepath-to-clipboard" + +# bottom toolbar +"1" = "select-color-index:1" +"2" = "select-color-index:2" +"3" = "select-color-index:3" +"4" = "select-color-index:4" +"5" = "select-color-index:5" +"6" = "select-color-index:6" +"7" = "select-color-index:7" +"8" = "select-color-index:8" +"9" = "select-color-index:9" +"minus" = "cycle-size" +#"..." = "select-size:(small|medium|large)" +"s" = "focus-annotation-size-factor" +"f" = "toggle-fill" # Font to use for text annotations [font] diff --git a/src/configuration.rs b/src/configuration.rs index 2f4ba9f6..5bbead8d 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -65,7 +65,7 @@ pub struct Configuration { profile_startup: bool, no_window_decoration: bool, brush_smooth_history_size: usize, - keybinds: Keybinds, + keybinds: HashMap, // key_binding -> tool_or_command zoom_factor: f32, pan_step_size: f32, text_move_length: f32, @@ -74,80 +74,6 @@ pub struct Configuration { app_id: Option, } -pub struct Keybinds { - shortcuts: HashMap, -} - -impl Keybinds { - pub fn get_tool(&self, key: char) -> Option { - self.shortcuts.get(&key).copied() - } - - pub fn shortcuts(&self) -> &HashMap { - &self.shortcuts - } - - /// Update a single keybind, only if it is valid - fn update_keybind(&mut self, key: Option, tool: Tools) { - if let Some(key_str) = key - && let Some(validated_key) = Self::validate_keybind(&key_str, tool) - { - self.shortcuts.retain(|_, v| *v != tool); - self.shortcuts.insert(validated_key, tool); - } - } - - /// A shortcut keybinding is only valid if it is one char - fn validate_keybind(key: &str, tool: Tools) -> Option { - let mut chars = key.chars(); - match (chars.next(), chars.next()) { - (Some(c), None) => Some(c), - _ => { - eprintln!( - "Warning: Invalid keybind: '{} = {}'. Keybinds must be single characters. Using default keybind instead.", - tool, key - ); - None - } - } - } - - /// Merge keybindings with default - /// Only replaces defaults if they are set - fn merge(&mut self, file_keybinds: KeybindsFile) { - self.update_keybind(file_keybinds.pointer, Tools::Pointer); - self.update_keybind(file_keybinds.crop, Tools::Crop); - self.update_keybind(file_keybinds.brush, Tools::Brush); - self.update_keybind(file_keybinds.line, Tools::Line); - self.update_keybind(file_keybinds.arrow, Tools::Arrow); - self.update_keybind(file_keybinds.rectangle, Tools::Rectangle); - self.update_keybind(file_keybinds.ellipse, Tools::Ellipse); - self.update_keybind(file_keybinds.text, Tools::Text); - self.update_keybind(file_keybinds.marker, Tools::Marker); - self.update_keybind(file_keybinds.blur, Tools::Blur); - self.update_keybind(file_keybinds.highlight, Tools::Highlight); - } -} - -impl Default for Keybinds { - fn default() -> Self { - let mut shortcuts = HashMap::new(); - shortcuts.insert('p', Tools::Pointer); - shortcuts.insert('c', Tools::Crop); - shortcuts.insert('b', Tools::Brush); - shortcuts.insert('i', Tools::Line); - shortcuts.insert('z', Tools::Arrow); - shortcuts.insert('r', Tools::Rectangle); - shortcuts.insert('e', Tools::Ellipse); - shortcuts.insert('t', Tools::Text); - shortcuts.insert('m', Tools::Marker); - shortcuts.insert('u', Tools::Blur); - shortcuts.insert('g', Tools::Highlight); - - Self { shortcuts } - } -} - #[derive(Default)] pub struct FontConfiguration { family: Option, @@ -290,7 +216,7 @@ impl From> for EarlyExit { } } -#[derive(Debug, Clone, Copy, Deserialize, PartialEq)] +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum Action { SaveToClipboard, @@ -341,6 +267,7 @@ impl Configuration { APP_CONFIG.write().merge(file, command_line); } + fn merge_general(&mut self, general: ConfigurationFileGeneral) { if let Some(v) = general.fullscreen { self.fullscreen = Some(v); @@ -439,6 +366,7 @@ impl Configuration { } // --- } + fn merge(&mut self, file: Option, command_line: CommandLine) { // input_filename is required and needs to be overwritten self.input_filename = command_line.filename; @@ -454,9 +382,7 @@ impl Configuration { if let Some(v) = file.font { self.font.merge(v); } - if let Some(v) = file.keybinds { - self.keybinds.merge(v); - } + self.keybinds = file.keybinds.unwrap_or_default(); } // overwrite with all specified values from command line @@ -691,7 +617,7 @@ impl Configuration { self.brush_smooth_history_size } - pub fn keybinds(&self) -> &Keybinds { + pub fn keybinds(&self) -> &HashMap { &self.keybinds } @@ -750,7 +676,7 @@ impl Default for Configuration { profile_startup: false, no_window_decoration: false, brush_smooth_history_size: 0, // default to 0, no history - keybinds: Keybinds::default(), + keybinds: HashMap::new(), zoom_factor: 1.1, pan_step_size: 50., text_move_length: 50.0, @@ -782,23 +708,7 @@ struct ConfigurationFile { general: Option, color_palette: Option, font: Option, - keybinds: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -struct KeybindsFile { - pointer: Option, - crop: Option, - brush: Option, - line: Option, - arrow: Option, - rectangle: Option, - ellipse: Option, - text: Option, - marker: Option, - blur: Option, - highlight: Option, + keybinds: Option>, } #[derive(Deserialize)] diff --git a/src/keybindings.rs b/src/keybindings.rs new file mode 100644 index 00000000..4a1f9b2c --- /dev/null +++ b/src/keybindings.rs @@ -0,0 +1,364 @@ +use std::collections::HashMap; +use std::fmt; +use std::str::FromStr; +use std::sync::OnceLock; + +use relm4::gtk; +use relm4::gtk::gdk::Key; + +use crate::configuration::{APP_CONFIG, Action}; +use crate::sketch_board::KeyEventMsg; +use crate::style::Size; +use crate::tools::Tools; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionTrigger { + Escape, + Enter, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShortcutCommand { + // generic + ToggleToolbars, + OpenGtkInspector, + PanLeft, + PanRight, + PanUp, + PanDown, + Zoom(i16), + DeleteSelection, + RunConfiguredActions(ActionTrigger), + + // top toolbar + OriginalScale, + FitToWindow, + ResetAll, + SelectTool(Tools), + Undo, + Redo, + RunAction(Action), + + // bottom toolbar + SelectColorIndex(u64), + CycleSize, + SelectSize(Size), + FocusAnnotationSizeFactor, + ToggleFill, +} + +impl fmt::Display for ShortcutCommand { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + // generic + ShortcutCommand::OpenGtkInspector => "open-gtk-inspector", + ShortcutCommand::PanLeft => "pan-left", + ShortcutCommand::PanRight => "pan-right", + ShortcutCommand::PanUp => "pan-up", + ShortcutCommand::PanDown => "pan-down", + ShortcutCommand::Zoom(factor) => { + write!(f, "zoom:{}", factor)?; + return Ok(()); + } + ShortcutCommand::DeleteSelection => "delete-selection", + ShortcutCommand::RunConfiguredActions(ActionTrigger::Escape) => "run-actions-on-escape", + ShortcutCommand::RunConfiguredActions(ActionTrigger::Enter) => "run-actions-on-enter", + ShortcutCommand::ToggleToolbars => "toggle-toolbars", + + // top toolbar + ShortcutCommand::OriginalScale => "original-scale", + ShortcutCommand::FitToWindow => "fit-to-window", + ShortcutCommand::ResetAll => "reset-all", + ShortcutCommand::Undo => "undo", + ShortcutCommand::Redo => "redo", + ShortcutCommand::SelectTool(tool) => { + write!(f, "{}", tool.to_string().to_lowercase())?; + return Ok(()); + } + ShortcutCommand::RunAction(action) => match action { + Action::SaveToClipboard => "save-to-clipboard", + Action::SaveToFile => "save-to-file", + Action::SaveToFileAs => "save-to-file-as", + Action::CopyFilepathToClipboard => "copy-filepath-to-clipboard", + Action::Exit => "exit", + }, + + // bottom toolbar + ShortcutCommand::SelectColorIndex(index) => { + write!(f, "select-color-index:{}", index + 1)?; + return Ok(()); + } + ShortcutCommand::CycleSize => "cycle-size", + ShortcutCommand::SelectSize(size) => match size { + Size::Small => "select-size:small", + Size::Medium => "select-size:medium", + Size::Large => "select-size:large", + }, + ShortcutCommand::FocusAnnotationSizeFactor => "focus-annotation-size-factor", + ShortcutCommand::ToggleFill => "toggle-fill", + }; + write!(f, "{}", name) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParseCommandError; + +impl FromStr for ShortcutCommand { + type Err = ParseCommandError; + + fn from_str(s: &str) -> Result { + match s { + // generic + "open-gtk-inspector" => Ok(ShortcutCommand::OpenGtkInspector), + "toggle-toolbars" => Ok(ShortcutCommand::ToggleToolbars), + "pan-left" => Ok(ShortcutCommand::PanLeft), + "pan-right" => Ok(ShortcutCommand::PanRight), + "pan-up" => Ok(ShortcutCommand::PanUp), + "pan-down" => Ok(ShortcutCommand::PanDown), + text if text.starts_with("zoom:") => { + let num_str = text.strip_prefix("zoom:").unwrap(); + if let Ok(num) = num_str.parse::() { + return Ok(ShortcutCommand::Zoom(num)); + } + Err(ParseCommandError) + } + "delete-selection" => Ok(ShortcutCommand::DeleteSelection), + "run-actions-on-escape" => { + Ok(ShortcutCommand::RunConfiguredActions(ActionTrigger::Escape)) + } + "run-actions-on-enter" => { + Ok(ShortcutCommand::RunConfiguredActions(ActionTrigger::Enter)) + } + + // top toolbar + "original-scale" => Ok(ShortcutCommand::OriginalScale), + "fit-to-window" => Ok(ShortcutCommand::FitToWindow), + "reset-all" => Ok(ShortcutCommand::ResetAll), + "undo" => Ok(ShortcutCommand::Undo), + "redo" => Ok(ShortcutCommand::Redo), + "select-tool" => Ok(ShortcutCommand::SelectTool(Tools::Rectangle)), + "save-to-file" => Ok(ShortcutCommand::RunAction(Action::SaveToFile)), + "save-to-file-as" => Ok(ShortcutCommand::RunAction(Action::SaveToFileAs)), + "save-to-clipboard" => Ok(ShortcutCommand::RunAction(Action::SaveToClipboard)), + "copy-filepath-to-clipboard" => { + Ok(ShortcutCommand::RunAction(Action::CopyFilepathToClipboard)) + } + "exit" => Ok(ShortcutCommand::RunAction(Action::Exit)), + + // bottom toolbar + text if text.starts_with("select-color-index:") => { + let num_str = text.strip_prefix("select-color-index:").unwrap(); + + if let Some(num) = num_str.parse::().ok().filter(|n| *n > 0) { + return Ok(ShortcutCommand::SelectColorIndex(num - 1)); + } + Err(ParseCommandError) + } + "cycle-size" => Ok(ShortcutCommand::CycleSize), + "select-size:small" => Ok(ShortcutCommand::SelectSize(Size::Small)), + "select-size:medium" => Ok(ShortcutCommand::SelectSize(Size::Medium)), + "select-size:large" => Ok(ShortcutCommand::SelectSize(Size::Large)), + "focus-annotation-size-factor" => Ok(ShortcutCommand::FocusAnnotationSizeFactor), + "toggle-fill" => Ok(ShortcutCommand::ToggleFill), + + _ => Err(ParseCommandError), + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct ShortcutRegistry { + key_bindings: HashMap, +} + +impl ShortcutRegistry { + pub fn validate_keybinding(binding: &str) -> Result<(), String> { + if let Some((keyval, modifier)) = gtk::accelerator_parse(binding) { + if gtk::accelerator_valid(keyval, modifier) { + Ok(()) + } else { + Err(format!( + "Keybinding '{}' parsed successfully but not a valid hardware shortcut context.", + binding + )) + } + } else { + Err(format!( + "Syntax Error: '{}' is not a recognized GTK accelerator string name.", + binding + )) + } + } + + fn add_key_binding(&mut self, key: &str, command: ShortcutCommand) -> bool { + if let Err(err) = Self::validate_keybinding(key) { + eprintln!( + "Invalid key binding '{}' for command {:?}: {}", + key, command, err + ); + return false; + } + self.key_bindings.insert(key.to_string(), command); + true + } + + pub fn from_config() -> Self { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(Self::build_from_config).clone() + } + + fn build_from_config() -> Self { + let mut registry = Self::default(); + + // generic + registry.add_key_binding("d", ShortcutCommand::OpenGtkInspector); + registry.add_key_binding("i", ShortcutCommand::OpenGtkInspector); + registry.add_key_binding("t", ShortcutCommand::ToggleToolbars); + registry.add_key_binding("Left", ShortcutCommand::PanLeft); + registry.add_key_binding("Right", ShortcutCommand::PanRight); + registry.add_key_binding("Up", ShortcutCommand::PanUp); + registry.add_key_binding("Down", ShortcutCommand::PanDown); + registry.add_key_binding("plus", ShortcutCommand::Zoom(1)); + registry.add_key_binding("minus", ShortcutCommand::Zoom(-1)); + registry.add_key_binding("Delete", ShortcutCommand::DeleteSelection); + registry.add_key_binding("Delete", ShortcutCommand::ResetAll); + registry.add_key_binding( + "Escape", + ShortcutCommand::RunConfiguredActions(ActionTrigger::Escape), + ); + registry.add_key_binding( + "Return", + ShortcutCommand::RunConfiguredActions(ActionTrigger::Enter), + ); + registry.add_key_binding( + "KP_Enter", + ShortcutCommand::RunConfiguredActions(ActionTrigger::Enter), + ); + + // top toolbar + registry.add_key_binding("1", ShortcutCommand::OriginalScale); + registry.add_key_binding("2", ShortcutCommand::FitToWindow); + registry.add_key_binding("z", ShortcutCommand::Undo); + registry.add_key_binding("y", ShortcutCommand::Redo); + registry.add_key_binding("p", ShortcutCommand::SelectTool(Tools::Pointer)); + registry.add_key_binding("c", ShortcutCommand::SelectTool(Tools::Crop)); + registry.add_key_binding("b", ShortcutCommand::SelectTool(Tools::Brush)); + registry.add_key_binding("i", ShortcutCommand::SelectTool(Tools::Line)); + registry.add_key_binding("z", ShortcutCommand::SelectTool(Tools::Arrow)); + registry.add_key_binding("r", ShortcutCommand::SelectTool(Tools::Rectangle)); + registry.add_key_binding("e", ShortcutCommand::SelectTool(Tools::Ellipse)); + registry.add_key_binding("t", ShortcutCommand::SelectTool(Tools::Text)); + registry.add_key_binding("m", ShortcutCommand::SelectTool(Tools::Marker)); + registry.add_key_binding("u", ShortcutCommand::SelectTool(Tools::Blur)); + registry.add_key_binding("g", ShortcutCommand::SelectTool(Tools::Highlight)); + registry.add_key_binding( + "c", + ShortcutCommand::RunAction(Action::SaveToClipboard), + ); + registry.add_key_binding( + "c", + ShortcutCommand::RunAction(Action::CopyFilepathToClipboard), + ); + registry.add_key_binding("s", ShortcutCommand::RunAction(Action::SaveToFile)); + registry.add_key_binding( + "s", + ShortcutCommand::RunAction(Action::SaveToFileAs), + ); + + // bottom toolbar + for i in 1..11 { + let key = (i % 10).to_string(); + registry.add_key_binding(&key, ShortcutCommand::SelectColorIndex(i - 1)); + } + + registry.add_key_binding("minus", ShortcutCommand::CycleSize); + registry.add_key_binding("s", ShortcutCommand::FocusAnnotationSizeFactor); + registry.add_key_binding("f", ShortcutCommand::ToggleFill); + + // merge with config keybinds, allowing config to override defaults + for (key, tool_or_command) in APP_CONFIG.read().keybinds() { + if let Ok(tool) = Tools::from_str(tool_or_command.as_str()) { + registry.add_key_binding(key, ShortcutCommand::SelectTool(tool)); + } else if let Ok(tool) = Tools::from_str(key.as_str()) { + registry.add_key_binding(tool_or_command, ShortcutCommand::SelectTool(tool)); + eprintln!("Deprecated syntax for key binding: {key} = \"{tool_or_command}\""); + eprintln!(" Please update the config to : \"{tool_or_command}\" = \"{key}\""); + } else if let Ok(command) = ShortcutCommand::from_str(tool_or_command.as_str()) { + registry.add_key_binding(key, command); + } else if tool_or_command == "none" { + registry.key_bindings.remove(key); + } else { + eprintln!("Unknown tool or command in config for key '{key}': '{tool_or_command}'"); + } + } + + registry + } + + pub fn get_command_for_key_event(&self, event: &KeyEventMsg) -> Option { + let key = gtk::accelerator_name(event.key, event.modifier).to_string(); + + let modifier_only = matches!( + event.key, + Key::Control_L + | Key::Control_R + | Key::Shift_L + | Key::Shift_R + | Key::Alt_L + | Key::Alt_R + | Key::Meta_L + | Key::Meta_R + | Key::Super_L + | Key::Super_R + ); + if let Some(command) = self.key_bindings.get(&key) { + Some(*command) + } else if !modifier_only { + eprintln!("Key {key} is not bound to a command or tool"); + None + } else { + None + } + } + + pub fn get_binding_for_command(&self, command: ShortcutCommand) -> Option { + self.key_bindings.iter().find_map(|(binding, cmd)| { + if *cmd == command { + Some(Self::format_binding_for_hint(binding)) + } else { + None + } + }) + } + + fn format_binding_for_hint(binding: &str) -> String { + let mut rest = binding; + let mut parts: Vec = Vec::new(); + + while rest.starts_with('<') { + let Some(end) = rest.find('>') else { + break; + }; + let token = &rest[1..end]; + parts.push(match token { + "Control" => "Ctrl".to_string(), + "Shift" => "Shift".to_string(), + "Alt" => "Alt".to_string(), + other => other.to_string(), + }); + rest = &rest[end + 1..]; + } + + let key = rest.trim_end_matches('>'); + if !key.is_empty() { + let key_label = match key { + single if single.chars().count() == 1 => single.to_uppercase(), + other => other.to_string(), + }; + parts.push(key_label); + } + + parts.join("+") + } +} diff --git a/src/main.rs b/src/main.rs index f090093f..16c5067c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,7 @@ mod configuration; mod femtovg_area; mod icons; mod ime; +mod keybindings; mod math; mod notification; mod sketch_board; @@ -36,6 +37,7 @@ mod tools; mod ui; use crate::sketch_board::{SketchBoard, SketchBoardInput}; +use crate::style::{Color, Size}; use crate::tools::Tools; pub static START_TIME: LazyLock> = @@ -69,6 +71,10 @@ enum AppInput { ToggleToolbarsDisplay, ToolSwitchShortcut(Tools), ColorSwitchShortcut(u64), + SetColor(Color), + SetFill(bool), + SetSize(Size), + FocusAnnotationSizeFactorShortcut, ScaleFactorChanged, FullscreenChanged(bool), DimensionsUpdate(Option<(i32, i32)>), @@ -277,6 +283,26 @@ impl Component for App { .sender() .emit(StyleToolbarInput::ColorButtonSelected(color_button)); } + AppInput::SetColor(color) => { + self.style_toolbar + .sender() + .emit(StyleToolbarInput::SetColor(color)); + } + AppInput::SetFill(fill_enabled) => { + self.style_toolbar + .sender() + .emit(StyleToolbarInput::SetFill(fill_enabled)); + } + AppInput::SetSize(size) => { + self.style_toolbar + .sender() + .emit(StyleToolbarInput::SetSize(size)); + } + AppInput::FocusAnnotationSizeFactorShortcut => { + self.style_toolbar + .sender() + .emit(StyleToolbarInput::FocusAnnotationSizeFactor); + } AppInput::ScaleFactorChanged => { self.sketch_board .sender() @@ -342,6 +368,12 @@ impl Component for App { SketchBoardOutput::ColorSwitchShortcut(index) => { AppInput::ColorSwitchShortcut(index) } + SketchBoardOutput::SetColor(color) => AppInput::SetColor(color), + SketchBoardOutput::SetFill(fill_enabled) => AppInput::SetFill(fill_enabled), + SketchBoardOutput::SetSize(size) => AppInput::SetSize(size), + SketchBoardOutput::FocusAnnotationSizeFactorShortcut => { + AppInput::FocusAnnotationSizeFactorShortcut + } SketchBoardOutput::DimensionsUpdate(dimensions) => { AppInput::DimensionsUpdate(dimensions) } diff --git a/src/sketch_board.rs b/src/sketch_board.rs index 464a15fe..1ebd578f 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -2,10 +2,9 @@ use anyhow::anyhow; use femtovg::imgref::Img; use femtovg::rgb::{ComponentBytes, RGBA}; -use keycode::{KeyMap, KeyMappingId}; use relm4::gtk::gdk_pixbuf::Pixbuf; use relm4::gtk::gdk_pixbuf::glib::Bytes; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::io::Write; use std::panic; use std::path::{Path, PathBuf}; @@ -21,9 +20,10 @@ use relm4::{Component, ComponentParts, ComponentSender, RelmWidgetExt, gtk}; use crate::configuration::{APP_CONFIG, Action}; use crate::femtovg_area::FemtoVGArea; use crate::ime::pango_adapter::spans_from_pango_attrs; +use crate::keybindings::{ActionTrigger, ShortcutCommand, ShortcutRegistry}; use crate::math::Vec2D; use crate::notification::log_result; -use crate::style::Style; +use crate::style::{Color, Size, Style}; use crate::tools::{Tool, ToolEvent, ToolUpdateResult, Tools, ToolsManager}; use crate::ui::toolbars::ToolbarEvent; use xdg::BaseDirectories; @@ -53,6 +53,10 @@ pub enum SketchBoardOutput { ToggleToolbarsDisplay, ToolSwitchShortcut(Tools), ColorSwitchShortcut(u64), + SetColor(Color), + SetSize(Size), + FocusAnnotationSizeFactorShortcut, + SetFill(bool), DimensionsUpdate(Option<(i32, i32)>), ToolEditingChanged(bool), } @@ -270,6 +274,8 @@ impl InputEvent { pub struct SketchBoard { renderer: FemtoVGArea, + ime_enabled: Rc>, + shortcut_registry: ShortcutRegistry, active_tool: Rc>, tool_edit_mode: bool, tools: ToolsManager, @@ -856,6 +862,9 @@ impl SketchBoard { } ToolbarEvent::SizeSelected(size) => { self.style.size = size; + sender + .output_sender() + .emit(SketchBoardOutput::SetSize(self.style.size)); self.active_tool .borrow_mut() .handle_event(ToolEvent::StyleChanged(self.style)) @@ -867,6 +876,9 @@ impl SketchBoard { ToolbarEvent::Reset => self.handle_reset(), ToolbarEvent::ToggleFill => { self.style.fill = !self.style.fill; + sender + .output_sender() + .emit(SketchBoardOutput::SetFill(self.style.fill)); self.active_tool .borrow_mut() .handle_event(ToolEvent::StyleChanged(self.style)) @@ -877,14 +889,21 @@ impl SketchBoard { .borrow_mut() .handle_event(ToolEvent::StyleChanged(self.style)) } + ToolbarEvent::SetFill(fill_enabled) => { + self.style.fill = fill_enabled; + self.active_tool + .borrow_mut() + .handle_event(ToolEvent::StyleChanged(self.style)); + ToolUpdateResult::Redraw + } ToolbarEvent::SaveFileAs => self.handle_action(&[Action::SaveToFileAs]), ToolbarEvent::Resize => self.handle_resize(), ToolbarEvent::OriginalScale => self.handle_original_scale(), - /* ToolbarEvent::CropDimensionsUpdated(dimensions) => { - sender - .output_sender() - .emit(SketchBoardOutput::DimensionsUpdate(Some(dimensions))); - ToolUpdateResult::Unmodified + /* ToolbarEvent::CropDimensionsUpdated(dimensions) => { + sender + .output_sender() + .emit(SketchBoardOutput::DimensionsUpdate(Some(dimensions))); + ToolUpdateResult::Unmodified }*/ } } @@ -910,28 +929,6 @@ impl SketchBoard { sender.input(SketchBoardInput::new_text_event(TextEventMsg::Commit( txt.to_string(), ))); - } else if let Some(tool) = txt - .chars() - .next() - .and_then(|char| APP_CONFIG.read().keybinds().get_tool(char)) - { - sender.input(SketchBoardInput::ToolbarEvent(ToolbarEvent::ToolSelected( - tool, - ))); - sender - .output_sender() - .emit(SketchBoardOutput::ToolSwitchShortcut(tool)); - } else if let Some(hotkey_digit) = - txt.chars().next().and_then(|char| char.to_digit(10)) - { - let index_digit = if hotkey_digit == 0 { - 9 - } else { - hotkey_digit - 1 - }; - sender - .output_sender() - .emit(SketchBoardOutput::ColorSwitchShortcut(index_digit as u64)); } } TextEventMsg::Preedit { @@ -963,6 +960,117 @@ impl SketchBoard { pub fn active_tool_type(&self) -> Tools { self.active_tool.borrow().get_tool_type() } + + fn dispatch_key_shortcut_command( + &mut self, + command: ShortcutCommand, + sender: ComponentSender, + active_tool_result: ToolUpdateResult, + ) -> ToolUpdateResult { + match command { + ShortcutCommand::SelectTool(tool) => { + sender.input(SketchBoardInput::ToolbarEvent(ToolbarEvent::ToolSelected( + tool, + ))); + sender + .output_sender() + .emit(SketchBoardOutput::ToolSwitchShortcut(tool)); + ToolUpdateResult::RedrawAndStopPropagation + } + ShortcutCommand::SelectColorIndex(index) => { + sender + .output_sender() + .emit(SketchBoardOutput::ColorSwitchShortcut(index)); + ToolUpdateResult::Unmodified + } + ShortcutCommand::SelectSize(size) => { + sender.input(SketchBoardInput::ToolbarEvent(ToolbarEvent::SizeSelected( + size, + ))); + ToolUpdateResult::Unmodified + } + ShortcutCommand::CycleSize => { + self.style.size = match self.style.size { + Size::Small => Size::Large, + Size::Medium => Size::Small, + Size::Large => Size::Medium, + }; + sender.input(SketchBoardInput::ToolbarEvent(ToolbarEvent::SizeSelected( + self.style.size, + ))); + ToolUpdateResult::Unmodified + } + ShortcutCommand::FocusAnnotationSizeFactor => { + sender + .output_sender() + .emit(SketchBoardOutput::FocusAnnotationSizeFactorShortcut); + ToolUpdateResult::Unmodified + } + ShortcutCommand::ToggleFill => { + sender.input(SketchBoardInput::ToolbarEvent(ToolbarEvent::ToggleFill)); + ToolUpdateResult::Unmodified + } + ShortcutCommand::Undo => self.handle_undo(), + ShortcutCommand::Redo => self.handle_redo(), + ShortcutCommand::ToggleToolbars => self.handle_toggle_toolbars_display(sender), + ShortcutCommand::RunAction(action) => { + self.renderer.request_render(&[action]); + ToolUpdateResult::Unmodified + } + ShortcutCommand::OpenGtkInspector => { + gtk::Window::set_interactive_debugging(true); + ToolUpdateResult::Unmodified + } + ShortcutCommand::PanLeft + | ShortcutCommand::PanRight + | ShortcutCommand::PanUp + | ShortcutCommand::PanDown => { + let pan_step_size = APP_CONFIG.read().pan_step_size(); + let (x, y) = match command { + ShortcutCommand::PanLeft => (-pan_step_size, 0.), + ShortcutCommand::PanRight => (pan_step_size, 0.), + ShortcutCommand::PanUp => (0., -pan_step_size), + ShortcutCommand::PanDown => (0., pan_step_size), + _ => (0., 0.), + }; + self.renderer.set_drag_offset(Vec2D::new(x, y)); + self.renderer.store_last_offset(); + self.renderer.request_render(&[]); + ToolUpdateResult::Unmodified + } + ShortcutCommand::Zoom(step_count) => { + if step_count != 0 { + let zoom_factor = APP_CONFIG.read().zoom_factor(); + let scale = if step_count > 0 { + zoom_factor.powi(step_count as i32) + } else { + (1.0 / zoom_factor).powi((-step_count) as i32) + }; + self.renderer.set_pointer_offset_center(); + self.renderer.set_zoom_scale(scale); + self.renderer.request_render(&[]); + } + ToolUpdateResult::Unmodified + } + ShortcutCommand::OriginalScale => self.handle_original_scale(), + ShortcutCommand::FitToWindow => self.handle_resize(), + ShortcutCommand::DeleteSelection => { + // Placeholder for future delete selection implementation + ToolUpdateResult::Unmodified + } + ShortcutCommand::ResetAll => self.handle_reset(), + ShortcutCommand::RunConfiguredActions(trigger) => { + if let ToolUpdateResult::Unmodified = active_tool_result { + let actions = match trigger { + ActionTrigger::Escape => APP_CONFIG.read().actions_on_escape(), + ActionTrigger::Enter => APP_CONFIG.read().actions_on_enter(), + }; + self.renderer.request_render(&actions); + } + active_tool_result + } + } + } } #[relm4::component(pub)] @@ -1076,8 +1184,12 @@ impl Component for SketchBoard { }, add_controller = gtk::EventControllerKey { - connect_key_pressed[sender] => move |controller, key, code, modifier | { - if let Some(im_context) = controller.im_context() { + connect_key_pressed[ + sender, + im_context = model.im_context.clone(), + ime_enabled = model.ime_enabled.clone()] => move |controller, key, code, modifier | { + // for text tool we propagate to IME + if ime_enabled.get() { im_context.focus_in(); if !im_context.filter_keypress(controller.current_event().unwrap()) { sender.input(SketchBoardInput::new_key_event(KeyEventMsg::new(key, code, modifier))); @@ -1098,7 +1210,6 @@ impl Component for SketchBoard { sender.input(SketchBoardInput::new_key_release_event(KeyEventMsg::new(key, code, modifier))); } }, - set_im_context: Some(&model.im_context), }, add_controller = gtk::EventControllerMotion { @@ -1118,6 +1229,16 @@ impl Component for SketchBoard { } fn update(&mut self, msg: SketchBoardInput, sender: ComponentSender, _root: &Self::Root) { + let ime_should_be_enabled = + self.active_tool_type() == Tools::Text && self.active_tool.borrow().input_enabled(); + + if !self.ime_enabled.get() && ime_should_be_enabled { + self.ime_enabled.set(true); + } else if self.ime_enabled.get() && !ime_should_be_enabled { + self.ime_enabled.set(false); + self.im_context.focus_out(); + } + // handle resize ourselves, pass everything else to tool let sender_clone = sender.clone(); let result = match msg { @@ -1128,112 +1249,23 @@ impl Component for SketchBoard { .borrow_mut() .handle_event(ToolEvent::Input(ie.clone())); - // eprintln!("active_tool_result={:?}", active_tool_result); - match active_tool_result { ToolUpdateResult::StopPropagation | ToolUpdateResult::RedrawAndStopPropagation => active_tool_result, - _ => { - if ke.key == Key::y && ke.modifier == ModifierType::CONTROL_MASK { - self.handle_redo() - } else if ke.is_one_of(Key::z, KeyMappingId::UsZ) - && ke.modifier == ModifierType::CONTROL_MASK - { - self.handle_undo() - } else if ke.is_one_of(Key::y, KeyMappingId::UsY) - && ke.modifier == ModifierType::CONTROL_MASK - { - self.handle_redo() - } else if ke.is_one_of(Key::t, KeyMappingId::UsT) - && ke.modifier == ModifierType::CONTROL_MASK - { - self.handle_toggle_toolbars_display(sender) - } else if ke.is_one_of(Key::s, KeyMappingId::UsS) - && ke.modifier == ModifierType::CONTROL_MASK - { - self.renderer.request_render(&[Action::SaveToFile]); - ToolUpdateResult::Unmodified - } else if ke.is_one_of(Key::s, KeyMappingId::UsS) - && ke.modifier - == (ModifierType::CONTROL_MASK | ModifierType::SHIFT_MASK) + ToolUpdateResult::Unmodified => { + if let Some(command) = + self.shortcut_registry.get_command_for_key_event(&ke) { - self.renderer.request_render(&[Action::SaveToFileAs]); - ToolUpdateResult::Unmodified - } else if ke.is_one_of(Key::c, KeyMappingId::UsC) - && ke.modifier == ModifierType::CONTROL_MASK - { - self.renderer.request_render(&[Action::SaveToClipboard]); - ToolUpdateResult::Unmodified - } else if ke.is_one_of(Key::c, KeyMappingId::UsC) - && ke.modifier - == (ModifierType::CONTROL_MASK | ModifierType::ALT_MASK) - { - self.renderer - .request_render(&[Action::CopyFilepathToClipboard]); - ToolUpdateResult::Unmodified - } else if (ke.is_one_of(Key::d, KeyMappingId::UsD) - || ke.is_one_of(Key::i, KeyMappingId::UsI)) - && ke.modifier - == (ModifierType::CONTROL_MASK | ModifierType::SHIFT_MASK) - { - /* GTK does not appear to offer any tracking for this, so - we'd have to track the state ourselves. But since the user may - just choose to close the inspector window, doing so adds little - benefit. - - Just enable it everytime, and let the user close the window if they - so wish. - */ - gtk::Window::set_interactive_debugging(true); - ToolUpdateResult::Unmodified - } else if (ke.is_one_of(Key::leftarrow, KeyMappingId::ArrowLeft) - || ke.is_one_of(Key::rightarrow, KeyMappingId::ArrowRight) - || ke.is_one_of(Key::uparrow, KeyMappingId::ArrowUp) - || ke.is_one_of(Key::downarrow, KeyMappingId::ArrowDown)) - && ke.modifier == ModifierType::ALT_MASK - { - let pan_step_size = APP_CONFIG.read().pan_step_size(); - match ke.key { - Key::Left => self - .renderer - .set_drag_offset(Vec2D::new(-pan_step_size, 0.)), - Key::Right => { - self.renderer.set_drag_offset(Vec2D::new(pan_step_size, 0.)) - } - Key::Up => self - .renderer - .set_drag_offset(Vec2D::new(0., -pan_step_size)), - Key::Down => { - self.renderer.set_drag_offset(Vec2D::new(0., pan_step_size)) - } - _ => { /* unreachable */ } - } - - self.renderer.store_last_offset(); - self.renderer.request_render(&[]); - ToolUpdateResult::Unmodified - } else if ke.modifier.is_empty() && ke.key == Key::Delete { - self.handle_reset() - } else if ke.modifier.is_empty() - && (ke.key == Key::Escape - || ke.key == Key::Return - || ke.key == Key::KP_Enter) - { - // First, let the tool handle the event. If the tool does nothing, we can do our thing (otherwise require a second keyboard press) - // Relying on ToolUpdateResult::Unmodified is probably not a good idea, but it's the only way at the moment. See discussion in #144 - if let ToolUpdateResult::Unmodified = active_tool_result { - let actions = if ke.key == Key::Escape { - APP_CONFIG.read().actions_on_escape() - } else { - APP_CONFIG.read().actions_on_enter() - }; - self.renderer.request_render(&actions); - }; - active_tool_result + self.dispatch_key_shortcut_command( + command, + sender, + active_tool_result, + ) } else { active_tool_result } } + _ => active_tool_result, } } else { if let InputEvent::Mouse(me) = &ie @@ -1349,8 +1381,15 @@ impl Component for SketchBoard { let im_context = gtk::IMMulticontext::new(); + let text_tool = tools.get_text_tool(); + + let initial_ime_enabled = + config.initial_tool() == Tools::Text && text_tool.borrow().input_enabled(); + let mut model = Self { renderer: FemtoVGArea::default(), + ime_enabled: Rc::new(Cell::new(initial_ime_enabled)), + shortcut_registry: ShortcutRegistry::from_config(), active_tool: tools.get(&config.initial_tool()), tool_edit_mode: false, style: Style::default(), @@ -1449,17 +1488,6 @@ impl KeyEventMsg { modifier, } } - - /// Matches one of providen keys. The modifier is not considered. - /// And the key has more priority over keycode. - fn is_one_of(&self, key: Key, code: KeyMappingId) -> bool { - // INFO: on linux the keycode from gtk4 is evdev keycode, so need to match by him if need - // to use layout-independent shortcuts. And notice that there is subtraction by 8, it's - // because of x11 compatibility in which the keycodes are in range [8,255]. So need shift - // them to get correct evdev keycode. - let keymap = KeyMap::from(code); - self.key == key || self.code as u16 - 8 == keymap.evdev - } } #[cfg(test)] diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 9e7583ef..194c69a7 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,10 +1,6 @@ -use std::{ - borrow::Cow, - cell::RefCell, - collections::HashMap, - fmt::{Debug, Display}, - rc::Rc, -}; +use std::fmt; +use std::str::FromStr; +use std::{borrow::Cow, cell::RefCell, collections::HashMap, fmt::Debug, rc::Rc}; use anyhow::Result; use femtovg::{Canvas, FontId, renderer::OpenGl}; @@ -189,9 +185,9 @@ pub enum Tools { Brush = 10, } -impl Tools { - pub fn display_name(&self) -> &'static str { - match self { +impl fmt::Display for Tools { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { Tools::Pointer => "Pointer", Tools::Crop => "Crop", Tools::Brush => "Brush", @@ -200,28 +196,35 @@ impl Tools { Tools::Rectangle => "Rectangle", Tools::Ellipse => "Ellipse", Tools::Text => "Text", - Tools::Marker => "Numbered Marker", + Tools::Marker => "Marker", Tools::Blur => "Blur", Tools::Highlight => "Highlight", - } + }; + write!(f, "{}", name) } } -// used for printing -impl Display for Tools { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Pointer => write!(f, "pointer"), - Self::Crop => write!(f, "crop"), - Self::Line => write!(f, "line"), - Self::Arrow => write!(f, "arrow"), - Self::Rectangle => write!(f, "rectangle"), - Self::Ellipse => write!(f, "ellipse"), - Self::Text => write!(f, "text"), - Self::Marker => write!(f, "marker"), - Self::Blur => write!(f, "blur"), - Self::Highlight => write!(f, "highlight"), - Self::Brush => write!(f, "brush"), +#[derive(Debug, PartialEq, Eq)] +pub struct ParseCommandError; + +impl FromStr for Tools { + type Err = ParseCommandError; + + fn from_str(s: &str) -> Result { + let lower_name = s.to_lowercase(); + match lower_name.as_str() { + "pointer" => Ok(Self::Pointer), + "crop" => Ok(Self::Crop), + "line" => Ok(Self::Line), + "arrow" => Ok(Self::Arrow), + "rectangle" => Ok(Self::Rectangle), + "ellipse" => Ok(Self::Ellipse), + "text" => Ok(Self::Text), + "marker" => Ok(Self::Marker), + "blur" => Ok(Self::Blur), + "highlight" => Ok(Self::Highlight), + "brush" => Ok(Self::Brush), + _ => Err(ParseCommandError), } } } @@ -229,6 +232,7 @@ impl Display for Tools { pub struct ToolsManager { tools: HashMap>>, crop_tool: Rc>, + text_tool: Rc>, } impl ToolsManager { @@ -259,12 +263,18 @@ impl ToolsManager { tools.insert(Tools::Brush, Rc::new(RefCell::new(BrushTool::default()))); let crop_tool = Rc::new(RefCell::new(CropTool::default())); - Self { tools, crop_tool } + let text_tool = Rc::new(RefCell::new(TextTool::default())); + Self { + tools, + crop_tool, + text_tool, + } } pub fn get(&self, tool: &Tools) -> Rc> { match tool { Tools::Crop => self.crop_tool.clone(), + Tools::Text => self.text_tool.clone(), _ => self .tools .get(tool) @@ -278,6 +288,10 @@ impl ToolsManager { pub fn get_crop_tool(&self) -> Rc> { self.crop_tool.clone() } + + pub fn get_text_tool(&self) -> Rc> { + self.text_tool.clone() + } } impl StaticVariantType for Tools { diff --git a/src/ui/toolbars.rs b/src/ui/toolbars.rs index 7dd42028..8c5db5d4 100644 --- a/src/ui/toolbars.rs +++ b/src/ui/toolbars.rs @@ -1,7 +1,8 @@ use std::{borrow::Cow, collections::HashMap}; use crate::{ - configuration::APP_CONFIG, + configuration::{APP_CONFIG, Action}, + keybindings::{ShortcutCommand, ShortcutRegistry}, style::{Color, Size}, tools::Tools, }; @@ -32,6 +33,9 @@ pub struct StyleToolbar { custom_color: Color, custom_color_pixbuf: Pixbuf, color_action: SimpleAction, + size_action: SimpleAction, + size_spin_button: gtk::SpinButton, + fill_enabled: bool, visible: bool, output_dimensions: String, } @@ -41,6 +45,7 @@ pub enum ToolbarEvent { FocusCanvas, ToolSelected(Tools), ColorSelected(Color), + SetFill(bool), SizeSelected(Size), Redo, Undo, @@ -65,11 +70,15 @@ pub enum ToolsToolbarInput { #[derive(Debug, Copy, Clone)] pub enum StyleToolbarInput { ColorButtonSelected(ColorButtons), + SetColor(Color), + SetFill(bool), + SetSize(Size), ShowColorDialog, ColorDialogFinished(Option), SetVisibility(bool), ToggleVisibility, DimensionsChanged((i32, i32)), + FocusAnnotationSizeFactor, } fn create_icon_pixbuf(color: Color) -> Pixbuf { @@ -77,10 +86,25 @@ fn create_icon_pixbuf(color: Color) -> Pixbuf { pixbuf.fill(color.to_rgba_u32()); pixbuf } + fn create_icon(color: Color) -> gtk::Image { gtk::Image::from_pixbuf(Some(&create_icon_pixbuf(color))) } +fn update_hint( + shortcut_registry: &ShortcutRegistry, + widget: &impl IsA, + command: ShortcutCommand, +) { + let command_name = command.to_string(); + let shortcut_hint = shortcut_registry.get_binding_for_command(command); + let new_hint = match shortcut_hint { + Some(hint) => format!("{command_name} ({hint})"), + None => command_name, + }; + widget.set_tooltip_text(Some(&new_hint)); +} + #[relm4::component(pub)] impl SimpleComponent for ToolsToolbar { type Init = (); @@ -99,45 +123,40 @@ impl SimpleComponent for ToolsToolbar { #[watch] set_visible: model.visible, + #[name(original_scale_button)] gtk::Button { set_focusable: false, set_hexpand: false, - set_icon_name: "resize-large-regular", - set_tooltip: "1:1", connect_clicked[sender] => move |_| {sender.output_sender().emit(ToolbarEvent::OriginalScale);}, }, + #[name(fit_to_window_button)] gtk::Button { set_focusable: false, set_hexpand: false, - set_icon_name: "page-fit-regular", - set_tooltip: "Resize", connect_clicked[sender] => move |_| {sender.output_sender().emit(ToolbarEvent::Resize);}, }, + #[name(reset_button)] gtk::Button { set_focusable: false, set_hexpand: false, - set_icon_name: "recycling-bin", - set_tooltip: "Reset", connect_clicked[sender] => move |_| {sender.output_sender().emit(ToolbarEvent::Reset);}, }, gtk::Separator {}, + #[name(undo_button)] gtk::Button { set_focusable: false, set_hexpand: false, - set_icon_name: "arrow-undo-filled", - set_tooltip: "Undo (Ctrl-Z)", connect_clicked[sender] => move |_| {sender.output_sender().emit(ToolbarEvent::Undo);}, }, + #[name(redo_button)] gtk::Button { set_focusable: false, set_hexpand: false, - set_icon_name: "arrow-redo-filled", - set_tooltip: "Redo (Ctrl-Y)", connect_clicked[sender] => move |_| {sender.output_sender().emit(ToolbarEvent::Redo);}, }, gtk::Separator {}, @@ -145,126 +164,100 @@ impl SimpleComponent for ToolsToolbar { gtk::ToggleButton { set_focusable: false, set_hexpand: false, - set_icon_name: "cursor-regular", - // tooltip set programmatically ActionablePlus::set_action::: Tools::Pointer, }, #[name(crop_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, - set_icon_name: "crop-filled", - // tooltip set programmatically ActionablePlus::set_action::: Tools::Crop, }, #[name(brush_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, - set_icon_name: "pen-regular", - // tooltip set programmatically ActionablePlus::set_action::: Tools::Brush, }, #[name(line_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, - set_icon_name: "minus-large", - // tooltip set programmatically ActionablePlus::set_action::: Tools::Line, }, #[name(arrow_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, - set_icon_name: "arrow-up-right-filled", - // tooltip set programmatically ActionablePlus::set_action::: Tools::Arrow, }, #[name(rectangle_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, - set_icon_name: "checkbox-unchecked-regular", - // tooltip set programmatically ActionablePlus::set_action::: Tools::Rectangle, }, #[name(ellipse_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, - set_icon_name: "circle-regular", - // tooltip set programmatically ActionablePlus::set_action::: Tools::Ellipse, }, #[name(text_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, - set_icon_name: "text-case-title-regular", - // tooltip set programmatically ActionablePlus::set_action::: Tools::Text, }, #[name(marker_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, - set_icon_name: "number-circle-1-regular", - // tooltip set programmatically ActionablePlus::set_action::: Tools::Marker, }, #[name(blur_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, - set_icon_name: "drop-regular", - // tooltip set programmatically ActionablePlus::set_action::: Tools::Blur, }, #[name(highlight_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, - set_icon_name: "highlight-regular", - // tooltip set programmatically ActionablePlus::set_action::: Tools::Highlight, }, gtk::Separator {}, + #[name(copy_to_clipboard_button)] gtk::Button { set_focusable: false, set_hexpand: false, - set_icon_name: "copy-regular", - set_tooltip: "Copy to clipboard (Ctrl+C)", connect_clicked[sender] => move |_| {sender.output_sender().emit(ToolbarEvent::CopyClipboard);}, }, + #[name(save_button)] gtk::Button { set_focusable: false, set_hexpand: false, - set_icon_name: "save-regular", - set_tooltip: "Save (Ctrl+S)", connect_clicked[sender] => move |_| {sender.output_sender().emit(ToolbarEvent::SaveFile);}, - set_visible: APP_CONFIG.read().output_filename().is_some() }, + #[name(save_as_button)] gtk::Button { set_focusable: false, set_hexpand: false, - set_icon_name: "save-multiple-regular", - set_tooltip: "Save as (Ctrl+Shift+S)", connect_clicked[sender] => move |_| {sender.output_sender().emit(ToolbarEvent::SaveFileAs);}, }, }, @@ -342,28 +335,32 @@ impl SimpleComponent for ToolsToolbar { (Tools::Highlight, widgets.highlight_button.clone()), ]); - // reverse shortcuts mapping - let config = APP_CONFIG.read(); - let tool_to_key_map: HashMap<&Tools, &char> = config - .keybinds() - .shortcuts() - .iter() - .inspect(|(hotkey, tool)| if hotkey.is_ascii_digit() { - eprintln!("Warning: hotkey `{}` for tool `{}` overrides built-in hotkey to select a color from the palette", hotkey, tool); - }) - .map(|(k, v)| (v, k)) - .collect(); + let shortcut_registry = ShortcutRegistry::from_config(); // Update tooltips based on configured keybinds for (tool, button) in &model.tool_buttons { - let display_name = tool.display_name(); + update_hint( + &shortcut_registry, + button, + ShortcutCommand::SelectTool(*tool), + ); + } - let tooltip = if let Some(key) = tool_to_key_map.get(tool) { - &format!("{} ({})", display_name, key.to_uppercase()) - } else { - display_name - }; - button.set_tooltip_text(Some(tooltip)); + #[rustfmt::skip] + let other_commands = vec![ + (ShortcutCommand::OriginalScale, &widgets.original_scale_button,), + (ShortcutCommand::FitToWindow, &widgets.fit_to_window_button), + (ShortcutCommand::ResetAll, &widgets.reset_button), + (ShortcutCommand::Undo, &widgets.undo_button), + (ShortcutCommand::Redo, &widgets.redo_button), + // in between are the tools + (ShortcutCommand::RunAction(Action::SaveToClipboard), &widgets.copy_to_clipboard_button), + (ShortcutCommand::RunAction(Action::SaveToFile), &widgets.save_button), + (ShortcutCommand::RunAction(Action::SaveToFileAs), &widgets.save_as_button), + ]; + + for (command, button) in other_commands { + update_hint(&shortcut_registry, button, command); } // Set initial active button correctly @@ -466,6 +463,7 @@ impl Component for StyleToolbar { set_visible: model.visible, gtk::Separator {}, + #[name(custom_color_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, @@ -487,6 +485,7 @@ impl Component for StyleToolbar { connect_clicked => StyleToolbarInput::ShowColorDialog, }, gtk::Separator {}, + #[name(size_small_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, @@ -495,6 +494,7 @@ impl Component for StyleToolbar { set_tooltip: "Small size", ActionablePlus::set_action::: Size::Small, }, + #[name(size_medium_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, @@ -503,6 +503,7 @@ impl Component for StyleToolbar { set_tooltip: "Medium size", ActionablePlus::set_action::: Size::Medium, }, + #[name(size_large_button)] gtk::ToggleButton { set_focusable: false, set_hexpand: false, @@ -574,24 +575,19 @@ impl Component for StyleToolbar { set_tooltip: "Output dimensions (width x height)", }, gtk::Separator {}, + #[name(fill_button)] gtk::Button { set_focusable: false, set_hexpand: false, - set_icon_name: if APP_CONFIG.read().default_fill_shapes() { + #[watch] + set_icon_name: if model.fill_enabled { "paint-bucket-filled" } else { "paint-bucket-regular" }, - set_tooltip: "Fill shape", - connect_clicked[sender] => move |button| { + connect_clicked[sender] => move |_| { sender.output_sender().emit(ToolbarEvent::ToggleFill); - let new_icon = if button.icon_name() == Some("paint-bucket-regular".into()) { - "paint-bucket-filled" - } else { - "paint-bucket-regular" - }; - button.set_icon_name(new_icon); }, }, }, @@ -624,7 +620,30 @@ impl Component for StyleToolbar { .output_sender() .emit(ToolbarEvent::ColorSelected(color)); } + StyleToolbarInput::SetColor(color) => { + let palette_match = APP_CONFIG + .read() + .color_palette() + .palette() + .iter() + .position(|&p| p == color) + .map(|index| ColorButtons::Palette(index as u64)) + .unwrap_or(ColorButtons::Custom); + + // Only update custom_color if this is not a palette color + if matches!(palette_match, ColorButtons::Custom) { + self.custom_color = color; + self.custom_color_pixbuf = create_icon_pixbuf(color); + } + self.color_action.change_state(&palette_match.to_variant()); + } + StyleToolbarInput::SetFill(fill_enabled) => { + self.fill_enabled = fill_enabled; + } + StyleToolbarInput::SetSize(size) => { + self.size_action.change_state(&size.to_variant()); + } StyleToolbarInput::SetVisibility(visible) => self.visible = visible, StyleToolbarInput::ToggleVisibility => { self.visible = !self.visible; @@ -632,6 +651,9 @@ impl Component for StyleToolbar { StyleToolbarInput::DimensionsChanged((width, height)) => { self.output_dimensions = format!("{}x{}", width, height); } + StyleToolbarInput::FocusAnnotationSizeFactor => { + self.size_spin_button.grab_focus(); + } } } @@ -640,6 +662,8 @@ impl Component for StyleToolbar { root: Self::Root, sender: ComponentSender, ) -> ComponentParts { + let shortcut_registry = ShortcutRegistry::from_config(); + for (i, &color) in APP_CONFIG .read() .color_palette() @@ -654,6 +678,15 @@ impl Component for StyleToolbar { .child(&create_icon(color)) .build(); btn.set_action::(ColorButtons::Palette(i as u64)); + + let color_tooltip = match shortcut_registry + .get_binding_for_command(ShortcutCommand::SelectColorIndex(i as u64)) + { + Some(hint) => format!("color {} ({hint})", i + 1), + None => format!("color {}", i + 1), + }; + btn.set_tooltip_text(Some(&color_tooltip)); + root.prepend(&btn); } @@ -663,7 +696,6 @@ impl Component for StyleToolbar { &ColorButtons::Palette(0), move |_, state, value| { *state = value; - sender_tmp.input(StyleToolbarInput::ColorButtonSelected(value)); }, ); @@ -688,16 +720,46 @@ impl Component for StyleToolbar { let custom_color_pixbuf = create_icon_pixbuf(custom_color); // create model - let model = StyleToolbar { + let mut model = StyleToolbar { custom_color, custom_color_pixbuf, color_action: SimpleAction::from(color_action.clone()), + size_action: SimpleAction::from(size_action.clone()), + size_spin_button: gtk::SpinButton::new(None::<>k::Adjustment>, 0.1, 2), + fill_enabled: APP_CONFIG.read().default_fill_shapes(), visible: !APP_CONFIG.read().default_hide_toolbars(), output_dimensions: String::new(), }; // create widgets let widgets = view_output!(); + model.size_spin_button = widgets.size_spin_button.clone(); + + update_hint( + &shortcut_registry, + &widgets.size_small_button, + ShortcutCommand::SelectSize(Size::Small), + ); + update_hint( + &shortcut_registry, + &widgets.size_medium_button, + ShortcutCommand::SelectSize(Size::Medium), + ); + update_hint( + &shortcut_registry, + &widgets.size_large_button, + ShortcutCommand::SelectSize(Size::Large), + ); + update_hint( + &shortcut_registry, + &widgets.size_spin_button, + ShortcutCommand::FocusAnnotationSizeFactor, + ); + update_hint( + &shortcut_registry, + &widgets.fill_button, + ShortcutCommand::ToggleFill, + ); let mut group = RelmActionGroup::::new(); group.add_action(color_action);