use super::model::{self, Completion, Node, Tone, Upstream, Weight};
use ratatui::{
Frame,
buffer::CellWidth,
layout::Rect,
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Paragraph},
};
use std::borrow::Cow;
use time::OffsetDateTime;
use tui_tree_widget::{Tree, TreeItem, TreeState};
use unicode_segmentation::UnicodeSegmentation;
const MARKER: usize = 1;
const FOLD: usize = 2;
const INDENT: usize = 2;
pub(super) fn color(tone: Tone) -> Color {
match tone {
Tone::Green => Color::Green,
Tone::Red => Color::Red,
Tone::Error => Color::Magenta,
Tone::Running | Tone::Queued => Color::Yellow,
Tone::Human => Color::Cyan,
Tone::Blocked => Color::LightRed,
Tone::Muted => Color::DarkGray,
}
}
pub(crate) fn plain(text: &str) -> Cow<'_, str> {
if !text.contains(char::is_control) {
return Cow::Borrowed(text);
}
Cow::Owned(
text.chars()
.filter_map(|character| match character {
'\n' | '\t' => Some(' '),
character if character.is_control() => None,
character => Some(character),
})
.collect(),
)
}
pub(crate) fn width(text: &str) -> usize {
plain(text)
.graphemes(true)
.map(|grapheme| usize::from(grapheme.cell_width()))
.sum()
}
pub(crate) fn fit(text: &str, max: usize) -> String {
let text = plain(text);
if width(&text) <= max {
return text.into_owned();
}
let mut fitted = String::new();
let mut used = 0;
for grapheme in text.graphemes(true) {
let size = usize::from(grapheme.cell_width());
if used + size + 1 > max {
break;
}
used += size;
fitted.push_str(grapheme);
}
if max > 0 {
fitted.push('…');
}
fitted
}
fn lead(depth: usize) -> usize {
MARKER + depth * INDENT + FOLD
}
fn column(nodes: &[Node], depth: usize, total: usize) -> usize {
let widest = nodes
.iter()
.map(|node| {
let own = lead(depth) + 2 + width(&node.name) + width(&node.marks) + 2;
own.max(column(&node.children, depth + 1, total))
})
.max()
.unwrap_or(0);
widest.min(total / 2)
}
pub(super) struct Layout<'h> {
pub total: usize,
pub compact: bool,
pub upstream: &'h [Upstream],
pub now: OffsetDateTime,
}
fn line(node: &Node, depth: usize, column: usize, star: bool, layout: &Layout) -> Line<'static> {
let lead = lead(depth);
let available = layout.total.saturating_sub(lead);
let right = &node.right_at(layout.now, layout.compact);
let tail = width(right) + if star { 2 } else { 0 };
let budget = if layout.compact {
available.saturating_sub(tail + 1)
} else {
column.saturating_sub(lead).max(4).min(available)
};
let artifact = node.id.strip_prefix("a:");
let emphasized = artifact.is_some_and(|id| {
layout
.upstream
.iter()
.any(|up| up.artifact == id && up.completion != Completion::Complete)
});
let dim = node.weight == Weight::Dim;
let row = if dim {
Style::new().fg(Color::DarkGray)
} else {
Style::new()
};
let mut name_style = row;
if node.weight == Weight::Bold || emphasized {
name_style = name_style.add_modifier(Modifier::BOLD);
}
let room = budget.saturating_sub(if layout.compact { 2 } else { 4 });
let (name, marks) = if width(&node.name) + width(&node.marks) <= room {
(
plain(&node.name).into_owned(),
plain(&node.marks).into_owned(),
)
} else if width(&node.name) < room {
let marks = fit(&node.marks, room - width(&node.name));
(plain(&node.name).into_owned(), marks)
} else {
(fit(&node.name, room), String::new())
};
let used = 2 + width(&name) + width(&marks);
let mut spans = vec![
Span::styled(
format!("{} ", node.glyph),
Style::new().fg(color(node.tone)),
),
Span::styled(name, name_style),
Span::styled(marks, Style::new().fg(Color::DarkGray)),
];
let mut used = used;
if !layout.compact && !node.text.is_empty() {
spans.push(Span::raw(" ".repeat(budget.saturating_sub(used))));
used = used.max(budget);
let mut room = available.saturating_sub(used + tail + usize::from(!right.is_empty()));
let text = match node.weight {
Weight::Bold => Style::new().fg(color(node.tone)),
_ => row,
};
for segment in &node.text {
if room == 0 {
break;
}
let fitted = fit(&segment.text, room);
room = room.saturating_sub(width(&fitted));
used += width(&fitted);
let style = segment
.tone
.map_or(text, |tone| Style::new().fg(color(tone)));
spans.push(Span::styled(fitted, style));
}
}
if !right.is_empty() || node.changed {
spans.push(Span::raw(
" ".repeat(available.saturating_sub(used + tail).max(1)),
));
let right_style = if artifact.is_some() || dim {
Style::new().fg(Color::DarkGray)
} else {
Style::new()
};
spans.push(Span::styled(plain(right).into_owned(), right_style));
if node.changed {
spans.push(Span::styled(
" *",
Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM),
));
}
}
Line::from(spans)
}
fn item(
node: &Node,
depth: usize,
column: usize,
star: bool,
layout: &Layout,
) -> std::io::Result<TreeItem<'static, String>> {
let children = node
.children
.iter()
.map(|child| item(child, depth + 1, column, star, layout))
.collect::<Result<_, _>>()?;
TreeItem::new(
node.id.clone(),
line(node, depth, column, star, layout),
children,
)
}
pub(super) fn items(
nodes: &[Node],
layout: &Layout,
) -> std::io::Result<Vec<TreeItem<'static, String>>> {
let column = column(nodes, 0, layout.total);
let star = nodes
.iter()
.any(|node| node.changed || node.children.iter().any(|child| child.changed));
nodes
.iter()
.map(|node| item(node, 0, column, star, layout))
.collect()
}
pub(super) fn marked(
items: &[TreeItem<'static, String>],
state: &TreeState<String>,
upstream: &[Upstream],
) -> Vec<(usize, Completion)> {
let visible = state.flatten(items);
upstream
.iter()
.filter_map(|up| {
let id = vec![format!("a:{}", up.artifact)];
visible
.iter()
.position(|row| row.identifier == id)
.map(|index| (index, up.completion))
})
.collect()
}
pub(super) fn draw(
frame: &mut Frame,
area: Rect,
block: Block<'static>,
nodes: &[Node],
state: &mut TreeState<String>,
layout: Layout,
) {
let inner = block.inner(area);
let layout = Layout {
total: usize::from(inner.width),
..layout
};
let upstream = layout.upstream;
let items = match items(nodes, &layout) {
Ok(items) => items,
Err(error) => {
frame.render_widget(Paragraph::new(error.to_string()).red(), area);
return;
}
};
let tree = match Tree::new(&items) {
Ok(tree) => tree,
Err(error) => {
frame.render_widget(Paragraph::new(error.to_string()).red(), area);
return;
}
};
frame.render_stateful_widget(
tree.block(block)
.highlight_style(Modifier::REVERSED.into())
.highlight_symbol(" ")
.node_open_symbol("▾ ")
.node_closed_symbol("▸ "),
area,
state,
);
let offset = state.get_offset();
let rows = usize::from(inner.height);
let (mut above, mut below) = (0, 0);
for (index, completion) in marked(&items, state, upstream) {
if index < offset {
above += 1;
} else if index >= offset + rows {
below += 1;
} else {
let (_, tone, _) = model::completion(completion);
let style = if completion == Completion::Complete {
Style::new().fg(Color::DarkGray)
} else {
Style::new().fg(color(tone)).add_modifier(Modifier::BOLD)
};
let y = inner.y + (index - offset) as u16;
if let Some(cell) = frame.buffer_mut().cell_mut((inner.x, y)) {
cell.set_symbol("↑").set_style(style);
}
}
}
let border = |y: u16| Rect::new(area.x + 1, y, area.width.saturating_sub(3), 1);
if above > 0 && area.height > 0 {
frame.render_widget(
Line::from(format!(" ↑{above} above ")).right_aligned(),
border(area.y),
);
}
if below > 0 && area.height > 1 {
frame.render_widget(
Line::from(format!(" ↑{below} below ")).right_aligned(),
border(area.bottom() - 1),
);
}
}