use std::{
io::{self, Write},
time::Duration,
};
use crossterm::{
cursor::{Hide, MoveTo, Show},
queue,
style::{Attribute, Color, Print, SetAttribute, SetBackgroundColor},
terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate},
};
use unicode_width::UnicodeWidthStr;
use crate::{
app::{App, DirKind, GroupMode, Mode, Row, SessionPage},
editbuf::EditBuffer,
format::{pad, rel_time, truncate},
path,
protocol::{Lifecycle, Preview, PreviewSource, RecoveryEntry, TaskView},
selection::Selection,
};
pub fn render(out: &mut impl Write, app: &mut App) -> io::Result<bool> {
let mut buf: Vec<u8> = Vec::with_capacity(app.cols as usize * app.rows as usize * 3 + 128);
queue!(buf, BeginSynchronizedUpdate)?;
match app.mode {
Mode::Attached => render_attached(&mut buf, app)?,
Mode::Peek => {
render_dashboard(&mut buf, app)?;
render_peek(&mut buf, app)?;
}
Mode::PickDir => {
render_dashboard(&mut buf, app)?;
render_pickdir(&mut buf, app)?;
}
Mode::PickGroup { .. } => {
render_dashboard(&mut buf, app)?;
render_pickgroup(&mut buf, app)?;
}
Mode::Find => {
render_dashboard(&mut buf, app)?;
render_find(&mut buf, app)?;
}
Mode::Controls => {
render_dashboard(&mut buf, app)?;
render_controls(&mut buf, app)?;
}
Mode::LoadSession => {
render_dashboard(&mut buf, app)?;
render_session_picker(&mut buf, app)?;
}
Mode::Disconnected => render_disconnected(&mut buf, app)?,
Mode::Dashboard | Mode::Spawn | Mode::SaveSession | Mode::Rename(_) => {
render_dashboard(&mut buf, app)?
}
}
queue!(buf, EndSynchronizedUpdate)?;
if buf == app.last_frame {
return Ok(false);
}
out.write_all(&buf)?;
out.flush()?;
app.last_frame = buf;
Ok(true)
}
fn put(out: &mut impl Write, y: u16, s: &str, cols: usize) -> io::Result<()> {
queue!(out, MoveTo(0, y), Print(pad(s, cols)))
}
fn dim(out: &mut impl Write, y: u16, s: &str, cols: usize) -> io::Result<()> {
queue!(
out,
MoveTo(0, y),
SetAttribute(Attribute::Dim),
Print(pad(s, cols)),
SetAttribute(Attribute::Reset)
)
}
fn rev(out: &mut impl Write, y: u16, s: &str, cols: usize, focused: bool) -> io::Result<()> {
queue!(out, MoveTo(0, y))?;
if focused {
queue!(out, SetAttribute(Attribute::Reverse))?;
} else {
queue!(out, SetBackgroundColor(Color::DarkGrey))?;
}
queue!(out, Print(pad(s, cols)), SetAttribute(Attribute::Reset))
}
fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> {
let cols = app.cols as usize;
let rows = app.rows;
queue!(out, Hide, MoveTo(0, 0))?;
let (mut running, mut idle, mut done) = (0u32, 0u32, 0u32);
for v in &app.views {
match v.lifecycle {
Lifecycle::Active => running += 1,
Lifecycle::Idle => idle += 1,
Lifecycle::Ok | Lifecycle::Failed => done += 1,
}
}
let list_top = 2u16;
let list_bottom = rows.saturating_sub(3);
let height = (usize::from(list_bottom) + 1).saturating_sub(usize::from(list_top));
let list = app.list_rows();
let sel_row = app.selected_row(&list);
let (start, count) = scroll_window(sel_row.unwrap_or(0), list.len(), height);
let mode_tag = if app.daemon_backed {
""
} else {
" · foreground"
};
let scroll_tag = match sel_row {
Some(s) if list.len() > height => {
let pos = list[..=s]
.iter()
.filter(|r| matches!(r, Row::Task(_)))
.count();
format!(" · {pos}/{}", app.views.len())
}
_ => String::new(),
};
let prefix = format!(" fleetcom {running} running · {idle} idle · {done} done by ");
let suffix = format!("{mode_tag}{scroll_tag}");
let segs = header_segments(&prefix, app.group_mode, &suffix);
let plain: String = segs.iter().map(|(t, _)| t.as_str()).collect();
let display = pad(&plain, cols);
let mut chars = display.chars();
queue!(out, MoveTo(0, 0))?;
for (text, intensity) in &segs {
let n = text.chars().count();
if n == 0 {
continue;
}
let piece: String = chars.by_ref().take(n).collect();
if piece.is_empty() {
break; }
let attr = match intensity {
Intensity::Bold => Attribute::Bold,
Intensity::Dim => Attribute::Dim,
};
queue!(
out,
SetAttribute(attr),
Print(piece),
SetAttribute(Attribute::Reset)
)?;
}
let rest: String = chars.collect();
if !rest.is_empty() {
queue!(out, Print(rest))?;
}
put(out, 1, "", cols)?;
let mut y = list_top;
for row in &list[start..start + count] {
match row {
Row::Section(label) => dim(out, y, &format!(" {label}"), cols)?,
Row::Task(ti) => {
let v = &app.views[*ti];
if app.selected_id == Some(v.id) {
rev(out, y, &task_row(v, cols), cols, app.terminal_focused)?;
} else if v.preview.source == PreviewSource::Marker {
dim_preview_row(out, y, v, cols)?;
} else {
put(out, y, &task_row(v, cols), cols)?;
}
}
}
y += 1;
}
while y <= list_bottom {
put(out, y, "", cols)?;
y += 1;
}
let cmd_y = rows.saturating_sub(2);
let cmd = cmdline(app);
match &cmd {
Some((line, _)) => put(out, cmd_y, line, cols)?,
None => match transient_line(app.notice(), app.status.as_deref()) {
Some(line) => put(out, cmd_y, &line, cols)?,
None => dim(out, cmd_y, " ❯ n run · @ dir · / find · s sort", cols)?,
},
}
dim(
out,
rows.saturating_sub(1),
" ↑↓ select · enter attach · space peek · ? controls",
cols,
)?;
match &cmd {
Some((_, cx)) => queue!(out, MoveTo(*cx, cmd_y), Show)?,
None => queue!(out, Hide)?,
}
Ok(())
}
fn transient_line(notice: Option<&str>, status: Option<&str>) -> Option<String> {
notice.or(status).map(|s| format!(" {s}"))
}
fn cmdline(app: &App) -> Option<(String, u16)> {
let prefix = match app.mode {
Mode::Spawn => spawn_prefix(app),
Mode::SaveSession => " save session as: ".to_string(),
Mode::Rename(_) => " rename task: ".to_string(),
_ => return None,
};
Some(caret_line(&prefix, &app.input, app.cols as usize))
}
fn caret_line(prefix: &str, buf: &EditBuffer, cols: usize) -> (String, u16) {
let line = format!("{prefix}{}", buf.as_str());
let cx = (prefix.width() + buf.before_caret().width()) as u16;
let cx = cx.min(truncate(&line, cols).width() as u16);
(line, cx)
}
fn spawn_prefix(app: &App) -> String {
let dir = (app.spawn_cwd != app.invocation_dir).then(|| path::abbreviate(&app.spawn_cwd));
prompt_line(dir.as_deref(), app.spawn_group.as_deref(), "")
}
fn prompt_line(dir: Option<&str>, group: Option<&str>, input: &str) -> String {
let mut line = String::from(" ❯ ");
for seg in [dir, group].into_iter().flatten() {
line.push_str(seg);
line.push_str(" ▸ ");
}
line.push_str(input);
line
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Intensity {
Bold,
Dim,
}
fn header_segments(prefix: &str, active: GroupMode, suffix: &str) -> Vec<(String, Intensity)> {
const STRIP: [GroupMode; 3] = [GroupMode::State, GroupMode::Dir, GroupMode::Custom];
let mut segs = vec![(prefix.to_string(), Intensity::Bold)];
for (i, m) in STRIP.iter().enumerate() {
if i > 0 {
segs.push((" · ".to_string(), Intensity::Dim));
}
let intensity = if *m == active {
Intensity::Bold
} else {
Intensity::Dim
};
segs.push((m.label().to_string(), intensity));
}
segs.push((suffix.to_string(), Intensity::Bold));
segs
}
fn display_label(v: &TaskView) -> &str {
v.name.as_deref().unwrap_or(&v.command)
}
fn attached_title(v: &TaskView) -> String {
match &v.name {
Some(n) => format!("{n} · {}", v.command),
None => v.command.clone(),
}
}
fn row_age(v: &TaskView) -> Duration {
let edge = match (v.lifecycle, v.parked) {
(Lifecycle::Ok | Lifecycle::Failed, _) => v.finished_ago,
(_, true) => v.quiet_ago,
(_, false) => None,
};
edge.unwrap_or(v.started_ago)
}
fn status_glyph(v: &TaskView) -> &'static str {
match v.lifecycle {
Lifecycle::Active => "✻",
Lifecycle::Idle => "∙",
Lifecycle::Ok => "✓",
Lifecycle::Failed => "✗",
}
}
fn task_row_parts(v: &TaskView, cols: usize) -> (String, String, String) {
let glyph = status_glyph(v);
let tag = if v.tagged { "◆" } else { " " };
let time = rel_time(row_age(v));
let title_w = 26.min(cols / 3);
let used = 2 + 1 + 1 + 1 + title_w + 1 + 1 + time.width();
let prev_w = cols.saturating_sub(used);
(
format!(" {glyph} {tag}{} ", pad(display_label(v), title_w)),
pad(&v.preview.text, prev_w),
format!(" {time}"),
)
}
fn task_row(v: &TaskView, cols: usize) -> String {
let (lead, preview, time) = task_row_parts(v, cols);
format!("{lead}{preview}{time}")
}
fn dim_preview_row(out: &mut impl Write, y: u16, v: &TaskView, cols: usize) -> io::Result<()> {
let (lead, preview, time) = task_row_parts(v, cols);
let lead = truncate(&lead, cols);
let mut rem = cols - lead.width();
let preview = truncate(&preview, rem);
rem -= preview.width();
queue!(
out,
MoveTo(0, y),
Print(lead),
SetAttribute(Attribute::Dim),
Print(preview),
SetAttribute(Attribute::Reset),
Print(pad(&time, rem))
)
}
fn top_border(label: &str, inner_w: usize) -> String {
let mut border = format!("─ {} ", truncate(label, inner_w.saturating_sub(4)));
let w = border.width();
if w < inner_w {
border.extend(std::iter::repeat_n('─', inner_w - w));
}
border
}
struct Overlay<'a> {
cols: usize,
rows: usize,
bw: usize,
bh: usize,
label: &'a str,
body: &'a [String],
footer: &'a str,
}
fn render_overlay(out: &mut impl Write, o: &Overlay) -> io::Result<()> {
let x0 = o.cols.saturating_sub(o.bw) / 2;
let y0 = o.rows.saturating_sub(o.bh) / 2;
let (inner_w, inner_h) = (o.bw.saturating_sub(2), o.bh.saturating_sub(2));
queue!(
out,
MoveTo(x0 as u16, y0 as u16),
Print(format!("┌{}┐", top_border(o.label, inner_w)))
)?;
for k in 0..inner_h {
let line = o.body.get(k).map_or("", String::as_str);
queue!(
out,
MoveTo(x0 as u16, (y0 + 1 + k) as u16),
Print(format!("│{}│", pad(line, inner_w)))
)?;
}
let by = (y0 + 1 + inner_h) as u16;
queue!(
out,
MoveTo(x0 as u16, by),
Print(format!("└{}┘", "─".repeat(inner_w)))
)?;
queue!(
out,
MoveTo((x0 + 2) as u16, by),
SetAttribute(Attribute::Dim),
Print(truncate(o.footer, inner_w)),
SetAttribute(Attribute::Reset)
)
}
fn render_peek(out: &mut impl Write, app: &App) -> io::Result<()> {
let Some(i) = app.selected_task() else {
return Ok(());
};
let v = &app.views[i];
let cols = app.cols as usize;
let rows = app.rows as usize;
let bw = (cols * 3 / 4).clamp(24, cols.max(24));
let bh = rows.saturating_sub(6).clamp(5, 16);
let lines: &[String] = app.screen_for(v.id).map_or(&[], |s| &s.lines);
let start = lines.len().saturating_sub(bh.saturating_sub(2));
let tail = &lines[start..];
let footer = format!(
" space/esc close · enter attach · preview: {} ",
preview_provenance(&v.preview)
);
render_overlay(
out,
&Overlay {
cols,
rows,
bw,
bh,
label: display_label(v),
body: tail,
footer: &footer,
},
)
}
fn preview_provenance(p: &Preview) -> String {
let mut s = p.source.label().to_string();
if let Some(rule) = p.rule {
s.push('/');
s.push_str(rule);
}
if p.frozen {
s.push_str(" (frozen)");
}
s
}
struct Control {
key: &'static str,
desc: &'static str,
group: &'static str,
}
impl Control {
const fn new(key: &'static str, desc: &'static str, group: &'static str) -> Self {
Self { key, desc, group }
}
}
const CONTROLS: [Control; 19] = [
Control::new("↑↓ / kj", "move selection", "Navigate"),
Control::new("Tab ⇧Tab", "jump section", "Navigate"),
Control::new("/", "find a task", "Navigate"),
Control::new("M", "next tagged", "Navigate"),
Control::new("enter", "attach", "Act"),
Control::new("space", "peek", "Act"),
Control::new("r", "rerun finished", "Act"),
Control::new("X", "kill or remove", "Act"),
Control::new("m", "tag in use", "Organize"),
Control::new("g", "assign group", "Organize"),
Control::new("R", "rename", "Organize"),
Control::new("s", "cycle grouping", "Organize"),
Control::new("n", "run here", "Create"),
Control::new("@", "run in a dir", "Create"),
Control::new("w", "save session", "Session"),
Control::new("o", "load session", "Session"),
Control::new("q", "detach", "Leave"),
Control::new("Q", "quit and kill", "Leave"),
Control::new("Ctrl-\\", "background", "Attached"),
];
fn control_desc(c: &Control, daemon_backed: bool) -> &'static str {
match c.key {
"q" if !daemon_backed => "quit",
_ => c.desc,
}
}
fn control_groups() -> Vec<&'static [Control]> {
let mut groups = Vec::new();
let mut start = 0;
for i in 1..=CONTROLS.len() {
if i == CONTROLS.len() || CONTROLS[i].group != CONTROLS[start].group {
groups.push(&CONTROLS[start..i]);
start = i;
}
}
groups
}
fn grouped_rows() -> usize {
control_groups()
.iter()
.map(|g| 1 + g.len().div_ceil(2))
.sum()
}
fn flat_rows() -> usize {
CONTROLS.len().div_ceil(2)
}
fn render_controls(out: &mut impl Write, app: &App) -> io::Result<()> {
let cols = app.cols as usize;
let rows = app.rows as usize;
let key_w = CONTROLS.iter().map(|c| c.key.width()).max().unwrap_or(0);
let desc_w = CONTROLS.iter().map(|c| c.desc.width()).max().unwrap_or(0);
let cell_w = key_w + 2 + desc_w;
let cell = |c: &Control| {
format!(
"{} {}",
pad(c.key, key_w),
control_desc(c, app.daemon_backed)
)
};
let two_col = |run: &[Control]| -> Vec<String> {
let h = run.len().div_ceil(2);
(0..h)
.map(|i| match run.get(h + i) {
Some(r) => format!(" {} {}", pad(&cell(&run[i]), cell_w), cell(r)),
None => format!(" {}", cell(&run[i])),
})
.collect()
};
let avail = rows.saturating_sub(4).max(3);
let body_h = avail - 2;
let mut hidden = 0;
let body: Vec<String> = if grouped_rows() <= body_h {
control_groups()
.into_iter()
.flat_map(|g| {
let header = std::iter::once(format!(" {}", g[0].group));
header.chain(two_col(g).into_iter().map(|r| format!(" {r}")))
})
.collect()
} else if flat_rows() <= body_h {
two_col(&CONTROLS)
} else {
let shown = (body_h * 2).min(CONTROLS.len());
hidden = CONTROLS.len() - shown;
two_col(&CONTROLS[..shown])
};
let content_w = body.iter().map(|s| s.width()).max().unwrap_or(0);
let bw = (content_w + 3).min(cols.max(4));
let bh = body.len() + 2;
let more = if hidden > 0 {
format!(" · +{hidden} more")
} else {
String::new()
};
render_overlay(
out,
&Overlay {
cols,
rows,
bw,
bh,
label: "controls",
body: &body,
footer: &format!(" ? esc close{more} "),
},
)
}
struct Panel<'a> {
header: String,
labels: &'a [String],
sel: usize,
max_rows: usize,
hint: String,
empty: Option<&'a str>,
cursor: Option<u16>,
}
fn render_panel(out: &mut impl Write, app: &App, p: &Panel) -> io::Result<()> {
let cols = app.cols as usize;
let rows = app.rows;
let total = p.labels.len();
let max_list = p.max_rows.min((rows as usize).saturating_sub(4)).max(1);
let (start, visible) = scroll_window(p.sel, total, max_list);
let body = visible.max(1);
let panel_h = (body + 2) as u16;
let top = rows.saturating_sub(panel_h).max(2);
rev(out, top, &p.header, cols, app.terminal_focused)?;
if total == 0 {
if let Some(msg) = p.empty {
dim(out, top + 1, msg, cols)?;
}
} else {
for row in 0..visible {
let idx = start + row;
let y = top + 1 + row as u16;
let marker = if idx == p.sel { "▸ " } else { " " };
let line = format!(" {marker}{}", p.labels[idx]);
if idx == p.sel {
rev(out, y, &line, cols, app.terminal_focused)?;
} else {
put(out, y, &line, cols)?;
}
}
}
let pos = if total > visible {
format!(" · {}/{}", p.sel + 1, total)
} else {
String::new()
};
dim(
out,
top + 1 + body as u16,
&format!(" {}{pos}", p.hint),
cols,
)?;
match p.cursor {
Some(cx) => queue!(out, MoveTo(cx, top), Show),
None => queue!(out, Hide),
}
}
fn render_pickdir(out: &mut impl Write, app: &App) -> io::Result<()> {
let labels: Vec<String> = app
.dir_candidates
.iter()
.map(|c| {
let sep = if c.label.ends_with('/') { "" } else { "/" };
format!("{}{sep}", c.label)
})
.collect();
let action = match app.dir_candidates.get(app.dir_sel).map(|c| c.kind) {
Some(DirKind::Use) => "enter run here",
Some(DirKind::Jump) => "enter run here · tab browse",
Some(DirKind::Into) => "enter/tab open",
None => "",
};
let (line, cx) = caret_line(" @ ", &app.dir_input, app.cols as usize);
render_panel(
out,
app,
&Panel {
header: format!("{line} "),
labels: &labels,
sel: app.dir_sel,
max_rows: 8,
hint: format!("{action} · ↑↓ pick · esc"),
empty: Some(" (no matching directories)"),
cursor: Some(cx),
},
)
}
fn render_pickgroup(out: &mut impl Write, app: &App) -> io::Result<()> {
let labels: Vec<String> = app
.group_candidates
.iter()
.map(|c| c.label.clone())
.collect();
let action = if app.group_is_new() {
"enter create"
} else {
match app.group_candidates.get(app.group_sel) {
Some(c) if c.group.is_some() => "enter assign",
Some(_) => "enter clear",
None => "",
}
};
let (line, cx) = caret_line(" g ", &app.group_input, app.cols as usize);
render_panel(
out,
app,
&Panel {
header: format!("{line} "),
labels: &labels,
sel: app.group_sel,
max_rows: 8,
hint: format!("{action} · ↑↓ pick · esc"),
empty: None,
cursor: Some(cx),
},
)
}
fn render_find(out: &mut impl Write, app: &App) -> io::Result<()> {
let sections = app.sections();
let section_of = |id: u64| -> &str {
sections
.iter()
.find(|(_, idxs)| idxs.iter().any(|&i| app.views[i].id == id))
.map_or("", |(label, _)| label.as_str())
};
let labels: Vec<String> = app
.find_candidates
.iter()
.map(|&id| match app.views.iter().find(|v| v.id == id) {
Some(v) => format!(
"{} {} · {}",
status_glyph(v),
display_label(v),
section_of(id)
),
None => String::new(),
})
.collect();
let (line, cx) = caret_line(" / ", &app.find_input, app.cols as usize);
render_panel(
out,
app,
&Panel {
header: format!("{line} "),
labels: &labels,
sel: app.find_sel,
max_rows: 8,
hint: "enter jump · ↑↓ pick · esc".to_string(),
empty: Some(" (no matching tasks)"),
cursor: Some(cx),
},
)
}
fn recovery_row(e: &RecoveryEntry) -> String {
let unit = if e.tasks == 1 { "task" } else { "tasks" };
format!(
"{} ago · {} {unit} · {}",
rel_time(Duration::from_secs(e.age_secs)),
e.tasks,
e.label
)
}
fn saved_page_hint(recovery: usize) -> String {
if recovery > 0 {
format!("↑↓ pick · enter load · tab recovery ({recovery}) · esc")
} else {
"↑↓ pick · enter load · esc".to_string()
}
}
fn render_session_picker(out: &mut impl Write, app: &App) -> io::Result<()> {
let recovery: Vec<String> = app.session_recovery.iter().map(recovery_row).collect();
let (header, labels, sel, hint, empty) = match app.session_page {
SessionPage::Saved => (
" load session",
app.session_names.as_slice(),
app.session_sel,
saved_page_hint(recovery.len()),
Some(" (no saved sessions)"),
),
SessionPage::Recovery => (
" recovery",
recovery.as_slice(),
app.recovery_sel,
"↑↓ pick · enter load · tab saved · esc".to_string(),
None,
),
};
render_panel(
out,
app,
&Panel {
header: header.to_string(),
labels,
sel,
max_rows: 10,
hint,
empty,
cursor: None,
},
)
}
fn center(s: &str, width: usize) -> String {
let len = s.width();
if len >= width {
return truncate(s, width);
}
let mut out = " ".repeat((width - len) / 2);
out.push_str(s);
pad(&out, width)
}
fn render_disconnected(out: &mut impl Write, app: &App) -> io::Result<()> {
let cols = app.cols as usize;
let rows = app.rows;
queue!(out, Hide)?;
for y in 0..rows {
put(out, y, "", cols)?;
}
let (title, hint) = if app.daemon_backed {
("⚠ daemon connection lost", "r reconnect q quit")
} else {
("⚠ core stopped", "q quit")
};
let mid = rows / 2;
put(out, mid.saturating_sub(1), ¢er(title, cols), cols)?;
dim(out, mid + 1, ¢er(hint, cols), cols)?;
Ok(())
}
fn render_attached(out: &mut impl Write, app: &App) -> io::Result<()> {
let Some(i) = app.focused_task() else {
return Ok(());
};
let v = &app.views[i];
let screen = app.screen_for(v.id);
queue!(out, Hide, MoveTo(0, 0))?;
if let Some(s) = screen {
out.write_all(&s.formatted)?;
for (row, col, text) in selection_overlay(app.selection(), &s.lines) {
queue!(
out,
MoveTo(col, row),
SetAttribute(Attribute::Reverse),
Print(text),
SetAttribute(Attribute::Reset)
)?;
}
}
let cols = app.cols as usize;
let title = attached_title(v);
let bar = attached_bar(&title, screen.map_or(0, |s| s.scrollback), app.notice());
rev(
out,
app.rows.saturating_sub(1),
&bar,
cols,
app.terminal_focused,
)?;
match screen {
Some(s) if !s.hide_cursor => queue!(out, MoveTo(s.cursor.1, s.cursor.0), Show)?,
_ => queue!(out, Hide)?,
}
Ok(())
}
fn selection_overlay<'a>(sel: Option<&Selection>, lines: &'a [String]) -> Vec<(u16, u16, &'a str)> {
let (Some(sel), Some(last)) = (sel, lines.len().checked_sub(1)) else {
return Vec::new();
};
lines
.iter()
.enumerate()
.filter_map(|(row, text)| {
sel.row_segment(row, text, last).map(|(col, seg)| {
(row as u16, col, seg)
})
})
.collect()
}
fn attached_bar(title: &str, scrollback: usize, notice: Option<&str>) -> String {
match (scrollback, notice) {
(0, Some(n)) => format!(" [attached] {title} {n}"),
(0, None) => format!(" [attached] {title} Ctrl-\\ background"),
(n, Some(msg)) => format!(" [scroll ↑{n}] {title} {msg}"),
(n, None) => {
format!(" [scroll ↑{n}] {title} Esc live · PgUp/PgDn move · Ctrl-\\ background")
}
}
}
pub fn scroll_window(sel: usize, total: usize, max: usize) -> (usize, usize) {
if total == 0 || max == 0 {
return (0, 0);
}
let count = total.min(max);
let start = if sel >= count { sel + 1 - count } else { 0 };
(start, count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selection_overlay_spans_rows_and_rounds_wide_glyphs() {
let lines: Vec<String> = ["a日本b", " mid ", "tail"]
.iter()
.map(|s| s.to_string())
.collect();
let mut sel = Selection::begin(0, 2);
sel.extend(2, 1);
assert_eq!(
selection_overlay(Some(&sel), &lines),
vec![(0, 1, "日本b"), (1, 0, " mid"), (2, 0, "ta")]
);
assert!(selection_overlay(None, &lines).is_empty());
assert!(selection_overlay(Some(&sel), &[]).is_empty());
}
#[test]
fn scroll_window_keeps_selection_visible() {
assert_eq!(scroll_window(0, 5, 8), (0, 5)); assert_eq!(scroll_window(4, 5, 8), (0, 5));
assert_eq!(scroll_window(7, 20, 8), (0, 8)); assert_eq!(scroll_window(8, 20, 8), (1, 8)); assert_eq!(scroll_window(19, 20, 8), (12, 8)); for sel in 0..20 {
let (start, count) = scroll_window(sel, 20, 8);
assert!(sel >= start && sel < start + count, "sel {sel} off-window");
}
}
fn recovery_entry(age_secs: u64, tasks: u32, label: &str) -> RecoveryEntry {
RecoveryEntry {
stem: "20260101-000000-1".into(),
label: label.into(),
tasks,
age_secs,
}
}
#[test]
fn recovery_row_shapes() {
assert_eq!(
recovery_row(&recovery_entry(3, 1, "autosaved 2026-01-01 00:00")),
"3s ago · 1 task · autosaved 2026-01-01 00:00"
);
assert_eq!(
recovery_row(&recovery_entry(240, 12, "autosaved 2026-01-02 08:30")),
"4m ago · 12 tasks · autosaved 2026-01-02 08:30"
);
assert_eq!(
recovery_row(&recovery_entry(2 * 86_400, 0, "x")),
"2d ago · 0 tasks · x"
);
}
#[test]
fn recovery_row_clips_column_exact_for_wide_labels() {
let long = "日本語のラベルがここに延々と続いています";
let row = recovery_row(&recovery_entry(90, 3, long));
assert!(row.starts_with("1m ago · 3 tasks · "));
for cols in [24usize, 40, 80] {
let clipped = pad(&row, cols);
assert_eq!(clipped.width(), cols, "cols {cols}: {clipped:?}");
}
}
#[test]
fn saved_page_hint_shows_the_count_only_when_nonzero() {
assert_eq!(saved_page_hint(0), "↑↓ pick · enter load · esc");
assert_eq!(
saved_page_hint(3),
"↑↓ pick · enter load · tab recovery (3) · esc"
);
}
#[test]
fn spawn_prompt_decoration_shapes() {
assert_eq!(prompt_line(None, None, "cargo test"), " ❯ cargo test");
assert_eq!(
prompt_line(Some("~/x"), None, "cargo test"),
" ❯ ~/x ▸ cargo test"
);
assert_eq!(
prompt_line(None, Some("alpha"), "cargo test"),
" ❯ alpha ▸ cargo test"
);
assert_eq!(
prompt_line(Some("~/x"), Some("alpha"), "cargo test"),
" ❯ ~/x ▸ alpha ▸ cargo test"
);
}
fn view(name: Option<&str>) -> TaskView {
TaskView {
id: 1,
command: "cargo test".into(),
cwd: std::path::PathBuf::from("/tmp"),
tagged: false,
group: None,
name: name.map(str::to_string),
lifecycle: Lifecycle::Active,
parked: false,
preview: Preview::floor(String::new()),
started_ago: std::time::Duration::from_secs(5),
quiet_ago: None,
finished_ago: None,
}
}
fn timed_view(
lifecycle: Lifecycle,
parked: bool,
quiet_ago: Option<Duration>,
finished_ago: Option<Duration>,
) -> TaskView {
TaskView {
lifecycle,
parked,
quiet_ago,
finished_ago,
started_ago: Duration::from_secs(2 * 60 * 60),
..view(None)
}
}
#[test]
fn task_row_time_column_follows_the_debounced_state() {
let quiet = Some(Duration::from_secs(4 * 60)); let exited = Some(Duration::from_secs(3)); let cases = [
(Lifecycle::Ok, false, None, exited, "3s"),
(Lifecycle::Failed, false, None, exited, "3s"),
(Lifecycle::Idle, true, quiet, None, "4m"),
(Lifecycle::Idle, true, None, None, "2h"), (Lifecycle::Active, false, None, None, "2h"),
(Lifecycle::Ok, false, None, None, "2h"), ];
for (lifecycle, parked, quiet_ago, finished_ago, want) in cases {
let row = task_row(&timed_view(lifecycle, parked, quiet_ago, finished_ago), 80);
assert!(
row.ends_with(want),
"{lifecycle:?} parked={parked} wanted {want:?}: {row:?}"
);
}
}
#[test]
fn task_row_prefers_the_custom_name() {
let row = task_row(&view(None), 80);
assert!(row.contains("cargo test"), "row was {row:?}");
let row = task_row(&view(Some("api server")), 80);
assert!(row.contains("api server"), "row was {row:?}");
assert!(
!row.contains("cargo test"),
"the name replaces the command: {row:?}"
);
}
#[test]
fn task_row_is_column_exact_for_wide_glyphs() {
let titles = [
"plain ascii title",
"日本語のタスクタイトルです", "🚀 emoji 🚀 title",
"e\u{0301}e\u{0301} combining", ];
let previews = [
"build ok",
"ビルド中の😀プレビュー出力がここに続いています",
"",
];
for cols in [40usize, 80] {
for title in titles {
for preview in previews {
let mut v = timed_view(Lifecycle::Active, false, None, None);
v.name = Some(title.to_string());
v.preview = Preview::floor(preview.to_string());
let row = task_row(&v, cols);
assert_eq!(
row.width(),
cols,
"cols {cols} title {title:?} preview {preview:?}: {row:?}"
);
assert!(row.ends_with(" 2h"), "time cell lost: {row:?}");
}
}
}
}
#[test]
fn task_row_cells_hold_their_column_budgets() {
let cols = 72;
let ascii = timed_view(Lifecycle::Active, false, None, None);
let mut wide = timed_view(Lifecycle::Active, false, None, None);
wide.name = Some("日本語のテスト".into());
wide.preview = Preview::floor("進捗 50% 😀".into());
let (al, ap, at) = task_row_parts(&ascii, cols);
let (wl, wp, wt) = task_row_parts(&wide, cols);
assert_eq!(al.width(), wl.width(), "lead width varies with content");
assert_eq!(ap.width(), wp.width(), "preview width varies with content");
assert_eq!(at.width(), wt.width(), "time width varies with content");
assert_eq!(wl.width() + wp.width() + wt.width(), cols);
}
#[test]
fn control_table_is_unique_and_partitioned_by_group() {
let mut keys: Vec<&str> = Vec::new();
for c in &CONTROLS {
assert!(!c.key.is_empty(), "empty key beside {:?}", c.desc);
assert!(!c.desc.is_empty(), "empty description beside {}", c.key);
assert!(!keys.contains(&c.key), "duplicate key {}", c.key);
keys.push(c.key);
}
let groups = control_groups();
let mut labels: Vec<&str> = groups.iter().map(|g| g[0].group).collect();
let total: usize = groups.iter().map(|g| g.len()).sum();
assert_eq!(total, CONTROLS.len(), "the runs must cover the table");
labels.sort_unstable();
let distinct = labels.len();
labels.dedup();
assert_eq!(labels.len(), distinct, "a group must be one contiguous run");
}
#[test]
fn control_forms_shrink_before_they_clip() {
assert_eq!(flat_rows(), CONTROLS.len().div_ceil(2));
assert_eq!(grouped_rows(), flat_rows() + control_groups().len());
}
#[test]
fn top_border_fills_to_inner_width() {
for label in ["cargo test", "日本語のテスト", "🚀 build", "e\u{0301}", ""] {
let b = top_border(label, 40);
assert_eq!(b.width(), 40, "label {label:?}: {b:?}");
}
let b = top_border(&"長".repeat(40), 40);
assert_eq!(b.width(), 40, "{b:?}");
}
#[test]
fn preview_provenance_label_shapes() {
let mut p = Preview::floor(String::new());
assert_eq!(preview_provenance(&p), "floor");
p.source = PreviewSource::Title;
p.frozen = true;
assert_eq!(preview_provenance(&p), "title (frozen)");
p.source = PreviewSource::Anchor;
p.rule = Some("claude-status");
p.frozen = false;
assert_eq!(preview_provenance(&p), "anchor/claude-status");
}
#[test]
fn attached_title_shows_name_and_command() {
assert_eq!(attached_title(&view(None)), "cargo test");
assert_eq!(
attached_title(&view(Some("api server"))),
"api server · cargo test"
);
}
#[test]
fn attached_bar_swaps_the_hint_for_an_active_notice() {
assert_eq!(
attached_bar("cargo test", 0, None),
" [attached] cargo test Ctrl-\\ background"
);
assert_eq!(
attached_bar("cargo test", 0, Some("copied 5 chars")),
" [attached] cargo test copied 5 chars"
);
assert_eq!(
attached_bar("cargo test", 3, None),
" [scroll ↑3] cargo test Esc live · PgUp/PgDn move · Ctrl-\\ background"
);
assert_eq!(
attached_bar("cargo test", 3, Some("copied 5 chars")),
" [scroll ↑3] cargo test copied 5 chars"
);
}
#[test]
fn transient_line_prefers_the_notice_over_the_status() {
assert_eq!(
transient_line(Some("copied 5 chars"), Some("saved 'x': 1 command(s)")),
Some(" copied 5 chars".to_string())
);
assert_eq!(
transient_line(None, Some("saved 'x': 1 command(s)")),
Some(" saved 'x': 1 command(s)".to_string())
);
assert_eq!(transient_line(None, None), None);
}
#[test]
fn header_strip_bolds_only_the_active_mode() {
for active in [GroupMode::State, GroupMode::Dir, GroupMode::Custom] {
let segs = header_segments("by ", active, " · tail");
let plain: String = segs.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(plain, "by state · dir · custom · tail");
assert_eq!(segs.first().unwrap(), &("by ".to_string(), Intensity::Bold));
assert_eq!(
segs.last().unwrap(),
&(" · tail".to_string(), Intensity::Bold)
);
for (text, intensity) in &segs[1..segs.len() - 1] {
let expect = if text == active.label() {
Intensity::Bold
} else {
Intensity::Dim
};
assert_eq!(*intensity, expect, "run {text:?} with active {active:?}");
}
}
}
}