pub mod chips;
pub mod dot;
pub mod gauge;
pub mod kv;
pub mod panel;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Span;
use ratatui::widgets::TableState;
use crate::tui::theme::Theme;
pub fn or_dash<T: ToString>(value: Option<T>) -> String {
value.map_or_else(|| "—".to_string(), |v| v.to_string())
}
pub fn row_style(theme: &Theme, selected: bool) -> Style {
let style = Style::default().fg(theme.text);
if selected {
style.bg(theme.surface0)
} else {
style
}
}
pub fn scroll_to(selected: Option<usize>) -> TableState {
TableState::default().with_selected(selected)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Band {
height: u16,
min: u16,
}
impl Band {
pub fn fixed(height: u16) -> Self {
Self {
height,
min: height,
}
}
pub fn shrinking(height: u16, min: u16) -> Self {
Self {
height,
min: min.min(height),
}
}
}
pub fn stack(area: Rect, bands: &[Band]) -> Vec<Rect> {
let gaps = bands.len().saturating_sub(1);
let total = bands.iter().map(|b| usize::from(b.height)).sum::<usize>() + gaps;
let mut excess = total.saturating_sub(usize::from(area.height));
let mut y = area.y;
bands
.iter()
.map(|band| {
let spare = band.height - band.min;
let give = u16::try_from(excess).unwrap_or(u16::MAX).min(spare);
excess -= usize::from(give);
let top = y.min(area.bottom());
let height = (band.height - give).min(area.bottom() - top);
y = top.saturating_add(height).saturating_add(1);
Rect {
y: top,
height,
..area
}
})
.collect()
}
pub fn wrap(text: &str, width: u16) -> Vec<String> {
let width = usize::from(width);
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split(' ').filter(|w| !w.is_empty()) {
if line.is_empty() {
line.push_str(word);
} else if Span::raw(line.as_str()).width() + 1 + Span::raw(word).width() <= width {
line.push(' ');
line.push_str(word);
} else {
lines.push(std::mem::replace(&mut line, word.to_string()));
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
fn spans(bands: &[Rect]) -> Vec<(u16, u16)> {
bands.iter().map(|r| (r.y, r.height)).collect()
}
#[test]
fn stack_places_bands_top_down_one_row_apart() {
let area = Rect::new(2, 5, 40, 20);
let bands = stack(
area,
&[Band::shrinking(3, 0), Band::fixed(1), Band::fixed(2)],
);
assert_eq!(spans(&bands), [(5, 3), (9, 1), (11, 2)]);
assert!(bands.iter().all(|r| r.x == 2 && r.width == 40));
}
#[test]
fn stack_shrinks_the_shrinking_band_so_the_rest_fit() {
let bands = stack(
Rect::new(0, 0, 40, 10),
&[Band::shrinking(30, 0), Band::fixed(1), Band::fixed(2)],
);
assert_eq!(spans(&bands), [(0, 5), (6, 1), (8, 2)]);
let bands = stack(
Rect::new(0, 0, 40, 3),
&[Band::shrinking(4, 0), Band::fixed(2), Band::fixed(2)],
);
assert!(bands.iter().all(|r| r.bottom() <= 3), "{bands:?}");
let bands = stack(Rect::new(0, 0, 40, 4), &[Band::fixed(1), Band::fixed(3)]);
assert_eq!(spans(&bands), [(0, 1), (2, 2)]);
}
#[test]
fn stack_shrinks_bands_first_to_last_down_to_their_minimums() {
let bands = stack(
Rect::new(0, 0, 40, 18),
&[
Band::fixed(1),
Band::shrinking(6, 4),
Band::shrinking(5, 2),
Band::shrinking(13, 3),
Band::fixed(1),
],
);
assert_eq!(spans(&bands), [(0, 1), (2, 4), (7, 2), (10, 6), (17, 1)]);
let bands = stack(
Rect::new(0, 0, 40, 6),
&[Band::shrinking(6, 4), Band::shrinking(5, 2)],
);
assert_eq!(spans(&bands), [(0, 4), (5, 1)]);
assert_eq!(Band::shrinking(2, 5), Band::fixed(2));
}
#[test]
fn wrap_fills_lines_greedily_at_spaces() {
assert_eq!(
wrap("a random sibling resolves too", 12),
["a random", "sibling", "resolves too"]
);
assert_eq!(wrap("fits on one line", 40), ["fits on one line"]);
assert!(wrap("", 10).is_empty());
assert!(wrap(" ", 10).is_empty());
}
#[test]
fn wrap_measures_display_width_and_keeps_long_words_whole() {
assert_eq!(wrap("resolves — this", 15), ["resolves — this"]);
assert_eq!(
wrap("see seer-probe-3f9a1c2e7b.seer.test now", 10),
["see", "seer-probe-3f9a1c2e7b.seer.test", "now"]
);
}
}