use crate::event::{Kind, State};
use std::io::IsTerminal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Stream {
Out,
Err,
}
const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const CYAN: &str = "\x1b[36m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const RED: &str = "\x1b[31m";
const MAGENTA: &str = "\x1b[35m";
const BLUE: &str = "\x1b[34m";
fn decide(no_color: Option<&str>, term: Option<&str>, force: Option<&str>, is_tty: bool) -> bool {
if no_color.is_some_and(|v| !v.is_empty()) {
return false;
}
if term == Some("dumb") {
return false;
}
if force.is_some_and(|v| !v.is_empty() && v != "0") {
return true;
}
is_tty
}
fn is_terminal(stream: Stream) -> bool {
match stream {
Stream::Out => std::io::stdout().is_terminal(),
Stream::Err => std::io::stderr().is_terminal(),
}
}
#[cfg(windows)]
mod windows_vt {
use super::Stream;
use std::ffi::c_void;
#[link(name = "kernel32")]
extern "system" {
fn GetStdHandle(n: u32) -> *mut c_void;
fn GetConsoleMode(h: *mut c_void, m: *mut u32) -> i32;
fn SetConsoleMode(h: *mut c_void, m: u32) -> i32;
fn GetConsoleScreenBufferInfo(h: *mut c_void, info: *mut ScreenBufferInfo) -> i32;
}
const STD_OUTPUT_HANDLE: u32 = -11i32 as u32;
const STD_ERROR_HANDLE: u32 = -12i32 as u32;
const ENABLE_VIRTUAL_TERMINAL_PROCESSING: u32 = 0x0004;
#[repr(C)]
struct Coord {
x: i16,
y: i16,
}
#[repr(C)]
struct SmallRect {
left: i16,
top: i16,
right: i16,
bottom: i16,
}
#[repr(C)]
#[allow(dead_code)]
struct ScreenBufferInfo {
size: Coord,
cursor: Coord,
attributes: u16,
window: SmallRect,
maximum_window: Coord,
}
pub(super) fn enabled(stream: Stream) -> bool {
let which = match stream {
Stream::Out => STD_OUTPUT_HANDLE,
Stream::Err => STD_ERROR_HANDLE,
};
let handle = unsafe { GetStdHandle(which) };
if handle.is_null() {
return false;
}
let mut mode: u32 = 0;
if unsafe { GetConsoleMode(handle, &mut mode) } == 0 {
return false;
}
if mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 {
return true;
}
unsafe { SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0 }
}
pub(super) fn width(stream: Stream) -> Option<usize> {
let which = match stream {
Stream::Out => STD_OUTPUT_HANDLE,
Stream::Err => STD_ERROR_HANDLE,
};
let handle = unsafe { GetStdHandle(which) };
if handle.is_null() {
return None;
}
let mut info = ScreenBufferInfo {
size: Coord { x: 0, y: 0 },
cursor: Coord { x: 0, y: 0 },
attributes: 0,
window: SmallRect {
left: 0,
top: 0,
right: 0,
bottom: 0,
},
maximum_window: Coord { x: 0, y: 0 },
};
if unsafe { GetConsoleScreenBufferInfo(handle, &mut info) } == 0 {
return None;
}
let columns = i32::from(info.window.right) - i32::from(info.window.left) + 1;
if columns > 0 {
Some(columns as usize)
} else {
None
}
}
}
static FORCE_PLAIN: AtomicBool = AtomicBool::new(false);
pub fn plain_only() {
FORCE_PLAIN.store(true, Ordering::SeqCst);
}
pub fn enabled(stream: Stream) -> bool {
if FORCE_PLAIN.load(Ordering::SeqCst) {
return false;
}
static OUT: OnceLock<bool> = OnceLock::new();
static ERR: OnceLock<bool> = OnceLock::new();
let cell = match stream {
Stream::Out => &OUT,
Stream::Err => &ERR,
};
*cell.get_or_init(|| compute(stream))
}
fn compute(stream: Stream) -> bool {
let no_color = std::env::var("NO_COLOR").ok();
let term = std::env::var("TERM").ok();
let force = std::env::var("CLICOLOR_FORCE").ok();
let is_tty = is_terminal(stream);
if !decide(
no_color.as_deref(),
term.as_deref(),
force.as_deref(),
is_tty,
) {
return false;
}
if force.as_deref().is_some_and(|v| !v.is_empty() && v != "0") {
return true;
}
#[cfg(windows)]
{
windows_vt::enabled(stream)
}
#[cfg(not(windows))]
{
true
}
}
fn span(stream: Stream, code: &str, text: &str) -> String {
if enabled(stream) {
format!("{code}{text}{RESET}")
} else {
text.to_string()
}
}
fn dual_span(stream: Stream, a: &str, b: &str, text: &str) -> String {
if enabled(stream) {
format!("{a}{b}{text}{RESET}")
} else {
text.to_string()
}
}
pub fn bold(stream: Stream, text: &str) -> String {
span(stream, BOLD, text)
}
pub fn dim(stream: Stream, text: &str) -> String {
span(stream, DIM, text)
}
pub fn path(stream: Stream, text: &str) -> String {
span(stream, CYAN, text)
}
pub fn good(stream: Stream, text: &str) -> String {
span(stream, GREEN, text)
}
pub fn change(stream: Stream, text: &str) -> String {
span(stream, YELLOW, text)
}
pub fn gone(stream: Stream, text: &str) -> String {
span(stream, RED, text)
}
pub fn warn(stream: Stream, text: &str) -> String {
span(stream, YELLOW, text)
}
pub fn verb(stream: Stream, word: &str) -> String {
match word {
"create" | "add" | "plant" | "write" => good(stream, word),
"replace" | "update" | "lock" => change(stream, word),
"remove" => gone(stream, word),
"keep" | "kept" | "already there" | "left as it is" | "unchanged" => dim(stream, word),
_ => word.to_string(),
}
}
pub fn kind_id(stream: Stream, kind: Kind, alias: &str) -> String {
let colour = match kind {
Kind::Goal | Kind::Assumption => MAGENTA,
Kind::Task => CYAN,
Kind::Decision => GREEN,
Kind::Finding => YELLOW,
Kind::Question => BLUE,
Kind::Pillar | Kind::Rule | Kind::Constraint => RED,
};
dual_span(stream, BOLD, colour, alias)
}
pub fn mark(stream: Stream, state: State) -> String {
let text = format!("[{}]", state.mark());
match state {
State::Active => text,
State::Done => good(stream, &text),
State::Suspended => change(stream, &text),
State::Abandoned => gone(stream, &text),
State::Superseded => dim(stream, &text),
}
}
pub fn wrap_title(lead: usize, text: &str, width: usize) -> Vec<String> {
let room = width.saturating_sub(lead).max(1);
let mut lines = Vec::new();
let mut cur = String::new();
for word in text.split_whitespace() {
let extra = usize::from(!cur.is_empty());
if !cur.is_empty() && cur.chars().count() + extra + word.chars().count() > room {
lines.push(std::mem::take(&mut cur));
} else if !cur.is_empty() {
cur.push(' ');
}
cur.push_str(word);
}
if !cur.is_empty() {
lines.push(cur);
}
lines
}
fn decide_width(
is_tty: bool,
force: Option<&str>,
columns: Option<&str>,
system: Option<usize>,
) -> Option<usize> {
let forced = force.is_some_and(|v| !v.is_empty() && v != "0");
if !is_tty && !forced {
return None;
}
let from_columns = columns
.and_then(|v| v.parse::<usize>().ok())
.filter(|n| *n > 0);
from_columns.or(system).filter(|n| *n >= 40)
}
#[cfg(windows)]
fn system_width(stream: Stream) -> Option<usize> {
windows_vt::width(stream)
}
#[cfg(unix)]
fn system_width(stream: Stream) -> Option<usize> {
unix_tty::width(stream)
}
#[cfg(not(any(windows, unix)))]
fn system_width(_stream: Stream) -> Option<usize> {
None
}
fn compute_width(stream: Stream) -> Option<usize> {
let force = std::env::var("CLICOLOR_FORCE").ok();
let columns = std::env::var("COLUMNS").ok();
decide_width(
is_terminal(stream),
force.as_deref(),
columns.as_deref(),
system_width(stream),
)
}
pub fn width(stream: Stream) -> Option<usize> {
if FORCE_PLAIN.load(Ordering::SeqCst) {
return None;
}
static OUT: OnceLock<Option<usize>> = OnceLock::new();
static ERR: OnceLock<Option<usize>> = OnceLock::new();
let cell = match stream {
Stream::Out => &OUT,
Stream::Err => &ERR,
};
*cell.get_or_init(|| compute_width(stream))
}
#[cfg(unix)]
mod unix_tty {
use super::Stream;
#[repr(C)]
#[allow(dead_code)]
struct WinSize {
row: u16,
col: u16,
x_pixel: u16,
y_pixel: u16,
}
#[cfg(all(target_os = "linux", target_env = "musl"))]
type Request = std::ffi::c_int;
#[cfg(not(all(target_os = "linux", target_env = "musl")))]
type Request = std::ffi::c_ulong;
#[cfg(target_os = "linux")]
const TIOCGWINSZ: Request = 0x5413;
#[cfg(any(
target_os = "macos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
))]
const TIOCGWINSZ: Request = 0x40087468;
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
))]
extern "C" {
fn ioctl(fd: std::ffi::c_int, request: Request, ...) -> std::ffi::c_int;
}
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
))]
pub(super) fn width(stream: Stream) -> Option<usize> {
let fd = match stream {
Stream::Out => 1,
Stream::Err => 2,
};
let mut ws = WinSize {
row: 0,
col: 0,
x_pixel: 0,
y_pixel: 0,
};
let rc = unsafe { ioctl(fd, TIOCGWINSZ, std::ptr::addr_of_mut!(ws)) };
if rc == 0 && ws.col > 0 {
Some(ws.col as usize)
} else {
None
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
)))]
pub(super) fn width(_stream: Stream) -> Option<usize> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_color_wins_over_everything_else() {
assert!(!decide(Some("1"), None, Some("1"), true));
assert!(!decide(Some("1"), None, Some("1"), false));
}
#[test]
fn an_empty_no_color_does_not_count() {
assert!(decide(Some(""), None, Some("1"), false));
}
#[test]
fn term_dumb_disables_regardless_of_force() {
assert!(!decide(None, Some("dumb"), Some("1"), true));
}
#[test]
fn clicolor_force_enables_with_no_terminal_at_all() {
assert!(decide(None, None, Some("1"), false));
assert!(decide(None, None, Some("yes"), false));
}
#[test]
fn clicolor_force_zero_does_not_count() {
assert!(!decide(None, None, Some("0"), false));
}
#[test]
fn with_no_env_the_terminal_alone_decides() {
assert!(decide(None, None, None, true));
assert!(!decide(None, None, None, false));
}
#[test]
fn verb_colours_come_from_the_word_not_a_flag() {
assert_eq!(verb(Stream::Out, "create"), good(Stream::Out, "create"));
assert_eq!(verb(Stream::Out, "replace"), change(Stream::Out, "replace"));
assert_eq!(verb(Stream::Out, "remove"), gone(Stream::Out, "remove"));
assert_eq!(verb(Stream::Out, "keep"), dim(Stream::Out, "keep"));
assert_eq!(verb(Stream::Out, "in"), "in");
}
#[test]
fn spans_carry_the_word_untouched_either_way() {
for f in [bold, dim, path, good, change, gone, warn] as [fn(Stream, &str) -> String; 7] {
assert!(f(Stream::Out, "hook").contains("hook"));
}
}
#[test]
fn kind_id_carries_the_alias_untouched() {
for k in [
Kind::Goal,
Kind::Task,
Kind::Decision,
Kind::Question,
Kind::Constraint,
Kind::Finding,
Kind::Assumption,
Kind::Pillar,
Kind::Rule,
] {
assert!(kind_id(Stream::Out, k, "x1").contains("x1"));
}
}
#[test]
fn kind_id_is_bold_and_coloured_in_the_one_span() {
assert_eq!(
kind_id(Stream::Out, Kind::Task, "t1"),
dual_span(Stream::Out, BOLD, CYAN, "t1")
);
assert_eq!(
kind_id(Stream::Out, Kind::Decision, "d1"),
dual_span(Stream::Out, BOLD, GREEN, "d1")
);
}
#[test]
fn mark_colours_the_brackets_by_state() {
assert_eq!(mark(Stream::Out, State::Active), "[ ]");
assert_eq!(mark(Stream::Out, State::Done), good(Stream::Out, "[x]"));
assert_eq!(
mark(Stream::Out, State::Suspended),
change(Stream::Out, "[~]")
);
assert_eq!(
mark(Stream::Out, State::Abandoned),
gone(Stream::Out, "[!]")
);
assert_eq!(
mark(Stream::Out, State::Superseded),
dim(Stream::Out, "[-]")
);
}
#[test]
fn wrap_title_keeps_short_text_on_one_line() {
assert_eq!(wrap_title(8, "short title", 60), vec!["short title"]);
}
#[test]
fn wrap_title_breaks_on_word_boundaries_within_the_room_lead_leaves() {
let lines = wrap_title(8, "one two three four five six seven eight nine ten", 20);
assert!(lines.iter().all(|l| l.chars().count() <= 12), "{lines:?}");
assert_eq!(
lines.join(" "),
"one two three four five six seven eight nine ten"
);
}
#[test]
fn a_single_word_longer_than_the_room_stays_whole() {
let lines = wrap_title(8, "supercalifragilisticexpialidocious", 20);
assert_eq!(lines, vec!["supercalifragilisticexpialidocious"]);
}
#[test]
fn wrap_title_returns_plain_chunks_with_no_leading_space() {
let lines = wrap_title(8, "alpha beta gamma delta epsilon zeta", 20);
assert!(lines.iter().all(|l| !l.starts_with(' ')), "{lines:?}");
}
#[test]
fn width_answers_nothing_without_a_terminal_or_force() {
assert_eq!(decide_width(false, None, Some("80"), Some(80)), None);
}
#[test]
fn width_prefers_columns_over_the_system_call() {
assert_eq!(decide_width(true, None, Some("100"), Some(80)), Some(100));
}
#[test]
fn width_falls_back_to_the_system_call_without_columns() {
assert_eq!(decide_width(true, None, None, Some(80)), Some(80));
}
#[test]
fn force_alone_answers_with_columns_and_no_terminal_at_all() {
assert_eq!(decide_width(false, Some("1"), Some("72"), None), Some(72));
}
#[test]
fn an_unusable_columns_value_falls_back_to_the_system_call() {
assert_eq!(decide_width(true, None, Some("0"), Some(80)), Some(80));
assert_eq!(decide_width(true, None, Some("nope"), Some(80)), Some(80));
}
#[test]
fn a_width_under_forty_columns_is_treated_as_no_answer() {
assert_eq!(decide_width(true, None, Some("39"), None), None);
assert_eq!(decide_width(true, None, None, Some(10)), None);
}
#[test]
fn no_system_answer_and_no_columns_is_no_answer() {
assert_eq!(decide_width(true, None, None, None), None);
}
}