use std::io::{self, Stdout, Write};
use std::time::Duration;
use crossterm::{
cursor::{Hide, MoveTo, Show},
queue,
style::{Attribute, Print, SetAttribute},
};
use crate::{
app::{App, DirKind, GroupMode, Mode, Row},
format::{pad, rel_time, truncate},
protocol::{Lifecycle, TaskView},
};
pub fn render(out: &mut Stdout, app: &mut App) -> io::Result<()> {
let mut buf: Vec<u8> = Vec::with_capacity(app.cols as usize * app.rows as usize * 3 + 128);
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::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)?
}
}
if buf != app.last_frame {
out.write_all(&buf)?;
out.flush()?;
app.last_frame = buf;
}
Ok(())
}
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) -> io::Result<()> {
queue!(
out,
MoveTo(0, y),
SetAttribute(Attribute::Reverse),
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];
let line = task_row(v, cols);
if app.selected_id == Some(v.id) {
rev(out, y, &line, cols)?;
} else {
put(out, y, &line, cols)?;
}
}
}
y += 1;
}
while y <= list_bottom {
put(out, y, "", cols)?;
y += 1;
}
let cmd_y = rows.saturating_sub(2);
match cmdline(app) {
Some(line) => put(out, cmd_y, &line, cols)?,
None => match &app.status {
Some(s) => put(out, cmd_y, &format!(" {s}"), cols)?,
None => dim(
out,
cmd_y,
" ❯ n run · @ dir · s sort · w save · o load",
cols,
)?,
},
}
let exit_hint = if app.daemon_backed {
"q detach · Q quit"
} else {
"q quit"
};
dim(
out,
rows.saturating_sub(1),
&format!(
" ↑↓ select · enter attach · space peek · n/@ new · s sort · m tag · g group · R rename · r rerun · X kill · {exit_hint}"
),
cols,
)?;
match cmdline(app) {
Some(line) => {
let cx = truncate(&line, cols).chars().count() as u16;
queue!(out, MoveTo(cx, cmd_y), Show)?;
}
None => queue!(out, Hide)?,
}
Ok(())
}
fn cmdline(app: &App) -> Option<String> {
match app.mode {
Mode::Spawn => Some(spawn_prompt(app)),
Mode::SaveSession => Some(format!(" save session as: {}", app.input)),
Mode::Rename => Some(format!(" rename task: {}", app.input)),
_ => None,
}
}
fn spawn_prompt(app: &App) -> String {
let dir = (app.spawn_cwd != app.invocation_dir).then(|| app.dir_label(&app.spawn_cwd));
prompt_line(dir.as_deref(), app.spawn_group.as_deref(), &app.input)
}
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 task_row(v: &TaskView, cols: usize) -> String {
let glyph = match v.lifecycle {
Lifecycle::Active => "✻",
Lifecycle::Idle => "∙",
Lifecycle::Ok => "✓",
Lifecycle::Failed => "✗",
};
let tag = if v.tagged { "◆" } else { " " };
let time = rel_time(row_age(v));
let title_w = 26.min(cols / 3);
let title = truncate(display_label(v), title_w);
let used = 2 + 2 + 2 + title_w + 1 + 1 + time.chars().count();
let prev_w = cols.saturating_sub(used);
let preview = truncate(&v.preview, prev_w);
format!(
" {g} {tg}{t:<tw$} {p:<pw$} {tm}",
g = glyph,
tg = tag,
t = title,
tw = title_w,
p = preview,
pw = prev_w,
tm = time,
)
}
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 x0 = cols.saturating_sub(bw) / 2;
let y0 = rows.saturating_sub(bh) / 2;
let inner_w = bw.saturating_sub(2);
let inner_h = bh.saturating_sub(2);
let empty: Vec<String> = Vec::new();
let lines = app.screen_for(v.id).map(|s| &s.lines).unwrap_or(&empty);
let start = lines.len().saturating_sub(inner_h);
let tail = &lines[start..];
let mut top_mid = format!(
"─ {} ",
truncate(display_label(v), inner_w.saturating_sub(4))
);
let tl = top_mid.chars().count();
if tl < inner_w {
top_mid.extend(std::iter::repeat_n('─', inner_w - tl));
}
queue!(
out,
MoveTo(x0 as u16, y0 as u16),
Print(format!("┌{top_mid}┐"))
)?;
for k in 0..inner_h {
let line = tail.get(k).map(String::as_str).unwrap_or("");
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(" space/esc close · enter attach ", inner_w)),
SetAttribute(Attribute::Reset)
)?;
Ok(())
}
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)?;
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)?;
} 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 cx = truncate(&format!(" @ {}", app.dir_input), app.cols as usize)
.chars()
.count() as u16;
render_panel(
out,
app,
&Panel {
header: format!(" @ {} ", app.dir_input),
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_input.is_empty() && app.group_candidates.len() < 2 {
"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 cx = truncate(&format!(" g {}", app.group_input), app.cols as usize)
.chars()
.count() as u16;
render_panel(
out,
app,
&Panel {
header: format!(" g {} ", app.group_input),
labels: &labels,
sel: app.group_sel,
max_rows: 8,
hint: format!("{action} · ↑↓ pick · esc"),
empty: None,
cursor: Some(cx),
},
)
}
fn render_session_picker(out: &mut impl Write, app: &App) -> io::Result<()> {
render_panel(
out,
app,
&Panel {
header: " load session".to_string(),
labels: &app.session_names,
sel: app.session_sel,
max_rows: 10,
hint: "↑↓ pick · enter load · esc".to_string(),
empty: Some(" (no saved sessions)"),
cursor: None,
},
)
}
fn center(s: &str, width: usize) -> String {
let len = s.chars().count();
if len >= width {
return truncate(s, width);
}
let mut out = " ".repeat((width - len) / 2);
out.push_str(s);
let cur = out.chars().count();
out.push_str(&" ".repeat(width - cur));
out
}
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 hint = if app.daemon_backed {
"r reconnect q quit"
} else {
"core stopped q quit"
};
let mid = rows / 2;
put(
out,
mid.saturating_sub(1),
¢er("⚠ daemon connection lost", 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)?;
}
let cols = app.cols as usize;
let title = attached_title(v);
let bar = match screen.map_or(0, |s| s.scrollback) {
0 => format!(" [attached] {title} Ctrl-\\ background"),
n => {
format!(" [scroll ↑{n}] {title} Esc live · PgUp/PgDn move · Ctrl-\\ background")
}
};
rev(out, app.rows.saturating_sub(1), &bar, cols)?;
match screen {
Some(s) if !s.hide_cursor => queue!(out, MoveTo(s.cursor.1, s.cursor.0), Show)?,
_ => queue!(out, Hide)?,
}
Ok(())
}
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 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");
}
}
#[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: 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::Idle, false, quiet, 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 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 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:?}");
}
}
}
}