use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::{
cursor, execute,
terminal::{self, ClearType},
};
use super::style::{Theme, visible_len};
use super::{BrowseAction, Display, Verbosity};
use crate::pipeline::StepResult;
#[cfg(unix)]
mod terminal_io {
use rustix::termios::{
LocalModes, OptionalActions, OutputModes, Termios, tcgetattr, tcsetattr,
};
use std::os::fd::AsFd;
pub fn suppress_echo<F: AsFd>(fd: F) -> Option<Termios> {
let fd = fd.as_fd();
let mut t = tcgetattr(fd).ok()?;
let backup = t.clone();
t.local_modes.remove(LocalModes::ECHO | LocalModes::ECHOE);
tcsetattr(fd, OptionalActions::Now, &t).ok()?;
Some(backup)
}
pub fn restore_signals_and_output<F: AsFd>(fd: F) {
let fd = fd.as_fd();
if let Ok(mut t) = tcgetattr(fd) {
t.output_modes.insert(OutputModes::OPOST);
t.local_modes.insert(LocalModes::ISIG);
let _ = tcsetattr(fd, OptionalActions::Now, &t);
}
}
pub fn restore<F: AsFd>(fd: F, t: &Termios) {
let _ = tcsetattr(fd, OptionalActions::Now, t);
}
}
fn timestamp() -> String {
chrono::Local::now().format("%H:%M:%S").to_string()
}
fn format_truncated_output(stdout: &str, stderr: &str) -> String {
let combined = if stderr.is_empty() {
stdout.to_string()
} else if stdout.is_empty() {
stderr.to_string()
} else if stdout.ends_with('\n') {
format!("{stdout}{stderr}")
} else {
format!("{stdout}\n{stderr}")
};
let lines: Vec<&str> = combined.lines().collect();
const MAX_DISPLAY_LINES: usize = 50;
const CONTEXT_LINES: usize = 25;
let mut out = String::new();
if lines.len() <= MAX_DISPLAY_LINES {
for line in &lines {
out.push_str(&format!(" {line}\n"));
}
} else {
for line in &lines[..CONTEXT_LINES] {
out.push_str(&format!(" {line}\n"));
}
let elided = lines.len() - (CONTEXT_LINES * 2);
out.push_str(&format!(
" ... [{elided} lines elided — see .baraddur/last-run.log] ...\n"
));
for line in &lines[lines.len() - CONTEXT_LINES..] {
out.push_str(&format!(" {line}\n"));
}
}
out
}
fn short_diagnostic(result: &StepResult) -> String {
if result.success {
return String::new();
}
match result.exit_code {
None => "command not found".into(),
Some(_) => {
let combined = format!("{}{}", result.stdout, result.stderr);
let non_empty: Vec<&str> = combined.lines().filter(|l| !l.trim().is_empty()).collect();
match non_empty.len() {
0 => String::new(),
1 => {
let line = non_empty[0];
let truncated: String = line.chars().take(40).collect();
if line.chars().count() > 40 {
format!("{truncated}…")
} else {
truncated
}
}
n => format!("{n} lines"),
}
}
}
}
fn default_editor_spawn(path: &Path, line: u32, _col: Option<u32>) -> std::io::Result<()> {
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".into());
let parts = shell_words::split(&editor).unwrap_or_else(|_| vec![editor.clone()]);
let (program, args) = parts.split_first().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty $EDITOR / $VISUAL")
})?;
let _ = std::process::Command::new(program)
.args(args)
.arg(format!("+{line}"))
.arg(path)
.status()?;
Ok(())
}
fn format_trigger_suffix(paths: Option<&[PathBuf]>) -> String {
match paths {
Some([p]) => format!(" · {}", p.display()),
Some(ps) => format!(" · {} files", ps.len()),
None => String::new(),
}
}
#[derive(Debug, PartialEq, Eq)]
enum RedrawStrategy {
Skip,
FullClear,
MoveUp(u16),
}
fn redraw_strategy(
rendered_lines: u16,
last_size: (u16, u16),
current_size: (u16, u16),
) -> RedrawStrategy {
if rendered_lines == 0 {
return RedrawStrategy::Skip;
}
let (_, h) = current_size;
if last_size != current_size || rendered_lines + 1 > h {
return RedrawStrategy::FullClear;
}
RedrawStrategy::MoveUp(rendered_lines)
}
pub struct PlainDisplay {
theme: Theme,
verbosity: Verbosity,
trigger_paths: Option<Vec<PathBuf>>,
run_start: Option<Instant>,
run_count: usize,
last_run_triggered: bool,
}
impl PlainDisplay {
pub fn new(theme: Theme, verbosity: Verbosity) -> Self {
Self {
theme,
verbosity,
trigger_paths: None,
run_start: None,
run_count: 0,
last_run_triggered: false,
}
}
}
impl Display for PlainDisplay {
fn set_trigger(&mut self, paths: &[PathBuf]) {
self.trigger_paths = Some(paths.to_vec());
}
fn banner(
&mut self,
root: &Path,
config_path: &Path,
_step_count: usize,
profile: Option<&str>,
) {
let profile_suffix = profile
.map(|p| format!("\n (profile: {p})"))
.unwrap_or_default();
eprintln!(
"baraddur: watching {}\n (config: {}){profile_suffix}",
root.display(),
config_path.display(),
);
}
fn run_started(&mut self, _step_names: &[String]) {
self.run_start = Some(Instant::now());
self.run_count += 1;
let trigger = self.trigger_paths.take();
self.last_run_triggered = trigger.is_some();
if self.verbosity != Verbosity::Quiet {
let suffix = format_trigger_suffix(trigger.as_deref());
println!("[{}] run #{} started{suffix}", timestamp(), self.run_count);
}
}
fn step_running(&mut self, name: &str) {
if self.verbosity != Verbosity::Quiet {
println!("[{}] ▸ {} running", timestamp(), name);
}
}
fn step_finished(&mut self, result: &StepResult) {
if self.verbosity == Verbosity::Quiet && result.success {
return;
}
let status = if result.success {
format!("{}", self.theme.pass_glyph())
} else {
format!("{}", self.theme.fail_glyph())
};
println!(
"[{}] ▸ {} {} ({:.1}s)",
timestamp(),
result.name,
status,
result.duration.as_secs_f64()
);
}
fn steps_skipped(&mut self, names: &[String]) {
if self.verbosity != Verbosity::Quiet {
let ts = timestamp();
for name in names {
println!("[{ts}] ▸ {name} {} skipped", self.theme.skip_glyph());
}
}
}
fn run_cancelled(&mut self) {
if self.verbosity != Verbosity::Quiet {
println!("[{}] run cancelled", timestamp());
}
}
fn run_finished(&mut self, results: &[StepResult]) {
let ts = timestamp();
for r in results.iter().filter(|r| !r.success) {
println!("[{ts}] --- {} output ---", r.name);
print!("{}", format_truncated_output(&r.stdout, &r.stderr));
}
if self.verbosity >= Verbosity::Verbose {
for r in results.iter().filter(|r| r.success) {
if !r.stdout.is_empty() {
println!("[{ts}] --- {} output ---", r.name);
for line in r.stdout.lines() {
println!(" {line}");
}
}
}
}
let failed = results.iter().filter(|r| !r.success).count();
let passed = results.iter().filter(|r| r.success).count();
let elapsed = self
.run_start
.take()
.map(|t| t.elapsed().as_secs_f64())
.unwrap_or_else(|| results.iter().map(|r| r.duration.as_secs_f64()).sum());
if self.verbosity != Verbosity::Quiet || failed > 0 {
println!("[{ts}] run complete: {failed} failed, {passed} passed, {elapsed:.1}s");
}
if results.is_empty() && self.last_run_triggered && self.verbosity != Verbosity::Quiet {
println!("[{ts}] no steps match changed paths");
}
let _ = std::io::stdout().flush();
}
fn hook_output(&mut self, text: &str) {
if self.verbosity == Verbosity::Quiet {
return;
}
let ts = timestamp();
println!("[{ts}] --- on_failure ---");
for line in text.lines() {
println!(" {line}");
}
let _ = std::io::stdout().flush();
}
fn hook_started(&mut self) {
if self.verbosity != Verbosity::Quiet {
println!("[{}] on_failure hook running…", timestamp());
let _ = std::io::stdout().flush();
}
}
}
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
#[derive(Debug, Clone)]
enum StepStatus {
Queued,
Running,
Passed(Duration),
Failed(Duration, String), Skipped,
}
pub struct TtyDisplay {
theme: Theme,
verbosity: Verbosity,
no_clear: bool,
step_names: Vec<String>,
statuses: Vec<StepStatus>,
name_width: usize,
rendered_lines: u16,
last_term_size: (u16, u16),
spinner_frame: usize,
has_running: bool,
#[cfg(unix)]
original_termios: Option<rustix::termios::Termios>,
step_outputs: Vec<String>,
expanded: Vec<bool>,
all_expanded: bool,
cursor: usize,
browse_active: bool,
last_key: Option<KeyCode>,
raw_mode_active: bool,
trigger_paths: Option<Vec<PathBuf>>,
run_count: usize,
run_divider: String,
run_start: Option<Instant>,
run_summary: String,
browse_scroll: usize,
hook_output_text: String,
transient_message: Option<String>,
last_trigger_paths: Option<Vec<PathBuf>>,
hook_running: bool,
root: PathBuf,
parsed_diagnostics: Vec<Vec<crate::output::diagnostic::Diagnostic>>,
current_diagnostic: Vec<usize>,
editor_spawn: EditorSpawn,
help_modal_active: bool,
help_painted_last: bool,
}
type EditorSpawn = Box<dyn Fn(&Path, u32, Option<u32>) -> std::io::Result<()> + Send + Sync>;
impl Drop for TtyDisplay {
fn drop(&mut self) {
if self.raw_mode_active {
let _ = terminal::disable_raw_mode();
let _ = execute!(std::io::stdout(), cursor::Show);
}
#[cfg(unix)]
if let Some(t) = &self.original_termios {
terminal_io::restore(std::io::stdin(), t);
}
}
}
impl TtyDisplay {
pub fn new(theme: Theme, verbosity: Verbosity, no_clear: bool) -> Self {
#[cfg(unix)]
let original_termios = terminal_io::suppress_echo(std::io::stdin());
Self {
theme,
verbosity,
no_clear,
step_names: Vec::new(),
statuses: Vec::new(),
name_width: 0,
rendered_lines: 0,
last_term_size: (0, 0),
spinner_frame: 0,
has_running: false,
#[cfg(unix)]
original_termios,
step_outputs: Vec::new(),
expanded: Vec::new(),
all_expanded: false,
cursor: 0,
browse_active: false,
last_key: None,
raw_mode_active: false,
trigger_paths: None,
run_count: 0,
run_divider: String::new(),
run_start: None,
run_summary: String::new(),
browse_scroll: 0,
hook_output_text: String::new(),
transient_message: None,
last_trigger_paths: None,
hook_running: false,
root: PathBuf::new(),
parsed_diagnostics: Vec::new(),
current_diagnostic: Vec::new(),
editor_spawn: Box::new(default_editor_spawn),
help_modal_active: false,
help_painted_last: false,
}
}
pub fn set_editor_spawn(&mut self, f: EditorSpawn) {
self.editor_spawn = f;
}
fn term_width() -> usize {
crossterm::terminal::size()
.map(|(c, _)| c as usize)
.unwrap_or(80)
}
fn visual_rows_for(text: &str, width: usize) -> u16 {
let vlen = visible_len(text);
if width == 0 || vlen == 0 {
1
} else {
vlen.div_ceil(width) as u16
}
}
fn term_height() -> u16 {
crossterm::terminal::size().map(|(_, r)| r).unwrap_or(24)
}
fn prepare_redraw_region(&mut self) {
let mut stdout = std::io::stdout();
let current = (Self::term_width() as u16, Self::term_height());
match redraw_strategy(self.rendered_lines, self.last_term_size, current) {
RedrawStrategy::Skip => {}
RedrawStrategy::FullClear => {
execute!(
stdout,
terminal::Clear(ClearType::All),
cursor::MoveTo(0, 0)
)
.ok();
self.rendered_lines = 0;
}
RedrawStrategy::MoveUp(n) => {
execute!(
stdout,
cursor::MoveUp(n),
terminal::Clear(ClearType::FromCursorDown)
)
.ok();
}
}
self.last_term_size = current;
}
fn raw_mode_on(&mut self) {
if terminal::enable_raw_mode().is_ok() {
self.raw_mode_active = true;
#[cfg(unix)]
terminal_io::restore_signals_and_output(std::io::stdin());
}
}
fn raw_mode_off(&mut self) {
if self.raw_mode_active {
let _ = terminal::disable_raw_mode();
self.raw_mode_active = false;
}
}
fn with_terminal_released<F, R>(&mut self, f: F) -> R
where
F: FnOnce(&mut Self) -> R,
{
let was_browse = self.browse_active;
if was_browse {
self.raw_mode_off();
let _ = execute!(std::io::stdout(), cursor::Show);
}
let result = f(self);
if was_browse {
self.raw_mode_on();
let _ = execute!(std::io::stdout(), cursor::Hide);
self.browse_redraw();
}
result
}
fn help_modal_lines(&self) -> Vec<String> {
let rows: &[(&str, &str)] = &[
("Navigation", "Diagnostics"),
(" j / ↓ down", " n next"),
(" k / ↑ up", " p prev"),
(" g g top", " e open in editor"),
(" G bottom", ""),
("", ""),
("Output", "Rerun"),
(" Enter / o toggle", " r pipeline"),
(" O expand", " f failed steps"),
("", " c step under cursor"),
];
let col_width = 26usize;
let mut out = Vec::with_capacity(rows.len() + 4);
out.push(format!(" {}", self.theme.cyan("Help")));
out.push(String::new());
for (left, right) in rows {
let is_header = !left.is_empty() && !left.starts_with(' ');
let is_right_header = !right.is_empty() && !right.starts_with(' ');
let left_styled = if left.is_empty() {
String::new()
} else if is_header {
format!("{}", self.theme.cyan(left))
} else {
format!("{}", self.theme.dim(left))
};
let right_styled = if right.is_empty() {
String::new()
} else if is_right_header {
format!("{}", self.theme.cyan(right))
} else {
format!("{}", self.theme.dim(right))
};
let left_pad = col_width.saturating_sub(visible_len(&left_styled));
out.push(format!(" {left_styled}{:left_pad$}{right_styled}", ""));
}
out.push(String::new());
out.push(format!(" {}", self.theme.dim("press any key to dismiss")));
out
}
fn advance_diagnostic(&mut self, delta: i32) -> bool {
let len = match self.parsed_diagnostics.get(self.cursor) {
Some(d) if !d.is_empty() => d.len() as i32,
_ => return false,
};
let cur = self
.current_diagnostic
.get(self.cursor)
.copied()
.unwrap_or(0) as i32;
let next = (cur + delta).rem_euclid(len) as usize;
self.current_diagnostic[self.cursor] = next;
true
}
fn open_current_diagnostic(&mut self) {
let Some(diags) = self.parsed_diagnostics.get(self.cursor) else {
return;
};
if diags.is_empty() {
self.transient_message = Some("no diagnostics on this step".into());
return;
}
let idx = self
.current_diagnostic
.get(self.cursor)
.copied()
.unwrap_or(0);
let diag = diags[idx].clone();
let abs = if diag.path.is_absolute() {
diag.path.clone()
} else {
self.root.join(&diag.path)
};
let result = self.with_terminal_released(|s| (s.editor_spawn)(&abs, diag.line, diag.col));
if let Err(e) = result {
self.transient_message = Some(format!("editor: {e}"));
}
}
fn redraw(&mut self) {
if self.verbosity == Verbosity::Quiet {
return;
}
let mut stdout = std::io::stdout();
let width = Self::term_width();
self.prepare_redraw_region();
let mut lines = 0u16;
if !self.run_divider.is_empty() {
let divider = self.divider_styled();
println!("{divider}");
lines += Self::visual_rows_for(÷r, width);
}
for (i, name) in self.step_names.iter().enumerate() {
let (glyph, diagnostic, duration_str) = match &self.statuses[i] {
StepStatus::Queued => (
format!("{}", self.theme.queued_glyph()),
String::new(),
String::new(),
),
StepStatus::Running => {
let frame = SPINNER_FRAMES[self.spinner_frame];
let g = format!("{}", self.theme.yellow(frame));
(g, String::new(), String::new())
}
StepStatus::Passed(d) => (
format!("{}", self.theme.pass_glyph()),
String::new(),
format!("{:.1}s", d.as_secs_f64()),
),
StepStatus::Failed(d, diag) => {
let d_str = format!("{:.1}s", d.as_secs_f64());
let diag_str = if diag.is_empty() {
String::new()
} else {
format!("{}", self.theme.dim(diag))
};
(format!("{}", self.theme.fail_glyph()), diag_str, d_str)
}
StepStatus::Skipped => (
format!("{}", self.theme.skip_glyph()),
format!("{}", self.theme.dim("skipped")),
String::new(),
),
};
let left = if diagnostic.is_empty() {
format!("▸ {:nw$} {glyph}", name, nw = self.name_width)
} else {
format!(
"▸ {:nw$} {glyph} {diagnostic}",
name,
nw = self.name_width
)
};
let line = if duration_str.is_empty() {
left
} else {
let right = format!("{}", self.theme.dim(&duration_str));
let left_vis = visible_len(&left);
let right_vis = visible_len(&right);
let pad = width.saturating_sub(left_vis + right_vis);
format!("{left}{:pad$}{right}", "")
};
println!("{line}");
lines += Self::visual_rows_for(&line, width);
}
self.rendered_lines = lines;
let _ = stdout.flush();
}
fn step_row(&self, i: usize, width: usize) -> (String, usize) {
let (glyph, diagnostic, duration_str) = match &self.statuses[i] {
StepStatus::Queued => (
format!("{}", self.theme.queued_glyph()),
String::new(),
String::new(),
),
StepStatus::Running => {
let frame = SPINNER_FRAMES[self.spinner_frame];
(
format!("{}", self.theme.yellow(frame)),
String::new(),
String::new(),
)
}
StepStatus::Passed(d) => (
format!("{}", self.theme.pass_glyph()),
String::new(),
format!("{:.1}s", d.as_secs_f64()),
),
StepStatus::Failed(d, diag) => {
let d_str = format!("{:.1}s", d.as_secs_f64());
let diag_str = if diag.is_empty() {
String::new()
} else {
format!("{}", self.theme.dim(diag))
};
(format!("{}", self.theme.fail_glyph()), diag_str, d_str)
}
StepStatus::Skipped => (
format!("{}", self.theme.skip_glyph()),
format!("{}", self.theme.dim("skipped")),
String::new(),
),
};
let arrow = if i == self.cursor && !self.theme.color_enabled() {
"▶"
} else {
"▸"
};
let raw_prefix = format!("{arrow} {:nw$}", self.step_names[i], nw = self.name_width);
let styled_prefix = if i == self.cursor && self.browse_active {
format!("{}", self.theme.selected(&raw_prefix))
} else {
raw_prefix
};
let left = if diagnostic.is_empty() {
format!("{styled_prefix} {glyph}")
} else {
format!("{styled_prefix} {glyph} {diagnostic}")
};
if duration_str.is_empty() {
let r = Self::visual_rows_for(&left, width) as usize;
(left, r)
} else {
let right = format!("{}", self.theme.dim(&duration_str));
let left_vis = visible_len(&left);
let right_vis = visible_len(&right);
let pad = width.saturating_sub(left_vis + right_vis);
let line = format!("{left}{:pad$}{right}", "");
let r = Self::visual_rows_for(&line, width) as usize;
(line, r)
}
}
fn expanded_output_lines(&self, i: usize, width: usize) -> Vec<(String, usize)> {
if !self.expanded.get(i).copied().unwrap_or(false) {
return Vec::new();
}
let Some(output) = self.step_outputs.get(i).filter(|o| !o.is_empty()) else {
return Vec::new();
};
let diags: &[crate::output::diagnostic::Diagnostic] = self
.parsed_diagnostics
.get(i)
.map(Vec::as_slice)
.unwrap_or(&[]);
let current = self.current_diagnostic.get(i).copied().unwrap_or(0);
let is_cursor_step = i == self.cursor;
let mut out = Vec::new();
for line in output.lines() {
let raw = line.strip_prefix(" ").unwrap_or(line);
let diag_idx = crate::output::diagnostic::extract_line(raw)
.and_then(|d| diags.iter().position(|x| x == &d));
let (display_line, r) = if let Some(idx) = diag_idx {
let is_current = is_cursor_step && idx == current;
let styled = if is_current {
if self.theme.color_enabled() {
format!(
"{} {}",
self.theme.cyan("▸"),
self.theme.cyan_underline(raw)
)
} else {
format!("▶ {raw}")
}
} else {
format!("{} {raw}", self.theme.dim("▸"))
};
let r = Self::visual_rows_for(&styled, width) as usize;
(styled, r)
} else {
let r = Self::visual_rows_for(line, width) as usize;
(line.to_string(), r)
};
out.push((display_line, r));
}
out
}
fn footer_lines(&self, width: usize) -> Vec<(String, usize)> {
if !self.browse_active {
return Vec::new();
}
let mut out: Vec<(String, usize)> = Vec::new();
out.push((String::new(), 1));
if !self.run_summary.is_empty() {
let r = Self::visual_rows_for(&self.run_summary, width) as usize;
out.push((self.run_summary.clone(), r));
out.push((String::new(), 1));
}
if self.hook_running {
let line = format!(" {}", self.theme.dim("running on_failure hook…"));
let r = Self::visual_rows_for(&line, width) as usize;
out.push((line, r));
out.push((String::new(), 1));
} else if !self.hook_output_text.is_empty() {
for line in self.hook_output_text.lines() {
let styled = format!(" {}", self.theme.dim(line));
let r = Self::visual_rows_for(&styled, width) as usize;
out.push((styled, r));
}
out.push((String::new(), 1));
}
let help = " ↕ j/k ⏎ toggle ▸ n/p/e ↺ r/f/c ? help · q quit";
let help_styled = format!("{}", self.theme.dim(help));
let r = Self::visual_rows_for(&help_styled, width) as usize;
out.push((help_styled, r));
if let Some(msg) = &self.transient_message {
let line = format!(" {}", self.theme.yellow(msg));
let r = Self::visual_rows_for(&line, width) as usize;
out.push((line, r));
}
out
}
fn compute_lines(&self, width: usize) -> (Vec<(String, usize)>, usize, usize) {
let mut all_lines: Vec<(String, usize)> = Vec::new();
let mut cursor_top_row = 0usize;
let mut cursor_row_height = 1usize;
let mut cumulative = 0usize;
if !self.run_divider.is_empty() {
all_lines.push((self.divider_styled(), 1));
cumulative += 1;
}
for i in 0..self.step_names.len() {
let (step_text, step_rows) = self.step_row(i, width);
if i == self.cursor {
cursor_top_row = cumulative;
cursor_row_height = step_rows;
}
cumulative += step_rows;
all_lines.push((step_text, step_rows));
for (line, r) in self.expanded_output_lines(i, width) {
cumulative += r;
all_lines.push((line, r));
}
}
for (line, r) in self.footer_lines(width) {
all_lines.push((line, r));
}
let _ = cumulative;
(all_lines, cursor_top_row, cursor_row_height)
}
fn clamp_scroll(
scroll: usize,
cursor_top: usize,
cursor_h: usize,
viewport: usize,
total_rows: usize,
) -> usize {
let mut scroll = scroll;
if cursor_top < scroll {
scroll = cursor_top;
} else if cursor_top + cursor_h > scroll + viewport {
scroll = cursor_top + cursor_h - viewport;
}
scroll.min(total_rows.saturating_sub(viewport))
}
fn viewport_slice(
lines: &[(String, usize)],
scroll: usize,
viewport: usize,
) -> (std::ops::Range<usize>, usize) {
let mut skip = scroll;
let mut rendered = 0usize;
let mut start: Option<usize> = None;
let mut end = lines.len();
for (i, (_, rows)) in lines.iter().enumerate() {
if skip > 0 {
if skip >= *rows {
skip -= rows;
continue;
}
skip = 0;
continue;
}
if rendered >= viewport {
end = i;
break;
}
if start.is_none() {
start = Some(i);
}
rendered += rows;
}
let start = start.unwrap_or(end);
(start..end.max(start), rendered)
}
fn browse_redraw(&mut self) {
let mut stdout = std::io::stdout();
let width = Self::term_width();
let term_height = Self::term_height() as usize;
let (all_lines, cursor_top_row, cursor_row_height) = self.compute_lines(width);
let total_rows: usize = all_lines.iter().map(|(_, r)| r).sum();
let viewport = term_height.saturating_sub(1);
self.browse_scroll = Self::clamp_scroll(
self.browse_scroll,
cursor_top_row,
cursor_row_height,
viewport,
total_rows,
);
if self.help_modal_active || self.help_painted_last {
self.last_term_size = (0, 0);
}
self.prepare_redraw_region();
let (range, rendered) = Self::viewport_slice(&all_lines, self.browse_scroll, viewport);
for (text, _) in &all_lines[range] {
println!("{text}");
}
self.rendered_lines = rendered as u16;
if self.help_modal_active {
self.draw_help_modal_overlay();
}
self.help_painted_last = self.help_modal_active;
let _ = stdout.flush();
}
fn draw_help_modal_overlay(&self) {
let mut stdout = std::io::stdout();
let term_w = Self::term_width();
let term_h = Self::term_height() as usize;
let lines = self.help_modal_lines();
let content_w = lines.iter().map(|l| visible_len(l)).max().unwrap_or(0);
let modal_w = (content_w + 2).min(term_w.saturating_sub(2)).max(8);
let modal_h = (lines.len() + 2).min(term_h.saturating_sub(1)).max(3);
let top = term_h.saturating_sub(modal_h) / 2;
let left = term_w.saturating_sub(modal_w) / 2;
let inner_w = modal_w.saturating_sub(2);
let top_border = format!("╭{}╮", "─".repeat(inner_w));
let bot_border = format!("╰{}╯", "─".repeat(inner_w));
let side = format!("{}", self.theme.cyan("│"));
execute!(stdout, cursor::MoveTo(left as u16, top as u16)).ok();
print!("{}", self.theme.cyan(&top_border));
let max_rows = modal_h.saturating_sub(2);
for (i, line) in lines.iter().take(max_rows).enumerate() {
let row = (top + 1 + i) as u16;
execute!(stdout, cursor::MoveTo(left as u16, row)).ok();
let vis = visible_len(line);
let pad = inner_w.saturating_sub(vis);
print!("{side}{line}{:pad$}{side}", "");
}
execute!(
stdout,
cursor::MoveTo(left as u16, (top + modal_h - 1) as u16)
)
.ok();
print!("{}", self.theme.cyan(&bot_border));
}
fn index_of(&self, name: &str) -> usize {
self.step_names
.iter()
.position(|n| n == name)
.unwrap_or_else(|| panic!("unknown step `{name}`"))
}
fn divider_styled(&self) -> String {
if self.run_divider.is_empty() {
return String::new();
}
let all_settled = self
.statuses
.iter()
.all(|s| !matches!(s, StepStatus::Running | StepStatus::Queued));
let any_failed = self
.statuses
.iter()
.any(|s| matches!(s, StepStatus::Failed(..)));
if all_settled && any_failed {
format!("{}", self.theme.red(&self.run_divider))
} else if all_settled {
format!("{}", self.theme.green(&self.run_divider))
} else {
format!("{}", self.theme.dim(&self.run_divider))
}
}
fn capture_step_outputs(&mut self, results: &[StepResult]) {
for r in results {
if let Some(idx) = self.step_names.iter().position(|n| n == &r.name) {
self.step_outputs[idx] = format_truncated_output(&r.stdout, &r.stderr);
self.expanded[idx] = !r.success;
let combined = if r.stderr.is_empty() {
r.stdout.clone()
} else if r.stdout.is_empty() {
r.stderr.clone()
} else {
format!("{}\n{}", r.stdout, r.stderr)
};
self.parsed_diagnostics[idx] = crate::output::diagnostic::parse(&combined);
self.current_diagnostic[idx] = 0;
}
}
self.cursor = results
.iter()
.find(|r| !r.success)
.and_then(|r| self.step_names.iter().position(|n| n == &r.name))
.unwrap_or(0);
self.all_expanded = results.iter().any(|r| !r.success);
}
fn summary_line(&self, results: &[StepResult], elapsed: f64) -> String {
let failed = results.iter().filter(|r| !r.success).count();
let passed = results.iter().filter(|r| r.success).count();
let skipped = self.step_names.len().saturating_sub(results.len());
let mut parts: Vec<String> = Vec::new();
if failed > 0 {
let s = format!("{failed} failed");
parts.push(format!("{}", self.theme.red(&s)));
}
let s = format!("{passed} passed");
parts.push(format!("{}", self.theme.green(&s)));
if skipped > 0 {
let s = format!("{skipped} skipped");
parts.push(format!("{}", self.theme.dim(&s)));
}
let time_str = if failed == 0 {
format!("all passing · {elapsed:.1}s")
} else {
format!("{elapsed:.1}s")
};
parts.push(format!("{}", self.theme.dim(&time_str)));
parts.join(" · ")
}
}
impl Display for TtyDisplay {
fn set_trigger(&mut self, paths: &[PathBuf]) {
self.trigger_paths = Some(paths.to_vec());
}
fn banner(
&mut self,
root: &Path,
config_path: &Path,
step_count: usize,
profile: Option<&str>,
) {
self.root = root.to_path_buf();
if self.verbosity == Verbosity::Quiet {
return;
}
let mut stdout = std::io::stdout();
execute!(
stdout,
terminal::Clear(ClearType::All),
cursor::MoveTo(0, 0)
)
.ok();
let width = Self::term_width();
let version = env!("CARGO_PKG_VERSION");
let prefix = format!("━━━ baraddur {version} ");
let fill = "━".repeat(width.saturating_sub(visible_len(&prefix)));
let header = format!("{prefix}{fill}");
println!("{}", self.theme.dim(&header));
println!("{} {}", self.theme.dim("watching:"), root.display());
let config_name = config_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let profile_suffix = profile
.map(|p| format!(" (profile: {p})"))
.unwrap_or_default();
println!(
"{} {} ({step_count} steps){profile_suffix}",
self.theme.dim("config: "),
config_name
);
println!("{}", self.theme.dim("press ^C to exit"));
let bottom = "━".repeat(width);
println!("{}", self.theme.dim(&bottom));
let _ = stdout.flush();
}
fn run_started(&mut self, step_names: &[String]) {
self.run_start = Some(Instant::now());
self.run_count += 1;
self.step_names = step_names.to_vec();
self.statuses = vec![StepStatus::Queued; step_names.len()];
self.name_width = step_names.iter().map(|n| n.len()).max().unwrap_or(0);
self.rendered_lines = 0;
self.has_running = false;
self.step_outputs = vec![String::new(); step_names.len()];
self.expanded = vec![false; step_names.len()];
self.all_expanded = false;
self.cursor = 0;
self.browse_active = false;
self.last_key = None;
self.browse_scroll = 0;
self.hook_output_text.clear();
self.transient_message = None;
self.hook_running = false;
self.parsed_diagnostics = vec![Vec::new(); step_names.len()];
self.current_diagnostic = vec![0; step_names.len()];
self.help_modal_active = false;
let trigger = self.trigger_paths.take();
self.last_trigger_paths = trigger.clone();
if self.verbosity == Verbosity::Quiet {
return;
}
let mut stdout = std::io::stdout();
if !self.no_clear {
execute!(
stdout,
terminal::Clear(ClearType::All),
cursor::MoveTo(0, 0)
)
.ok();
}
let ts = chrono::Local::now().format("%H:%M:%S").to_string();
let trigger_str = format_trigger_suffix(trigger.as_deref());
let width = Self::term_width();
let prefix = format!("━━━ #{} {ts}{trigger_str} ", self.run_count);
let fill = "━".repeat(width.saturating_sub(visible_len(&prefix)));
self.run_divider = format!("{prefix}{fill}");
self.redraw();
}
fn step_running(&mut self, name: &str) {
let idx = self.index_of(name);
self.statuses[idx] = StepStatus::Running;
self.has_running = true;
self.redraw();
}
fn step_finished(&mut self, result: &StepResult) {
let idx = self.index_of(&result.name);
let diag = short_diagnostic(result);
self.statuses[idx] = if result.success {
StepStatus::Passed(result.duration)
} else {
StepStatus::Failed(result.duration, diag)
};
self.has_running = self
.statuses
.iter()
.any(|s| matches!(s, StepStatus::Running));
self.redraw();
}
fn steps_skipped(&mut self, names: &[String]) {
for name in names {
let idx = self.index_of(name);
self.statuses[idx] = StepStatus::Skipped;
}
self.redraw();
}
fn run_cancelled(&mut self) {
self.rendered_lines = 0;
self.has_running = false;
}
fn run_finished(&mut self, results: &[StepResult]) {
self.has_running = false;
self.capture_step_outputs(results);
if self.verbosity == Verbosity::Quiet && results.iter().all(|r| r.success) {
self.rendered_lines = 0;
return;
}
let elapsed = self
.run_start
.take()
.map(|t| t.elapsed().as_secs_f64())
.unwrap_or_else(|| results.iter().map(|r| r.duration.as_secs_f64()).sum());
println!();
self.rendered_lines += 1;
let summary = self.summary_line(results, elapsed);
self.run_summary = summary.clone();
println!("{summary}");
let width = Self::term_width();
self.rendered_lines += Self::visual_rows_for(&summary, width);
if results.is_empty() && self.last_trigger_paths.is_some() {
self.transient_message = Some(match self.last_trigger_paths.as_deref() {
Some([p]) => format!("no steps match changed path: {}", p.display()),
Some(ps) if ps.len() > 1 => {
format!("no steps match {} changed paths", ps.len())
}
_ => "no steps match changed paths".into(),
});
}
let _ = std::io::stdout().flush();
}
fn tick(&mut self) {
if self.has_running {
self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len();
self.redraw();
}
}
fn enter_browse_mode(&mut self) {
self.browse_active = true;
self.raw_mode_on();
let _ = execute!(std::io::stdout(), cursor::Hide);
self.browse_redraw();
}
fn exit_browse_mode(&mut self) {
self.browse_active = false;
self.browse_redraw();
self.raw_mode_off();
let _ = execute!(std::io::stdout(), cursor::Show);
}
fn browse_redraw_if_active(&mut self) {
if self.browse_active {
self.browse_redraw();
}
}
fn hook_output(&mut self, text: &str) {
self.hook_output_text = text.to_string();
self.hook_running = false;
if self.browse_active {
self.browse_redraw();
}
}
fn hook_started(&mut self) {
self.hook_running = true;
if self.browse_active {
self.browse_redraw();
}
}
fn hook_finished(&mut self) {
self.hook_running = false;
if self.browse_active {
self.browse_redraw();
}
}
fn handle_key(&mut self, key: KeyEvent) -> BrowseAction {
if self.help_modal_active {
self.help_modal_active = false;
self.last_key = None;
return BrowseAction::Redraw;
}
let n = self.step_names.len();
if n == 0 {
return if matches!(key.code, KeyCode::Char('q')) {
BrowseAction::Quit
} else {
BrowseAction::Noop
};
}
let had_message = self.transient_message.take().is_some();
let action = match key.code {
KeyCode::Char('j') | KeyCode::Down => {
self.cursor = (self.cursor + 1).min(n - 1);
self.last_key = None;
BrowseAction::Redraw
}
KeyCode::Char('k') | KeyCode::Up => {
self.cursor = self.cursor.saturating_sub(1);
self.last_key = None;
BrowseAction::Redraw
}
KeyCode::Char('g') => {
if self.last_key == Some(KeyCode::Char('g')) {
self.cursor = 0;
self.last_key = None;
BrowseAction::Redraw
} else {
self.last_key = Some(KeyCode::Char('g'));
BrowseAction::Noop
}
}
KeyCode::Char('G') => {
self.cursor = n - 1;
self.last_key = None;
BrowseAction::Redraw
}
KeyCode::Enter | KeyCode::Char('o') => {
self.expanded[self.cursor] = !self.expanded[self.cursor];
self.last_key = None;
BrowseAction::Redraw
}
KeyCode::Char('O') => {
self.all_expanded = !self.all_expanded;
for e in &mut self.expanded {
*e = self.all_expanded;
}
self.last_key = None;
BrowseAction::Redraw
}
KeyCode::Char('r') => {
self.last_key = None;
BrowseAction::Rerun
}
KeyCode::Char('f') => {
self.last_key = None;
if self
.statuses
.iter()
.any(|s| matches!(s, StepStatus::Failed(..)))
{
BrowseAction::RerunFailed
} else {
self.transient_message = Some("no failures to re-run".into());
BrowseAction::Redraw
}
}
KeyCode::Char('c') => {
self.last_key = None;
BrowseAction::RerunCursor(self.step_names[self.cursor].clone())
}
KeyCode::Char('n') => {
self.last_key = None;
if self.advance_diagnostic(1) {
BrowseAction::Redraw
} else {
self.transient_message = Some("no diagnostics on this step".into());
BrowseAction::Redraw
}
}
KeyCode::Char('p') => {
self.last_key = None;
if self.advance_diagnostic(-1) {
BrowseAction::Redraw
} else {
self.transient_message = Some("no diagnostics on this step".into());
BrowseAction::Redraw
}
}
KeyCode::Char('e') => {
self.last_key = None;
self.open_current_diagnostic();
BrowseAction::Redraw
}
KeyCode::Char('?') => {
self.last_key = None;
self.help_modal_active = true;
BrowseAction::Redraw
}
KeyCode::Char('q') => BrowseAction::Quit,
_ => {
self.last_key = None;
BrowseAction::Noop
}
};
if matches!(action, BrowseAction::Noop) && had_message {
BrowseAction::Redraw
} else {
action
}
}
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
use rustix::termios::{LocalModes, OutputModes, tcgetattr};
use rustix_openpty::openpty;
use std::os::fd::AsFd;
#[test]
fn suppress_echo_clears_echo_and_restore_brings_it_back() {
let pty = openpty(None, None).expect("openpty failed");
let user = pty.user.as_fd();
let before = tcgetattr(user).unwrap();
assert!(
before.local_modes.contains(LocalModes::ECHO),
"pty should start with echo on"
);
let backup = terminal_io::suppress_echo(user).expect("suppress_echo failed");
let during = tcgetattr(user).unwrap();
assert!(
!during.local_modes.contains(LocalModes::ECHO),
"ECHO should be cleared after suppress_echo"
);
assert!(
!during.local_modes.contains(LocalModes::ECHOE),
"ECHOE should also be cleared"
);
terminal_io::restore(user, &backup);
let after = tcgetattr(user).unwrap();
assert!(
after.local_modes.contains(LocalModes::ECHO),
"ECHO should be restored after restore()"
);
}
#[test]
fn handle_key_c_returns_rerun_cursor_for_step_under_cursor() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
d.cursor = 1;
let action = d.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
match action {
BrowseAction::RerunCursor(name) => assert_eq!(name, "beta"),
other => panic!("expected RerunCursor(\"beta\"), got {other:?}"),
}
}
#[test]
fn diagnostic_navigation_and_editor_spawn() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::{Arc, Mutex};
type EditorCall = (std::path::PathBuf, u32, Option<u32>);
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.banner(
std::path::Path::new("/tmp/repo"),
std::path::Path::new("/tmp/repo/.baraddur.toml"),
1,
None,
);
d.run_started(&["step".to_string()]);
d.parsed_diagnostics[0] = vec![
crate::output::diagnostic::Diagnostic {
path: std::path::PathBuf::from("a.rs"),
line: 1,
col: Some(1),
},
crate::output::diagnostic::Diagnostic {
path: std::path::PathBuf::from("b.rs"),
line: 2,
col: Some(2),
},
crate::output::diagnostic::Diagnostic {
path: std::path::PathBuf::from("c.rs"),
line: 3,
col: None,
},
];
let calls: Arc<Mutex<Vec<EditorCall>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = calls.clone();
d.set_editor_spawn(Box::new(move |path, line, col| {
recorder
.lock()
.unwrap()
.push((path.to_path_buf(), line, col));
Ok(())
}));
d.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
d.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
d.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
d.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
d.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
d.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
d.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
let recorded = calls.lock().unwrap().clone();
assert_eq!(recorded.len(), 4);
assert_eq!(
recorded[0],
(std::path::PathBuf::from("/tmp/repo/a.rs"), 1, Some(1))
);
assert_eq!(
recorded[1],
(std::path::PathBuf::from("/tmp/repo/b.rs"), 2, Some(2))
);
assert_eq!(
recorded[2],
(std::path::PathBuf::from("/tmp/repo/c.rs"), 3, None)
);
assert_eq!(
recorded[3],
(std::path::PathBuf::from("/tmp/repo/b.rs"), 2, Some(2))
);
}
#[test]
fn help_modal_opens_on_question_mark_and_dismisses_on_any_key() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["step".to_string()]);
assert!(!d.help_modal_active);
d.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert!(d.help_modal_active, "? should open the modal");
let action = d.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(matches!(action, BrowseAction::Redraw));
assert!(!d.help_modal_active, "any key should dismiss the modal");
}
#[test]
fn editor_key_with_no_diagnostics_is_no_op() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::{Arc, Mutex};
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["step".to_string()]);
let invoked = Arc::new(Mutex::new(false));
let flag = invoked.clone();
d.set_editor_spawn(Box::new(move |_, _, _| {
*flag.lock().unwrap() = true;
Ok(())
}));
d.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
assert!(!*invoked.lock().unwrap(), "editor should not be invoked");
assert_eq!(
d.transient_message.as_deref(),
Some("no diagnostics on this step")
);
}
fn mk_step_result(
success: bool,
exit_code: Option<i32>,
stdout: &str,
stderr: &str,
) -> StepResult {
StepResult {
name: "s".into(),
success,
exit_code,
stdout: stdout.into(),
stderr: stderr.into(),
duration: Duration::from_secs(0),
stdout_truncated: false,
stderr_truncated: false,
}
}
#[test]
fn short_diagnostic_empty_when_step_succeeded() {
let r = mk_step_result(true, Some(0), "anything\n", "");
assert_eq!(short_diagnostic(&r), "");
}
#[test]
fn short_diagnostic_reports_command_not_found_for_no_exit_code() {
let r = mk_step_result(false, None, "", "");
assert_eq!(short_diagnostic(&r), "command not found");
}
#[test]
fn short_diagnostic_empty_when_no_non_blank_output() {
let r = mk_step_result(false, Some(1), " \n\n \n", "");
assert_eq!(short_diagnostic(&r), "");
}
#[test]
fn short_diagnostic_returns_single_short_line_untouched() {
let r = mk_step_result(false, Some(1), "boom\n", "");
assert_eq!(short_diagnostic(&r), "boom");
}
#[test]
fn short_diagnostic_truncates_single_long_line_at_40_chars() {
let line = "x".repeat(50);
let r = mk_step_result(false, Some(1), &line, "");
let out = short_diagnostic(&r);
assert_eq!(out.chars().filter(|c| *c == 'x').count(), 40);
assert!(out.ends_with('…'));
}
#[test]
fn short_diagnostic_summarizes_multiple_lines_as_count() {
let r = mk_step_result(false, Some(2), "error one\nerror two\nerror three\n", "");
assert_eq!(short_diagnostic(&r), "3 lines");
}
#[test]
fn short_diagnostic_merges_stdout_and_stderr_for_line_count() {
let r = mk_step_result(false, Some(1), "a\n", "b\nc\n");
assert_eq!(short_diagnostic(&r), "3 lines");
}
#[test]
fn run_finished_quiet_all_pass_renders_nothing() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string(), "b".to_string()]);
d.rendered_lines = 5;
d.run_finished(&[
mk_step_result(true, Some(0), "", ""),
mk_step_result(true, Some(0), "", ""),
]);
assert_eq!(d.rendered_lines, 0);
assert!(
d.run_summary.is_empty(),
"no summary should be built in the quiet early return"
);
}
#[test]
fn run_finished_failing_run_sets_summary_and_clears_running() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["s".to_string()]);
d.has_running = true;
d.run_finished(&[mk_step_result(false, Some(1), "boom\n", "")]);
assert!(!d.run_summary.is_empty(), "summary line should be stashed");
assert!(d.run_summary.contains("1 failed"));
assert!(!d.has_running, "has_running must be cleared at run end");
}
#[test]
fn run_finished_no_match_message_single_path() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.set_trigger(&[PathBuf::from("src/foo.rs")]);
d.run_started(&[]); d.run_finished(&[]);
assert_eq!(
d.transient_message.as_deref(),
Some("no steps match changed path: src/foo.rs")
);
}
#[test]
fn run_finished_no_match_message_multiple_paths() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.set_trigger(&[PathBuf::from("a.rs"), PathBuf::from("b.rs")]);
d.run_started(&[]);
d.run_finished(&[]);
assert_eq!(
d.transient_message.as_deref(),
Some("no steps match 2 changed paths")
);
}
#[test]
fn run_finished_no_match_message_no_paths() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.set_trigger(&[]); d.run_started(&[]);
d.run_finished(&[]);
assert_eq!(
d.transient_message.as_deref(),
Some("no steps match changed paths")
);
}
#[test]
fn clamp_scroll_snaps_up_when_cursor_above_window() {
assert_eq!(TtyDisplay::clamp_scroll(5, 2, 1, 10, 20), 2);
}
#[test]
fn clamp_scroll_pulls_down_when_cursor_below_window() {
assert_eq!(TtyDisplay::clamp_scroll(0, 15, 2, 10, 20), 7);
}
#[test]
fn clamp_scroll_unchanged_when_cursor_inside_window() {
assert_eq!(TtyDisplay::clamp_scroll(2, 3, 1, 10, 20), 2);
}
#[test]
fn clamp_scroll_caps_to_zero_when_content_shorter_than_viewport() {
assert_eq!(TtyDisplay::clamp_scroll(5, 5, 1, 10, 3), 0);
}
fn rows_lines(rows: &[usize]) -> Vec<(String, usize)> {
rows.iter()
.enumerate()
.map(|(i, r)| (format!("line{i}"), *r))
.collect()
}
#[test]
fn viewport_slice_renders_everything_when_it_fits() {
let lines = rows_lines(&[1, 1, 1, 1, 1]);
let (range, rendered) = TtyDisplay::viewport_slice(&lines, 0, 10);
assert_eq!(range, 0..5);
assert_eq!(rendered, 5);
}
#[test]
fn viewport_slice_skips_whole_lines_on_clean_scroll_boundary() {
let lines = rows_lines(&[1, 1, 1, 1, 1]);
let (range, rendered) = TtyDisplay::viewport_slice(&lines, 2, 10);
assert_eq!(range, 2..5);
assert_eq!(rendered, 3);
}
#[test]
fn viewport_slice_stops_when_viewport_fills() {
let lines = rows_lines(&[1, 1, 1, 1, 1]);
let (range, rendered) = TtyDisplay::viewport_slice(&lines, 0, 2);
assert_eq!(range, 0..2);
assert_eq!(rendered, 2);
}
#[test]
fn viewport_slice_drops_a_partially_skipped_wrapped_line() {
let lines = rows_lines(&[3, 1, 1]);
let (range, rendered) = TtyDisplay::viewport_slice(&lines, 2, 10);
assert_eq!(range, 1..3);
assert_eq!(rendered, 2);
}
#[test]
fn viewport_slice_empty_lines_is_empty_range() {
let lines: Vec<(String, usize)> = Vec::new();
let (range, rendered) = TtyDisplay::viewport_slice(&lines, 0, 10);
assert_eq!(range, 0..0);
assert_eq!(rendered, 0);
}
#[test]
fn browse_redraw_smoke_paints_headless() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["alpha".to_string(), "beta".to_string()]);
d.run_finished(&[mk_step_result(false, Some(1), "boom\n", "")]);
d.browse_active = true;
d.browse_redraw();
assert!(
d.rendered_lines > 0,
"browse_redraw should paint at least the step rows"
);
d.help_modal_active = true;
d.browse_redraw();
assert!(
d.help_painted_last,
"modal paint should record help_painted_last"
);
}
#[test]
fn help_modal_lines_has_header_columns_and_dismiss_footer() {
use super::super::style::strip_ansi;
let d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
let lines = d.help_modal_lines();
assert_eq!(lines.len(), 14, "unexpected line count: {lines:#?}");
let plain: Vec<String> = lines.iter().map(|l| strip_ansi(l)).collect();
assert_eq!(plain[0].trim(), "Help");
assert_eq!(plain[1], "");
assert!(
plain[2].contains("Navigation") && plain[2].contains("Diagnostics"),
"first row should carry both section headers, got {:?}",
plain[2]
);
assert!(
plain
.iter()
.any(|l| l.contains("Output") && l.contains("Rerun")),
"expected a row pairing Output / Rerun"
);
assert!(plain.last().unwrap().contains("press any key to dismiss"));
}
#[test]
fn help_modal_lines_pads_left_column_to_align_right_column() {
use super::super::style::{strip_ansi, visible_len};
let d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
let lines = d.help_modal_lines();
let plain: Vec<String> = lines.iter().map(|l| strip_ansi(l)).collect();
let header_row = plain
.iter()
.find(|l| l.contains("Navigation") && l.contains("Diagnostics"))
.expect("header row");
let body_row = plain
.iter()
.find(|l| l.contains("down") && l.contains("next"))
.expect("body row");
let header_right = visible_len(&header_row[..header_row.find("Diagnostics").unwrap()]);
let body_right = visible_len(&body_row[..body_row.find(" n").unwrap()]);
assert_eq!(header_right, 28);
assert_eq!(body_right, 28);
}
#[test]
fn divider_styled_returns_empty_when_divider_is_empty() {
let d = TtyDisplay::new(Theme::new(true), Verbosity::Normal, true);
assert_eq!(d.divider_styled(), "");
}
#[test]
fn divider_styled_is_dim_while_steps_still_running() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(true), Verbosity::Normal, true);
d.run_divider = "── run ──".into();
d.statuses = vec![
StepStatus::Passed(Duration::from_secs(0)),
StepStatus::Running,
];
let out = d.divider_styled();
assert_eq!(strip_ansi(&out), "── run ──");
assert!(out.contains("\x1b[2m"), "expected dim ANSI, got {out:?}");
assert!(!out.contains("\x1b[32m") && !out.contains("\x1b[31m"));
}
#[test]
fn divider_styled_is_green_when_all_settled_and_passing() {
let mut d = TtyDisplay::new(Theme::new(true), Verbosity::Normal, true);
d.run_divider = "── run ──".into();
d.statuses = vec![
StepStatus::Passed(Duration::from_secs(0)),
StepStatus::Skipped,
];
let out = d.divider_styled();
assert!(
out.contains("[38;5;10m"),
"expected green ANSI, got {out:?}"
);
assert!(!out.contains("[38;5;9m") && !out.contains("[2m"));
}
#[test]
fn divider_styled_is_red_when_all_settled_and_any_failed() {
let mut d = TtyDisplay::new(Theme::new(true), Verbosity::Normal, true);
d.run_divider = "── run ──".into();
d.statuses = vec![
StepStatus::Passed(Duration::from_secs(0)),
StepStatus::Failed(Duration::from_secs(0), "boom".into()),
];
let out = d.divider_styled();
assert!(out.contains("[38;5;9m"), "expected red ANSI, got {out:?}");
assert!(!out.contains("[38;5;10m") && !out.contains("[2m"));
}
#[test]
fn step_row_queued_shows_glyph_only() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
let (line, rows) = d.step_row(0, 80);
let plain = strip_ansi(&line);
assert_eq!(rows, 1);
assert!(
plain.contains("check"),
"row should contain step name: {plain:?}"
);
assert!(
!plain.contains('s'),
"queued row should not show duration: {plain:?}"
);
}
#[test]
fn step_row_passed_appends_right_aligned_duration() {
use super::super::style::{strip_ansi, visible_len};
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
d.statuses[0] = StepStatus::Passed(Duration::from_millis(1500));
let (line, rows) = d.step_row(0, 80);
let plain = strip_ansi(&line);
assert_eq!(rows, 1);
assert!(plain.contains("check"));
assert!(
plain.ends_with("1.5s"),
"duration should be right-aligned: {plain:?}"
);
assert_eq!(visible_len(&line), 80);
}
#[test]
fn step_row_failed_with_diagnostic_includes_diag_text() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
d.statuses[0] = StepStatus::Failed(Duration::from_millis(2300), "boom".into());
let (line, _rows) = d.step_row(0, 80);
let plain = strip_ansi(&line);
assert!(plain.contains("check"));
assert!(plain.contains("boom"), "diagnostic text missing: {plain:?}");
assert!(plain.ends_with("2.3s"));
}
#[test]
fn step_row_cursor_uses_filled_arrow_in_no_color_mode() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["a".to_string(), "b".to_string()]);
d.cursor = 1;
let (cursor_line, _) = d.step_row(1, 80);
let (other_line, _) = d.step_row(0, 80);
assert!(
strip_ansi(&cursor_line).contains("▶"),
"NO_COLOR cursor row should use filled `▶`: {cursor_line:?}"
);
assert!(
!strip_ansi(&other_line).contains("▶"),
"non-cursor rows should use hollow `▸`: {other_line:?}"
);
}
#[test]
fn expanded_output_lines_empty_when_not_expanded() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
d.step_outputs[0] = " some output\n".into();
assert!(d.expanded_output_lines(0, 80).is_empty());
}
#[test]
fn expanded_output_lines_empty_when_no_captured_output() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
d.expanded[0] = true;
assert!(d.expanded_output_lines(0, 80).is_empty());
}
#[test]
fn expanded_output_lines_emits_one_entry_per_line() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
d.expanded[0] = true;
d.step_outputs[0] = " line one\n line two\n line three\n".into();
let lines = d.expanded_output_lines(0, 80);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].0, " line one");
assert_eq!(lines[1].0, " line two");
assert_eq!(lines[2].0, " line three");
}
#[test]
fn expanded_output_lines_highlights_current_diagnostic_on_cursor_step() {
use super::super::style::strip_ansi;
use crate::output::diagnostic::Diagnostic;
use std::path::PathBuf;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
d.cursor = 0;
d.expanded[0] = true;
d.step_outputs[0] = " src/foo.rs:10:5: error: bad\n".into();
d.parsed_diagnostics[0] = vec![Diagnostic {
path: PathBuf::from("src/foo.rs"),
line: 10,
col: Some(5),
}];
d.current_diagnostic[0] = 0;
let lines = d.expanded_output_lines(0, 80);
assert_eq!(lines.len(), 1);
let plain = strip_ansi(&lines[0].0);
assert!(
plain.starts_with("▶ "),
"current diag should lead with `▶ `: {plain:?}"
);
assert!(plain.contains("src/foo.rs:10:5"));
}
#[test]
fn footer_lines_empty_when_browse_not_active() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
d.run_summary = "1 passed".into();
assert!(d.footer_lines(80).is_empty());
}
#[test]
fn footer_lines_includes_summary_help_bar_and_spacer() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
d.browse_active = true;
d.run_summary = "1 passed in 1.2s".into();
let lines = d.footer_lines(80);
let plain: Vec<String> = lines.iter().map(|(s, _)| strip_ansi(s)).collect();
assert_eq!(plain[0], "");
assert!(plain.iter().any(|l| l.contains("1 passed in 1.2s")));
assert!(
plain.iter().any(|l| l.contains("q quit")),
"help bar missing from footer: {plain:#?}"
);
}
#[test]
fn footer_lines_keeps_help_bar_when_modal_is_active() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
d.browse_active = true;
d.help_modal_active = true;
let lines = d.footer_lines(80);
let plain: Vec<String> = lines.iter().map(|(s, _)| strip_ansi(s)).collect();
assert!(
plain.iter().any(|l| l.contains("q quit")),
"help bar should remain in footer while modal is active: {plain:#?}"
);
assert!(
!plain.iter().any(|l| l.contains("press any key to dismiss")),
"modal content should not be inlined into the footer: {plain:#?}"
);
}
#[test]
fn footer_lines_renders_hook_running_block_while_in_flight() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
d.browse_active = true;
d.hook_running = true;
let lines = d.footer_lines(80);
let plain: Vec<String> = lines.iter().map(|(s, _)| strip_ansi(s)).collect();
assert!(
plain.iter().any(|l| l.contains("running on_failure hook")),
"in-flight hook line missing: {plain:#?}"
);
}
#[test]
fn footer_lines_appends_transient_message_at_end() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["check".to_string()]);
d.browse_active = true;
d.transient_message = Some("no failures to rerun".into());
let lines = d.footer_lines(80);
let last = strip_ansi(&lines.last().unwrap().0);
assert!(
last.contains("no failures to rerun"),
"transient message should be the last footer entry: {last:?}"
);
}
#[test]
fn compute_lines_empty_state_returns_empty_layout() {
let d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
let (lines, top, h) = d.compute_lines(80);
assert!(lines.is_empty());
assert_eq!(top, 0);
assert_eq!(h, 1);
}
#[test]
fn compute_lines_anchors_cursor_to_step_offset() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["a".to_string(), "b".to_string(), "c".to_string()]);
d.run_divider.clear();
d.cursor = 2;
let (lines, top, h) = d.compute_lines(80);
assert_eq!(lines.len(), 3, "expected one row per step");
assert_eq!(top, 2);
assert_eq!(h, 1);
}
#[test]
fn compute_lines_offsets_cursor_by_run_divider() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["a".to_string(), "b".to_string()]);
d.cursor = 0;
d.run_divider = "── run ──".into();
let (lines, top, _) = d.compute_lines(80);
assert_eq!(lines.len(), 3);
assert_eq!(top, 1);
}
#[test]
fn compute_lines_shifts_cursor_anchor_by_earlier_step_expansion() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["a".to_string(), "b".to_string()]);
d.run_divider.clear(); d.cursor = 1;
d.expanded[0] = true;
d.step_outputs[0] = " line one\n line two\n line three\n".into();
let (lines, top, _) = d.compute_lines(80);
assert_eq!(lines.len(), 5);
assert_eq!(top, 4);
}
#[test]
fn compute_lines_appends_footer_after_step_block() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["a".to_string()]);
d.browse_active = true;
d.run_summary = "1 passed in 1.2s".into();
let (lines, _, _) = d.compute_lines(80);
assert!(lines.len() > 1, "expected step + footer entries");
let plain: Vec<String> = lines.iter().map(|(s, _)| strip_ansi(s)).collect();
let summary_idx = plain
.iter()
.position(|l| l.contains("1 passed in 1.2s"))
.expect("summary line missing");
let step_idx = plain
.iter()
.position(|l| l.contains('a'))
.expect("step row missing");
assert!(step_idx < summary_idx, "footer should follow step block");
}
#[test]
fn capture_step_outputs_expands_failed_steps_and_points_cursor_to_first_fail() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["a".to_string(), "b".to_string(), "c".to_string()]);
let results = vec![
mk_step_result(true, Some(0), "", ""),
mk_step_result(false, Some(1), "boom\n", ""),
mk_step_result(true, Some(0), "", ""),
];
let results: Vec<StepResult> = results
.into_iter()
.enumerate()
.map(|(i, mut r)| {
r.name = d.step_names[i].clone();
r
})
.collect();
d.capture_step_outputs(&results);
assert_eq!(d.expanded, vec![false, true, false]);
assert_eq!(d.cursor, 1);
assert!(d.all_expanded, "any failure should set all_expanded");
assert!(
d.step_outputs[1].contains("boom"),
"captured output should preserve content: {:?}",
d.step_outputs[1]
);
}
#[test]
fn capture_step_outputs_keeps_cursor_at_zero_when_all_passed() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["a".to_string(), "b".to_string()]);
let mut results = vec![
mk_step_result(true, Some(0), "", ""),
mk_step_result(true, Some(0), "", ""),
];
results[0].name = "a".into();
results[1].name = "b".into();
d.capture_step_outputs(&results);
assert_eq!(d.cursor, 0);
assert!(!d.all_expanded);
assert_eq!(d.expanded, vec![false, false]);
}
#[test]
fn summary_line_formats_all_passing_branch() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["a".to_string(), "b".to_string()]);
let mut results = vec![
mk_step_result(true, Some(0), "", ""),
mk_step_result(true, Some(0), "", ""),
];
results[0].name = "a".into();
results[1].name = "b".into();
let line = strip_ansi(&d.summary_line(&results, 1.2));
assert!(line.contains("2 passed"), "missing pass count: {line:?}");
assert!(
line.contains("all passing · 1.2s"),
"missing time suffix: {line:?}"
);
assert!(!line.contains("failed"));
}
#[test]
fn summary_line_leads_with_failed_count_when_any_failed() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["a".to_string(), "b".to_string()]);
let mut results = vec![
mk_step_result(false, Some(1), "boom\n", ""),
mk_step_result(true, Some(0), "", ""),
];
results[0].name = "a".into();
results[1].name = "b".into();
let line = strip_ansi(&d.summary_line(&results, 0.5));
assert!(
line.starts_with("1 failed"),
"wrong leading token: {line:?}"
);
assert!(line.contains("1 passed"));
assert!(
line.ends_with("0.5s"),
"expected bare time suffix: {line:?}"
);
assert!(!line.contains("all passing"));
}
#[test]
fn summary_line_includes_skipped_when_some_steps_missing_from_results() {
use super::super::style::strip_ansi;
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Normal, true);
d.run_started(&["a".to_string(), "b".to_string(), "c".to_string()]);
let mut results = vec![mk_step_result(true, Some(0), "", "")];
results[0].name = "a".into();
let line = strip_ansi(&d.summary_line(&results, 1.0));
assert!(line.contains("1 passed"));
assert!(
line.contains("2 skipped"),
"missing skipped count: {line:?}"
);
}
fn dispatch(d: &mut TtyDisplay, ch: char) -> BrowseAction {
use crossterm::event::{KeyEvent, KeyModifiers};
d.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE))
}
#[test]
fn handle_key_j_moves_cursor_down_and_clamps_at_last_step() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string(), "b".to_string(), "c".to_string()]);
assert!(matches!(dispatch(&mut d, 'j'), BrowseAction::Redraw));
assert_eq!(d.cursor, 1);
dispatch(&mut d, 'j');
dispatch(&mut d, 'j'); assert_eq!(d.cursor, 2);
}
#[test]
fn handle_key_k_moves_cursor_up_and_clamps_at_zero() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string(), "b".to_string()]);
d.cursor = 1;
assert!(matches!(dispatch(&mut d, 'k'), BrowseAction::Redraw));
assert_eq!(d.cursor, 0);
dispatch(&mut d, 'k'); assert_eq!(d.cursor, 0);
}
#[test]
fn handle_key_gg_chord_jumps_cursor_to_top() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string(), "b".to_string(), "c".to_string()]);
d.cursor = 2;
assert!(matches!(dispatch(&mut d, 'g'), BrowseAction::Noop));
assert_eq!(d.cursor, 2);
assert!(matches!(dispatch(&mut d, 'g'), BrowseAction::Redraw));
assert_eq!(d.cursor, 0);
}
#[test]
fn handle_key_capital_g_jumps_cursor_to_bottom() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string(), "b".to_string(), "c".to_string()]);
d.cursor = 0;
assert!(matches!(dispatch(&mut d, 'G'), BrowseAction::Redraw));
assert_eq!(d.cursor, 2);
}
#[test]
fn handle_key_enter_toggles_expansion_at_cursor() {
use crossterm::event::{KeyEvent, KeyModifiers};
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string(), "b".to_string()]);
d.cursor = 1;
assert!(!d.expanded[1]);
d.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(d.expanded[1]);
assert!(!d.expanded[0], "only the cursor step should toggle");
d.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(!d.expanded[1], "second press should toggle off");
}
#[test]
fn handle_key_o_toggles_same_as_enter() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string()]);
assert!(!d.expanded[0]);
dispatch(&mut d, 'o');
assert!(d.expanded[0]);
}
#[test]
fn handle_key_capital_o_toggles_expansion_for_all_steps() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string(), "b".to_string(), "c".to_string()]);
assert!(d.expanded.iter().all(|e| !e));
dispatch(&mut d, 'O');
assert!(d.expanded.iter().all(|e| *e), "all should expand");
dispatch(&mut d, 'O');
assert!(d.expanded.iter().all(|e| !e), "all should collapse");
}
#[test]
fn handle_key_q_returns_quit() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string()]);
assert!(matches!(dispatch(&mut d, 'q'), BrowseAction::Quit));
}
#[test]
fn handle_key_q_returns_quit_even_with_no_steps() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
assert!(matches!(dispatch(&mut d, 'q'), BrowseAction::Quit));
}
#[test]
fn handle_key_r_returns_rerun() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string()]);
assert!(matches!(dispatch(&mut d, 'r'), BrowseAction::Rerun));
}
#[test]
fn handle_key_f_with_failures_returns_rerun_failed() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string()]);
d.statuses[0] = StepStatus::Failed(Duration::from_secs(0), "boom".into());
assert!(matches!(dispatch(&mut d, 'f'), BrowseAction::RerunFailed));
}
#[test]
fn handle_key_f_without_failures_sets_transient_message_and_redraws() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string()]);
d.statuses[0] = StepStatus::Passed(Duration::from_secs(0));
assert!(matches!(dispatch(&mut d, 'f'), BrowseAction::Redraw));
assert_eq!(
d.transient_message.as_deref(),
Some("no failures to re-run")
);
}
#[test]
fn handle_key_unknown_key_clears_visible_message_and_promotes_to_redraw() {
let mut d = TtyDisplay::new(Theme::new(false), Verbosity::Quiet, true);
d.run_started(&["a".to_string()]);
d.transient_message = Some("stale".into());
assert!(matches!(dispatch(&mut d, 'x'), BrowseAction::Redraw));
assert!(d.transient_message.is_none());
}
#[test]
fn redraw_strategy_skips_when_nothing_was_rendered() {
assert_eq!(redraw_strategy(0, (80, 24), (80, 24)), RedrawStrategy::Skip,);
assert_eq!(
redraw_strategy(0, (80, 24), (100, 30)),
RedrawStrategy::Skip,
);
}
#[test]
fn redraw_strategy_uses_move_up_when_size_unchanged_and_fits() {
assert_eq!(
redraw_strategy(5, (80, 24), (80, 24)),
RedrawStrategy::MoveUp(5),
);
}
#[test]
fn redraw_strategy_falls_back_to_full_clear_on_width_change() {
assert_eq!(
redraw_strategy(5, (80, 24), (100, 24)),
RedrawStrategy::FullClear,
);
}
#[test]
fn redraw_strategy_falls_back_to_full_clear_on_height_change() {
assert_eq!(
redraw_strategy(5, (80, 24), (80, 30)),
RedrawStrategy::FullClear,
);
}
#[test]
fn redraw_strategy_treats_first_call_as_size_change() {
assert_eq!(
redraw_strategy(5, (0, 0), (80, 24)),
RedrawStrategy::FullClear,
);
}
#[test]
fn redraw_strategy_falls_back_to_full_clear_when_rendered_exceeds_viewport() {
assert_eq!(
redraw_strategy(24, (80, 24), (80, 24)),
RedrawStrategy::FullClear,
);
assert_eq!(
redraw_strategy(100, (80, 24), (80, 24)),
RedrawStrategy::FullClear,
);
}
#[test]
fn redraw_strategy_move_up_at_height_boundary() {
assert_eq!(
redraw_strategy(23, (80, 24), (80, 24)),
RedrawStrategy::MoveUp(23),
);
}
#[test]
fn restore_signals_and_output_reenables_opost_and_isig() {
use rustix::termios::{OptionalActions, tcsetattr};
let pty = openpty(None, None).expect("openpty failed");
let user = pty.user.as_fd();
let mut t = tcgetattr(user).unwrap();
t.output_modes.remove(OutputModes::OPOST);
t.local_modes.remove(LocalModes::ISIG);
tcsetattr(user, OptionalActions::Now, &t).unwrap();
terminal_io::restore_signals_and_output(user);
let after = tcgetattr(user).unwrap();
assert!(
after.output_modes.contains(OutputModes::OPOST),
"OPOST should be re-enabled"
);
assert!(
after.local_modes.contains(LocalModes::ISIG),
"ISIG should be re-enabled"
);
}
}