diff --git a/README.md b/README.md index a1977b6..7126fc6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -## Towards a Mouse Free UX for Most APPs on macOS (WIP) +## Towards a Mouse Free UX for Most APPs on macOS This tiny tool aims to ease the pain of diff --git a/src/action/word_picker.rs b/src/action/word_picker.rs index 86d213d..f1eb606 100644 --- a/src/action/word_picker.rs +++ b/src/action/word_picker.rs @@ -1,12 +1,14 @@ use crate::{ action::html_to_attributed_string, config::{GlyphlowTheme, cgcolor_to_rgba}, + drawer::GlyphlowDrawingLayer, util::{estimate_frame_for_text, hint_label_from_index}, }; use objc2::rc::Retained; use objc2_app_kit::{NSFont, NSFontAttributeName}; use objc2_core_foundation::CGSize; use objc2_foundation::{NSMutableAttributedString, NSRange}; +use objc2_quartz_core::{CALayer, CATextLayer}; use regex::Regex; use std::sync::OnceLock; use unicode_width::UnicodeWidthStr; @@ -34,11 +36,22 @@ pub struct WordPicker { raw: String, words: Vec, offsets: Vec, + pub label_prefix: String, + pub text_prefix: String, + screen_ratio: f64, + theme: GlyphlowTheme, + pub is_searching: bool, pub digits: u32, + pub text_layer: Option>, } impl WordPicker { - pub fn new(text: String) -> Self { + pub fn new( + text: String, + window: &Retained, + theme: &GlyphlowTheme, + screen_size: CGSize, + ) -> Self { let (word_strings, offsets) = multilingual_split(&text); let digits = word_strings.len().ilog(26) + 1; let mut words = Vec::new(); @@ -46,21 +59,62 @@ impl WordPicker { let label = hint_label_from_index(i, digits); words.push(Word { text, label }); } - Self { + + let CGSize { width, height } = screen_size; + let mut word_picker = Self { raw: text, words, offsets, digits, + is_searching: false, + label_prefix: String::new(), + text_prefix: String::new(), + screen_ratio: width / (height + 0.01), + theme: theme.clone(), + text_layer: None, + }; + + if let Some(attr_string) = word_picker.get_attributed_string(None) { + let text_size = word_picker.estimate_text_size(&attr_string, theme, screen_size); + word_picker.text_layer = + Some(window.draw_attributed_string(attr_string, screen_size, text_size, theme)); + } + + word_picker + } + + pub fn start_searching(&mut self, multi_selection_idx: Option) { + self.is_searching = true; + self.text_prefix.clear(); + if let Some(text_layer) = self.text_layer.as_ref() + && let Some(attr_string) = self.get_attributed_string(multi_selection_idx) + { + unsafe { text_layer.setString(Some(&attr_string)) }; + }; + } + + pub fn finish_searching(&mut self, multi_selection_idx: Option) { + self.is_searching = false; + if let Some(text_layer) = self.text_layer.as_ref() + && let Some(attr_string) = self.get_attributed_string(multi_selection_idx) + { + unsafe { text_layer.setString(Some(&attr_string)) }; + }; + } + + pub fn update_prefix(&mut self, prefix: &str) { + if self.is_searching { + self.text_prefix = prefix.to_lowercase(); + } else { + self.label_prefix = prefix.to_string(); } } /// Returns HTML string - fn to_string( - &self, - prefix: &str, - width_height_ratio: f64, - multi_selection_idx: Option, - ) -> String { + fn to_string(&self, width_height_ratio: f64, multi_selection_idx: Option) -> String { + let label_prefix = self.label_prefix.as_str(); + let text_prefix = self.text_prefix.as_str(); + let total_unicode_width = self.words.iter().map(|w| w.text.width()).sum::() + self.words.len() * (2 + self.digits as usize); // ideal_width / (total_unicode_width / ideal_width) 󰾞 ratio * 3 @@ -69,7 +123,16 @@ impl WordPicker { .round() as usize; let line_span_head = ""; - let mut buffer = String::new(); + let mut buffer = format!( + "{}", + if self.is_searching { + format!("/{}", self.text_prefix) + } else { + "Press / to search".into() + } + ); + + buffer.push('\n'); let mut line_width = 0; buffer.push_str(line_span_head); @@ -80,17 +143,20 @@ impl WordPicker { { // For already selected start/end, dim the label ("rh", helper(&w.label, "d")) - } else if !prefix.is_empty() && w.label.starts_with(prefix) { + } else if (!label_prefix.is_empty() || !text_prefix.is_empty()) + && w.label.starts_with(label_prefix) + && w.text.to_lowercase().contains(text_prefix) + { // For matched, highlight the label suffix ( "m", format!( "{}{}", - prefix, - w.label.get(prefix.len()..).unwrap_or_default() + label_prefix, + w.label.get(label_prefix.len()..).unwrap_or_default() ), ) - } else if prefix.is_empty() { + } else if label_prefix.is_empty() && text_prefix.is_empty() { // No prefix, highlight the all labels ("n", helper(&w.label, "h")) } else { @@ -120,11 +186,15 @@ impl WordPicker { buffer } - pub fn matched_words(&self, prefix: &str) -> Vec<(usize, String)> { + pub fn matched_words(&self) -> Vec<(usize, String)> { self.words .iter() .enumerate() - .filter(|(_, w)| !prefix.is_empty() && w.label.starts_with(prefix)) + .filter(|(_, w)| { + (!self.label_prefix.is_empty() || !self.text_prefix.is_empty()) + && w.label.starts_with(&self.label_prefix) + && w.text.to_lowercase().contains(&self.text_prefix) + }) .map(|(idx, w)| (idx, w.text.clone())) .collect() } @@ -143,29 +213,35 @@ impl WordPicker { pub fn get_attributed_string( &self, - screen_size: CGSize, - theme: &GlyphlowTheme, - prefix: &str, multi_selection_idx: Option, - ) -> Option<(CGSize, Retained)> { - let CGSize { width, height } = screen_size; - let html_str = self.to_string(prefix, width / (height + 0.01), multi_selection_idx); + ) -> Option> { + let html_str = self.to_string(self.screen_ratio, multi_selection_idx); // CSS colors let attr_string = html_to_attributed_string( &html_str, - Some(&replace_color_in_css(WORD_PICKER_STYLE, theme, 3)), + Some(&replace_color_in_css(WORD_PICKER_STYLE, &self.theme, 3)), )?; unsafe { attr_string.addAttribute_value_range( NSFontAttributeName, - &theme.menu_font, + &self.theme.menu_font, NSRange::new(0, attr_string.length()), ); } - let (size, _) = estimate_frame_for_text(&attr_string, (width * 3.0, height * 3.0)); + Some(attr_string) + } + + fn estimate_text_size( + &mut self, + attr_string: &Retained, + theme: &GlyphlowTheme, + screen_size: CGSize, + ) -> CGSize { + let CGSize { width, height } = screen_size; + let (size, _) = estimate_frame_for_text(attr_string, (width * 3.0, height * 3.0)); // In case the default font size is too large let shrinkage = (width / size.width).min(height / size.height); @@ -175,6 +251,7 @@ impl WordPicker { if let Some(new_font) = NSFont::fontWithName_size(&theme.menu_font.fontName(), font_size) { + self.theme.menu_font = new_font.clone(); unsafe { attr_string.addAttribute_value_range( NSFontAttributeName, @@ -183,12 +260,11 @@ impl WordPicker { ); } - let (size, _) = estimate_frame_for_text(&attr_string, (width, height)); - return Some((size, attr_string)); + let (size, _) = estimate_frame_for_text(attr_string, (width, height)); + return size; }; } - - Some((size, attr_string)) + size } } diff --git a/src/app_executor.rs b/src/app_executor.rs index 6d7e486..61e2818 100644 --- a/src/app_executor.rs +++ b/src/app_executor.rs @@ -24,7 +24,7 @@ use objc2_core_foundation::CGSize; use objc2_quartz_core::CALayer; use rdev::{Button, EventType, simulate}; use std::{ - collections::VecDeque, + collections::{HashSet, VecDeque}, path::PathBuf, sync::{Arc, Mutex}, time::Duration, @@ -431,29 +431,66 @@ impl AppExecutor { tokio::spawn(async move { delay(sender, timeout_secs).await }); } - fn draw_word_picker(&self) -> (Vec<(usize, String)>, u32) { + fn draw_word_picker(&mut self) { let word_picker = self .word_picker - .as_ref() + .as_mut() .expect("Internal Error: No word picker set."); - if let Some((text_size, attr_string)) = word_picker.get_attributed_string( - self.screen_size, - &self.config.theme, - &self.key_prefix, - self.multi_selection.one_side_idex, - ) { - self.window.draw_attributed_string( - attr_string, - self.screen_size, - text_size, - &self.config.theme, - ); + + if let Some(text_layer) = word_picker.text_layer.as_ref() + && let Some(attr_string) = + word_picker.get_attributed_string(self.multi_selection.one_side_idex) + { + unsafe { text_layer.setString(Some(&attr_string)) }; }; + } - ( - word_picker.matched_words(&self.key_prefix), - word_picker.digits, - ) + /// If only 1 word is matched, then update the selected text and show the menu + fn check_word_picker(&mut self) { + let Some(wp) = self.word_picker.as_mut() else { + return; + }; + let matched_words = wp.matched_words(); + let is_searching = wp.is_searching; + + // Duplicated words when multi_selection is off + let unique_matching = matched_words.len() == 1 + || (!self.multi_selection.is_on + && matched_words + .iter() + .map(|(_, w)| w) + .collect::>() + .len() + == 1); + + if !is_searching + && (self.key_prefix.len() == self.hint_width as usize + || (!wp.text_prefix.is_empty() && wp.label_prefix.is_empty())) + && unique_matching + && let Some((idx, text)) = matched_words.first() + { + if self.multi_selection.is_on { + if let Some((idx1, idx2)) = self.multi_selection.set_one_side(*idx) { + let text = self + .word_picker + .as_ref() + .expect("Internal Error: no word picker set yet.") + .select_range(idx1, idx2) + .expect("Internal Error: wrong word picker indexing."); + self.update_selected_text_and_show_menu(text.clone()) + } else { + self.key_prefix.clear(); + // Reset for another side + if let Some(wp) = self.word_picker.as_mut() { + wp.text_prefix.clear(); + wp.label_prefix.clear() + }; + self.draw_word_picker(); + } + } else { + self.update_selected_text_and_show_menu(text.clone()) + } + } } fn select_app_window(&mut self, vis_level: VisibilityCheckingLevel) -> Option { @@ -1118,12 +1155,18 @@ impl AppExecutor { } } FilterMode::WordPicking => { - if !self.multi_selection.is_on || self.multi_selection.one_side_idex.is_none() { + if let Some(wp) = self.word_picker.as_mut() + && wp.is_searching + { + wp.finish_searching(self.multi_selection.one_side_idex); + self.key_prefix = wp.label_prefix.clone(); + } else if !self.multi_selection.is_on + || self.multi_selection.one_side_idex.is_none() + { // Go back to text action menu self.word_picker = None; self.draw_element_menu("", &RoleOfInterest::PseudoText, true); } else { - self.clear_drawing(); self.multi_selection.clear_one_side(); self.draw_word_picker(); } @@ -1148,7 +1191,9 @@ impl AppExecutor { } else { self.key_prefix.pop(); } - } else if self.key_prefix.len() < self.hint_width as usize { + } else if self.key_prefix.len() < self.hint_width as usize + || self.word_picker.as_ref().is_some_and(|wp| wp.is_searching) + { self.key_prefix.push(key_char); } @@ -1160,31 +1205,11 @@ impl AppExecutor { self.filter_by_key().await; } FilterMode::WordPicking => { - self.clear_drawing(); - let (matched_words, digits) = self.draw_word_picker(); - self.hint_width = digits; - - if self.key_prefix.len() == digits as usize - && matched_words.len() == 1 - && let Some((idx, text)) = matched_words.first() - { - if self.multi_selection.is_on { - if let Some((idx1, idx2)) = self.multi_selection.set_one_side(*idx) { - let text = self - .word_picker - .as_ref() - .expect("Internal Error: no word picker set yet.") - .select_range(idx1, idx2) - .expect("Internal Error: wrong word picker indexing."); - self.update_selected_text_and_show_menu(text.clone()) - } else { - self.key_prefix.clear(); - self.draw_word_picker(); - } - } else { - self.update_selected_text_and_show_menu(text.clone()) - } - } + if let Some(wp) = self.word_picker.as_mut() { + wp.update_prefix(&self.key_prefix); + }; + self.draw_word_picker(); + self.check_word_picker(); } } } @@ -1232,12 +1257,14 @@ impl AppExecutor { true } TextAction::Split => { - let word_picker = WordPicker::new(text); + self.set_mode(Mode::WordPicking); + self.clear_drawing(); + let word_picker = + WordPicker::new(text, &self.window, &self.config.theme, self.screen_size); + self.hint_width = word_picker.digits; self.clear_cache(); self.word_picker = Some(word_picker); - self.set_mode(Mode::WordPicking); - self.draw_word_picker(); true } TextAction::Editor => { @@ -1445,6 +1472,19 @@ impl AppExecutor { self.perform_scroll_action(sa); } AppSignal::TextAction(ta) => self.perform_text_action(ta), + AppSignal::WordPickerStartSearch => { + if let Some(wp) = self.word_picker.as_mut() { + wp.start_searching(self.multi_selection.one_side_idex); + self.key_prefix.clear(); + } + } + AppSignal::WordPickerFinishSearch => { + if let Some(wp) = self.word_picker.as_mut() { + wp.finish_searching(self.multi_selection.one_side_idex); + self.key_prefix = wp.label_prefix.clone(); + } + self.check_word_picker(); + } AppSignal::ScreenShot => { self.clear_drawing(); let frame = if let Some(eoi) = self.selected.as_ref() { diff --git a/src/config.rs b/src/config.rs index a03a156..09da64e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -72,7 +72,7 @@ pub struct CommandAction { pub key: String, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GlyphlowTheme { #[serde(with = "nsfont_format", default = "default_hint_font")] pub hint_font: Retained, diff --git a/src/drawer.rs b/src/drawer.rs index 86fce9a..4e5efd5 100644 --- a/src/drawer.rs +++ b/src/drawer.rs @@ -76,7 +76,7 @@ pub trait GlyphlowDrawingLayer { screen_size: CGSize, text_size: CGSize, theme: &GlyphlowTheme, - ); + ) -> Retained; } impl GlyphlowDrawingLayer for CALayer { @@ -151,7 +151,7 @@ impl GlyphlowDrawingLayer for CALayer { // Background Box let (size, _) = estimate_frame_for_text(&attr_string, (screen_size.width, screen_size.height)); - let (x_offset, y_offset, box_layer) = text_box_with_attributed_string( + let (x_offset, y_offset, _, box_layer) = text_box_with_attributed_string( attr_string, true, bg_color, @@ -199,8 +199,8 @@ impl GlyphlowDrawingLayer for CALayer { screen_size: CGSize, text_size: CGSize, theme: &GlyphlowTheme, - ) { - let (_, _, text_box) = text_box_with_attributed_string( + ) -> Retained { + let (_, _, text_layer, text_box) = text_box_with_attributed_string( attr_string, false, &theme.menu_bg_color, @@ -212,6 +212,7 @@ impl GlyphlowDrawingLayer for CALayer { text_box.setBorderWidth(2.0); text_box.setBorderColor(Some(&theme.menu_fg_color)); self.addSublayer(&text_box); + text_layer } fn draw_menu( @@ -310,7 +311,7 @@ fn draw_text_box( screen_size, size, ) - .2 + .3 } } @@ -322,7 +323,7 @@ fn text_box_with_attributed_string( center: Center, screen_size: CGSize, frame_size: CGSize, -) -> (f64, f64, Retained) { +) -> (f64, f64, Retained, Retained) { let CGSize { width, height } = frame_size; let box_width = width + (margin * 2.0); @@ -358,5 +359,5 @@ fn text_box_with_attributed_string( text_layer.setContentsScale(2.0); // Retina crispness container.addSublayer(&text_layer); container.setCornerRadius(margin); - (o_x - o_x_move, o_y - o_y_move, container) + (o_x - o_x_move, o_y - o_y_move, text_layer, container) } diff --git a/src/key_listener.rs b/src/key_listener.rs index 2f1e242..bd09b9c 100644 --- a/src/key_listener.rs +++ b/src/key_listener.rs @@ -59,6 +59,9 @@ pub enum AppSignal { ReadClipboard, ScreenShot, FrameOCR, + // Word Picker + WordPickerStartSearch, + WordPickerFinishSearch, } #[derive(Debug, PartialEq)] @@ -362,7 +365,22 @@ impl KeyListener { self.send(AppSignal::ToggleMultiSelection); true } - Mode::WordPicking => self.filter_helper(&key, state, FilterMode::WordPicking), + Mode::WordPicking => { + match key { + Key::Slash => self.send(AppSignal::WordPickerStartSearch), + Key::Return => self.send(AppSignal::WordPickerFinishSearch), + Key::Escape => { + self.send(AppSignal::DeActivate); + *state = Mode::Idle; + } + _ => { + let key_char = key.to_char(); + let key_char = if key_char == ' ' { '󱁐' } else { key_char }; + self.send(AppSignal::Filter(key_char, FilterMode::WordPicking)); + } + } + true + } Mode::Filtering => self.filter_helper(&key, state, FilterMode::Generic), Mode::OCRResultFiltering => self.filter_helper(&key, state, FilterMode::OCR), Mode::TextActionMenu => self.menu_helper(&key, MenuType::TextAction, state, key_state), diff --git a/src/os_util.rs b/src/os_util.rs index 1ffd6f2..0b44c11 100644 --- a/src/os_util.rs +++ b/src/os_util.rs @@ -48,7 +48,7 @@ pub fn get_system_alarm_window() -> Option { pub fn get_focused() -> Option<(i32, AXUIElement, bool)> { let workspace = NSWorkspace::sharedWorkspace(); let app = workspace.frontmostApplication()?; - // eprintln!("Focused app: {:?}", app.bundleIdentifier()); + // println!("Focused app: {:?}", app.bundleIdentifier()); let pid = app.processIdentifier(); Some((