use crossterm::terminal;
use crate::ui::text::{display_width, truncate_to_width};
pub const MIN_COLS: u16 = 20;
pub const MIN_ROWS: u16 = 3;
pub const FALLBACK_COLS: u16 = 80;
pub const FALLBACK_ROWS: u16 = 24;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Viewport {
pub cols: u16,
pub rows: u16,
}
impl Viewport {
#[must_use]
pub fn resolve(raw_cols: u16, raw_rows: u16) -> Self {
Self {
cols: resolve_dimension(raw_cols, "COLUMNS", FALLBACK_COLS),
rows: resolve_dimension(raw_rows, "LINES", FALLBACK_ROWS),
}
}
#[must_use]
pub fn current() -> Self {
match terminal::size() {
Ok((cols, rows)) => Self::resolve(cols, rows),
Err(_) => Self::resolve(0, 0),
}
}
#[must_use]
pub fn is_renderable(self) -> bool {
self.cols >= MIN_COLS && self.rows >= MIN_ROWS
}
#[must_use]
pub fn too_small_notice(self) -> String {
let Self { cols, rows } = self;
let width = cols as usize;
let long =
format!("all-smi needs at least {MIN_COLS}x{MIN_ROWS}, this terminal is {cols}x{rows}");
if display_width(&long) <= width {
return long;
}
let short = format!("{cols}x{rows} < {MIN_COLS}x{MIN_ROWS}");
if display_width(&short) <= width {
return short;
}
truncate_to_width(&short, width).into_owned()
}
}
fn resolve_dimension(raw: u16, env_key: &str, fallback: u16) -> u16 {
if raw > 0 {
return raw;
}
dimension_from_env(std::env::var(env_key).ok().as_deref(), fallback)
}
fn dimension_from_env(value: Option<&str>, fallback: u16) -> u16 {
value
.and_then(|value| value.trim().parse::<u16>().ok())
.filter(|&parsed| parsed > 0)
.unwrap_or(fallback)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_size_resolves_to_the_fallback_geometry() {
let viewport = Viewport::resolve(0, 0);
assert_eq!(viewport.cols, FALLBACK_COLS);
assert_eq!(viewport.rows, FALLBACK_ROWS);
assert!(
viewport.is_renderable(),
"a pty with no geometry must still render a normal frame"
);
}
#[test]
fn substitution_is_per_dimension() {
assert_eq!(
Viewport::resolve(0, 30),
Viewport {
cols: FALLBACK_COLS,
rows: 30
}
);
assert_eq!(
Viewport::resolve(100, 0),
Viewport {
cols: 100,
rows: FALLBACK_ROWS
}
);
}
#[test]
fn a_real_size_passes_through_untouched() {
assert_eq!(
Viewport::resolve(100, 30),
Viewport {
cols: 100,
rows: 30
}
);
assert_eq!(Viewport::resolve(12, 2), Viewport { cols: 12, rows: 2 });
}
#[test]
fn environment_supplies_a_missing_dimension_when_it_is_usable() {
assert_eq!(dimension_from_env(Some("120"), FALLBACK_COLS), 120);
assert_eq!(dimension_from_env(Some(" 40 "), FALLBACK_COLS), 40);
}
#[test]
fn unusable_environment_values_fall_through_to_the_fallback() {
for value in [
None,
Some(""),
Some("0"),
Some("abc"),
Some("-5"),
Some("99999"),
] {
assert_eq!(
dimension_from_env(value, FALLBACK_COLS),
FALLBACK_COLS,
"{value:?} must not be trusted as a dimension"
);
}
}
#[test]
fn renderable_floor_is_inclusive_and_rejects_just_below() {
assert!(
Viewport {
cols: MIN_COLS,
rows: MIN_ROWS
}
.is_renderable()
);
assert!(
!Viewport {
cols: MIN_COLS - 1,
rows: MIN_ROWS
}
.is_renderable()
);
assert!(
!Viewport {
cols: MIN_COLS,
rows: MIN_ROWS - 1
}
.is_renderable()
);
}
#[test]
fn degenerate_and_tiny_sizes_are_not_renderable() {
for (cols, rows) in [
(0, 0),
(1, 1),
(0, 30),
(200, 0),
(200, 1),
(20, 2),
(19, 3),
] {
assert!(
!Viewport { cols, rows }.is_renderable(),
"{cols}x{rows} must not be treated as renderable"
);
}
}
#[test]
fn notice_never_exceeds_the_available_width() {
for cols in 0u16..=90 {
let notice = Viewport { cols, rows: 2 }.too_small_notice();
assert!(
display_width(¬ice) <= cols as usize,
"notice {notice:?} overflows {cols} columns"
);
}
}
#[test]
fn notice_states_both_the_requirement_and_the_actual_size() {
let notice = Viewport { cols: 80, rows: 2 }.too_small_notice();
assert!(notice.contains("20x3"), "notice must state the minimum");
assert!(notice.contains("80x2"), "notice must state what it got");
}
#[test]
fn notice_degrades_to_a_short_form_when_the_long_one_will_not_fit() {
let notice = Viewport { cols: 24, rows: 2 }.too_small_notice();
assert_eq!(notice, "24x2 < 20x3");
}
#[test]
fn notice_is_plain_text_with_no_escape_sequences() {
let notice = Viewport { cols: 80, rows: 2 }.too_small_notice();
assert!(
!notice.contains('\u{1b}'),
"notice must stay greppable in a captured transcript"
);
}
}