use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use unicode_width::UnicodeWidthStr;
const MIN_WORKSPACE_TAIL: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SizeClass {
Full,
Comfortable,
Tight,
Cramped,
Extreme,
BelowMinimum,
}
#[derive(Debug, Clone)]
pub struct DegradationState {
pub size_class: SizeClass,
pub sessions_visible: bool,
pub sessions_width: u16,
pub hint_drop_level: u8,
pub title_level: u8,
pub show_borders: bool,
pub show_ls_info: bool,
}
pub struct DegradationConfig {
pub sessions_width_ratio: f64,
pub sessions_min_width: u16,
pub panel_min_width: u16,
pub panel_min_height: u16,
}
impl Default for DegradationConfig {
fn default() -> Self {
Self {
sessions_width_ratio: 0.4,
sessions_min_width: 20,
panel_min_width: 20,
panel_min_height: 4,
}
}
}
#[must_use]
pub const fn is_below_minimum(width: u16, height: u16) -> bool {
width < 5 || height < 2
}
pub fn render_below_minimum(area: Rect, buf: &mut Buffer) {
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_char('\u{2573}'); }
}
}
}
#[must_use]
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "terminal dimensions are bounded by u16"
)]
pub fn compute_degradation(
width: u16,
height: u16,
panel_count: usize,
config: &DegradationConfig,
) -> DegradationState {
if is_below_minimum(width, height) {
return DegradationState {
size_class: SizeClass::BelowMinimum,
sessions_visible: false,
sessions_width: 0,
hint_drop_level: 7,
title_level: 6,
show_borders: false,
show_ls_info: false,
};
}
let panels_per_row = if panel_count == 0 {
1
} else {
isqrt_ceil(panel_count)
};
let ideal_sessions = (f64::from(width) * config.sessions_width_ratio) as u16;
let panels_per_row_u16 = (panels_per_row as u16).max(1);
let min_events_width = config.panel_min_width.saturating_mul(panels_per_row_u16);
let (sessions_visible, sessions_width) = if panel_count == 0 {
(true, width)
} else {
let events_with_ideal = width.saturating_sub(ideal_sessions);
if events_with_ideal >= min_events_width {
(true, ideal_sessions)
} else {
let available_for_sessions = width.saturating_sub(min_events_width);
if available_for_sessions >= config.sessions_min_width {
(true, available_for_sessions)
} else {
(false, 0)
}
}
};
let events_width = width.saturating_sub(sessions_width);
let per_panel_width = if panels_per_row > 0 && events_width > 0 {
events_width / panels_per_row_u16
} else {
width
};
let rows = if panel_count > 0 {
panel_count.div_ceil(panels_per_row).max(1) as u16
} else {
1
};
let available_height = height.saturating_sub(1); let per_panel_height = if rows > 0 {
available_height / rows
} else {
available_height
};
let hint_avail = width.saturating_sub(6); let hint_drop_level = compute_hint_drop_level(hint_avail);
let title_level = compute_title_level(per_panel_width);
let show_borders = per_panel_height >= config.panel_min_height;
let show_ls_info = per_panel_width >= 30;
let size_class = if hint_drop_level == 0 && show_borders && show_ls_info && sessions_visible {
SizeClass::Full
} else if show_borders && sessions_visible {
SizeClass::Comfortable
} else if show_borders {
SizeClass::Tight
} else if per_panel_height >= 2 {
SizeClass::Cramped
} else {
SizeClass::Extreme
};
DegradationState {
size_class,
sessions_visible,
sessions_width,
hint_drop_level,
title_level,
show_borders,
show_ls_info,
}
}
const fn isqrt_ceil(n: usize) -> usize {
if n == 0 {
return 0;
}
let s = n.isqrt();
if s * s == n { s } else { s + 1 }
}
const fn compute_hint_drop_level(available: u16) -> u8 {
if available >= 25 {
0
} else if available >= 17 {
1
} else if available >= 13 {
2
} else if available >= 9 {
3
} else if available >= 5 {
4
} else if available >= 3 {
5
} else {
6
}
}
const fn compute_title_level(per_panel_width: u16) -> u8 {
if per_panel_width >= 35 {
0
} else if per_panel_width >= 25 {
1
} else if per_panel_width >= 17 {
2
} else if per_panel_width >= 14 {
3
} else if per_panel_width >= 7 {
4
} else if per_panel_width >= 2 {
5
} else {
6
}
}
#[must_use]
pub fn degrade_title(session_id: &str, workspace: Option<&str>, max_width: u16) -> String {
let max = max_width as usize;
let id8 = if session_id.len() > 8 {
&session_id[..8]
} else {
session_id
};
if let Some(ws) = workspace {
let full = format!("Events [{id8} {ws}]");
if UnicodeWidthStr::width(full.as_str()) <= max {
return full;
}
let prefix = format!("Events [{id8} \u{2026}");
let suffix = "]";
let overhead = UnicodeWidthStr::width(prefix.as_str()) + UnicodeWidthStr::width(suffix);
let budget = max.saturating_sub(overhead);
if budget >= MIN_WORKSPACE_TAIL {
let ws_chars: Vec<char> = ws.chars().collect();
let start = ws_chars.len().saturating_sub(budget);
let ws_tail: String = ws_chars[start..].iter().collect();
let candidate = format!("{prefix}{ws_tail}{suffix}");
if UnicodeWidthStr::width(candidate.as_str()) <= max {
return candidate;
}
}
}
let level2 = format!("Events [{id8}]");
if UnicodeWidthStr::width(level2.as_str()) <= max {
return level2;
}
let id4 = if session_id.len() > 4 {
&session_id[..4]
} else {
session_id
};
let level3 = format!("Events [{id4}\u{2026}]");
if UnicodeWidthStr::width(level3.as_str()) <= max {
return level3;
}
let level4 = format!("[{id4}\u{2026}]");
if UnicodeWidthStr::width(level4.as_str()) <= max {
return level4;
}
if max >= 2 {
let id_budget = max - 1; let id_prefix: String = session_id.chars().take(id_budget).collect();
return format!("{id_prefix}\u{2026}");
}
if max == 1 {
return session_id
.chars()
.next()
.map(String::from)
.unwrap_or_default();
}
String::new()
}
const ALL_HINTS: [(&str, &str); 5] = [
("z", "\u{2550}"), ("v", "\u{2592}"), ("f", "\u{2591}"), ("?", ""),
("q", "\u{2718}"), ];
fn hint_display_width(key: &str, symbol: &str) -> usize {
if symbol.is_empty() {
UnicodeWidthStr::width(key)
} else {
UnicodeWidthStr::width(key) + 1 + UnicodeWidthStr::width(symbol)
}
}
fn hints_width_with_separators(hints: &[(&str, &str)]) -> usize {
if hints.is_empty() {
return 0;
}
let content: usize = hints.iter().map(|(k, s)| hint_display_width(k, s)).sum();
let seps = (hints.len() - 1) * 3; content + seps
}
fn hints_width_spaced(hints: &[(&str, &str)]) -> usize {
if hints.is_empty() {
return 0;
}
let content: usize = hints.iter().map(|(k, s)| hint_display_width(k, s)).sum();
content + hints.len() - 1
}
#[must_use]
pub fn degrade_hints(max_width: u16) -> Vec<(&'static str, &'static str)> {
let max = max_width as usize;
let all = ALL_HINTS.to_vec();
if hints_width_with_separators(&all) <= max {
return all;
}
if hints_width_spaced(&all) <= max {
return all;
}
for drop_count in 1..=4 {
let remaining = ALL_HINTS[drop_count..].to_vec();
if hints_width_spaced(&remaining) <= max {
return remaining;
}
}
Vec::new()
}
#[must_use]
pub fn degrade_sessions_path(path: &str, max_width: u16) -> String {
let max = max_width as usize;
if UnicodeWidthStr::width(path) <= max {
return path.to_string();
}
let path_chars: Vec<char> = path.chars().collect();
for drop in 2..path_chars.len() {
let tail: String = path_chars[drop..].iter().collect();
if !tail.contains('/') {
break;
}
let candidate = format!("\u{2026}{tail}");
if UnicodeWidthStr::width(candidate.as_str()) <= max {
return candidate;
}
}
let basename = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
if UnicodeWidthStr::width(basename) <= max {
return basename.to_string();
}
let base_chars: Vec<char> = basename.chars().collect();
if max >= 2 {
let keep = max - 1; let prefix: String = base_chars.iter().take(keep).collect();
return format!("{prefix}\u{2026}");
}
if max == 1 {
return base_chars.first().map_or_else(String::new, char::to_string);
}
String::new()
}
#[must_use]
pub fn degrade_ls_title(servers: &[String], max_width: u16) -> String {
if servers.is_empty() {
return String::new();
}
let max = max_width as usize;
let full = build_ls_string(servers, true, true);
if UnicodeWidthStr::width(full.as_str()) <= max {
return full;
}
let no_icons = build_ls_string(servers, false, true);
if UnicodeWidthStr::width(no_icons.as_str()) <= max {
return no_icons;
}
let no_seps = build_ls_string(servers, false, false);
if UnicodeWidthStr::width(no_seps.as_str()) <= max {
return no_seps;
}
String::new()
}
fn build_ls_string(servers: &[String], icons: bool, fancy_sep: bool) -> String {
let mut result = String::new();
for (i, name) in servers.iter().enumerate() {
if i > 0 {
if fancy_sep {
result.push_str(" \u{2571} "); } else {
result.push(' ');
}
}
if icons {
result.push_str("\u{25CF} "); }
result.push_str(name);
}
result
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
#[test]
fn test_below_minimum() {
assert!(is_below_minimum(4, 1));
assert!(is_below_minimum(3, 2));
assert!(is_below_minimum(5, 1));
assert!(!is_below_minimum(5, 2));
assert!(!is_below_minimum(100, 50));
}
#[test]
fn test_render_below_minimum() {
let backend = TestBackend::new(3, 1);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|f| {
render_below_minimum(f.area(), f.buffer_mut());
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
for x in 0u16..3 {
assert_eq!(
buf[(x, 0u16)].symbol(),
"\u{2573}",
"cell ({x}, 0) should be \u{2573}"
);
}
}
#[test]
fn test_full_size_no_degradation() {
let config = DegradationConfig::default();
let state = compute_degradation(200, 50, 2, &config);
assert!(state.sessions_visible, "sessions should be visible");
assert_eq!(state.hint_drop_level, 0, "no hint drops at full width");
assert!(state.show_borders, "borders should be shown");
}
#[test]
fn test_narrow_hides_sessions() {
let config = DegradationConfig::default();
let state = compute_degradation(40, 24, 2, &config);
assert!(
!state.sessions_visible,
"sessions should be hidden at 40 wide with 2 panels"
);
}
#[test]
fn test_title_degradation_full() {
let result = degrade_title("029ba740", Some("~/Projects/Catenary"), 50);
assert!(
result.contains("029ba740"),
"full title should contain session ID: {result}"
);
assert!(
result.contains("~/Projects/Catenary"),
"full title should contain workspace: {result}"
);
}
#[test]
fn test_title_degradation_drop_workspace() {
let result = degrade_title("029ba740", Some("~/Projects/Catenary"), 20);
assert!(
result.contains("029ba740"),
"title should contain session ID: {result}"
);
assert!(
!result.contains("Catenary"),
"title should not contain workspace at width 20: {result}"
);
}
#[test]
fn test_title_degradation_truncate_id() {
let result = degrade_title("029ba740", Some("~/Projects/Catenary"), 12);
assert!(
result.contains('\u{2026}'),
"truncated title should contain ellipsis: {result}"
);
assert!(
result.contains("029b"),
"truncated title should contain ID prefix: {result}"
);
}
#[test]
fn test_title_degradation_extreme() {
let result = degrade_title("029ba740", Some("~/Projects/Catenary"), 3);
assert!(
result.contains('\u{2026}'),
"extreme title should contain ellipsis: {result}"
);
assert!(
UnicodeWidthStr::width(result.as_str()) <= 3,
"extreme title width should be \u{2264} 3: {result}"
);
}
#[test]
fn test_hints_full_width() {
let hints = degrade_hints(60);
assert_eq!(hints.len(), 5, "all 5 hints should be present at width 60");
assert_eq!(hints[0], ("z", "\u{2550}"));
assert_eq!(hints[4], ("q", "\u{2718}"));
}
#[test]
fn test_hints_narrow() {
let hints = degrade_hints(15);
assert!(
hints.len() < 5,
"fewer than 5 hints at width 15, got {}",
hints.len()
);
assert!(
!hints.is_empty(),
"should still have some hints at width 15"
);
}
#[test]
fn test_hints_minimal() {
let hints = degrade_hints(2);
assert!(hints.is_empty(), "no hints should fit at width 2");
}
#[test]
fn test_sessions_path_full() {
let result = degrade_sessions_path("~/Projects/Catenary", 30);
assert_eq!(result, "~/Projects/Catenary");
}
#[test]
fn test_sessions_path_truncated() {
let result = degrade_sessions_path("~/Projects/Catenary", 12);
assert_eq!(result, "\u{2026}ts/Catenary");
}
#[test]
fn test_sessions_path_erosion_chain() {
assert_eq!(
degrade_sessions_path("~/Projects/Catenary", 18),
"\u{2026}Projects/Catenary"
);
assert_eq!(
degrade_sessions_path("~/Projects/Catenary", 14),
"\u{2026}ects/Catenary"
);
assert_eq!(
degrade_sessions_path("~/Projects/Catenary", 10),
"\u{2026}/Catenary"
);
assert_eq!(degrade_sessions_path("~/Projects/Catenary", 9), "Catenary");
assert_eq!(
degrade_sessions_path("~/Projects/Catenary", 7),
"Catena\u{2026}"
);
}
#[test]
fn test_sessions_path_minimal() {
let result = degrade_sessions_path("~/Projects/Catenary", 5);
assert!(
UnicodeWidthStr::width(result.as_str()) <= 5,
"minimal path should be \u{2264} 5 columns: {result}"
);
assert!(
result.contains('\u{2026}'),
"minimal path should contain ellipsis: {result}"
);
}
#[test]
fn test_ls_title_full() {
let servers = vec!["rust".to_string(), "ts".to_string()];
let result = degrade_ls_title(&servers, 40);
assert!(
result.contains("rust"),
"full LS title should contain 'rust': {result}"
);
assert!(
result.contains("ts"),
"full LS title should contain 'ts': {result}"
);
assert!(
result.contains('\u{25CF}'),
"full LS title should contain status icon: {result}"
);
}
#[test]
fn test_ls_title_dropped() {
let servers = vec!["rust".to_string(), "ts".to_string()];
let result = degrade_ls_title(&servers, 5);
assert!(
result.is_empty(),
"LS title should be empty at width 5: {result}"
);
}
}