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}