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
4 changes: 2 additions & 2 deletions src/app_engine/interaction.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use super::AppEngine;
use crate::{
Mode, ScrollAction, TextAction,
action::{WordPicker, get_dictionary_attributed_string},
action::{WordPicker, get_dictionary_attributed_string, text_to_clipboard},
ax_element::{ElementOfInterest, GetAttribute, SetAttribute},
config::RoleOfInterest,
};
Expand Down Expand Up @@ -143,7 +143,7 @@ impl AppEngine {
// 1. URL handling
let keep_drawing = match ta {
TextAction::Copy => {
crate::action::text_to_clipboard(&text);
text_to_clipboard(&text);
self.notify_then_deactivate("Copied to clipboard.", Level::Info);
true
}
Expand Down
3 changes: 2 additions & 1 deletion src/app_engine/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ impl AppEngine {
self.element_cache.clear();
self.hint_prefix.clear();
self.search_prefix.clear();
self.search_targets.clear();
self.search_debounce_counter = 0;
self.is_searching = false;
self.multi_selection.reset();
self.search_debounce_counter = self.search_debounce_counter.wrapping_add(1);
}

pub(super) fn notify_then_deactivate(&mut self, msg: &str, log_level: Level) {
Expand Down
5 changes: 3 additions & 2 deletions src/app_engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@ pub struct AppEngine {
/// Used for drawing hint boxes on screen
pub(super) hint_boxes: Vec<HintBox>,
pub(super) hint_prefix: String,
/// Search related
pub(super) is_searching: bool,
pub(super) search_prefix: String,
pub(super) search_targets: Vec<String>,
pub(super) search_debounce_counter: usize,
pub(super) overlay_frame: Frame,
pub(super) drawer: UIDrawer,
/// Which elements of interest to look for
Expand All @@ -83,7 +85,6 @@ pub struct AppEngine {
/// For editing element text values
pub(super) temp_file: PathBuf,
pub(super) signal_sender: Sender<AppSignal>,
pub(super) search_debounce_counter: usize,
/// Special treatment for Electron based apps.
/// Like simulate mouse clicking instead of `element.press()`
pub(super) last_app_window_info: AppWindowInfo,
Expand Down Expand Up @@ -198,7 +199,7 @@ impl AppEngine {
}
AppSignal::FinishSearch(mode) => {
self.is_searching = false;
self.search_debounce_counter = self.search_debounce_counter.wrapping_add(1);
self.search_debounce_counter = 0;
self.set_mode(mode.to_app_mode());
if self.word_picker.is_some() {
self.drawer.hide_search_bar();
Expand Down
2 changes: 1 addition & 1 deletion src/ax_element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,7 @@ fn traverse_elements(
return;
};

// WARN: Performance critical! Exclude electron elements scrolled off y axis,
// PERF: Performance critical! Exclude electron elements scrolled off y axis,
if ele_fp.frame.is_some_and(|f| {
let (w, h) = f.size();
(h == 0.0 && f.bottom_right.y == window_frame.bottom_right.y)
Expand Down
4 changes: 4 additions & 0 deletions src/user_interface/drawer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,11 @@ impl UIDrawer {
let msg = format!("/{}", format_fixed_width(prefix, SEARCH_BAR_WIDTH));
autoreleasepool(|_| {
if init {
// Disable movement animations
CATransaction::begin();
CATransaction::setDisableActions(true);
self.reposition_search_bar();
CATransaction::commit();
self.search_bar.show();
}

Expand Down
10 changes: 4 additions & 6 deletions src/user_interface/hint_box.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
use std::collections::{HashMap, VecDeque};

use crate::config::GlyphlowTheme;
use crate::user_interface::calibrated_origin;
use crate::util::{Frame, digits_by_length, estimate_frame_for_text};
use objc2::{AnyThread, rc::Retained};
use objc2_core_foundation::{CFRetained, CGSize};
use objc2_core_graphics::{CGColor, CGMutablePath};
use objc2_foundation::{NSMutableAttributedString, NSPoint, NSRange, NSRect, NSSize, NSString};
use objc2_quartz_core::{CALayer, CAShapeLayer, CATextLayer, kCAAlignmentCenter};

use crate::config::GlyphlowTheme;
use crate::user_interface::calibrated_origin;
use crate::util::{Frame, digits_by_length, estimate_frame_for_text};
use std::collections::{HashMap, VecDeque};

pub fn hint_label_from_index(i: usize, digits: Option<u32>) -> String {
if i == 0 && digits.is_none() {
Expand Down
163 changes: 68 additions & 95 deletions src/util.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use std::cmp::Ordering;

use core_foundation::attributed_string::CFAttributedStringRef;
use core_foundation::{attributed_string::CFAttributedStringRef, base::CFRange};
use core_graphics_types::geometry::CGSize;
use core_text::framesetter::CTFramesetter;
use objc2::rc::Retained;
use objc2_core_foundation::{CGPoint, CGRect, CGSize as OCGSize};
use objc2_foundation::{NSMutableAttributedString, NSSize};
use regex::Regex;
use std::{borrow::Cow, cmp::Ordering};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};

#[derive(Debug, Clone, PartialEq, Copy, Default)]
Expand All @@ -20,15 +20,11 @@ pub fn estimate_frame_for_text(
) -> (OCGSize, isize) {
let cf_attr_string = Retained::as_ptr(attr_string) as CFAttributedStringRef;
let framesetter = CTFramesetter::new_with_attributed_string(cf_attr_string);
let (core_graphics_types::geometry::CGSize { width, height }, range) = framesetter
.suggest_frame_size_with_constraints(
core_foundation::base::CFRange {
location: 0,
length: 0,
},
std::ptr::null(),
core_graphics_types::geometry::CGSize::new(size.0, size.1),
);
let (CGSize { width, height }, range) = framesetter.suggest_frame_size_with_constraints(
CFRange::init(0, 0),
std::ptr::null(),
CGSize::new(size.0, size.1),
);
(OCGSize::new(width, height), range.length)
}

Expand Down Expand Up @@ -290,49 +286,30 @@ pub fn lower_ascii(text: &str) -> String {
any_ascii::any_ascii(text).to_ascii_lowercase()
}

pub fn format_fixed_width(input: &str, fixed_length: usize) -> String {
pub fn format_fixed_width(input: &str, fixed_length: usize) -> Cow<'_, str> {
if input.width() <= fixed_length {
return Cow::Borrowed(input);
}

if fixed_length == 0 {
return "".into();
}
let target_suffix_w = fixed_length - 1;

let mut current_width = 0;
let mut trunc_byte_idx = input.len();
let mut trunc_width = 0;

// Scan backwards: Only inspects up to `fixed_length + 1` worth of characters
for (idx, ch) in input.char_indices().rev() {
let ch_width = ch.width().unwrap_or(1);
for ch in input.chars().rev() {
let ch_width = ch.width().unwrap_or_default();
current_width += ch_width;

// Track the best byte cut-off point for the suffix
if current_width <= target_suffix_w {
trunc_byte_idx = idx;
trunc_width = current_width;
}

// Early exit: The moment we know the string is too long, we build the truncation
if current_width > fixed_length {
let suffix = &input[trunc_byte_idx..];
let padding_spaces = target_suffix_w - trunc_width;

let mut s = String::with_capacity(1 + padding_spaces + suffix.len());
s.push('.');
for _ in 0..padding_spaces {
s.push(' ');
}
s.push_str(suffix);
return s;
if current_width > fixed_length - 1 {
break;
}
trunc_byte_idx -= ch.len_utf8();
}

// Case: The entire string fits within the layout -> Pad it out
let padding_spaces = fixed_length - current_width;
let mut s = String::with_capacity(input.len() + padding_spaces);
s.push_str(input);
for _ in 0..padding_spaces {
s.push(' ');
}
s
let mut s = ".".to_string();
s.push_str(&input[trunc_byte_idx..]);
Cow::Owned(s)
}

#[cfg(test)]
Expand Down Expand Up @@ -683,78 +660,74 @@ mod select_range_tests {
}

#[cfg(test)]
mod misc_tests {
mod format_str_tests {
use super::format_fixed_width;

#[test]
fn test_exact_and_padding_ascii() {
// Exact match: No padding, no truncation
assert_eq!(format_fixed_width("hello", 5), "hello");
fn test_empty_and_zero_edges() {
// Empty input should always result in an empty string
assert_eq!(format_fixed_width("", 5).to_string(), "");
assert_eq!(format_fixed_width("", 0).to_string(), "");

// Needs padding: Short string, padded to the right with spaces
assert_eq!(format_fixed_width("abc", 6), "abc ");
// Fixed length of 0 on non-empty input should return an empty string
assert_eq!(format_fixed_width("hello", 0).to_string(), "");
}

#[test]
fn test_truncation_ascii() {
// Standard truncation from the left with a single dot
// "abcdefghijk" length is 11. Target is 10.
// 10 - 1 (for ".") = 9 slots for the suffix ("cdefghijk")
assert_eq!(format_fixed_width("abcdefghijk", 10), ".cdefghijk");

// Tight truncation
assert_eq!(format_fixed_width("abcdef", 4), ".def");
fn test_ascii_no_truncation() {
assert_eq!(format_fixed_width("hello", 5).to_string(), "hello");
assert_eq!(format_fixed_width("abc", 5).to_string(), "abc");
}

#[test]
fn test_empty_and_minimal_lengths() {
// Empty string should just turn into pure padding
assert_eq!(format_fixed_width("", 5), " ");
assert_eq!(format_fixed_width("", 0), "");
fn test_ascii_truncation() {
// Budget = 3. Suffix width = 3 - 1 = 2 ("lo")
assert_eq!(format_fixed_width("hello", 3).to_string(), ".lo");

// Extreme edge case: Requested length is exactly 1 or 2
assert_eq!(format_fixed_width("longstring", 2), ".g");
assert_eq!(format_fixed_width("longstring", 1), ".");
// Budget = 1. Suffix width = 1 - 1 = 0 ("")
assert_eq!(format_fixed_width("hello", 1).to_string(), ".");
}

#[test]
fn test_unicode_padding() {
// "こんにちは" (Konnichiwa) has 5 characters, but a visual width of 10.
// Target 12 means it needs exactly 2 trailing spaces.
assert_eq!(format_fixed_width("こんにちは", 12), "こんにちは ");
fn test_cjk_characters() {
// "こんにちは" (Konnichiwa) has a total visual width of 10
let input = "こんにちは";

assert_eq!(format_fixed_width(input, 10).to_string(), "こんにちは");

// Exact fit truncation: Dot (1) + "に" (2) + "は" (2) = 5
assert_eq!(format_fixed_width(input, 5).to_string(), ".ちは");

// Truncation with 1 unit of slack space (next character doesn't fit)
assert_eq!(format_fixed_width(input, 6).to_string(), ".ちは");
}

#[test]
fn test_unicode_clean_truncation() {
// Target 7 -> Suffix target is 6 (7 - 1).
// "にちは" is 3 characters of width 2 each = 6 total width. Fits perfectly.
assert_eq!(format_fixed_width("こんにちは", 7), ".にちは");
fn test_emojis() {
// "🦀" has a visual width of 2. "🦀🦀🦀" total width = 6.
let input = "🦀🦀🦀";

assert_eq!(format_fixed_width(input, 6).to_string(), "🦀🦀🦀");

// Truncation: Dot (1) + "🦀" (2) + "🦀" (2) = 5
assert_eq!(format_fixed_width(input, 5).to_string(), ".🦀🦀");
}

#[test]
fn test_unicode_mid_character_truncation_alignment() {
// CRITICAL EDGE CASE: Truncation hits right in the middle of a wide character.
// Target 8 -> Suffix target is 7 (8 - 1).
// "にちは" has a width of 6. The next char "ん" has a width of 2 (Total 8).
// 8 exceeds our suffix target of 7, so "ん" must be dropped.
// This leaves 1 empty visual slot (7 - 6 = 1), which must be filled with a space.
assert_eq!(format_fixed_width("こんにちは", 8), ". にちは");

// Verify total visual width of the output is exactly 8
// "." (1) + " " (1) + "に" (2) + "ち" (2) + "は" (2) = 8
let result = format_fixed_width("こんにちは", 8);
let actual_width: usize = result
.chars()
.map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(1))
.sum();
assert_eq!(actual_width, 8);
fn test_mixed_width_strings() {
// "Rust🦀" -> "Rust" (4) + "🦀" (2) = Total width 6
let input = "Rust🦀";

assert_eq!(format_fixed_width(input, 3).to_string(), ".🦀");
assert_eq!(format_fixed_width(input, 5).to_string(), ".st🦀");
}

#[test]
fn test_fallback_width_handling() {
// Control characters like '\n' return None from .width()
// Your updated logic defaults them to 1 via .unwrap_or(1)
// "\n\n" is treated as width 2. Target 5 -> pads with 3 spaces.
assert_eq!(format_fixed_width("\n\n", 5), "\n\n ");
fn test_combining_diacritic_quirk() {
// "xyz" + combining acute accent (\u{301})
let input = "xyz\u{301}";

// Accent detaches and glues itself to the ellipsis dot
assert_eq!(format_fixed_width(input, 1).to_string(), ".\u{301}");
}
}