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///   (or the config editor in `AppMode::ConfigEditor`), then modal overlays on the full frame.
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    // Config editor is a dedicated app mode that occupies the same main
421    // content area as package/news views.
422    if matches!(app.app_mode, AppMode::ConfigEditor) {
423        let editor_state = std::mem::take(&mut app.config_editor_state);
424        modals::config_editor::render_config_editor_window(f, app, main_chunks[1], &editor_state);
425        app.config_editor_state = editor_state;
426
427        // Same stacking as package/news: modals (e.g. Updates after refresh) must paint here;
428        // otherwise `app.modal` is non-`None` and keys are consumed with no visible overlay.
429        modals::render_modals(f, app, area);
430
431        // Use the editor area as the anchor for dropdown overlays so
432        // top-row menus remain operable in this mode.
433        results::render_dropdowns(f, app, main_chunks[1]);
434        render_toast(f, app, area);
435        return;
436    }
437
438    // Split main content into the three vertical bands (visual order from settings)
439    let chunks = Layout::default()
440        .direction(Direction::Vertical)
441        .constraints([
442            Constraint::Length(band_lengths[0]),
443            Constraint::Length(band_lengths[1]),
444            Constraint::Length(band_lengths[2]),
445        ])
446        .split(main_chunks[1]);
447
448    let order = app.main_pane_order;
449    let mut results_band: Option<ratatui::prelude::Rect> = None;
450    for (slot, role) in order.iter().enumerate() {
451        let chunk = chunks[slot];
452        match role {
453            MainVerticalPane::Results => {
454                results::render_results(f, app, chunk);
455                results_band = Some(chunk);
456            }
457            MainVerticalPane::Middle => middle::render_middle(f, app, chunk),
458            MainVerticalPane::PackageInfo => {
459                if matches!(app.app_mode, AppMode::News) {
460                    details::render_news_details(f, app, chunk);
461                } else {
462                    details::render_details(f, app, chunk);
463                }
464            }
465        }
466    }
467    modals::render_modals(f, app, area);
468
469    // Render dropdowns last to ensure they appear on top layer (now for both modes)
470    if let Some(r) = results_band {
471        results::render_dropdowns(f, app, r);
472    }
473
474    // Render transient toast (bottom-right) if present
475    render_toast(f, app, area);
476}
477
478#[cfg(test)]
479mod tests {
480    /// What: Ensure the top-level UI renderer draws successfully and records key rectangles.
481    ///
482    /// Inputs:
483    /// - `app`: Minimal [`AppState`] seeded with one result, URL, and optional toast message.
484    ///
485    /// Output:
486    /// - Rendering completes twice (with and without toast) and critical rects become `Some`.
487    ///
488    /// Details:
489    /// - Uses `TestBackend` to render `ui`, verifying toast handling and rect bookkeeping without
490    ///   panics across successive draws.
491    ///
492    /// What: Initialize minimal English translations for tests.
493    ///
494    /// Inputs:
495    /// - `app`: `AppState` to populate with translations
496    ///
497    /// Output:
498    /// - Populates `app.translations` and `app.translations_fallback` with minimal English translations
499    ///
500    /// Details:
501    /// - Sets up only the translations needed for tests to pass
502    fn init_test_translations(app: &mut crate::state::AppState) {
503        use std::collections::HashMap;
504        let mut translations = HashMap::new();
505        // Details
506        translations.insert("app.details.fields.url".to_string(), "URL".to_string());
507        translations.insert("app.details.url_label".to_string(), "URL:".to_string());
508        // Results
509        translations.insert("app.results.title".to_string(), "Results".to_string());
510        translations.insert("app.results.buttons.sort".to_string(), "Sort".to_string());
511        translations.insert(
512            "app.results.buttons.options".to_string(),
513            "Options".to_string(),
514        );
515        translations.insert(
516            "app.results.buttons.panels".to_string(),
517            "Panels".to_string(),
518        );
519        translations.insert(
520            "app.results.buttons.config_lists".to_string(),
521            "Config/Lists".to_string(),
522        );
523        translations.insert("app.results.buttons.menu".to_string(), "Menu".to_string());
524        translations.insert("app.results.filters.aur".to_string(), "AUR".to_string());
525        translations.insert("app.results.filters.core".to_string(), "core".to_string());
526        translations.insert("app.results.filters.extra".to_string(), "extra".to_string());
527        translations.insert(
528            "app.results.filters.multilib".to_string(),
529            "multilib".to_string(),
530        );
531        translations.insert("app.results.filters.eos".to_string(), "EOS".to_string());
532        translations.insert(
533            "app.results.filters.cachyos".to_string(),
534            "CachyOS".to_string(),
535        );
536        translations.insert("app.results.filters.artix".to_string(), "Artix".to_string());
537        translations.insert(
538            "app.results.filters.artix_omniverse".to_string(),
539            "OMNI".to_string(),
540        );
541        translations.insert(
542            "app.results.filters.artix_universe".to_string(),
543            "UNI".to_string(),
544        );
545        translations.insert(
546            "app.results.filters.artix_lib32".to_string(),
547            "LIB32".to_string(),
548        );
549        translations.insert(
550            "app.results.filters.artix_galaxy".to_string(),
551            "GALAXY".to_string(),
552        );
553        translations.insert(
554            "app.results.filters.artix_world".to_string(),
555            "WORLD".to_string(),
556        );
557        translations.insert(
558            "app.results.filters.artix_system".to_string(),
559            "SYSTEM".to_string(),
560        );
561        translations.insert(
562            "app.results.filters.blackarch".to_string(),
563            "BlackArch".to_string(),
564        );
565        translations.insert(
566            "app.results.filters.manjaro".to_string(),
567            "Manjaro".to_string(),
568        );
569        // Toasts
570        translations.insert(
571            "app.toasts.copied_to_clipboard".to_string(),
572            "Copied to clipboard".to_string(),
573        );
574        translations.insert("app.toasts.title_news".to_string(), "News".to_string());
575        translations.insert(
576            "app.toasts.title_clipboard".to_string(),
577            "Clipboard".to_string(),
578        );
579        app.translations = translations.clone();
580        app.translations_fallback = translations;
581    }
582
583    #[test]
584    fn ui_renders_frame_and_sets_rects_and_toast() {
585        use ratatui::{Terminal, backend::TestBackend};
586
587        let backend = TestBackend::new(120, 40);
588        let mut term = Terminal::new(backend).expect("failed to create test terminal");
589        let mut app = crate::state::AppState::default();
590        init_test_translations(&mut app);
591        // Seed minimal data to exercise all three sections
592        app.results = vec![crate::state::PackageItem {
593            name: "pkg".into(),
594            version: "1".into(),
595            description: String::new(),
596            source: crate::state::Source::Aur,
597            popularity: None,
598            out_of_date: None,
599            orphaned: false,
600        }];
601        app.all_results = app.results.clone();
602        app.selected = 0;
603        app.list_state.select(Some(0));
604        app.details.url = "https://example.com".into();
605        app.toast_message = Some(crate::i18n::t(&app, "app.toasts.copied_to_clipboard"));
606
607        term.draw(|f| {
608            super::ui(f, &mut app);
609        })
610        .expect("failed to draw test terminal");
611
612        // Expect rects set by sub-renderers
613        assert!(app.results_rect.is_some());
614        assert!(app.details_rect.is_some());
615        assert!(app.url_button_rect.is_some());
616
617        // Verify buffer was rendered with correct dimensions
618        let buffer = term.backend().buffer();
619        assert_eq!(buffer.area.width, 120);
620        assert_eq!(buffer.area.height, 40);
621
622        // Second render without toast should still work
623        app.toast_message = None;
624        term.draw(|f| {
625            super::ui(f, &mut app);
626        })
627        .expect("failed to draw test terminal second time");
628
629        // Verify rects are still set after second render
630        assert!(app.results_rect.is_some());
631        assert!(app.details_rect.is_some());
632        assert!(app.url_button_rect.is_some());
633
634        // Verify buffer dimensions remain correct
635        let buffer = term.backend().buffer();
636        assert_eq!(buffer.area.width, 120);
637        assert_eq!(buffer.area.height, 40);
638    }
639
640    /// What: Regression — `Modal::Updates` must paint while `AppMode::ConfigEditor` is active.
641    ///
642    /// Inputs:
643    /// - `AppState` in config editor mode with a minimal `Modal::Updates` and `init_test_translations`.
644    ///
645    /// Output:
646    /// - Frame buffer contains the visible package name from the updates list.
647    ///
648    /// Details:
649    /// - The config-editor branch of `ui` previously returned before `render_modals`, leaving a
650    ///   non-`None` modal invisible while input stayed modal-gated.
651    #[test]
652    fn config_editor_mode_renders_updates_modal_content() {
653        use ratatui::{Terminal, backend::TestBackend};
654
655        let backend = TestBackend::new(120, 40);
656        let mut term = Terminal::new(backend).expect("failed to create test terminal");
657        let mut app = crate::state::AppState::default();
658        init_test_translations(&mut app);
659        app.app_mode = crate::state::types::AppMode::ConfigEditor;
660        app.modal = crate::state::Modal::Updates {
661            entries: vec![("testpkg".into(), "1".into(), "2".into())],
662            scroll: 0,
663            selected: 0,
664            filter_active: false,
665            filter_query: String::new(),
666            filter_caret: 0,
667            last_selected_pkg_name: None,
668            filtered_indices: vec![0],
669            selected_pkg_names: std::collections::HashSet::new(),
670        };
671
672        term.draw(|f| {
673            super::ui(f, &mut app);
674        })
675        .expect("failed to draw config editor with updates modal");
676
677        let buffer = term.backend().buffer();
678        let mut saw_pkg = false;
679        for row in 0..buffer.area.height {
680            let mut line = String::new();
681            for col in 0..buffer.area.width {
682                line.push_str(buffer[(col, row)].symbol());
683            }
684            if line.contains("testpkg") {
685                saw_pkg = true;
686                break;
687            }
688        }
689        assert!(
690            saw_pkg,
691            "updates modal should render above config editor (expected testpkg in buffer)"
692        );
693    }
694
695    #[test]
696    fn vertical_band_lengths_cover_all_six_permutations() {
697        use crate::state::MainVerticalPane::{Middle, PackageInfo, Results};
698        let heights = super::LayoutHeights {
699            results: 11,
700            middle: 22,
701            details: 33,
702        };
703        let cases: [([crate::state::MainVerticalPane; 3], [u16; 3]); 6] = [
704            ([Results, Middle, PackageInfo], [11, 22, 33]),
705            ([Results, PackageInfo, Middle], [11, 33, 22]),
706            ([Middle, Results, PackageInfo], [22, 11, 33]),
707            ([Middle, PackageInfo, Results], [22, 33, 11]),
708            ([PackageInfo, Results, Middle], [33, 11, 22]),
709            ([PackageInfo, Middle, Results], [33, 22, 11]),
710        ];
711        for (order, expected) in cases {
712            assert_eq!(
713                super::vertical_band_lengths_for_order(order, &heights),
714                expected,
715                "order mismatch for {order:?}"
716            );
717        }
718    }
719
720    #[test]
721    fn default_vertical_limits_match_historical_layout_heights() {
722        let constraints =
723            super::LayoutConstraints::from_limits(&crate::state::VerticalLayoutLimits::default());
724        let h39 = super::calculate_layout_heights(39, &constraints);
725        assert_eq!((h39.results, h39.middle, h39.details), (17, 5, 17));
726        let h10 = super::calculate_layout_heights(10, &constraints);
727        assert_eq!((h10.results, h10.middle, h10.details), (3, 4, 3));
728        let h5 = super::calculate_layout_heights(5, &constraints);
729        assert_eq!((h5.results, h5.middle, h5.details), (3, 3, 0));
730    }
731
732    #[test]
733    fn ui_renders_when_main_pane_order_puts_results_last() {
734        use crate::state::MainVerticalPane::{Middle, PackageInfo, Results};
735        use ratatui::{Terminal, backend::TestBackend};
736
737        let backend = TestBackend::new(120, 40);
738        let mut term = Terminal::new(backend).expect("failed to create test terminal");
739        let mut app = crate::state::AppState::default();
740        init_test_translations(&mut app);
741        app.main_pane_order = [PackageInfo, Middle, Results];
742        app.results = vec![crate::state::PackageItem {
743            name: "pkg".into(),
744            version: "1".into(),
745            description: String::new(),
746            source: crate::state::Source::Aur,
747            popularity: None,
748            out_of_date: None,
749            orphaned: false,
750        }];
751        app.all_results = app.results.clone();
752        app.selected = 0;
753        app.list_state.select(Some(0));
754        app.details.url = "https://example.com".into();
755
756        term.draw(|f| {
757            super::ui(f, &mut app);
758        })
759        .expect("draw with reordered panes");
760
761        assert!(app.results_rect.is_some());
762        assert!(app.details_rect.is_some());
763    }
764}