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 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 if let Some(r) = results_band {
453 results::render_dropdowns(f, app, r);
454 }
455
456 render_toast(f, app, area);
458}
459
460#[cfg(test)]
461mod tests {
462 fn init_test_translations(app: &mut crate::state::AppState) {
485 use std::collections::HashMap;
486 let mut translations = HashMap::new();
487 translations.insert("app.details.fields.url".to_string(), "URL".to_string());
489 translations.insert("app.details.url_label".to_string(), "URL:".to_string());
490 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 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 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 assert!(app.results_rect.is_some());
596 assert!(app.details_rect.is_some());
597 assert!(app.url_button_rect.is_some());
598
599 let buffer = term.backend().buffer();
601 assert_eq!(buffer.area.width, 120);
602 assert_eq!(buffer.area.height, 40);
603
604 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 assert!(app.results_rect.is_some());
613 assert!(app.details_rect.is_some());
614 assert!(app.url_button_rect.is_some());
615
616 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}