1use crate::config::Config;
14use crate::core::WindowHint;
15use crate::render::{Color, FontWeight, TextRenderer, primitives};
16use crate::ui::Theme;
17use tiny_skia::Pixmap;
18
19const BASE_PADDING: f32 = 20.0;
24
25const BASE_ROW_HEIGHT: f32 = 48.0;
27
28const BASE_ROW_SPACING: f32 = 8.0;
30
31const BASE_BADGE_WIDTH: f32 = 48.0;
33
34const BASE_BADGE_HEIGHT: f32 = 32.0;
36
37const BASE_BORDER_RADIUS: f32 = 8.0;
39
40const BASE_APP_COLUMN_WIDTH: f32 = 180.0;
42
43const BASE_TEXT_SIZE: f32 = 16.0;
45
46const BASE_BORDER_WIDTH: f32 = 3.0;
48
49const BASE_CORNER_RADIUS: f32 = 16.0;
51
52const BASE_COLUMN_GAP: f32 = 16.0;
54
55struct Layout {
57 padding: f32,
59 row_height: f32,
61 row_spacing: f32,
63 badge_width: f32,
65 badge_height: f32,
67 badge_radius: f32,
69 app_column_width: f32,
71 text_size: f32,
73 badge_text_size: f32,
75 border_width: f32,
77 corner_radius: f32,
79 column_gap: f32,
81}
82
83impl Layout {
84 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum OverlayPhase {
106 Initial,
108 Full,
110}
111
112pub struct Overlay {
114 width: u32,
116 height: u32,
118 scale: f32,
120 theme: Theme,
122 layout: Layout,
124}
125
126impl Overlay {
127 pub fn new(width: u32, height: u32, scale: f32, config: &Config) -> Self {
129 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 fn scaled_dimensions(&self) -> Option<(u32, u32)> {
145 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 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 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 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 self.render_screen_border(&mut pixmap);
197
198 Some(pixmap)
199 }
200
201 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 self.render_screen_border(&mut pixmap);
218
219 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 let selection = selection.min(visible_hints.len().saturating_sub(1));
232
233 let card = self.calculate_card_dimensions(
235 &visible_hints,
236 scaled_width as f32,
237 scaled_height as f32,
238 );
239
240 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 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 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 if !input.is_empty() {
274 self.render_input_indicator(&mut pixmap, &card, input);
275 }
276
277 Some(pixmap)
278 }
279
280 fn calculate_card_dimensions(
282 &self,
283 hints: &[&WindowHint],
284 screen_width: f32,
285 screen_height: f32,
286 ) -> CardRect {
287 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 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 let content_height = hints.len() as f32
302 * (self.layout.row_height + self.layout.row_spacing)
303 - self.layout.row_spacing; let card_height = content_height + self.layout.padding * 2.0;
305
306 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 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 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 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 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), );
354 }
355
356 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 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 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 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 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 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 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 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 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, 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
535struct CardRect {
537 x: f32,
538 y: f32,
539 width: f32,
540 height: f32,
541}
542
543fn extract_app_name(app_id: &str) -> String {
545 let name = app_id.split('.').next_back().unwrap_or(app_id);
547
548 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}