use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Widget, Wrap};
use crate::theme::Theme;
#[derive(Debug, Clone, Default)]
pub struct StartupAction {
pub trigger: String,
pub label: String,
pub description: String,
}
#[derive(Debug, Clone, Default)]
pub struct StartupSection {
pub title: String,
pub lines: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct StartupPanelData {
pub actions: Vec<StartupAction>,
pub sections: Vec<StartupSection>,
}
pub struct StartupPanelView<'a> {
data: &'a StartupPanelData,
theme: &'a Theme,
}
impl<'a> StartupPanelView<'a> {
pub fn new(data: &'a StartupPanelData, theme: &'a Theme) -> Self {
Self { data, theme }
}
}
impl Widget for StartupPanelView<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 24 || area.height < 8 {
return;
}
let outer = Block::default()
.title(Line::from(Span::styled(" imp ", self.theme.accent_style())))
.borders(Borders::ALL)
.border_style(self.theme.border_style());
let inner = outer.inner(area);
outer.render(area, buf);
if inner.height < 12 {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(3)])
.split(inner);
render_actions(chunks[0], buf, self.theme, &self.data.actions);
render_sections(chunks[1], buf, self.theme, &self.data.sections);
return;
}
let actions_height = action_block_height(inner.width, self.data.actions.len());
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(actions_height), Constraint::Min(6)])
.split(inner);
render_actions(chunks[0], buf, self.theme, &self.data.actions);
render_sections(chunks[1], buf, self.theme, &self.data.sections);
}
}
fn render_actions(area: Rect, buf: &mut Buffer, theme: &Theme, actions: &[StartupAction]) {
if area.height < 3 || area.width < 18 || actions.is_empty() {
return;
}
let block = Block::default()
.title(Line::from(Span::styled(
" common actions ",
theme.header_style(),
)))
.borders(Borders::ALL)
.border_style(theme.accent_style());
let inner = block.inner(area);
block.render(area, buf);
if inner.height == 0 || inner.width == 0 {
return;
}
if inner.width >= 96 && actions.len() >= 4 {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(inner);
let mid = actions.len().div_ceil(2);
render_action_lines(columns[0], buf, theme, &actions[..mid]);
render_action_lines(columns[1], buf, theme, &actions[mid..]);
return;
}
render_action_lines(inner, buf, theme, actions);
}
fn render_action_lines(area: Rect, buf: &mut Buffer, theme: &Theme, actions: &[StartupAction]) {
let lines = actions
.iter()
.map(|action| {
Line::from(vec![
Span::styled(
format!(" {:<11}", action.trigger),
theme.accent_style().add_modifier(Modifier::BOLD),
),
Span::styled(
action.label.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {}", action.description), theme.muted_style()),
])
})
.collect::<Vec<_>>();
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_sections(area: Rect, buf: &mut Buffer, theme: &Theme, sections: &[StartupSection]) {
if sections.is_empty() || area.height == 0 || area.width == 0 {
return;
}
let visible_count = visible_section_count(area.width, area.height, sections.len());
let visible_sections = §ions[..visible_count];
if area.width >= 96 {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
])
.split(area);
for (section, rect) in visible_sections.iter().zip(columns.iter().copied()) {
render_section(rect, buf, theme, section);
}
return;
}
match visible_sections.len() {
0 => {}
1 => render_section(area, buf, theme, &visible_sections[0]),
2 => {
let chunks = if area.width >= 90 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area)
};
render_section(chunks[0], buf, theme, &visible_sections[0]);
render_section(chunks[1], buf, theme, &visible_sections[1]);
}
3 => {
if area.width >= 120 {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(area);
for (section, rect) in visible_sections.iter().zip(chunks.iter().copied()) {
render_section(rect, buf, theme, section);
}
} else if area.width >= 78 && area.height >= 12 {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[0]);
render_section(top[0], buf, theme, &visible_sections[0]);
render_section(top[1], buf, theme, &visible_sections[1]);
render_section(rows[1], buf, theme, &visible_sections[2]);
} else {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(34),
Constraint::Percentage(33),
Constraint::Percentage(33),
])
.split(area);
for (section, rect) in visible_sections.iter().zip(chunks.iter().copied()) {
render_section(rect, buf, theme, section);
}
}
}
_ => {
let constraints =
vec![
Constraint::Length((area.height / visible_sections.len() as u16).max(3));
visible_sections.len()
];
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
for (section, rect) in visible_sections.iter().zip(rows.iter().copied()) {
render_section(rect, buf, theme, section);
}
}
}
}
fn render_section(area: Rect, buf: &mut Buffer, theme: &Theme, section: &StartupSection) {
if area.height < 3 || area.width < 12 {
return;
}
let block = Block::default()
.title(Line::from(Span::styled(
format!(" {} ", section.title),
theme.header_style(),
)))
.borders(Borders::ALL)
.border_style(theme.border_style());
let inner = block.inner(area);
block.render(area, buf);
let lines = if section.lines.is_empty() {
vec![Line::from(Span::styled("none", theme.muted_style()))]
} else {
section
.lines
.iter()
.map(|line| render_section_line(line, theme))
.collect()
};
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(inner, buf);
}
fn render_section_line(line: &str, theme: &Theme) -> Line<'static> {
if let Some(rest) = line.strip_prefix("• ") {
if let Some((label, value)) = rest.split_once(':') {
return Line::from(vec![
Span::styled("• ", theme.accent_style()),
Span::styled(format!("{label}:"), theme.muted_style()),
Span::raw(value.to_string()),
]);
}
return Line::from(vec![
Span::styled("• ", theme.accent_style()),
Span::raw(rest.to_string()),
]);
}
Line::from(Span::styled(line.to_string(), theme.muted_style()))
}
fn action_block_height(width: u16, action_count: usize) -> u16 {
if action_count == 0 {
return 0;
}
if width >= 96 && action_count >= 4 {
4
} else {
(action_count as u16 + 2).clamp(4, 8)
}
}
fn visible_section_count(width: u16, height: u16, total: usize) -> usize {
if total == 0 {
return 0;
}
if width < 48 || height < 10 {
total.min(1)
} else if width < 72 || height < 16 {
total.min(2)
} else if width < 110 || height < 22 {
total.min(3)
} else {
total.min(4)
}
}
pub fn summarize_lines(lines: Vec<String>, max_items: usize) -> Vec<String> {
if lines.len() <= max_items {
return lines;
}
let hidden = lines.len() - max_items;
let mut visible = lines.into_iter().take(max_items).collect::<Vec<_>>();
visible.push(format!("… +{hidden} more"));
visible
}
pub fn summarize_inline(items: Vec<String>, max_items: usize) -> String {
if items.is_empty() {
return "none".to_string();
}
if items.len() <= max_items {
return items.join(", ");
}
let hidden = items.len() - max_items;
let visible = items.into_iter().take(max_items).collect::<Vec<_>>();
format!("{} … +{hidden} more", visible.join(", "))
}
pub fn truncate_preview(text: &str, max_lines: usize, max_chars: usize) -> String {
if max_lines == 0 || max_chars == 0 || text.is_empty() {
return String::new();
}
let mut lines = Vec::new();
let mut used_chars = 0usize;
let mut truncated = false;
for line in text.lines() {
let next_len = line.chars().count() + usize::from(!lines.is_empty());
if lines.len() >= max_lines || used_chars + next_len > max_chars {
truncated = true;
break;
}
used_chars += next_len;
lines.push(line.to_string());
}
let mut preview = lines.join("\n");
if truncated {
if !preview.is_empty() {
preview.push_str("\n");
}
preview.push_str("[… truncated preview]");
}
preview
}
#[cfg(test)]
mod tests {
use super::{summarize_inline, summarize_lines, truncate_preview, visible_section_count};
#[test]
fn summarize_lines_appends_hidden_count() {
let lines = vec![
"one".to_string(),
"two".to_string(),
"three".to_string(),
"four".to_string(),
];
let summarized = summarize_lines(lines, 2);
assert_eq!(summarized, vec!["one", "two", "… +2 more"]);
}
#[test]
fn summarize_inline_compacts_items() {
let text = summarize_inline(
vec!["ask".into(), "bash".into(), "read".into(), "edit".into()],
2,
);
assert_eq!(text, "ask, bash … +2 more");
}
#[test]
fn truncate_preview_marks_truncation() {
let text = "a\nb\nc\nd";
let preview = truncate_preview(text, 2, 32);
assert_eq!(preview, "a\nb\n[… truncated preview]");
}
#[test]
fn narrow_layout_prioritizes_fewer_sections() {
assert_eq!(visible_section_count(44, 20, 4), 1);
assert_eq!(visible_section_count(68, 14, 4), 2);
assert_eq!(visible_section_count(100, 20, 4), 3);
assert_eq!(visible_section_count(120, 24, 4), 4);
}
}