1use 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
27mod details;
29pub mod helpers;
30mod middle;
32mod modals;
34mod results;
36mod updates;
38
39struct LayoutConstraints {
48 min_results: u16,
50 min_middle: u16,
52 min_package_info: u16,
54 max_results: u16,
56 max_middle: u16,
58}
59
60impl LayoutConstraints {
61 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
80struct LayoutHeights {
89 results: u16,
91 middle: u16,
93 details: u16,
95}
96
97const 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
121fn 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
143fn 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 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
189fn 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
231fn 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#[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 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 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 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
335pub 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 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 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 updates::render_updates_button(f, app, main_chunks[0]);
372
373 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 results::render_dropdowns(f, app, chunks[0]);
394
395 render_toast(f, app, area);
397}
398
399#[cfg(test)]
400mod tests {
401 fn init_test_translations(app: &mut crate::state::AppState) {
424 use std::collections::HashMap;
425 let mut translations = HashMap::new();
426 translations.insert("app.details.fields.url".to_string(), "URL".to_string());
428 translations.insert("app.details.url_label".to_string(), "URL:".to_string());
429 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 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 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 assert!(app.results_rect.is_some());
531 assert!(app.details_rect.is_some());
532 assert!(app.url_button_rect.is_some());
533
534 let buffer = term.backend().buffer();
536 assert_eq!(buffer.area.width, 120);
537 assert_eq!(buffer.area.height, 40);
538
539 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 assert!(app.results_rect.is_some());
548 assert!(app.details_rect.is_some());
549 assert!(app.url_button_rect.is_some());
550
551 let buffer = term.backend().buffer();
553 assert_eq!(buffer.area.width, 120);
554 assert_eq!(buffer.area.height, 40);
555 }
556}