1use 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
29mod details;
31pub mod helpers;
32mod middle;
34mod modals;
36mod results;
38mod updates;
40
41pub fn cycle_pkgbuild_view_section(app: &mut AppState) {
53 details::cycle_pkgbuild_view_section(app);
54}
55
56struct LayoutConstraints {
65 min_results: u16,
67 min_middle: u16,
69 min_package_info: u16,
71 max_results: u16,
73 max_middle: u16,
75}
76
77impl LayoutConstraints {
78 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
99struct LayoutHeights {
108 results: u16,
110 middle: u16,
112 details: u16,
114}
115
116const 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
140fn 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
162fn 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 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
208fn 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
250fn 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
274fn 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#[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 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 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 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
380pub 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 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 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 updates::render_updates_button(f, app, main_chunks[0]);
419
420 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 modals::render_modals(f, app, area);
430
431 results::render_dropdowns(f, app, main_chunks[1]);
434 render_toast(f, app, area);
435 return;
436 }
437
438 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 if let Some(r) = results_band {
471 results::render_dropdowns(f, app, r);
472 }
473
474 render_toast(f, app, area);
476}
477
478#[cfg(test)]
479mod tests {
480 fn init_test_translations(app: &mut crate::state::AppState) {
503 use std::collections::HashMap;
504 let mut translations = HashMap::new();
505 translations.insert("app.details.fields.url".to_string(), "URL".to_string());
507 translations.insert("app.details.url_label".to_string(), "URL:".to_string());
508 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 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 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 assert!(app.results_rect.is_some());
614 assert!(app.details_rect.is_some());
615 assert!(app.url_button_rect.is_some());
616
617 let buffer = term.backend().buffer();
619 assert_eq!(buffer.area.width, 120);
620 assert_eq!(buffer.area.height, 40);
621
622 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 assert!(app.results_rect.is_some());
631 assert!(app.details_rect.is_some());
632 assert!(app.url_button_rect.is_some());
633
634 let buffer = term.backend().buffer();
636 assert_eq!(buffer.area.width, 120);
637 assert_eq!(buffer.area.height, 40);
638 }
639
640 #[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}