use super::*;
pub(super) fn render_lines_panel(
frame: &mut ratatui::Frame,
area: Rect,
title: &str,
lines: Vec<Line<'static>>,
) {
render_lines_panel_with_title(frame, area, panel_title(title), lines);
}
pub(super) fn render_lines_panel_with_title(
frame: &mut ratatui::Frame,
area: Rect,
title: Line<'static>,
lines: Vec<Line<'static>>,
) {
let block = panel_block_with_title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
frame.render_widget(Clear, inner);
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
pub(super) fn render_event_line(entry: &TimelineEntry, width: usize) -> Line<'static> {
let (action, detail) = entry
.detail
.split_once(" • ")
.map(|(action, detail)| (action.to_string(), detail.to_string()))
.unwrap_or_else(|| (entry.detail.clone(), String::new()));
let timestamp = fit_text(&entry.timestamp, 8);
let actor = fit_text(&entry.actor, (width / 5).clamp(8, 16));
let action = fit_text(&action, (width / 4).clamp(10, 20));
let action_style = match entry.tone {
Tone::Muted => Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
_ => Style::default()
.fg(entry.tone.color())
.add_modifier(Modifier::BOLD),
};
let prefix_width = timestamp.chars().count()
+ tone_glyph(entry.tone).chars().count()
+ actor.chars().count()
+ action.chars().count()
+ 10;
let detail_width = width.saturating_sub(prefix_width);
let mut spans = vec![
Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(
tone_glyph(entry.tone),
Style::default().fg(entry.tone.color()),
),
Span::raw(" "),
Span::styled(
actor,
Style::default()
.fg(actor_color(&entry.actor, entry.tone))
.add_modifier(Modifier::BOLD),
),
Span::styled(" • ", Style::default().fg(Color::DarkGray)),
Span::styled(action, action_style),
];
if detail_width > 0 && !detail.is_empty() {
spans.push(Span::styled(" • ", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
fit_text(&detail, detail_width),
Style::default().fg(Color::DarkGray),
));
}
Line::from(spans)
}
pub(super) fn render_compact_event_line(entry: &TimelineEntry, width: usize) -> Line<'static> {
let (action, detail) = entry
.detail
.split_once(" • ")
.map(|(action, detail)| (action.to_string(), detail.to_string()))
.unwrap_or_else(|| (entry.detail.clone(), String::new()));
let glyph = tone_glyph(entry.tone);
let actor = fit_text(&entry.actor, (width / 5).clamp(8, 16));
let action = fit_text(&action, (width / 4).clamp(10, 20));
let action_style = match entry.tone {
Tone::Muted => Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
_ => Style::default()
.fg(entry.tone.color())
.add_modifier(Modifier::BOLD),
};
let prefix_width = glyph.chars().count() + actor.chars().count() + action.chars().count() + 8;
let detail_width = width.saturating_sub(prefix_width);
let mut spans = vec![
Span::styled(glyph.to_string(), Style::default().fg(entry.tone.color())),
Span::raw(" "),
Span::styled(
actor,
Style::default()
.fg(actor_color(&entry.actor, entry.tone))
.add_modifier(Modifier::BOLD),
),
Span::styled(" • ", Style::default().fg(Color::DarkGray)),
Span::styled(action, action_style),
];
if detail_width > 0 && !detail.is_empty() {
spans.push(Span::styled(" • ", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
fit_text(&detail, detail_width),
Style::default().fg(Color::DarkGray),
));
}
Line::from(spans)
}
pub(super) fn compact_inline_text_line(
label: &str,
label_style: Style,
content: &str,
width: usize,
) -> Line<'static> {
if width == 0 {
return Line::from("");
}
let label_width = label.chars().count().min(COMPACT_LABEL_WIDTH).min(width);
let content_width = width.saturating_sub(label_width + 1);
if content_width == 0 {
return Line::from(Span::styled(fit_text(label, width), label_style));
}
Line::from(vec![
Span::styled(fit_text(label, label_width), label_style),
Span::styled(" ", Style::default().fg(Color::DarkGray)),
Span::styled(
fit_text(&one_line(content), content_width),
Style::default().fg(Color::White),
),
])
}
pub(super) fn render_file_change_line(file: &ChangedFileStat, width: usize) -> Line<'static> {
let status_width = 1usize;
let delta_width = 5usize;
let path_width = width.saturating_sub(status_width + delta_width * 2 + 3);
let additions = file
.additions
.map(|value| format!("+{value}"))
.unwrap_or_else(|| "+-".to_string());
let deletions = file
.deletions
.map(|value| format!("-{value}"))
.unwrap_or_else(|| "--".to_string());
Line::from(vec![
Span::styled(file.status.clone(), file_status_style(&file.status)),
Span::raw(" "),
Span::styled(
format!("{additions:>width$}", width = delta_width),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled(
format!("{deletions:>width$}", width = delta_width),
Style::default().fg(Color::Red),
),
Span::raw(" "),
Span::styled(
compact_path(&file.path, path_width),
Style::default().fg(Color::Gray),
),
])
}
pub(super) fn render_workset_header_line(
workset: &store::WorksetRecord,
width: usize,
) -> Line<'static> {
let marker = "▣ ";
let status_width = 10usize;
let unit_count = format!("{:02}", workset.items.len());
let fixed_width = marker.chars().count() + status_width + 1 + unit_count.chars().count() + 2;
let id_width = width.saturating_sub(fixed_width).max(1);
Line::from(vec![
Span::styled(marker, Style::default().fg(Color::DarkGray)),
Span::styled(
pad_cell(&workset.status.to_ascii_uppercase(), status_width),
workset_status_style(&workset.status),
),
Span::raw(" "),
Span::styled(unit_count, Style::default().fg(Color::Magenta)),
Span::styled("u", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(
fit_text(&workset.id, id_width),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
])
}
pub(super) fn workset_separator_line(width: usize) -> Line<'static> {
Line::from(Span::styled(
"─".repeat(width.min(72)),
Style::default().fg(Color::DarkGray),
))
}
pub(super) fn render_workset_item_lines(
item: &store::WorksetItemRecord,
width: usize,
) -> Vec<Line<'static>> {
let position_label = if item.position < 100 {
format!("{:02}", item.position)
} else {
item.position.to_string()
};
let position_width = position_label.chars().count();
let role_width = 12usize;
let prefix_width = 2 + position_width + 1 + role_width + 1;
let title_width = width.saturating_sub(prefix_width).max(1);
let title_style = Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD);
let title_parts = wrapped_text_segments(&item.title, title_width);
let mut lines = Vec::with_capacity(title_parts.len().max(1));
for (index, part) in title_parts.into_iter().enumerate() {
if index == 0 {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().fg(Color::DarkGray)),
Span::styled(position_label.clone(), Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(
pad_cell(
&fit_text(&item.role.to_ascii_uppercase(), role_width),
role_width,
),
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
Span::styled(part, title_style),
]));
} else {
lines.push(Line::from(vec![
Span::styled(
" ".repeat(prefix_width),
Style::default().fg(Color::DarkGray),
),
Span::styled(part, title_style),
]));
}
}
if !item.scope.is_empty() {
push_workset_labeled_lines(
&mut lines,
" ",
"SCOPE",
&item.scope,
width,
Style::default().fg(Color::DarkGray),
Style::default().fg(Color::DarkGray),
);
}
if !item.depends_on.is_empty() {
push_workset_labeled_lines(
&mut lines,
" ",
"DEPS",
&item.depends_on.join(", "),
width,
Style::default().fg(Color::Yellow),
Style::default().fg(Color::DarkGray),
);
}
if !item.acceptance.is_empty() {
push_workset_labeled_lines(
&mut lines,
" ",
"PASS",
&item.acceptance,
width,
Style::default().fg(Color::Green),
Style::default().fg(Color::DarkGray),
);
}
if let Some(notes) = item.notes.as_deref().filter(|notes| !notes.is_empty()) {
push_workset_labeled_lines(
&mut lines,
" ",
"NOTE",
notes,
width,
Style::default().fg(Color::DarkGray),
Style::default().fg(Color::DarkGray),
);
}
lines
}
pub(super) fn push_workset_labeled_lines(
lines: &mut Vec<Line<'static>>,
indent: &str,
label: &str,
text: &str,
width: usize,
label_style: Style,
text_style: Style,
) {
push_wrapped_prefixed_lines(
lines,
&format!("{}{:<6} ", indent, fit_text(label, 6)),
text,
width,
label_style,
text_style,
);
}
pub(super) fn push_wrapped_prefixed_lines(
lines: &mut Vec<Line<'static>>,
prefix: &str,
text: &str,
width: usize,
prefix_style: Style,
text_style: Style,
) {
let prefix_width = prefix.chars().count();
let text_width = width.saturating_sub(prefix_width);
if text_width == 0 {
lines.push(Line::from(Span::styled(
fit_text(prefix, width),
prefix_style,
)));
return;
}
for (index, part) in wrapped_text_segments(text, text_width)
.into_iter()
.enumerate()
{
let line_prefix = if index == 0 {
prefix.to_string()
} else {
" ".repeat(prefix_width)
};
lines.push(Line::from(vec![
Span::styled(line_prefix, prefix_style),
Span::styled(part, text_style),
]));
}
}
pub(super) fn wrapped_text_segments(text: &str, width: usize) -> Vec<String> {
wrap_soft_line(&one_line(text), width)
.into_iter()
.map(|part| part.trim_end().to_string())
.collect()
}
pub(super) fn panel_block(title: &str) -> Block<'static> {
panel_block_with_title(panel_title(title))
}
pub(super) fn panel_block_with_title(title: Line<'static>) -> Block<'static> {
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
}
pub(super) fn panel_title(title: &str) -> Line<'static> {
panel_title_segments(vec![Span::styled(
title.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)])
}
pub(super) fn panel_title_segments(segments: Vec<Span<'static>>) -> Line<'static> {
let mut spans = Vec::with_capacity(segments.len() + 2);
let border_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
spans.push(Span::styled(" [ ", border_style));
spans.extend(segments);
spans.push(Span::styled(" ] ", border_style));
Line::from(spans)
}
pub(super) fn header_line(columns: &[(&str, usize)], width: usize) -> Line<'static> {
let mut content = String::new();
for (index, (label, column_width)) in columns.iter().enumerate() {
if index > 0 {
content.push_str(" ");
}
content.push_str(&pad_cell(label, *column_width));
}
Line::from(Span::styled(
fit_text(&content, width),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
))
}
pub(super) fn render_wrapped_row(
row: &WrappedRow,
selection: Option<(SelectionPoint, SelectionPoint)>,
) -> Line<'static> {
let selected_style = Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let Some((start, end)) = selection else {
if row.spans.is_empty() {
return Line::from("");
}
return Line::from(
row.spans
.iter()
.map(|segment| Span::styled(segment.text.clone(), segment.style))
.collect::<Vec<_>>(),
);
};
let Some((selection_start, selection_end)) = selection_overlap_for_row(row, &start, &end)
else {
if row.spans.is_empty() {
return Line::from("");
}
return Line::from(
row.spans
.iter()
.map(|segment| Span::styled(segment.text.clone(), segment.style))
.collect::<Vec<_>>(),
);
};
if row.text.is_empty() || selection_start == selection_end {
if row.spans.is_empty() {
return Line::from("");
}
return Line::from(
row.spans
.iter()
.map(|segment| Span::styled(segment.text.clone(), segment.style))
.collect::<Vec<_>>(),
);
}
let mut spans = Vec::new();
let mut offset = 0usize;
for segment in &row.spans {
let segment_len = segment.text.chars().count();
let segment_start = offset;
let segment_end = offset + segment_len;
let overlap_start = selection_start.max(segment_start);
let overlap_end = selection_end.min(segment_end);
if overlap_start >= overlap_end {
if !segment.text.is_empty() {
spans.push(Span::styled(segment.text.clone(), segment.style));
}
offset = segment_end;
continue;
}
let before_len = overlap_start.saturating_sub(segment_start);
let selected_len = overlap_end.saturating_sub(overlap_start);
let after_len = segment_end.saturating_sub(overlap_end);
if before_len > 0 {
spans.push(Span::styled(
take_chars(&segment.text, before_len),
segment.style,
));
}
if selected_len > 0 {
let selected_text: String = segment
.text
.chars()
.skip(before_len)
.take(selected_len)
.collect();
spans.push(Span::styled(selected_text, selected_style));
}
if after_len > 0 {
let after_text: String = segment
.text
.chars()
.skip(before_len + selected_len)
.take(after_len)
.collect();
spans.push(Span::styled(after_text, segment.style));
}
offset = segment_end;
}
if spans.is_empty() {
Line::from("")
} else {
Line::from(spans)
}
}