open_sesame/ui/
overlay.rs

1//! Overlay window rendering
2//!
3//! Renders the window switcher overlay with proper layout and alignment.
4//!
5//! # Border Lifecycle
6//!
7//! The screen-edge border is the **first** visual element rendered and remains
8//! visible throughout the entire application lifecycle until exit. It provides
9//! immediate visual feedback that sesame is active.
10//!
11//! The popup card (window list) appears on top of the border after `overlay_delay`.
12
13use crate::config::Config;
14use crate::core::WindowHint;
15use crate::render::{Color, FontWeight, TextRenderer, primitives};
16use crate::ui::Theme;
17use tiny_skia::Pixmap;
18
19// Layout constants based on Material Design spacing scale
20// Reference: https://material.io/design/layout/spacing-methods.html
21
22/// Base padding for card edges (Material Design 4-point grid)
23const BASE_PADDING: f32 = 20.0;
24
25/// Row height for touch targets (Material Design minimum 48dp)
26const BASE_ROW_HEIGHT: f32 = 48.0;
27
28/// Spacing between rows (Material Design dense spacing)
29const BASE_ROW_SPACING: f32 = 8.0;
30
31/// Badge width for 2-character hints
32const BASE_BADGE_WIDTH: f32 = 48.0;
33
34/// Badge height for comfortable reading
35const BASE_BADGE_HEIGHT: f32 = 32.0;
36
37/// Border radius for modern aesthetic
38const BASE_BORDER_RADIUS: f32 = 8.0;
39
40/// App name column width (fits ~20 characters)
41const BASE_APP_COLUMN_WIDTH: f32 = 180.0;
42
43/// Text size for body text (readable at 1080p)
44const BASE_TEXT_SIZE: f32 = 16.0;
45
46/// Border width for visibility without dominance
47const BASE_BORDER_WIDTH: f32 = 3.0;
48
49/// Corner radius for card (Material Design rounded corners)
50const BASE_CORNER_RADIUS: f32 = 16.0;
51
52/// Gap between columns for visual separation
53const BASE_COLUMN_GAP: f32 = 16.0;
54
55/// Layout configuration calculated for the current display
56struct Layout {
57    /// Scaled card padding
58    padding: f32,
59    /// Scaled row height
60    row_height: f32,
61    /// Scaled row spacing
62    row_spacing: f32,
63    /// Scaled badge width
64    badge_width: f32,
65    /// Scaled badge height
66    badge_height: f32,
67    /// Scaled badge corner radius
68    badge_radius: f32,
69    /// Scaled app name column width
70    app_column_width: f32,
71    /// Scaled text size
72    text_size: f32,
73    /// Scaled badge text size
74    badge_text_size: f32,
75    /// Scaled border width
76    border_width: f32,
77    /// Scaled corner radius
78    corner_radius: f32,
79    /// Column gap between elements
80    column_gap: f32,
81}
82
83impl Layout {
84    /// Create layout scaled for the given display parameters
85    fn new(scale: f32) -> Self {
86        Self {
87            padding: BASE_PADDING * scale,
88            row_height: BASE_ROW_HEIGHT * scale,
89            row_spacing: BASE_ROW_SPACING * scale,
90            badge_width: BASE_BADGE_WIDTH * scale,
91            badge_height: BASE_BADGE_HEIGHT * scale,
92            badge_radius: BASE_BORDER_RADIUS * scale,
93            app_column_width: BASE_APP_COLUMN_WIDTH * scale,
94            text_size: BASE_TEXT_SIZE * scale,
95            badge_text_size: BASE_TEXT_SIZE * scale,
96            border_width: BASE_BORDER_WIDTH * scale,
97            corner_radius: BASE_CORNER_RADIUS * scale,
98            column_gap: BASE_COLUMN_GAP * scale,
99        }
100    }
101}
102
103/// Phase of overlay display
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum OverlayPhase {
106    /// Initial delay - show border highlight only
107    Initial,
108    /// Full overlay with window list
109    Full,
110}
111
112/// Overlay renderer
113pub struct Overlay {
114    /// Display width in pixels
115    width: u32,
116    /// Display height in pixels
117    height: u32,
118    /// Scale factor for HiDPI
119    scale: f32,
120    /// Theme for rendering
121    theme: Theme,
122    /// Calculated layout
123    layout: Layout,
124}
125
126impl Overlay {
127    /// Create a new overlay renderer
128    pub fn new(width: u32, height: u32, scale: f32, config: &Config) -> Self {
129        // Clamp scale to reasonable range to prevent crashes/OOM from invalid values
130        let scale = scale.clamp(0.5, 4.0);
131
132        Self {
133            width,
134            height,
135            scale,
136            theme: Theme::from_config(config),
137            layout: Layout::new(scale),
138        }
139    }
140
141    /// Validate and compute scaled dimensions
142    ///
143    /// Returns `None` if dimensions are invalid (zero or too large).
144    fn scaled_dimensions(&self) -> Option<(u32, u32)> {
145        // Use checked arithmetic to prevent overflow on extreme inputs
146        let scaled_width = (self.width as f32 * self.scale).min(u32::MAX as f32) as u32;
147        let scaled_height = (self.height as f32 * self.scale).min(u32::MAX as f32) as u32;
148
149        // Validates dimensions are within acceptable bounds (non-zero, maximum 16384px)
150        if scaled_width == 0 || scaled_height == 0 || scaled_width > 16384 || scaled_height > 16384
151        {
152            tracing::warn!(
153                "Invalid overlay dimensions: {}x{} (from {}x{} @ {}x scale)",
154                scaled_width,
155                scaled_height,
156                self.width,
157                self.height,
158                self.scale
159            );
160            return None;
161        }
162
163        Some((scaled_width, scaled_height))
164    }
165
166    /// Render the screen-edge border highlight
167    ///
168    /// This is the foundational visual element that remains visible throughout
169    /// the entire overlay lifecycle. It's rendered first and stays until exit.
170    fn render_screen_border(&self, pixmap: &mut Pixmap) {
171        let border_width = self.layout.border_width * 2.0;
172        let half_border = border_width / 2.0;
173        let width = pixmap.width() as f32;
174        let height = pixmap.height() as f32;
175
176        primitives::stroke_rounded_rect(
177            pixmap,
178            half_border,
179            half_border,
180            width - border_width,
181            height - border_width,
182            self.layout.corner_radius,
183            self.theme.card_border,
184            border_width,
185        );
186    }
187
188    /// Render the initial phase (border highlight only, transparent center)
189    pub fn render_initial(&self) -> Option<Pixmap> {
190        let (scaled_width, scaled_height) = self.scaled_dimensions()?;
191
192        let mut pixmap = Pixmap::new(scaled_width, scaled_height)?;
193        // Background remains transparent
194
195        // Draw border highlight around the entire screen
196        self.render_screen_border(&mut pixmap);
197
198        Some(pixmap)
199    }
200
201    /// Render the full overlay with window list
202    ///
203    /// The screen-edge border is **always** rendered first, then the popup card
204    /// is rendered on top. This ensures the border remains visible throughout.
205    pub fn render_full(
206        &self,
207        hints: &[WindowHint],
208        input: &str,
209        selection: usize,
210    ) -> Option<Pixmap> {
211        let (scaled_width, scaled_height) = self.scaled_dimensions()?;
212
213        let mut pixmap = Pixmap::new(scaled_width, scaled_height)?;
214        // Background remains transparent
215
216        // Renders the screen border first as the foundational visual element
217        self.render_screen_border(&mut pixmap);
218
219        // Filter visible hints based on input
220        let visible_hints: Vec<_> = hints
221            .iter()
222            .filter(|h| input.is_empty() || h.hint.matches_input(input))
223            .collect();
224
225        if visible_hints.is_empty() {
226            self.render_no_matches_card(&mut pixmap, input);
227            return Some(pixmap);
228        }
229
230        // Clamp selection to valid range to prevent out-of-bounds access
231        let selection = selection.min(visible_hints.len().saturating_sub(1));
232
233        // Calculate card dimensions
234        let card = self.calculate_card_dimensions(
235            &visible_hints,
236            scaled_width as f32,
237            scaled_height as f32,
238        );
239
240        // Draw card background
241        primitives::fill_rounded_rect(
242            &mut pixmap,
243            card.x,
244            card.y,
245            card.width,
246            card.height,
247            self.layout.corner_radius,
248            self.theme.card_background,
249        );
250
251        // Draw card border
252        primitives::stroke_rounded_rect(
253            &mut pixmap,
254            card.x,
255            card.y,
256            card.width,
257            card.height,
258            self.layout.corner_radius,
259            self.theme.card_border,
260            self.layout.border_width,
261        );
262
263        // Draw each hint row
264        for (i, hint) in visible_hints.iter().enumerate() {
265            let row_y = card.y
266                + self.layout.padding
267                + i as f32 * (self.layout.row_height + self.layout.row_spacing);
268            let is_selected = i == selection;
269            self.render_hint_row(&mut pixmap, &card, row_y, hint, input, is_selected);
270        }
271
272        // Draw input indicator if typing
273        if !input.is_empty() {
274            self.render_input_indicator(&mut pixmap, &card, input);
275        }
276
277        Some(pixmap)
278    }
279
280    /// Calculate card position and dimensions
281    fn calculate_card_dimensions(
282        &self,
283        hints: &[&WindowHint],
284        screen_width: f32,
285        screen_height: f32,
286    ) -> CardRect {
287        // Calculate required width based on content
288        let min_title_width = 200.0 * self.scale;
289        let content_width = self.layout.padding * 2.0
290            + self.layout.badge_width
291            + self.layout.column_gap
292            + self.layout.app_column_width
293            + self.layout.column_gap
294            + min_title_width;
295
296        // Card width constrained to content size, maximum 90% screen width or 700px
297        let max_width = (screen_width * 0.9).min(700.0 * self.scale);
298        let card_width = content_width.max(400.0 * self.scale).min(max_width);
299
300        // Card height calculated from number of hint rows
301        let content_height = hints.len() as f32
302            * (self.layout.row_height + self.layout.row_spacing)
303            - self.layout.row_spacing; // Excludes trailing spacing
304        let card_height = content_height + self.layout.padding * 2.0;
305
306        // Center the card
307        let card_x = (screen_width - card_width) / 2.0;
308        let card_y = (screen_height - card_height) / 2.0;
309
310        CardRect {
311            x: card_x,
312            y: card_y,
313            width: card_width,
314            height: card_height,
315        }
316    }
317
318    /// Render a single hint row with proper column alignment
319    fn render_hint_row(
320        &self,
321        pixmap: &mut Pixmap,
322        card: &CardRect,
323        row_y: f32,
324        hint: &WindowHint,
325        input: &str,
326        is_selected: bool,
327    ) {
328        let layout = &self.layout;
329
330        // Determine match state
331        let is_exact_match = !input.is_empty() && hint.hint.equals_input(input);
332        let is_partial_match =
333            !input.is_empty() && hint.hint.matches_input(input) && !is_exact_match;
334
335        // Column positions
336        let badge_x = card.x + layout.padding;
337        let app_x = badge_x + layout.badge_width + layout.column_gap;
338        let title_x = app_x + layout.app_column_width + layout.column_gap;
339        let title_max_width = card.x + card.width - title_x - layout.padding;
340
341        // Draw selection highlight background
342        if is_selected {
343            let highlight_x = card.x + layout.padding / 2.0;
344            let highlight_width = card.width - layout.padding;
345            primitives::fill_rounded_rect(
346                pixmap,
347                highlight_x,
348                row_y,
349                highlight_width,
350                layout.row_height,
351                layout.badge_radius,
352                Color::rgba(255, 255, 255, 25), // Semi-transparent white highlight
353            );
354        }
355
356        // === BADGE COLUMN ===
357        let badge_y = row_y + (layout.row_height - layout.badge_height) / 2.0;
358
359        let badge_bg = if is_exact_match {
360            self.theme.badge_matched_background
361        } else if is_partial_match {
362            Color::rgba(
363                self.theme.badge_background.r.saturating_add(30),
364                self.theme.badge_background.g.saturating_add(30),
365                self.theme.badge_background.b.saturating_add(30),
366                self.theme.badge_background.a,
367            )
368        } else {
369            self.theme.badge_background
370        };
371
372        // Draw badge background
373        primitives::fill_rounded_rect(
374            pixmap,
375            badge_x,
376            badge_y,
377            layout.badge_width,
378            layout.badge_height,
379            layout.badge_radius,
380            badge_bg,
381        );
382
383        // Renders badge text centered with semibold weight and uppercase styling
384        let hint_text = hint.hint.as_string().to_uppercase();
385        let hint_text_width = TextRenderer::measure_text_weighted(
386            &hint_text,
387            layout.badge_text_size,
388            FontWeight::Semibold,
389        );
390        let hint_text_height = TextRenderer::line_height(layout.badge_text_size);
391        let hint_text_x = badge_x + (layout.badge_width - hint_text_width) / 2.0;
392        let hint_text_y = badge_y + (layout.badge_height + hint_text_height) / 2.0
393            - TextRenderer::descent(layout.badge_text_size);
394
395        let badge_text_color = if is_exact_match {
396            self.theme.badge_matched_text
397        } else {
398            self.theme.badge_text
399        };
400
401        TextRenderer::render_text_weighted(
402            pixmap,
403            &hint_text,
404            hint_text_x,
405            hint_text_y,
406            layout.badge_text_size,
407            badge_text_color.to_skia(),
408            FontWeight::Semibold,
409        );
410
411        // === APP NAME COLUMN ===
412        let text_height = TextRenderer::line_height(layout.text_size);
413        let text_baseline_y = row_y + (layout.row_height + text_height) / 2.0
414            - TextRenderer::descent(layout.text_size);
415
416        let app_name = extract_app_name(&hint.app_id);
417        let truncated_app =
418            TextRenderer::truncate_to_width(&app_name, layout.app_column_width, layout.text_size);
419
420        TextRenderer::render_text(
421            pixmap,
422            &truncated_app,
423            app_x,
424            text_baseline_y,
425            layout.text_size,
426            self.theme.text_primary.to_skia(),
427        );
428
429        // === TITLE COLUMN ===
430        if title_max_width > 50.0 {
431            let truncated_title =
432                TextRenderer::truncate_to_width(&hint.title, title_max_width, layout.text_size);
433
434            TextRenderer::render_text(
435                pixmap,
436                &truncated_title,
437                title_x,
438                text_baseline_y,
439                layout.text_size,
440                self.theme.text_secondary.to_skia(),
441            );
442        }
443    }
444
445    /// Render "no matches" card (border already rendered by caller)
446    fn render_no_matches_card(&self, pixmap: &mut Pixmap, input: &str) {
447        let width = pixmap.width() as f32;
448        let height = pixmap.height() as f32;
449
450        let message = format!("No matches for '{}'", input);
451        let text_size = self.layout.text_size * 1.2;
452        let text_width = TextRenderer::measure_text(&message, text_size);
453        let text_height = TextRenderer::line_height(text_size);
454
455        // Small card for the message
456        let card_padding = self.layout.padding * 2.0;
457        let card_width = text_width + card_padding * 2.0;
458        let card_height = text_height + card_padding * 2.0;
459        let card_x = (width - card_width) / 2.0;
460        let card_y = (height - card_height) / 2.0;
461
462        primitives::fill_rounded_rect(
463            pixmap,
464            card_x,
465            card_y,
466            card_width,
467            card_height,
468            self.layout.corner_radius,
469            self.theme.card_background,
470        );
471
472        primitives::stroke_rounded_rect(
473            pixmap,
474            card_x,
475            card_y,
476            card_width,
477            card_height,
478            self.layout.corner_radius,
479            self.theme.card_border,
480            self.layout.border_width,
481        );
482
483        let text_x = card_x + card_padding;
484        let text_y = card_y + card_padding + TextRenderer::ascent(text_size);
485
486        TextRenderer::render_text(
487            pixmap,
488            &message,
489            text_x,
490            text_y,
491            text_size,
492            self.theme.text_primary.to_skia(),
493        );
494    }
495
496    /// Render input indicator below the card
497    fn render_input_indicator(&self, pixmap: &mut Pixmap, card: &CardRect, input: &str) {
498        let text = format!("› {}", input);
499        let text_size = self.layout.text_size;
500        let text_width = TextRenderer::measure_text(&text, text_size);
501        let text_height = TextRenderer::line_height(text_size);
502
503        // Small pill below the card
504        let pill_padding_h = self.layout.padding;
505        let pill_padding_v = self.layout.padding / 2.0;
506        let pill_width = text_width + pill_padding_h * 2.0;
507        let pill_height = text_height + pill_padding_v * 2.0;
508        let pill_x = card.x + (card.width - pill_width) / 2.0;
509        let pill_y = card.y + card.height + self.layout.padding;
510
511        primitives::fill_rounded_rect(
512            pixmap,
513            pill_x,
514            pill_y,
515            pill_width,
516            pill_height,
517            pill_height / 2.0, // Fully rounded ends
518            self.theme.badge_background,
519        );
520
521        let text_x = pill_x + pill_padding_h;
522        let text_y = pill_y + pill_padding_v + TextRenderer::ascent(text_size);
523
524        TextRenderer::render_text(
525            pixmap,
526            &text,
527            text_x,
528            text_y,
529            text_size,
530            self.theme.text_primary.to_skia(),
531        );
532    }
533}
534
535/// Rectangle for card positioning
536struct CardRect {
537    x: f32,
538    y: f32,
539    width: f32,
540    height: f32,
541}
542
543/// Extract a friendly app name from app_id
544fn extract_app_name(app_id: &str) -> String {
545    // Handle reverse-DNS style (com.mitchellh.ghostty -> ghostty)
546    let name = app_id.split('.').next_back().unwrap_or(app_id);
547
548    // Capitalize first letter
549    let mut chars: Vec<char> = name.chars().collect();
550    if let Some(first) = chars.first_mut() {
551        *first = first.to_ascii_uppercase();
552    }
553    chars.into_iter().collect()
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_overlay_creation() {
562        let config = Config::default();
563        let overlay = Overlay::new(1920, 1080, 1.0, &config);
564        assert_eq!(overlay.width, 1920);
565        assert_eq!(overlay.height, 1080);
566    }
567
568    #[test]
569    fn test_overlay_phase_eq() {
570        assert_eq!(OverlayPhase::Initial, OverlayPhase::Initial);
571        assert_ne!(OverlayPhase::Initial, OverlayPhase::Full);
572    }
573
574    #[test]
575    fn test_extract_app_name() {
576        assert_eq!(extract_app_name("com.mitchellh.ghostty"), "Ghostty");
577        assert_eq!(extract_app_name("firefox"), "Firefox");
578        assert_eq!(extract_app_name("org.mozilla.firefox"), "Firefox");
579        assert_eq!(extract_app_name("microsoft-edge"), "Microsoft-edge");
580    }
581}