Skip to main content

pacsea/
ui.rs

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