open_sesame/core/hint.rs
1//! Hint assignment and sequences
2//!
3//! Assigns letter hints to windows based on app configuration.
4//! Uses repeated letters for multiple windows: g, gg, ggg
5
6use crate::core::window::{AppId, Window, WindowId};
7use std::collections::HashMap;
8use std::fmt;
9
10/// A hint sequence (e.g., "g", "gg", "ggg")
11///
12/// Optimized for the common case of short sequences. Uses a base character
13/// and repetition count to represent Vimium-style hints efficiently.
14///
15/// # Examples
16///
17/// ```
18/// use open_sesame::core::hint::HintSequence;
19///
20/// // Create a hint sequence
21/// let hint = HintSequence::new('g', 2);
22/// assert_eq!(hint.base(), 'g');
23/// assert_eq!(hint.count(), 2);
24/// assert_eq!(hint.as_string(), "gg");
25///
26/// // Parse from string
27/// let parsed = HintSequence::from_repeated("ggg").unwrap();
28/// assert_eq!(parsed.count(), 3);
29///
30/// // Match user input
31/// assert!(hint.matches_input("g"));
32/// assert!(hint.matches_input("gg"));
33/// assert!(!hint.matches_input("ggg"));
34/// ```
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct HintSequence {
37 /// The base character
38 base: char,
39 /// Number of repetitions (1 = "g", 2 = "gg", etc.)
40 count: usize,
41}
42
43impl HintSequence {
44 /// Create a new hint sequence
45 pub fn new(base: char, count: usize) -> Self {
46 Self {
47 base: base.to_ascii_lowercase(),
48 count: count.max(1),
49 }
50 }
51
52 /// Create from a repeated letter string
53 pub fn from_repeated(s: &str) -> Option<Self> {
54 let s = s.to_lowercase();
55 let mut chars = s.chars();
56 let base = chars.next()?;
57
58 if !base.is_ascii_alphabetic() {
59 return None;
60 }
61
62 let count = s.len();
63 // Ensures all characters match the base character
64 if s.chars().all(|c| c == base) {
65 Some(Self::new(base, count))
66 } else {
67 None
68 }
69 }
70
71 /// Get the base character
72 pub fn base(&self) -> char {
73 self.base
74 }
75
76 /// Get the repetition count
77 pub fn count(&self) -> usize {
78 self.count
79 }
80
81 /// Convert to string representation
82 pub fn as_string(&self) -> String {
83 self.base.to_string().repeat(self.count)
84 }
85
86 /// Returns true if this sequence is a prefix of the given input.
87 pub fn matches_input(&self, input: &str) -> bool {
88 let normalized = normalize_input(input);
89 self.as_string().starts_with(&normalized)
90 }
91
92 /// Returns true if this sequence exactly equals the input.
93 pub fn equals_input(&self, input: &str) -> bool {
94 let normalized = normalize_input(input);
95 self.as_string() == normalized
96 }
97}
98
99impl fmt::Display for HintSequence {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 write!(f, "{}", self.as_string())
102 }
103}
104
105/// Normalizes input to canonical hint format.
106///
107/// Supports two input patterns:
108/// - Repeated letters: g, gg, ggg
109/// - Letter + number: g1, g2, g3
110fn normalize_input(input: &str) -> String {
111 let input = input.to_lowercase();
112
113 // Handles letter + number pattern (e.g., "g2", "f3")
114 if input.len() >= 2 {
115 let chars: Vec<char> = input.chars().collect();
116 let last = chars[chars.len() - 1];
117
118 if last.is_ascii_digit() {
119 // Locates the start of numeric suffix
120 let mut letter_end = chars.len() - 1;
121 while letter_end > 0 && chars[letter_end - 1].is_ascii_digit() {
122 letter_end -= 1;
123 }
124
125 if letter_end > 0 {
126 let letters: String = chars[..letter_end].iter().collect();
127 let num_str: String = chars[letter_end..].iter().collect();
128
129 if let Ok(num) = num_str.parse::<usize>()
130 && num > 0
131 && num <= 26 // Prevent integer overflow/memory exhaustion (26 is max reasonable)
132 && let Some(base) = letters.chars().next()
133 && letters.chars().all(|c| c == base)
134 {
135 // Repeats the base letter 'num' times for valid pattern
136 return base.to_string().repeat(num);
137 }
138 }
139 }
140 }
141
142 input
143}
144
145/// A hint assigned to a window
146///
147/// Associates a hint sequence with a specific window for activation.
148///
149/// # Examples
150///
151/// ```
152/// use open_sesame::core::{WindowHint, hint::HintSequence, WindowId};
153///
154/// let hint = WindowHint {
155/// hint: HintSequence::new('f', 1),
156/// window_id: WindowId::new("window-123"),
157/// app_id: "firefox".to_string(),
158/// title: "GitHub".to_string(),
159/// index: 0,
160/// };
161///
162/// assert_eq!(hint.hint_string(), "f");
163/// assert_eq!(hint.app_id, "firefox");
164/// ```
165#[derive(Debug, Clone)]
166pub struct WindowHint {
167 /// The hint sequence
168 pub hint: HintSequence,
169 /// Window ID for activation
170 pub window_id: WindowId,
171 /// Application ID (as string for display)
172 pub app_id: String,
173 /// Window title
174 pub title: String,
175 /// Original index in window list
176 pub index: usize,
177}
178
179impl WindowHint {
180 /// Returns the hint as a string for display.
181 pub fn hint_string(&self) -> String {
182 self.hint.to_string()
183 }
184}
185
186/// Result of hint assignment
187///
188/// Contains all window hints generated from a window list, maintaining
189/// MRU (Most Recently Used) order for Alt+Tab behavior.
190///
191/// # Examples
192///
193/// ```
194/// use open_sesame::core::{HintAssignment, Window, AppId};
195///
196/// let windows = vec![
197/// Window::new("win-1", "firefox", "Tab 1"),
198/// Window::new("win-2", "firefox", "Tab 2"),
199/// Window::new("win-3", "ghostty", "Terminal"),
200/// ];
201///
202/// // Assign hints using a key lookup function
203/// let assignment = HintAssignment::assign(&windows, |app_id| {
204/// match app_id.as_str() {
205/// "firefox" => Some('f'),
206/// "ghostty" => Some('g'),
207/// _ => None,
208/// }
209/// });
210///
211/// assert_eq!(assignment.hints().len(), 3);
212///
213/// // Check assigned hints
214/// let hint_strings: Vec<_> = assignment.hints()
215/// .iter()
216/// .map(|h| h.hint_string())
217/// .collect();
218///
219/// assert!(hint_strings.contains(&"f".to_string()));
220/// assert!(hint_strings.contains(&"ff".to_string()));
221/// assert!(hint_strings.contains(&"g".to_string()));
222/// ```
223#[derive(Debug)]
224pub struct HintAssignment {
225 /// Assigned hints sorted by hint string
226 pub hints: Vec<WindowHint>,
227}
228
229impl HintAssignment {
230 /// Creates a new hint assignment from windows.
231 ///
232 /// Uses a key lookup function to determine the base hint for each app.
233 pub fn assign<F>(windows: &[Window], key_for_app: F) -> Self
234 where
235 F: Fn(&AppId) -> Option<char>,
236 {
237 let mut hints = Vec::new();
238
239 // Groups windows by their preferred base letter
240 let mut by_base: HashMap<char, Vec<(usize, &Window)>> = HashMap::new();
241
242 for (i, window) in windows.iter().enumerate() {
243 let base = key_for_app(&window.app_id)
244 .or_else(|| auto_generate_key(&window.app_id))
245 .unwrap_or('x');
246 by_base.entry(base).or_default().push((i, window));
247 }
248
249 // Assigns hints using repeated letters
250 for (base, windows_group) in &by_base {
251 for (window_idx, (original_index, window)) in windows_group.iter().enumerate() {
252 let hint = HintSequence::new(*base, window_idx + 1);
253
254 hints.push(WindowHint {
255 hint,
256 window_id: window.id.clone(),
257 app_id: window.app_id.as_str().to_string(),
258 title: window.title.clone(),
259 index: *original_index,
260 });
261 }
262 }
263
264 // Maintains hints in window order (MRU order) for Alt+Tab behavior.
265 // The first hint represents the "previous" window for quick switching.
266 hints.sort_by_key(|a| a.index);
267
268 Self { hints }
269 }
270
271 /// Get all hints
272 pub fn hints(&self) -> &[WindowHint] {
273 &self.hints
274 }
275
276 /// Find a hint by window ID
277 pub fn find_by_window_id(&self, id: &WindowId) -> Option<&WindowHint> {
278 self.hints.iter().find(|h| &h.window_id == id)
279 }
280}
281
282/// Automatically generates a key from app ID.
283fn auto_generate_key(app_id: &AppId) -> Option<char> {
284 let name = app_id.last_segment().to_lowercase();
285 name.chars().find(|c| c.is_ascii_alphabetic())
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn test_hint_sequence() {
294 let seq = HintSequence::new('g', 2);
295 assert_eq!(seq.base(), 'g');
296 assert_eq!(seq.count(), 2);
297 assert_eq!(format!("{}", seq), "gg");
298 }
299
300 #[test]
301 fn test_hint_sequence_from_repeated() {
302 assert_eq!(
303 HintSequence::from_repeated("ggg"),
304 Some(HintSequence::new('g', 3))
305 );
306 assert_eq!(
307 HintSequence::from_repeated("G"),
308 Some(HintSequence::new('g', 1))
309 );
310 assert_eq!(HintSequence::from_repeated("gf"), None);
311 assert_eq!(HintSequence::from_repeated("123"), None);
312 }
313
314 #[test]
315 fn test_normalize_input() {
316 // Letter + number patterns
317 assert_eq!(normalize_input("g1"), "g");
318 assert_eq!(normalize_input("g2"), "gg");
319 assert_eq!(normalize_input("g3"), "ggg");
320 assert_eq!(normalize_input("f10"), "ffffffffff");
321
322 // Repeated letters pass through
323 assert_eq!(normalize_input("g"), "g");
324 assert_eq!(normalize_input("gg"), "gg");
325
326 // Case insensitive
327 assert_eq!(normalize_input("G2"), "gg");
328 }
329
330 #[test]
331 fn test_hint_matching() {
332 let seq = HintSequence::new('g', 1);
333 assert!(seq.matches_input("g"));
334 assert!(seq.matches_input("G"));
335 assert!(seq.equals_input("g"));
336 assert!(!seq.equals_input("gg"));
337 }
338
339 #[test]
340 fn test_hint_assignment() {
341 let windows = vec![
342 Window::mock("firefox", "Tab 1"),
343 Window::mock("firefox", "Tab 2"),
344 Window::mock("ghostty", "Terminal"),
345 ];
346
347 let assignment = HintAssignment::assign(&windows, |app_id| match app_id.as_str() {
348 "firefox" => Some('f'),
349 "ghostty" => Some('g'),
350 _ => None,
351 });
352
353 assert_eq!(assignment.hints.len(), 3);
354
355 let hint_strings: Vec<_> = assignment.hints.iter().map(|h| h.hint_string()).collect();
356 assert!(hint_strings.contains(&"f".to_string()));
357 assert!(hint_strings.contains(&"ff".to_string()));
358 assert!(hint_strings.contains(&"g".to_string()));
359 }
360}