use super::clock::format_elapsed;
use super::FRAMES;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Status {
Queued,
Running,
Ok,
Fail,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Line {
pub at: f64,
pub text: String,
}
#[derive(Clone, Debug)]
pub struct Proc {
pub label: String,
pub status: Status,
pub elapsed: f64,
pub lines: Vec<Line>,
pub note: Option<String>,
pub detail: Option<String>,
pub expanded: bool,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Sty {
Plain,
Dim,
Bold,
Cyan,
Green,
Red,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Seg {
pub text: String,
pub sty: Sty,
}
impl Seg {
fn new(text: impl Into<String>, sty: Sty) -> Seg {
Seg { text: text.into(), sty }
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Row {
pub proc: Option<usize>,
pub segs: Vec<Seg>,
}
impl Row {
pub fn plain(&self) -> String {
self.segs.iter().map(|s| s.text.as_str()).collect()
}
}
#[derive(Debug, Default)]
pub struct Model {
pub procs: Vec<Proc>,
scroll: usize,
follow: bool,
}
impl Model {
pub fn new() -> Model {
Model { procs: Vec::new(), scroll: 0, follow: true }
}
pub fn add(&mut self, label: impl Into<String>) -> usize {
self.procs.push(Proc {
label: label.into(),
status: Status::Queued,
elapsed: 0.0,
lines: Vec::new(),
note: None,
detail: None,
expanded: false,
});
self.procs.len() - 1
}
pub fn set_status(&mut self, i: usize, status: Status) {
if let Some(p) = self.procs.get_mut(i) {
p.status = status;
}
}
pub fn set_elapsed(&mut self, i: usize, elapsed: f64) {
if let Some(p) = self.procs.get_mut(i) {
p.elapsed = elapsed;
}
}
pub fn set_note(&mut self, i: usize, note: Option<String>) {
if let Some(p) = self.procs.get_mut(i) {
p.note = note;
}
}
pub fn set_detail(&mut self, i: usize, detail: Option<String>) {
if let Some(p) = self.procs.get_mut(i) {
p.detail = detail;
}
}
pub fn push_line(&mut self, i: usize, at: f64, text: impl Into<String>) {
if let Some(p) = self.procs.get_mut(i) {
p.lines.push(Line { at, text: text.into() });
}
}
pub fn tail_lines(&self, i: usize, max: usize) -> Vec<String> {
let Some(p) = self.procs.get(i) else {
return Vec::new();
};
let start = p.lines.len().saturating_sub(max);
p.lines[start..].iter().map(|l| l.text.clone()).collect()
}
pub fn toggle(&mut self, i: usize) {
if let Some(p) = self.procs.get_mut(i) {
p.expanded = !p.expanded;
if p.expanded {
self.follow = false;
self.scroll = self.proc_first_row_index(i);
}
}
}
pub fn set_all_expanded(&mut self, expanded: bool) {
for p in &mut self.procs {
p.expanded = expanded;
}
if expanded {
self.follow = true;
}
}
pub fn layout(&self, width: usize, frame: u64) -> Vec<Row> {
let width = width.max(1);
let mut rows = Vec::new();
for (i, p) in self.procs.iter().enumerate() {
rows.push(fit_row(header_row_framed(i, p, frame), width));
if p.expanded {
if p.lines.is_empty() {
rows.push(fit_row(Row { proc: None, segs: vec![Seg::new(" (no output yet)", Sty::Dim)] }, width));
} else {
for line in &p.lines {
rows.push(fit_row(output_row(line), width));
}
}
}
}
rows
}
pub fn total_rows(&self, width: usize) -> usize {
self.layout(width, 0).len()
}
pub fn view(&self, width: usize, height: usize, frame: u64) -> (Vec<Row>, usize) {
let all = self.layout(width, frame);
let height = height.max(1);
let max_off = all.len().saturating_sub(height);
let off = self.viewport_offset(height, max_off);
let end = (off + height).min(all.len());
(all[off..end].to_vec(), off)
}
pub fn scroll_by(&mut self, delta: isize, width: usize, height: usize) {
let total = self.total_rows(width);
let height = height.max(1);
let max_off = total.saturating_sub(height);
let cur = self.viewport_offset(height, max_off);
let next = cur.saturating_add_signed(delta).min(max_off);
self.scroll = next;
self.follow = next >= max_off;
}
pub fn scroll_to_top(&mut self) {
self.scroll = 0;
self.follow = false;
}
pub fn scroll_to_bottom(&mut self) {
self.follow = true;
}
fn proc_first_row_index(&self, proc_index: usize) -> usize {
let mut row = 0usize;
for (i, p) in self.procs.iter().enumerate() {
if i == proc_index {
return row;
}
row += rows_for_proc(p);
}
row.saturating_sub(1)
}
fn viewport_offset(&self, _height: usize, max_off: usize) -> usize {
if self.follow {
max_off
} else {
self.scroll.min(max_off)
}
}
}
fn rows_for_proc(p: &Proc) -> usize {
1 + if p.expanded {
if p.lines.is_empty() {
1
} else {
p.lines.len()
}
} else {
0
}
}
pub fn shortcut_label(proc_index: usize) -> String {
if proc_index <= 9 {
format!("[{}] ", proc_index)
} else if proc_index <= 35 {
format!("[{}] ", char::from(b'A' + (proc_index - 10) as u8))
} else {
String::new()
}
}
pub fn proc_index_from_key(c: char) -> Option<usize> {
if c.is_ascii_digit() {
Some((c as u8 - b'0') as usize)
} else if c.is_ascii_alphabetic() {
Some(10 + (c.to_ascii_uppercase() as u8 - b'A') as usize)
} else {
None
}
}
fn glyph(status: Status, frame: u64) -> Seg {
match status {
Status::Queued => Seg::new("·", Sty::Dim),
Status::Running => Seg::new(FRAMES[(frame as usize) % FRAMES.len()], Sty::Cyan),
Status::Ok => Seg::new("✓", Sty::Green),
Status::Fail => Seg::new("✗", Sty::Red),
}
}
fn header_row_framed(i: usize, p: &Proc, frame: u64) -> Row {
let triangle = if p.expanded { "▼ " } else { "▶ " };
let key = shortcut_label(i);
let mut segs = vec![
Seg::new(triangle, Sty::Dim),
Seg::new(key, Sty::Dim),
glyph(p.status, frame),
Seg::new(" ", Sty::Plain),
Seg::new(p.label.clone(), Sty::Bold),
Seg::new(" ", Sty::Plain),
Seg::new(format_elapsed(p.elapsed), Sty::Dim),
];
let tail = match p.status {
Status::Ok | Status::Fail => p.detail.as_deref(),
_ => p.note.as_deref(),
};
if let Some(t) = tail.filter(|t| !t.is_empty()) {
let sty = if p.status == Status::Fail { Sty::Red } else { Sty::Dim };
segs.push(Seg::new(" ", Sty::Plain));
segs.push(Seg::new(t.to_string(), sty));
}
Row { proc: Some(i), segs }
}
fn output_row(line: &Line) -> Row {
Row {
proc: None,
segs: vec![
Seg::new(" ", Sty::Plain),
Seg::new(format!("+{}", format_elapsed(line.at)), Sty::Dim),
Seg::new(" ", Sty::Plain),
Seg::new(line.text.clone(), Sty::Plain),
],
}
}
fn fit_row(row: Row, width: usize) -> Row {
let mut out = Vec::new();
let mut used = 0usize;
for seg in row.segs {
if used >= width {
break;
}
let w = display_width(&seg.text);
if used + w <= width {
used += w;
out.push(seg);
} else {
let clipped = clip(&seg.text, width - used);
if !clipped.is_empty() {
out.push(Seg { text: clipped, sty: seg.sty });
}
break;
}
}
Row { proc: row.proc, segs: out }
}
fn display_width(s: &str) -> usize {
console::measure_text_width(s)
}
fn clip(s: &str, max: usize) -> String {
if max == 0 {
return String::new();
}
console::truncate_str(s, max, "…").into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
fn demo_model() -> Model {
let mut m = Model::new();
let b = m.add("build");
m.set_status(b, Status::Ok);
m.set_elapsed(b, 4.0);
m.push_line(b, 0.5, "STEP 1/3");
m.push_line(b, 3.9, "done");
let s = m.add("opencode: add");
m.set_status(s, Status::Running);
m.set_elapsed(s, 1.2);
m.set_note(s, Some("2 + 3 = 5".into()));
m.push_line(s, 1.0, "running add");
m
}
#[test]
fn collapsed_layout_is_one_row_per_proc() {
let m = demo_model();
let rows = m.layout(80, 0);
assert_eq!(rows.len(), 2, "collapsed: one header each");
assert_eq!(rows[0].proc, Some(0));
assert_eq!(rows[1].proc, Some(1));
assert!(rows[0].plain().starts_with("▶ [0] "), "collapsed triangle + shortcut: {:?}", rows[0].plain());
assert!(rows[0].plain().contains("build"));
assert!(rows[0].plain().contains("4s"));
}
#[test]
fn expanding_reveals_timestamped_output() {
let mut m = demo_model();
m.toggle(0);
let rows = m.layout(80, 0);
assert_eq!(rows.len(), 4);
assert!(rows[0].plain().starts_with("▼ [0] "), "expanded triangle + shortcut");
assert_eq!(rows[1].proc, None, "output line is not a click target");
assert!(rows[1].plain().contains("+0.5s") && rows[1].plain().contains("STEP 1/3"), "{:?}", rows[1].plain());
assert!(rows[2].plain().contains("+3s") && rows[2].plain().contains("done"), "{:?}", rows[2].plain());
}
#[test]
fn expanded_with_no_output_shows_a_placeholder() {
let mut m = Model::new();
let i = m.add("empty");
m.toggle(i);
let rows = m.layout(80, 0);
assert_eq!(rows.len(), 2);
assert!(rows[1].plain().contains("no output"), "{:?}", rows[1].plain());
}
#[test]
fn running_glyph_animates_with_the_frame() {
let m = demo_model();
let g0 = m.layout(80, 0)[1].segs[2].text.clone();
let g1 = m.layout(80, 1)[1].segs[2].text.clone();
assert_ne!(g0, g1, "the running spinner glyph should advance with the frame");
assert!(FRAMES.contains(&g0.as_str()) && FRAMES.contains(&g1.as_str()));
}
#[test]
fn running_shows_note_finished_shows_detail() {
let mut m = Model::new();
let i = m.add("s");
m.set_status(i, Status::Running);
m.set_note(i, Some("latest line".into()));
m.set_detail(i, Some("final detail".into()));
assert!(m.layout(80, 0)[0].plain().contains("latest line"), "running uses note");
m.set_status(i, Status::Ok);
assert!(m.layout(80, 0)[0].plain().contains("final detail"), "finished uses detail");
}
#[test]
fn rows_never_exceed_the_width() {
let mut m = Model::new();
let i = m.add("a-very-long-skill-label-that-keeps-going-and-going");
m.set_status(i, Status::Running);
m.set_note(i, Some("an extremely long trailing note ".repeat(20)));
m.toggle(i);
m.push_line(i, 1.0, "x".repeat(500));
for w in [10usize, 20, 40, 80] {
for row in m.layout(w, 0) {
assert!(display_width(&row.plain()) <= w, "row '{}' exceeds width {w}", row.plain());
}
}
}
#[test]
fn view_follows_the_tail_then_honors_scroll() {
let mut m = Model::new();
let i = m.add("s");
m.toggle(i);
for n in 0..20 {
m.push_line(i, n as f64, format!("line {n}"));
}
let total = m.total_rows(80);
assert_eq!(total, 21, "1 header + 20 lines");
let (vis, off) = m.view(80, 5, 0);
assert_eq!(vis.len(), 5);
assert_eq!(off, 0);
assert!(vis[1].plain().contains("line 0"));
m.scroll_by(100, 80, 5);
let (_, off2) = m.view(80, 5, 0);
assert_eq!(off2, total - 5);
assert!(m.view(80, 5, 0).0.last().unwrap().plain().contains("line 19"));
m.scroll_by(100, 80, 5);
assert_eq!(m.view(80, 5, 0).1, total - 5);
}
#[test]
fn expanded_proc_opens_at_its_header_not_the_fleet_tail() {
let mut m = Model::new();
let a = m.add("skill-a");
let _b = m.add("skill-b");
m.toggle(a);
for n in 0..20 {
m.push_line(a, n as f64, format!("a-{n}"));
}
let total = m.total_rows(80);
assert_eq!(total, 22, "a: header+20 lines, b: header");
let (vis, off) = m.view(80, 5, 0);
assert_eq!(off, 0, "proc a starts at row 0");
assert!(vis[0].plain().contains("skill-a"));
assert!(vis[1].plain().contains("a-0"));
assert!(!vis.iter().any(|r| r.plain().contains("a-19")));
}
#[test]
fn new_lines_do_not_move_viewport_while_reading_from_the_top() {
let mut m = Model::new();
let a = m.add("skill-a");
let _b = m.add("skill-b");
m.toggle(a);
for n in 0..10 {
m.push_line(a, n as f64, format!("a-{n}"));
}
let (vis, off) = m.view(80, 5, 0);
assert!(vis.iter().any(|r| r.plain().contains("a-0")));
m.push_line(a, 11.0, "a-10");
m.push_line(a, 12.0, "a-11");
let (vis2, off2) = m.view(80, 5, 0);
assert_eq!(off2, off);
assert!(vis2.iter().any(|r| r.plain().contains("a-0")));
assert!(!vis2.iter().any(|r| r.plain().contains("a-11")));
}
#[test]
fn expand_later_proc_scrolls_to_its_header() {
let mut m = Model::new();
for _ in 0..6 {
m.add("header-only");
}
let target = m.add("skill-f");
m.toggle(target);
for n in 0..5 {
m.push_line(target, n as f64, format!("f-{n}"));
}
let (vis, off) = m.view(80, 5, 0);
assert_eq!(off, 6, "six collapsed headers precede skill-f");
assert!(vis[0].plain().contains("skill-f"));
assert!(vis.get(1).is_some_and(|r| r.plain().contains("f-0")));
}
#[test]
fn scroll_to_top_and_bottom() {
let mut m = Model::new();
let i = m.add("s");
m.toggle(i);
for n in 0..30 {
m.push_line(i, n as f64, format!("l{n}"));
}
m.scroll_to_top();
assert_eq!(m.view(80, 5, 0).1, 0);
m.scroll_to_bottom();
assert_eq!(m.view(80, 5, 0).1, m.total_rows(80) - 5);
}
#[test]
fn shortcut_labels_cover_many_procs() {
assert_eq!(shortcut_label(0), "[0] ");
assert_eq!(shortcut_label(9), "[9] ");
assert_eq!(shortcut_label(10), "[A] ");
assert_eq!(shortcut_label(15), "[F] ");
assert_eq!(proc_index_from_key('0'), Some(0));
assert_eq!(proc_index_from_key('5'), Some(5));
assert_eq!(proc_index_from_key('a'), Some(10));
assert_eq!(proc_index_from_key('F'), Some(15));
}
#[test]
fn toggle_flips_only_the_target_proc() {
let mut m = demo_model();
assert!(!m.procs[0].expanded && !m.procs[1].expanded);
m.toggle(1);
assert!(!m.procs[0].expanded && m.procs[1].expanded);
m.set_all_expanded(true);
assert!(m.procs[0].expanded && m.procs[1].expanded);
m.set_all_expanded(false);
assert!(!m.procs[0].expanded && !m.procs[1].expanded);
}
}