pacsea/
ui.rs

1//! TUI rendering for Pacsea.
2//!
3//! This module renders the full terminal user interface using `ratatui`.
4//! The layout is split vertically into three regions:
5//!
6//! 1) Results list (top): shows search matches and keeps the current selection
7//!    centered when possible
8//! 2) Middle row (three columns): Recent (left), Search input (center), and
9//!    Install list (right), each styled based on focus
10//! 3) Details pane (bottom): rich package information with a clickable URL and
11//!    a contextual help footer displaying keybindings
12//!
13//! The renderer also draws modal overlays for alerts and install confirmation.
14//! It updates `app.url_button_rect` to make the URL clickable when available.
15use ratatui::{
16    Frame,
17    layout::{Constraint, Direction, Layout},
18    style::Style,
19    text::Span,
20    widgets::{Block, Paragraph},
21};
22
23use crate::i18n;
24use crate::state::types::AppMode;
25use crate::{state::AppState, theme::theme};
26
27/// Details pane rendering module.
28mod details;
29pub mod helpers;
30/// Middle row rendering module.
31mod middle;
32/// Modal overlays rendering module.
33mod modals;
34/// Search results rendering module.
35mod results;
36/// Updates pane rendering module.
37mod updates;
38
39/// What: Layout height constraints for UI panes.
40///
41/// Inputs: None (struct definition)
42///
43/// Output: None (struct definition)
44///
45/// Details:
46/// - Groups minimum and maximum height constraints to reduce data flow complexity.
47struct LayoutConstraints {
48    /// Minimum height for results pane.
49    min_results: u16,
50    /// Minimum height for middle pane.
51    min_middle: u16,
52    /// Minimum height for package info pane.
53    min_package_info: u16,
54    /// Maximum height for results pane.
55    max_results: u16,
56    /// Maximum height for middle pane.
57    max_middle: u16,
58}
59
60impl LayoutConstraints {
61    /// What: Create default layout constraints.
62    ///
63    /// Inputs: None
64    ///
65    /// Output: `LayoutConstraints` with default values
66    ///
67    /// Details:
68    /// - Returns constraints with standard minimum and maximum heights for all panes.
69    const fn new() -> Self {
70        Self {
71            min_results: 3,
72            min_middle: 3,
73            min_package_info: 3,
74            max_results: 17,
75            max_middle: 5,
76        }
77    }
78}
79
80/// What: Calculated layout heights for UI panes.
81///
82/// Inputs: None (struct definition)
83///
84/// Output: None (struct definition)
85///
86/// Details:
87/// - Groups related layout parameters to reduce data flow complexity by grouping related fields.
88struct LayoutHeights {
89    /// Height for results pane.
90    results: u16,
91    /// Height for middle pane.
92    middle: u16,
93    /// Height for details pane.
94    details: u16,
95}
96
97/// What: Calculate middle pane height based on available space and constraints.
98///
99/// Inputs:
100/// - `available_h`: Available height for middle pane
101/// - `min_results_h`: Minimum height required for results pane
102/// - `constraints`: Layout constraints
103///
104/// Output:
105/// - Returns calculated middle pane height
106///
107/// Details:
108/// - Uses match expression to determine height based on available space thresholds.
109const fn calculate_middle_height(
110    available_h: u16,
111    min_results_h: u16,
112    constraints: &LayoutConstraints,
113) -> u16 {
114    match available_h {
115        h if h >= constraints.max_middle + min_results_h => constraints.max_middle,
116        h if h >= constraints.min_middle + min_results_h => h.saturating_sub(min_results_h),
117        _ => constraints.min_middle,
118    }
119}
120
121/// What: Calculate results pane height based on available space and middle height.
122///
123/// Inputs:
124/// - `available_h`: Available height for results pane
125/// - `middle_h`: Height allocated to middle pane
126/// - `constraints`: Layout constraints
127///
128/// Output:
129/// - Returns calculated results pane height
130///
131/// Details:
132/// - Clamps results height between minimum and maximum constraints.
133fn calculate_results_height(
134    available_h: u16,
135    middle_h: u16,
136    constraints: &LayoutConstraints,
137) -> u16 {
138    available_h
139        .saturating_sub(middle_h)
140        .clamp(constraints.min_results, constraints.max_results)
141}
142
143/// What: Allocate layout heights when package info pane can be shown.
144///
145/// Inputs:
146/// - `available_h`: Total available height
147/// - `constraints`: Layout constraints
148///
149/// Output:
150/// - Returns `LayoutHeights` with allocated heights
151///
152/// Details:
153/// - Allocates 75% of space to Results and Middle, remainder to Package Info.
154/// - Redistributes if Package Info doesn't have minimum space.
155fn allocate_with_package_info(available_h: u16, constraints: &LayoutConstraints) -> LayoutHeights {
156    let top_middle_share = (available_h * 3) / 4;
157
158    let search_h_initial =
159        calculate_middle_height(top_middle_share, constraints.min_results, constraints);
160    let remaining_for_results = top_middle_share.saturating_sub(search_h_initial);
161    let top_h = remaining_for_results.clamp(constraints.min_results, constraints.max_results);
162
163    let unused_results_space = remaining_for_results.saturating_sub(top_h);
164    let search_h = (search_h_initial + unused_results_space).min(constraints.max_middle);
165
166    let remaining_for_package = available_h.saturating_sub(top_h).saturating_sub(search_h);
167
168    match remaining_for_package {
169        h if h >= constraints.min_package_info => LayoutHeights {
170            results: top_h,
171            middle: search_h,
172            details: remaining_for_package,
173        },
174        _ => {
175            // Redistribute: Middle gets max first, then Results gets the rest
176            let search_h_final =
177                calculate_middle_height(available_h, constraints.min_results, constraints);
178            let top_h_final = calculate_results_height(available_h, search_h_final, constraints);
179
180            LayoutHeights {
181                results: top_h_final,
182                middle: search_h_final,
183                details: 0,
184            }
185        }
186    }
187}
188
189/// What: Allocate layout heights when package info pane cannot be shown.
190///
191/// Inputs:
192/// - `available_h`: Total available height
193/// - `constraints`: Layout constraints
194///
195/// Output:
196/// - Returns `LayoutHeights` with allocated heights (details = 0)
197///
198/// Details:
199/// - Allocates all space between Results and Middle panes.
200/// - Adjusts if minimum constraints exceed available space.
201fn allocate_without_package_info(
202    available_h: u16,
203    constraints: &LayoutConstraints,
204) -> LayoutHeights {
205    let search_h = calculate_middle_height(available_h, constraints.min_results, constraints);
206    let mut top_h = calculate_results_height(available_h, search_h, constraints);
207
208    match (top_h + search_h).cmp(&available_h) {
209        std::cmp::Ordering::Greater => {
210            top_h = available_h
211                .saturating_sub(constraints.min_middle)
212                .clamp(constraints.min_results, constraints.max_results);
213            let search_h_adjusted = available_h
214                .saturating_sub(top_h)
215                .clamp(constraints.min_middle, constraints.max_middle);
216
217            LayoutHeights {
218                results: top_h,
219                middle: search_h_adjusted,
220                details: 0,
221            }
222        }
223        _ => LayoutHeights {
224            results: top_h,
225            middle: search_h,
226            details: 0,
227        },
228    }
229}
230
231/// What: Calculate layout heights for Results, Middle, and Details panes.
232///
233/// Inputs:
234/// - `available_h`: Available height after reserving space for updates button
235///
236/// Output:
237/// - Returns `LayoutHeights` with calculated heights for all panes
238///
239/// Details:
240/// - Implements priority-based layout allocation with min/max constraints.
241/// - Uses match expression to choose allocation strategy based on available space.
242fn calculate_layout_heights(available_h: u16) -> LayoutHeights {
243    let constraints = LayoutConstraints::new();
244    let min_top_middle_total = constraints.min_results + constraints.min_middle;
245    let space_after_min = available_h.saturating_sub(min_top_middle_total);
246
247    match space_after_min {
248        s if s >= constraints.min_package_info => {
249            allocate_with_package_info(available_h, &constraints)
250        }
251        _ => allocate_without_package_info(available_h, &constraints),
252    }
253}
254
255/// What: Render toast message overlay in bottom-right corner.
256///
257/// Inputs:
258/// - `f`: `ratatui` frame to render into
259/// - `app`: Application state containing toast message
260/// - `area`: Full terminal area for positioning
261///
262/// Output:
263/// - Renders toast widget if message is present
264///
265/// Details:
266/// - Positions toast in bottom-right corner with appropriate sizing.
267/// - Uses match expression to determine toast title based on message content.
268#[allow(clippy::many_single_char_names)]
269fn render_toast(f: &mut Frame, app: &AppState, area: ratatui::prelude::Rect) {
270    let Some(msg) = &app.toast_message else {
271        return;
272    };
273
274    let th = theme();
275    let inner_w = u16::try_from(msg.len())
276        .unwrap_or(u16::MAX)
277        .min(area.width.saturating_sub(4));
278    let w = inner_w.saturating_add(2 + 2);
279    let h: u16 = 3;
280    let x = area.x + area.width.saturating_sub(w).saturating_sub(1);
281    let y = area.y + area.height.saturating_sub(h).saturating_sub(1);
282
283    let rect = ratatui::prelude::Rect {
284        x,
285        y,
286        width: w,
287        height: h,
288    };
289
290    // Determine toast type by checking against all known news-related translation keys
291    // This is language-agnostic as it compares the actual translated text
292    // List of all news-related toast translation keys (add new ones here as needed)
293    let news_keys = ["app.toasts.no_new_news", "app.news_button.loading"];
294    let is_news_toast = news_keys.iter().any(|key| {
295        let translated = i18n::t(app, key);
296        msg == &translated
297    });
298
299    // Check for news age messages by comparing against translation keys or content pattern
300    let translated_all = i18n::t(app, "app.results.options_menu.news_age_all");
301    let is_news_age_toast = msg == &translated_all
302        || msg.starts_with("News age:")
303        || msg.to_lowercase().contains("news age");
304
305    // Check for clipboard messages by content (language-agnostic pattern matching)
306    let msg_lower = msg.to_lowercase();
307    let is_clipboard_toast = msg_lower.contains("clipboard")
308        || msg_lower.contains("wl-copy")
309        || msg_lower.contains("xclip")
310        || msg_lower.contains("copied")
311        || msg_lower.contains("copying");
312
313    let title_text = if is_news_toast || is_news_age_toast {
314        i18n::t(app, "app.toasts.title_news")
315    } else if is_clipboard_toast {
316        i18n::t(app, "app.toasts.title_clipboard")
317    } else {
318        i18n::t(app, "app.toasts.title_notification")
319    };
320
321    let content = Span::styled(msg.clone(), Style::default().fg(th.text));
322    let p = Paragraph::new(content)
323        .block(
324            ratatui::widgets::Block::default()
325                .title(Span::styled(title_text, Style::default().fg(th.overlay1)))
326                .borders(ratatui::widgets::Borders::ALL)
327                .border_style(Style::default().fg(th.overlay1))
328                .style(Style::default().bg(th.mantle)),
329        )
330        .style(Style::default().bg(th.mantle));
331
332    f.render_widget(p, rect);
333}
334
335/// What: Render a full frame of the Pacsea TUI.
336///
337/// Inputs:
338/// - `f`: `ratatui` frame to render into
339/// - `app`: Mutable application state; updated during rendering for selection offsets,
340///   cursor position, and clickable geometry
341///
342/// Output:
343/// - Draws the entire interface and updates hit-test rectangles used by mouse handlers.
344///
345/// Details:
346/// - Applies global theme/background; renders Results (top), Middle (left/center/right), Details
347///   (bottom), and Modal overlays.
348/// - Keeps results selection centered by adjusting list offset.
349/// - Computes and records clickable rects (URL, Sort/Filters, Options/Config/Panels, status label).
350pub fn ui(f: &mut Frame, app: &mut AppState) {
351    const UPDATES_H: u16 = 1;
352    let th = theme();
353    let area = f.area();
354
355    // Background
356    let bg = Block::default().style(Style::default().bg(th.base));
357    f.render_widget(bg, area);
358    let available_h = area.height.saturating_sub(UPDATES_H);
359    let layout = calculate_layout_heights(available_h);
360
361    // Split area into updates row and main content
362    let main_chunks = Layout::default()
363        .direction(Direction::Vertical)
364        .constraints([
365            Constraint::Length(UPDATES_H),
366            Constraint::Length(layout.results + layout.middle + layout.details),
367        ])
368        .split(area);
369
370    // Render updates button in the top row
371    updates::render_updates_button(f, app, main_chunks[0]);
372
373    // Split main content into results, middle, and details
374    let chunks = Layout::default()
375        .direction(Direction::Vertical)
376        .constraints([
377            Constraint::Length(layout.results),
378            Constraint::Length(layout.middle),
379            Constraint::Length(layout.details),
380        ])
381        .split(main_chunks[1]);
382
383    results::render_results(f, app, chunks[0]);
384    middle::render_middle(f, app, chunks[1]);
385    if matches!(app.app_mode, AppMode::News) {
386        details::render_news_details(f, app, chunks[2]);
387    } else {
388        details::render_details(f, app, chunks[2]);
389    }
390    modals::render_modals(f, app, area);
391
392    // Render dropdowns last to ensure they appear on top layer (now for both modes)
393    results::render_dropdowns(f, app, chunks[0]);
394
395    // Render transient toast (bottom-right) if present
396    render_toast(f, app, area);
397}
398
399#[cfg(test)]
400mod tests {
401    /// What: Ensure the top-level UI renderer draws successfully and records key rectangles.
402    ///
403    /// Inputs:
404    /// - `app`: Minimal [`AppState`] seeded with one result, URL, and optional toast message.
405    ///
406    /// Output:
407    /// - Rendering completes twice (with and without toast) and critical rects become `Some`.
408    ///
409    /// Details:
410    /// - Uses `TestBackend` to render `ui`, verifying toast handling and rect bookkeeping without
411    ///   panics across successive draws.
412    ///
413    /// What: Initialize minimal English translations for tests.
414    ///
415    /// Inputs:
416    /// - `app`: `AppState` to populate with translations
417    ///
418    /// Output:
419    /// - Populates `app.translations` and `app.translations_fallback` with minimal English translations
420    ///
421    /// Details:
422    /// - Sets up only the translations needed for tests to pass
423    fn init_test_translations(app: &mut crate::state::AppState) {
424        use std::collections::HashMap;
425        let mut translations = HashMap::new();
426        // Details
427        translations.insert("app.details.fields.url".to_string(), "URL".to_string());
428        translations.insert("app.details.url_label".to_string(), "URL:".to_string());
429        // Results
430        translations.insert("app.results.title".to_string(), "Results".to_string());
431        translations.insert("app.results.buttons.sort".to_string(), "Sort".to_string());
432        translations.insert(
433            "app.results.buttons.options".to_string(),
434            "Options".to_string(),
435        );
436        translations.insert(
437            "app.results.buttons.panels".to_string(),
438            "Panels".to_string(),
439        );
440        translations.insert(
441            "app.results.buttons.config_lists".to_string(),
442            "Config/Lists".to_string(),
443        );
444        translations.insert("app.results.buttons.menu".to_string(), "Menu".to_string());
445        translations.insert("app.results.filters.aur".to_string(), "AUR".to_string());
446        translations.insert("app.results.filters.core".to_string(), "core".to_string());
447        translations.insert("app.results.filters.extra".to_string(), "extra".to_string());
448        translations.insert(
449            "app.results.filters.multilib".to_string(),
450            "multilib".to_string(),
451        );
452        translations.insert("app.results.filters.eos".to_string(), "EOS".to_string());
453        translations.insert(
454            "app.results.filters.cachyos".to_string(),
455            "CachyOS".to_string(),
456        );
457        translations.insert("app.results.filters.artix".to_string(), "Artix".to_string());
458        translations.insert(
459            "app.results.filters.artix_omniverse".to_string(),
460            "OMNI".to_string(),
461        );
462        translations.insert(
463            "app.results.filters.artix_universe".to_string(),
464            "UNI".to_string(),
465        );
466        translations.insert(
467            "app.results.filters.artix_lib32".to_string(),
468            "LIB32".to_string(),
469        );
470        translations.insert(
471            "app.results.filters.artix_galaxy".to_string(),
472            "GALAXY".to_string(),
473        );
474        translations.insert(
475            "app.results.filters.artix_world".to_string(),
476            "WORLD".to_string(),
477        );
478        translations.insert(
479            "app.results.filters.artix_system".to_string(),
480            "SYSTEM".to_string(),
481        );
482        translations.insert(
483            "app.results.filters.manjaro".to_string(),
484            "Manjaro".to_string(),
485        );
486        // Toasts
487        translations.insert(
488            "app.toasts.copied_to_clipboard".to_string(),
489            "Copied to clipboard".to_string(),
490        );
491        translations.insert("app.toasts.title_news".to_string(), "News".to_string());
492        translations.insert(
493            "app.toasts.title_clipboard".to_string(),
494            "Clipboard".to_string(),
495        );
496        app.translations = translations.clone();
497        app.translations_fallback = translations;
498    }
499
500    #[test]
501    fn ui_renders_frame_and_sets_rects_and_toast() {
502        use ratatui::{Terminal, backend::TestBackend};
503
504        let backend = TestBackend::new(120, 40);
505        let mut term = Terminal::new(backend).expect("failed to create test terminal");
506        let mut app = crate::state::AppState::default();
507        init_test_translations(&mut app);
508        // Seed minimal data to exercise all three sections
509        app.results = vec![crate::state::PackageItem {
510            name: "pkg".into(),
511            version: "1".into(),
512            description: String::new(),
513            source: crate::state::Source::Aur,
514            popularity: None,
515            out_of_date: None,
516            orphaned: false,
517        }];
518        app.all_results = app.results.clone();
519        app.selected = 0;
520        app.list_state.select(Some(0));
521        app.details.url = "https://example.com".into();
522        app.toast_message = Some(crate::i18n::t(&app, "app.toasts.copied_to_clipboard"));
523
524        term.draw(|f| {
525            super::ui(f, &mut app);
526        })
527        .expect("failed to draw test terminal");
528
529        // Expect rects set by sub-renderers
530        assert!(app.results_rect.is_some());
531        assert!(app.details_rect.is_some());
532        assert!(app.url_button_rect.is_some());
533
534        // Verify buffer was rendered with correct dimensions
535        let buffer = term.backend().buffer();
536        assert_eq!(buffer.area.width, 120);
537        assert_eq!(buffer.area.height, 40);
538
539        // Second render without toast should still work
540        app.toast_message = None;
541        term.draw(|f| {
542            super::ui(f, &mut app);
543        })
544        .expect("failed to draw test terminal second time");
545
546        // Verify rects are still set after second render
547        assert!(app.results_rect.is_some());
548        assert!(app.details_rect.is_some());
549        assert!(app.url_button_rect.is_some());
550
551        // Verify buffer dimensions remain correct
552        let buffer = term.backend().buffer();
553        assert_eq!(buffer.area.width, 120);
554        assert_eq!(buffer.area.height, 40);
555    }
556}