Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
132 changes: 104 additions & 28 deletions src/action/word_picker.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -34,33 +36,85 @@ pub struct WordPicker {
raw: String,
words: Vec<Word>,
offsets: Vec<usize>,
pub label_prefix: String,
pub text_prefix: String,
screen_ratio: f64,
theme: GlyphlowTheme,
pub is_searching: bool,
pub digits: u32,
pub text_layer: Option<Retained<CATextLayer>>,
}

impl WordPicker {
pub fn new(text: String) -> Self {
pub fn new(
text: String,
window: &Retained<CALayer>,
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();
for (i, text) in word_strings.into_iter().enumerate() {
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<usize>) {
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<usize>) {
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<usize>,
) -> String {
fn to_string(&self, width_height_ratio: f64, multi_selection_idx: Option<usize>) -> 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::<usize>()
+ self.words.len() * (2 + self.digits as usize);
// ideal_width / (total_unicode_width / ideal_width) 󰾞 ratio * 3
Expand All @@ -69,7 +123,16 @@ impl WordPicker {
.round() as usize;

let line_span_head = "<span class=\"line\">";
let mut buffer = String::new();
let mut buffer = format!(
"<span class=\"h\">{}</span>",
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);

Expand All @@ -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!(
"<span class=\"d\">{}</span><span class=\"h\">{}</span>",
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 {
Expand Down Expand Up @@ -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()
}
Expand All @@ -143,29 +213,35 @@ impl WordPicker {

pub fn get_attributed_string(
&self,
screen_size: CGSize,
theme: &GlyphlowTheme,
prefix: &str,
multi_selection_idx: Option<usize>,
) -> Option<(CGSize, Retained<NSMutableAttributedString>)> {
let CGSize { width, height } = screen_size;
let html_str = self.to_string(prefix, width / (height + 0.01), multi_selection_idx);
) -> Option<Retained<NSMutableAttributedString>> {
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<NSMutableAttributedString>,
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);
Expand All @@ -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,
Expand All @@ -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
}
}

Expand Down
Loading
Loading