diff --git a/src/app_executor.rs b/src/app_executor.rs index 155a19e..5a696c5 100644 --- a/src/app_executor.rs +++ b/src/app_executor.rs @@ -1,6 +1,6 @@ use crate::{ - AppSignal, DASH_BOARD_MENU_ITEMS, FilterMode, IMAGE_ACTION_MENU_ITEMS, MenuItem, Mode, - SCROLLBAR_MENU_ITEMS, ScrollAction, TEXT_ACTION_MENU_ITEMS, TextAction, + AppSignal, DASH_BOARD_MENU_ITEMS, FilterMode, IMAGE_ACTION_MENU_ITEMS, KeyState, MenuItem, + Mode, SCROLLBAR_MENU_ITEMS, ScrollAction, TEXT_ACTION_MENU_ITEMS, TextAction, action::{ OCRResult, WordPicker, get_dictionary_attributed_string, perform_ocr, screen_shot, text_from_clipboard, text_to_clipboard, @@ -67,7 +67,7 @@ impl MultiSeletionState { /// mainly cached UI elements, and some related drawings pub struct AppExecutor { state: Arc>, - simulating_keys: Arc>, + key_state: Arc>, /// Used for drawing hint boxes on screen hint_boxes: Vec, element_cache: ElementCache, @@ -102,7 +102,7 @@ pub struct AppExecutor { impl AppExecutor { pub fn new( state: Arc>, - simulating_keys: Arc>, + key_state: Arc>, config: GlyphlowConfig, window: Retained, screen_size: CGSize, @@ -111,7 +111,7 @@ impl AppExecutor { ) -> Self { Self { state, - simulating_keys, + key_state, hint_boxes: vec![], element_cache: ElementCache::new( config.element_min_width as f64, @@ -145,8 +145,8 @@ impl AppExecutor { } fn set_simulating_key(&self, flag: bool) { - if let Ok(mut is_sim) = self.simulating_keys.lock() { - *is_sim = flag; + if let Ok(mut ks) = self.key_state.lock() { + ks.is_simulating = flag; } } @@ -210,61 +210,146 @@ impl AppExecutor { self.draw_selected_frame(); } - fn draw_image_action_menu(&self) { - let mut msg = "Pick an Action for Image".to_string(); - msg.push_str(&Self::menu_string(&IMAGE_ACTION_MENU_ITEMS)); - for workflow in self.config.workflows.iter() { - if self.is_workflow_valid(workflow) { - msg.push_str(&format!("\n({}) {}", workflow.key, workflow.display)); + fn menu_format_helper( + key: &str, + display: &str, + prefix_len: usize, + max_key_len: usize, + ) -> String { + let padding = " ".repeat(max_key_len - key.chars().count()); + let filling = "_".repeat(prefix_len); + format!( + "\n{padding}({filling}{}) {display}", + key.chars().skip(prefix_len).collect::(), + ) + } + + fn menu_msg_alignment_helper( + &self, + head: &str, + builtin_menu_items: &[MenuItem], + need_editor: bool, + need_action: bool, + need_workflow: bool, + key_prefix: &str, + ) -> String { + let prefix_len = key_prefix.chars().count(); + let mut max_key_len = 1; + let mut menu_itmes = Vec::new(); + + // Skip static single key menu items + // when searching for multi-key actions + for it in builtin_menu_items { + if it.key.starts_with(key_prefix) { + max_key_len = max_key_len.max(it.key.chars().count()); + menu_itmes.push((it.key, it.description)); } } + + // Editor entry + if need_editor + && let Some(editor) = self.config.editor.as_ref() + && editor.key.starts_with(key_prefix) + { + max_key_len = max_key_len.max(editor.key.chars().count()); + menu_itmes.push((&editor.key, &editor.display)); + } + + // TODO: refactor this if we introduce actions for elements other than text + if need_action { + for action in self.config.text_actions.iter() { + if action.key.starts_with(key_prefix) { + max_key_len = max_key_len.max(action.key.chars().count()); + menu_itmes.push((&action.key, &action.display)); + } + } + } + + // Workflows valid for current selected element + if need_workflow { + for workflow in self.config.workflows.iter() { + if workflow.key.starts_with(key_prefix) && self.is_workflow_valid(workflow) { + max_key_len = max_key_len.max(workflow.key.chars().count()); + menu_itmes.push((&workflow.key, &workflow.display)); + } + } + } + + if menu_itmes.is_empty() { + return "Wrong key sequence\nPress Backspace to go back".to_string(); + } + + // Aligned + let mut msg = head.to_string(); + for (key, display) in menu_itmes { + msg.push_str(&Self::menu_format_helper( + key, + display, + prefix_len, + max_key_len, + )); + } + + msg + } + + fn draw_dashboard(&self, key_prefix: &str) { + let msg = self.menu_msg_alignment_helper( + "Pick a Target:", + &DASH_BOARD_MENU_ITEMS, + true, + false, + true, + key_prefix, + ); + + self.clear_drawing(); + self.draw_selected_frame(); self.draw_menu(&msg); } - fn draw_text_action_menu(&self, text: &str) { + fn draw_image_action_menu(&self, key_prefix: &str) { + let msg = self.menu_msg_alignment_helper( + "Pick an Action for Image:", + &IMAGE_ACTION_MENU_ITEMS, + false, + false, + true, + key_prefix, + ); + + self.draw_menu(&msg); + } + + fn draw_text_action_menu(&self, text: &str, key_prefix: &str) { // Truncate long text let text = if text.len() > MAX_TEXT_DISPLAY_LEN { &format!("{:.max_len$}...", text, max_len = MAX_TEXT_DISPLAY_LEN) } else { text }; - let mut msg = "Pick an Action for Text".to_string(); - msg.push_str(&format!("\n\n{}\n", text)); - msg.push_str(&Self::menu_string(&TEXT_ACTION_MENU_ITEMS)); - if let Some(editor) = self.config.editor.as_ref() { - msg.push_str(&format!("\n({}) {}", editor.key, editor.display)); - } - for action in self.config.text_actions.iter() { - msg.push_str(&format!("\n({}) {}", action.key, action.display)); - } - for workflow in self.config.workflows.iter() { - if self.is_workflow_valid(workflow) { - msg.push_str(&format!("\n({}) {}", workflow.key, workflow.display)); - } - } - self.draw_menu(&msg); - } + let header = format!("Pick an Action for Text:\n\n{}\n", text); + let msg = self.menu_msg_alignment_helper( + &header, + &TEXT_ACTION_MENU_ITEMS, + true, + true, + true, + key_prefix, + ); - fn draw_scroll_bar_menu(&self) { - let mut msg = "Pick a Scrolling Action:".to_string(); - msg.push_str(&Self::menu_string(&SCROLLBAR_MENU_ITEMS)); self.draw_menu(&msg); } - fn draw_dash_board(&self) { - let mut msg = "Pick a Target:".to_string(); - msg.push_str(&Self::menu_string(&DASH_BOARD_MENU_ITEMS)); - if let Some(editor) = self.config.editor.as_ref() { - msg.push_str(&format!("\n({}) {}", editor.key, editor.display)); - } - // Workflows for current selected element - for workflow in self.config.workflows.iter() { - if self.is_workflow_valid(workflow) { - msg.push_str(&format!("\n({}) {}", workflow.key, workflow.display)); - } - } - self.clear_drawing(); - self.draw_selected_frame(); + fn draw_scrolling_menu(&self, key_prefix: &str) { + let msg = self.menu_msg_alignment_helper( + "Pick a Scrolling Action:", + &SCROLLBAR_MENU_ITEMS, + false, + false, + false, + key_prefix, + ); self.draw_menu(&msg); } @@ -292,15 +377,6 @@ impl AppExecutor { tokio::spawn(async move { delay_and_deactivate(sender, timeout_secs).await }); } - fn menu_string(items: &[MenuItem]) -> String { - let mut res = String::new(); - for item in items { - res.push('\n'); - res.push_str(&item.to_string()); - } - res - } - fn draw_word_picker(&self) -> (Vec<(usize, String)>, u32) { let word_picker = self .word_picker @@ -337,7 +413,7 @@ impl AppExecutor { // e.g. Discord if is_electron && pid != self.last_pid { let _ = focused_app.role(); - std::thread::sleep(Duration::from_millis(self.config.menu_wait_ms)); + std::thread::sleep(Duration::from_millis(self.config.electron_initial_wait_ms)); } self.last_pid = pid; @@ -431,7 +507,7 @@ impl AppExecutor { self.clear_cache(); self.set_mode(Mode::Scrolling); self.clear_drawing(); - self.draw_scroll_bar_menu(); + self.draw_scrolling_menu(""); } else { self.clear_drawing(); self.notify_then_deactivate("No relevant UI elements found.", Level::Warn); @@ -618,7 +694,7 @@ impl AppExecutor { Target::Image => { self.selected = Some(eoi.clone()); self.set_mode(Mode::ImageActionMenu); - self.draw_image_action_menu(); + self.draw_image_action_menu(""); } Target::Custom(_) => { self.selected = Some(eoi.clone()); @@ -653,7 +729,7 @@ impl AppExecutor { } else if let Some(text) = context { self.selected = Some(eoi.clone()); self.set_mode(Mode::TextActionMenu); - self.draw_text_action_menu(text); + self.draw_text_action_menu(text, ""); } } Target::ChildElement => { @@ -662,7 +738,7 @@ impl AppExecutor { // Actions for current selected element if self.element_cache.cache.is_empty() { self.set_mode(Mode::DashBoard); - self.draw_dash_board(); + self.draw_dashboard(""); } else { self.draw_hints_from_cache(); } @@ -671,7 +747,7 @@ impl AppExecutor { self.selected = Some(eoi.clone()); self.clear_cache(); self.set_mode(Mode::Scrolling); - self.draw_scroll_bar_menu(); + self.draw_scrolling_menu(""); } Target::Editable => { self.selected = Some(eoi.clone()); @@ -686,6 +762,7 @@ impl AppExecutor { match self.open_editor(&text) { Ok(_) => { self.set_mode(Mode::Editing); + self.selected = None; } Err(e) => { self.notify_then_deactivate( @@ -859,6 +936,10 @@ impl AppExecutor { let center = frame.center(); self.press_on_element(element, role, center); } + WorkFlowAction::Click => { + let (x, y) = frame.center(); + Self::simulate_click(x, y, false); + } WorkFlowAction::ShowMenu => { let center = frame.center(); self.right_click_menu_on_element(element, center); @@ -920,14 +1001,14 @@ impl AppExecutor { fn update_selected_text_and_show_menu(&mut self, new_text: String) { self.set_mode(Mode::TextActionMenu); - self.draw_text_action_menu(&new_text); + self.draw_text_action_menu(&new_text, ""); self.update_selected_text(new_text); } pub async fn handle_signal(&mut self, signal: AppSignal) { match signal { AppSignal::DashBoard => { - self.draw_dash_board(); + self.draw_dashboard(""); } AppSignal::Activate(target) => { let quick_follow = target == Target::Scrollable @@ -944,6 +1025,27 @@ impl AppExecutor { AppSignal::RunWorkFlow(idx) => { self.execute_workflow(idx); } + AppSignal::MenuRefresh(key_prefix, menu_mode) => { + self.clear_drawing(); + match menu_mode { + Mode::DashBoard => { + self.draw_dashboard(&key_prefix); + } + Mode::TextActionMenu => { + self + .draw_text_action_menu(&self.selected.as_ref().and_then(|eoi| eoi.context.clone()).expect( + "Internal Error: selected text should be kept during menu refreshing.", + ), &key_prefix); + } + Mode::Scrolling => { + self.draw_scrolling_menu(&key_prefix); + } + Mode::ImageActionMenu => { + self.draw_image_action_menu(&key_prefix); + } + _ => (), + } + } AppSignal::ToggleMultiSelection => match self.target { Target::Text | Target::ImageOCR => { self.multi_selection.toggle(); @@ -1039,6 +1141,14 @@ impl AppExecutor { ScrollAction::DecreaseDistance => { self.config.scroll_distance /= 1.5; } + ScrollAction::Top => { + Self::scroll_to_value(element, 0.0); + self.draw_scrolling_menu(""); + } + ScrollAction::Bottom => { + Self::scroll_to_value(element, 1.0); + self.draw_scrolling_menu(""); + } } } else { let distance = (frame.size().1 * self.config.scroll_distance).max(1.0) as i64; @@ -1061,6 +1171,20 @@ impl AppExecutor { ScrollAction::DecreaseDistance => { self.config.scroll_distance /= 1.5; } + ScrollAction::Top => { + Self::simulate_event(&EventType::Wheel { + delta_x: 0, + delta_y: 999999, + }); + self.draw_scrolling_menu(""); + } + ScrollAction::Bottom => { + Self::simulate_event(&EventType::Wheel { + delta_x: 0, + delta_y: -999999, + }); + self.draw_scrolling_menu(""); + } } } } @@ -1169,10 +1293,19 @@ impl AppExecutor { self.update_editing_text(new_text); } else if pb != self.temp_file { match GlyphlowConfig::load_config(&pb) { - Ok(new_config) => { + Ok(mut new_config) => { self.element_cache.reload_config(&new_config); + let need_warning = !self.config.safe_reload(&mut new_config); self.config = new_config; - self.notify_then_deactivate("Configuration reloaded.\nKeybinding changes won't be applied until next launch.", Level::Warn); + + if need_warning { + self.notify_then_deactivate( + "Restart the app to apply full changes", + Level::Warn, + ); + } else { + self.notify_then_deactivate("Configuration reloaded", Level::Info); + } } Err(msg) => { self.notify_then_deactivate(&msg, Level::Error); diff --git a/src/config.rs b/src/config.rs index 3c3f011..4f1f129 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,21 +34,22 @@ pub struct CustomTarget { pub size: Option<(f64, f64)>, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub enum WorkFlowAction { SelectAll, Focus, Press, + Click, ShowMenu, KeyCombo(KeyBinding), SearchFor(CustomTarget), Sleep(u64), } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct WorkFlow { pub display: String, - pub key: char, + pub key: String, #[serde(default = "default_starting_role")] pub starting_role: RoleOfInterest, pub actions: Vec, @@ -58,12 +59,12 @@ fn default_starting_role() -> RoleOfInterest { RoleOfInterest::Generic } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct CommandAction { pub command: String, pub args: Vec, pub display: String, - pub key: char, + pub key: String, } #[derive(Serialize, Deserialize, Debug)] @@ -226,6 +227,7 @@ impl AlphabeticKey for Key { Key::Backspace | Key::Delete => '-', Key::LeftBracket => '[', Key::RightBracket => ']', + Key::ShiftLeft | Key::ShiftRight => '󰘶', _ => ' ', } } @@ -287,7 +289,7 @@ impl AlphabeticKey for Key { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct KeyBinding { #[serde(with = "key_combo_format")] pub keys: Vec, @@ -333,8 +335,54 @@ pub struct GlyphlowConfig { pub dictionaries: Vec, #[serde(default = "default_vis_level")] pub visibility_checking_level: VisibilityCheckingLevel, - #[serde(default = "default_menu_wait_ms")] - pub menu_wait_ms: u64, + #[serde(default = "default_wait_ms")] + pub electron_initial_wait_ms: u64, +} + +impl GlyphlowConfig { + pub fn safe_reload(&self, other: &mut GlyphlowConfig) -> bool { + let mut compatible = true; + + if self.global_trigger_key != other.global_trigger_key { + compatible = false; + other.global_trigger_key = self.global_trigger_key.clone(); + } + + match (self.editor.as_ref(), other.editor.as_ref()) { + (Some(e1), Some(e2)) if e1.key != e2.key => { + compatible = false; + other.editor = self.editor.clone(); + } + (None, Some(_)) | (Some(_), None) => { + compatible = false; + other.editor = self.editor.clone(); + } + _ => (), + } + + if self.text_actions.len() != other.text_actions.len() + || self + .text_actions + .iter() + .zip(other.text_actions.iter()) + .any(|(a1, a2)| a1.key != a2.key) + { + compatible = false; + other.text_actions = self.text_actions.clone(); + } + + if self.workflows.len() != other.workflows.len() + || self + .workflows + .iter() + .zip(other.workflows.iter()) + .any(|(w1, w2)| w1.key != w2.key) + { + compatible = false; + other.workflows = self.workflows.clone(); + } + compatible + } } fn default_global_keybinding() -> KeyBinding { @@ -348,7 +396,7 @@ fn default_text_actions() -> Vec { fn default_workflows() -> Vec { vec![ WorkFlow { - key: 'R', + key: "R".into(), display: " ProofRead".into(), starting_role: RoleOfInterest::TextField, actions: vec![ @@ -365,7 +413,7 @@ fn default_workflows() -> Vec { ], }, WorkFlow { - key: 'C', + key: "C".into(), display: "⮺ Copy".into(), starting_role: RoleOfInterest::Image, actions: vec![ @@ -380,7 +428,7 @@ fn default_workflows() -> Vec { ], }, WorkFlow { - key: 'L', + key: "L".into(), display: " Copy Link".into(), starting_role: RoleOfInterest::Image, actions: vec![ @@ -395,13 +443,13 @@ fn default_workflows() -> Vec { ], }, WorkFlow { - key: '[', + key: "[".into(), display: "󰳽 Press [Left Click]".into(), starting_role: RoleOfInterest::Generic, actions: vec![WorkFlowAction::Press], }, WorkFlow { - key: ']', + key: "]".into(), display: " Menu [Right Click]".into(), starting_role: RoleOfInterest::Generic, actions: vec![ @@ -441,7 +489,7 @@ fn default_vis_level() -> VisibilityCheckingLevel { VisibilityCheckingLevel::Loose } -fn default_menu_wait_ms() -> u64 { +fn default_wait_ms() -> u64 { 100 } @@ -461,7 +509,7 @@ impl Default for GlyphlowConfig { ocr_languages: default_ocr_languages(), dictionaries: default_dictionaries(), visibility_checking_level: default_vis_level(), - menu_wait_ms: default_menu_wait_ms(), + electron_initial_wait_ms: default_wait_ms(), } } } @@ -726,4 +774,83 @@ mod tests { assert_eq!(config.scroll_distance, 0.05); assert_eq!(config.ocr_languages, vec!["en-US".to_string()]); } + + #[test] + fn test_safe_reload_compatibility() { + let mut old_config = GlyphlowConfig::default(); + let mut new_config = GlyphlowConfig::default(); + + assert!(new_config.safe_reload(&mut old_config)); + + new_config.scroll_distance = 99.9; + assert!( + new_config.safe_reload(&mut old_config), + "Minor settings should be compatible" + ); + + new_config.global_trigger_key = KeyBinding { + keys: vec![Key::ControlLeft, Key::KeyX], + }; + assert!( + !new_config.safe_reload(&mut old_config), + "Hotkey change should be incompatible" + ); + assert_eq!( + old_config.global_trigger_key, new_config.global_trigger_key, + "Should sync value" + ); + + new_config.editor = Some(CommandAction { + command: "code".into(), + args: vec![], + display: "VSCode".into(), + key: "E".into(), + }); + assert!( + !new_config.safe_reload(&mut old_config), + "Adding editor should be incompatible" + ); + + // 5. Test change in workflow keys + new_config.workflows[0].key = "X".into(); + assert!( + !new_config.safe_reload(&mut old_config), + "Workflow key change should be incompatible" + ); + } + + #[test] + fn test_safe_reload_workflow_len_mismatch() { + let mut old_config = GlyphlowConfig::default(); + let mut new_config = GlyphlowConfig::default(); + + new_config.workflows.pop(); + + let compatible = new_config.safe_reload(&mut old_config); + assert!(!compatible, "Changing workflow count must be incompatible"); + assert_eq!( + old_config.workflows.len(), + new_config.workflows.len(), + "Workflow list should sync" + ); + } + + #[test] + fn test_safe_reload_text_actions() { + let mut old_config = GlyphlowConfig::default(); + let mut new_config = GlyphlowConfig::default(); + + new_config.text_actions.push(CommandAction { + command: "echo".into(), + args: vec![], + display: "Echo".into(), + key: "E".into(), + }); + + assert!( + !new_config.safe_reload(&mut old_config), + "New text action should be incompatible" + ); + assert_eq!(old_config.text_actions.len(), 1); + } } diff --git a/src/key_listener.rs b/src/key_listener.rs index 232c65e..3e95048 100644 --- a/src/key_listener.rs +++ b/src/key_listener.rs @@ -29,6 +29,8 @@ pub enum ScrollAction { DownRight, IncreaseDistance, DecreaseDistance, + Top, + Bottom, } #[derive(Debug, PartialEq, Clone)] @@ -45,6 +47,7 @@ pub enum AppSignal { Activate(Target), DeActivate, Filter(char, FilterMode), + MenuRefresh(String, Mode), // Sub state signals FileUpdate(PathBuf), ClearNotification, @@ -62,12 +65,12 @@ pub enum AppSignal { #[derive(Debug, PartialEq)] pub struct MenuItem { pub description: &'static str, - pub key: char, + pub key: &'static str, pub action: AppSignal, } impl MenuItem { - pub const fn new(description: &'static str, key: char, action: AppSignal) -> MenuItem { + pub const fn new(description: &'static str, key: &'static str, action: AppSignal) -> MenuItem { MenuItem { description, key, @@ -76,6 +79,17 @@ impl MenuItem { } } +impl MenuItem { + pub fn pretty_print(&self, prefix_len: usize) -> String { + let prefix = "_".repeat(prefix_len); + format!( + "({prefix}{}) {}", + self.key.chars().skip(prefix_len).collect::(), + self.description + ) + } +} + impl Display for MenuItem { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "({}) {}", self.key, self.description) @@ -83,54 +97,64 @@ impl Display for MenuItem { } pub const DASH_BOARD_MENU_ITEMS: [MenuItem; 9] = [ - MenuItem::new("󰦨 Text", 'T', AppSignal::Activate(Target::Text)), - MenuItem::new("󰳽 Press", 'P', AppSignal::Activate(Target::Clickable)), - MenuItem::new("󱕒 ScrollBar", 'S', AppSignal::Activate(Target::Scrollable)), - MenuItem::new("󰊄 Input", 'I', AppSignal::Activate(Target::Editable)), - MenuItem::new(" Image", 'M', AppSignal::Activate(Target::Image)), - MenuItem::new("󰙅 Element", 'E', AppSignal::Activate(Target::ChildElement)), - MenuItem::new("󰆟 ScreenShot", 'R', AppSignal::ScreenShot), - MenuItem::new("󱄺 Image OCR", 'O', AppSignal::FrameOCR), - MenuItem::new(" Read Clipboard", 'C', AppSignal::ReadClipboard), + MenuItem::new("󰦨 Text", "T", AppSignal::Activate(Target::Text)), + MenuItem::new("󰳽 Press", "P", AppSignal::Activate(Target::Clickable)), + MenuItem::new("󱕒 ScrollBar", "S", AppSignal::Activate(Target::Scrollable)), + MenuItem::new("󰊄 Input", "I", AppSignal::Activate(Target::Editable)), + MenuItem::new(" Image", "M", AppSignal::Activate(Target::Image)), + MenuItem::new( + "󰙅 Element Explorer", + "E", + AppSignal::Activate(Target::ChildElement), + ), + MenuItem::new("󰆟 ScreenShot", "R", AppSignal::ScreenShot), + MenuItem::new("󱄺 Image OCR", "O", AppSignal::FrameOCR), + MenuItem::new(" Read Clipboard", "C", AppSignal::ReadClipboard), ]; -pub const SCROLLBAR_MENU_ITEMS: [MenuItem; 4] = [ +pub const SCROLLBAR_MENU_ITEMS: [MenuItem; 6] = [ MenuItem::new( "> Down/Right", - 'J', + "J", AppSignal::ScrollAction(ScrollAction::DownRight), ), MenuItem::new( "< Up/Left", - 'K', + "K", AppSignal::ScrollAction(ScrollAction::UpLeft), ), MenuItem::new( "+ Distance Increase", - 'I', + "I", AppSignal::ScrollAction(ScrollAction::IncreaseDistance), ), MenuItem::new( "- Distance Decrease", - 'D', + "D", AppSignal::ScrollAction(ScrollAction::DecreaseDistance), ), + MenuItem::new("󰢦 Top", "GG", AppSignal::ScrollAction(ScrollAction::Top)), + MenuItem::new( + "󰢢 Bottom", + "󰘶G", + AppSignal::ScrollAction(ScrollAction::Bottom), + ), ]; pub const TEXT_ACTION_MENU_ITEMS: [MenuItem; 3] = [ - MenuItem::new("⮺ Copy", 'C', AppSignal::TextAction(TextAction::Copy)), + MenuItem::new("⮺ Copy", "C", AppSignal::TextAction(TextAction::Copy)), MenuItem::new( "◫ Dictionary", - 'D', + "D", AppSignal::TextAction(TextAction::Dictionary), ), - MenuItem::new("󰃻 Split", 'S', AppSignal::TextAction(TextAction::Split)), + MenuItem::new("󰃻 Split", "S", AppSignal::TextAction(TextAction::Split)), ]; pub const IMAGE_ACTION_MENU_ITEMS: [MenuItem; 1] = - [MenuItem::new("󱄺 Image OCR", 'O', AppSignal::FrameOCR)]; + [MenuItem::new("󱄺 Image OCR", "O", AppSignal::FrameOCR)]; -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub enum Mode { DashBoard, Filtering, @@ -146,17 +170,19 @@ pub enum Mode { #[derive(Debug)] pub struct KeyListener { - pub text_actions: HashMap, - pub image_actions: HashMap, - pub dashboard_actions: HashMap, - pub scroll_actions: HashMap, + pub text_actions: HashMap, + pub image_actions: HashMap, + pub dashboard_actions: HashMap, + pub scroll_actions: HashMap, sender: Sender, global_key_binding: KeyBinding, } impl KeyListener { - fn iter_from(items: [MenuItem; N]) -> impl Iterator { - items.into_iter().map(|it| (it.key, it.action)) + fn iter_from( + items: [MenuItem; N], + ) -> impl Iterator { + items.into_iter().map(|it| (it.key.to_string(), it.action)) } pub fn new(sender: Sender, config: &GlyphlowConfig) -> KeyListener { @@ -166,10 +192,10 @@ impl KeyListener { .workflows .iter() .enumerate() - .map(|(idx, wf)| (wf.key, AppSignal::RunWorkFlow(idx))) + .map(|(idx, wf)| (wf.key.clone(), AppSignal::RunWorkFlow(idx))) .chain( config.text_actions.iter().enumerate().map(|(idx, ca)| { - (ca.key, AppSignal::TextAction(TextAction::UserDefined(idx))) + (ca.key.clone(), AppSignal::TextAction(TextAction::UserDefined(idx))) }), ) .chain(Self::iter_from(TEXT_ACTION_MENU_ITEMS)) @@ -179,7 +205,7 @@ impl KeyListener { .workflows .iter() .enumerate() - .map(|(idx, wf)| (wf.key, AppSignal::RunWorkFlow(idx))) + .map(|(idx, wf)| (wf.key.clone(), AppSignal::RunWorkFlow(idx))) .chain(Self::iter_from(DASH_BOARD_MENU_ITEMS)) .collect::>(); @@ -187,7 +213,7 @@ impl KeyListener { .workflows .iter() .enumerate() - .map(|(idx, wf)| (wf.key, AppSignal::RunWorkFlow(idx))) + .map(|(idx, wf)| (wf.key.clone(), AppSignal::RunWorkFlow(idx))) .chain(Self::iter_from(IMAGE_ACTION_MENU_ITEMS)) .collect::>(); @@ -195,10 +221,13 @@ impl KeyListener { if let Some(editor_command) = config.editor.as_ref() { text_actions.insert( - editor_command.key, + editor_command.key.clone(), AppSignal::TextAction(TextAction::Editor), ); - dashboard_actions.insert(editor_command.key, AppSignal::Activate(Target::Edit)); + dashboard_actions.insert( + editor_command.key.clone(), + AppSignal::Activate(Target::Edit), + ); } KeyListener { @@ -217,18 +246,31 @@ impl KeyListener { } } - fn helper( + fn menu_helper( &self, key: &Key, - key_signals: &HashMap, + key_signals: &HashMap, mut state: MutexGuard<'_, Mode>, + key_state: &mut KeyState, ) -> bool { let key_char = key.to_char(); - if let Some(signal) = key_signals.get(&key_char) { + if key_char == '-' { + key_state.pop(); + } else { + key_state.push(key_char); + } + if let Some(signal) = key_signals.get(&key_state.prefix) { self.send(signal.clone()); + key_state.clear_prefix(); } else if key_char == ' ' { - self.send(AppSignal::DeActivate); *state = Mode::Idle; + self.send(AppSignal::DeActivate); + key_state.clear_prefix(); + } else { + self.send(AppSignal::MenuRefresh( + key_state.prefix.clone(), + state.clone(), + )); } true } @@ -245,7 +287,7 @@ impl KeyListener { } /// Returns true if key is effective, and should be swallowed by this app - pub fn key_down(&self, key: Key, state: &Mutex, pressed_keys: &HashSet) -> bool { + pub fn key_down(&self, key: Key, state: &Mutex, key_state: &mut KeyState) -> bool { let Ok(mut state) = state.try_lock() else { return false; }; @@ -254,9 +296,9 @@ impl KeyListener { Mode::Editing | Mode::Idle => { if self.global_key_binding.keys.iter().all(|k| { k == &key - || pressed_keys.contains(k) + || key_state.pressed_keys.contains(k) || k.right_alternative() - .is_some_and(|r| *k == r || pressed_keys.contains(&r)) + .is_some_and(|r| *k == r || key_state.pressed_keys.contains(&r)) }) { self.send(AppSignal::DashBoard); *state = Mode::DashBoard; @@ -265,7 +307,7 @@ impl KeyListener { false } } - Mode::DashBoard => self.helper(&key, &self.dashboard_actions, state), + Mode::DashBoard => self.menu_helper(&key, &self.dashboard_actions, state, key_state), // To act on selected parent node Mode::Filtering if key == Key::Return => { self.send(AppSignal::DashBoard); @@ -281,9 +323,9 @@ impl KeyListener { Mode::WordPicking => self.filter_helper(&key, state, FilterMode::WordPicking), Mode::Filtering => self.filter_helper(&key, state, FilterMode::Generic), Mode::OCRResultFiltering => self.filter_helper(&key, state, FilterMode::OCR), - Mode::TextActionMenu => self.helper(&key, &self.text_actions, state), - Mode::ImageActionMenu => self.helper(&key, &self.image_actions, state), - Mode::Scrolling => self.helper(&key, &self.scroll_actions, state), + Mode::TextActionMenu => self.menu_helper(&key, &self.text_actions, state, key_state), + Mode::ImageActionMenu => self.menu_helper(&key, &self.image_actions, state, key_state), + Mode::Scrolling => self.menu_helper(&key, &self.scroll_actions, state, key_state), Mode::WaitAndDeactivate => { self.send(AppSignal::DeActivate); *state = Mode::Idle; @@ -292,3 +334,32 @@ impl KeyListener { } } } + +#[derive(Debug, Default)] +pub struct KeyState { + pub pressed_keys: HashSet, + pub prefix: String, + pub is_simulating: bool, +} + +impl KeyState { + pub fn key_down(&mut self, key: &Key) { + self.pressed_keys.insert(*key); + } + + pub fn key_up(&mut self, key: &Key) { + self.pressed_keys.remove(key); + } + + pub fn clear_prefix(&mut self) { + self.prefix.clear(); + } + + pub fn push(&mut self, key_char: char) { + self.prefix.push(key_char); + } + + pub fn pop(&mut self) { + self.prefix.pop(); + } +} diff --git a/src/main.rs b/src/main.rs index 407d1e1..c842020 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use core_foundation::{ runloop::{CFRunLoopRunInMode, kCFRunLoopDefaultMode}, }; use glyphlow::{ - AppExecutor, AppSignal, KeyListener, Mode, + AppExecutor, AppSignal, KeyListener, KeyState, Mode, config::{GlyphlowConfig, get_config_path}, drawer::{GlyphlowDrawingLayer, create_overlay_window, get_main_screen_size}, os_util::check_accessibility_permissions, @@ -52,8 +52,8 @@ async fn main() { let key_listener = KeyListener::new(tx, &config); let state = Arc::new(Mutex::new(Mode::Idle)); - let pressed_keys = Arc::new(Mutex::new(HashSet::new())); - let simulating_keys = Arc::new(Mutex::new(false)); + // Need this because rdev::grab takes a Fn not FnMut + let key_state = Arc::new(Mutex::new(KeyState::default())); let mtm = MainThreadMarker::new().expect("Not on main thread"); let screen_size = get_main_screen_size(mtm); @@ -102,7 +102,7 @@ async fn main() { let (ttx, mut trx) = mpsc::channel::<()>(100); let mut app_executor = AppExecutor::new( state.clone(), - simulating_keys.clone(), + key_state.clone(), config, window, screen_size, @@ -111,23 +111,22 @@ async fn main() { ); thread::spawn(move || { - let pressed_keys = pressed_keys.clone(); - let simulating = simulating_keys.clone(); + let key_state = key_state.clone(); let state = state.clone(); let _ = grab(move |event| { - let Ok(mut keys) = pressed_keys.lock() else { + let Ok(mut k_s) = key_state.lock() else { return Some(event); }; - if !simulating.lock().is_ok_and(|s| !*s) { + if k_s.is_simulating { return Some(event); } let swallow = match event.event_type { EventType::KeyPress(key) => { - keys.insert(key); - key_listener.key_down(key, &state, &keys) + k_s.key_down(&key); + key_listener.key_down(key, &state, &mut k_s) } EventType::KeyRelease(key) => { - keys.remove(&key); + k_s.key_up(&key); false } _ => false, @@ -144,7 +143,7 @@ async fn main() { _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => { // NOTE: necessary for up-to-date get_focused_pid and UI drawing unsafe { - CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, Boolean::from(false)); + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.01, Boolean::from(false)); } } }