use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph, Wrap}, Frame, }; use crate::app::state::Palette; pub(super) fn render_panel_shell( frame: &mut Frame, area: Rect, border_color: Color, bg: Color, ) -> Option { if area.width < 2 || area.height < 2 { return None; } let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .border_set(ratatui::symbols::border::PLAIN) .style(Style::default().bg(bg)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); Some(inner) } pub(super) fn panel_contrast_fg(p: &Palette) -> Color { match p.panel_bg { Color::Reset => p.surface_dim, color => color, } } pub(crate) fn centered_popup_rect(area: Rect, popup_w: u16, popup_h: u16) -> Option { let popup_w = popup_w.min(area.width.saturating_sub(4)); let popup_h = popup_h.min(area.height.saturating_sub(2)); if popup_w < 4 || popup_h < 4 { return None; } let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; Some(Rect::new(popup_x, popup_y, popup_w, popup_h)) } pub(super) fn render_modal_shell( frame: &mut Frame, area: Rect, popup_w: u16, popup_h: u16, p: &Palette, ) -> Option { let popup = centered_popup_rect(area, popup_w, popup_h)?; render_panel_shell(frame, popup, p.accent, p.panel_bg) } pub(super) fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { let line = Line::from(vec![Span::styled( title, Style::default().fg(p.text).add_modifier(Modifier::BOLD), )]); frame.render_widget(Paragraph::new(line), area); } #[derive(Debug, Clone, Copy)] pub(crate) struct ModalStackAreas { pub header: Rect, pub content: Rect, pub footer: Option, pub actions: Option, } pub(crate) fn modal_stack_areas( inner: Rect, header_height: u16, footer_height: u16, actions_height: u16, gap: u16, ) -> ModalStackAreas { #[derive(Clone, Copy)] enum Slot { Header, Content, Footer, Actions, } let mut constraints = Vec::new(); let mut slots = Vec::new(); let mut push = |slot: Slot, constraint: Constraint| { if !slots.is_empty() { constraints.push(Constraint::Length(gap)); } constraints.push(constraint); slots.push(slot); }; push(Slot::Header, Constraint::Length(header_height)); push(Slot::Content, Constraint::Min(0)); if footer_height > 0 { push(Slot::Footer, Constraint::Length(footer_height)); } if actions_height > 0 { push(Slot::Actions, Constraint::Length(actions_height)); } let areas = Layout::vertical(constraints).split(inner); let mut header = Rect::default(); let mut content = Rect::default(); let mut footer = None; let mut actions = None; for (slot, area) in slots.into_iter().zip(areas.iter().step_by(2).copied()) { match slot { Slot::Header => header = area, Slot::Content => content = area, Slot::Footer => footer = Some(area), Slot::Actions => actions = Some(area), } } ModalStackAreas { header, content, footer, actions, } } pub(crate) fn action_button_text(hint: Option<&str>, label: &str) -> String { match hint { Some(hint) => format!(" {hint} {label} "), None => format!(" {label} "), } } pub(crate) fn action_button_width(hint: Option<&str>, label: &str) -> u16 { action_button_text(hint, label).chars().count() as u16 } pub(crate) struct ActionButtonSpec<'a> { pub hint: Option<&'a str>, pub label: &'a str, } pub(crate) fn action_button_row_rects( area: Rect, buttons: &[ActionButtonSpec<'_>], gap: u16, row_offset: u16, ) -> Vec { let widths: Vec = buttons .iter() .map(|button| action_button_width(button.hint, button.label)) .collect(); centered_button_row(area, &widths, gap, row_offset) } pub(super) fn render_action_button( frame: &mut Frame, rect: Rect, hint: Option<&str>, label: &str, style: Style, ) { frame.render_widget( Paragraph::new(action_button_text(hint, label)) .style(style) .alignment(Alignment::Center), rect, ); } pub(crate) fn render_modal_description(frame: &mut Frame, area: Rect, text: &str, style: Style) { frame.render_widget( Paragraph::new(format!(" {text}")) .style(style) .wrap(Wrap { trim: false }), area, ); } pub(crate) fn modal_choice_rows(area: Rect, count: usize, row_height: u16) -> Vec { let mut rows = Vec::with_capacity(count); let mut y = area.y; for _ in 0..count { if y >= area.y + area.height { break; } let remaining = area.y + area.height - y; let height = row_height.min(remaining); rows.push(Rect::new(area.x, y, area.width, height)); y = y.saturating_add(row_height); } rows } pub(crate) fn render_modal_choice_list( frame: &mut Frame, area: Rect, title: &str, description: &str, options: &[(&str, T)], current_value: T, selected_idx: usize, p: &Palette, row_height: u16, ) where T: Copy + PartialEq, { let [desc_area, _, list_area] = Layout::vertical([ Constraint::Length(2), Constraint::Length(1), Constraint::Min(2), ]) .areas::<3>(area); render_modal_description( frame, desc_area, description, Style::default().fg(p.overlay1), ); let rows = modal_choice_rows(list_area, options.len(), row_height); for (idx, ((label, value), row)) in options.iter().zip(rows.iter()).enumerate() { let is_active = *value == current_value; let is_selected = idx == selected_idx; let marker = if is_active { " ✓" } else { "" }; let style = if is_selected { Style::default() .bg(p.surface0) .fg(p.text) .add_modifier(Modifier::BOLD) } else { Style::default().fg(p.subtext0) }; frame.render_widget( Paragraph::new(format!(" {title}: {label}{marker}")) .style(style) .wrap(Wrap { trim: false }), *row, ); } } pub(super) fn centered_button_row( inner: Rect, widths: &[u16], gap: u16, row_offset: u16, ) -> Vec { let total_w = widths .iter() .copied() .sum::() .saturating_add(gap.saturating_mul(widths.len().saturating_sub(1) as u16)); let mut x = inner.x + inner.width.saturating_sub(total_w) / 2; let y = inner.y + row_offset.min(inner.height.saturating_sub(1)); widths .iter() .map(|w| { let rect = Rect::new( x, y, (*w).min(inner.width.saturating_sub(x.saturating_sub(inner.x))), 1, ); x = x.saturating_add(*w).saturating_add(gap); rect }) .collect() }