use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use crate::view::palette;
pub(crate) const CUT: char = '…';
pub(crate) const GAP: usize = 2;
pub(crate) fn indent() -> String {
" ".repeat(GAP)
}
pub struct Fitted {
identity: Vec<Span<'static>>,
title: Vec<Span<'static>>,
state: Vec<Span<'static>>,
briefly: Option<Vec<Span<'static>>>,
whole: Style,
title_or_nothing: bool,
state_or_nothing: bool,
}
impl Fitted {
pub(crate) fn new(
identity: Vec<Span<'static>>,
title: Vec<Span<'static>>,
state: Vec<Span<'static>>,
) -> Self {
Self {
identity,
title,
state,
briefly: None,
whole: Style::new(),
title_or_nothing: false,
state_or_nothing: false,
}
}
#[must_use]
pub(crate) fn briefly(mut self, state: Vec<Span<'static>>) -> Self {
self.briefly = Some(state);
self
}
#[must_use]
pub(crate) fn title_or_nothing(mut self) -> Self {
self.title_or_nothing = true;
self
}
#[must_use]
pub(crate) fn state_or_nothing(mut self) -> Self {
self.state_or_nothing = true;
self
}
#[must_use]
pub fn selected(mut self) -> Self {
self.whole = self.whole.patch(palette::SELECTED);
self
}
#[must_use]
pub(crate) fn toned(mut self, tone: Style) -> Self {
self.whole = tone.patch(self.whole);
self
}
}
impl Widget for Fitted {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let area = Rect { height: 1, ..area };
let width = area.width as usize;
let identity = columns(&self.identity);
let spans = if identity >= width {
cut_to(self.identity, width)
} else {
let room = width - identity;
let (title, state) = match self.briefly {
None => {
let state = fit(self.state, room.saturating_sub(GAP), self.state_or_nothing);
let left = room - columns(&state) - if state.is_empty() { 0 } else { GAP };
(
fit(self.title, left.saturating_sub(GAP), self.title_or_nothing),
state,
)
}
Some(briefly) => {
let kept = columns(&briefly).min(columns(&self.state)) + GAP;
let title = fit(
self.title,
room.saturating_sub(GAP + kept),
self.title_or_nothing,
);
let left = room - columns(&title) - if title.is_empty() { 0 } else { GAP };
let limit = left.saturating_sub(GAP);
let state = if columns(&self.state) <= limit {
self.state
} else {
fit(briefly, limit, self.state_or_nothing)
};
(title, state)
}
};
let mut spans = self.identity;
if !title.is_empty() {
spans.push(Span::raw(" ".repeat(GAP)));
spans.extend(title);
}
if !state.is_empty() {
let pad = width.saturating_sub(columns(&spans) + columns(&state));
spans.push(Span::raw(" ".repeat(pad)));
spans.extend(state);
}
spans
};
Line::from(spans).style(self.whole).render(area, buf);
}
}
pub(crate) fn columns(spans: &[Span<'static>]) -> usize {
spans.iter().map(Span::width).sum()
}
fn fit(spans: Vec<Span<'static>>, limit: usize, or_nothing: bool) -> Vec<Span<'static>> {
if or_nothing && columns(&spans) > limit {
return Vec::new();
}
cut_to(spans, limit)
}
fn cut_to(spans: Vec<Span<'static>>, limit: usize) -> Vec<Span<'static>> {
if columns(&spans) <= limit {
return spans;
}
if limit == 0 {
return Vec::new();
}
let room = limit - columns(&[Span::raw(CUT.to_string())]);
let mut kept: Vec<Span<'static>> = Vec::new();
let mut used = 0;
for span in spans {
let width = span.width();
if used + width <= room {
used += width;
kept.push(span);
continue;
}
let head = head_of(&span.content, room - used);
if !head.is_empty() {
kept.push(Span::styled(head, span.style));
}
break;
}
kept.push(Span::raw(CUT.to_string()));
kept
}
fn head_of(text: &str, limit: usize) -> String {
let mut head = String::new();
let mut used = 0;
for glyph in text.chars() {
let width = Span::raw(String::from(glyph)).width();
if used + width > limit {
break;
}
used += width;
head.push(glyph);
}
head
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use crate::view::painted::Painted;
fn a_row() -> Fitted {
Fitted::new(
vec![Span::raw("orb-7")],
vec![Span::raw("a title")],
vec![Span::raw("open")],
)
}
fn blank(width: usize, height: usize) -> Vec<String> {
vec![" ".repeat(width); height]
}
fn drawn(row: Fitted, width: u16) -> String {
Painted::of(row, width, 1).rows().remove(0)
}
#[test]
fn a_title_that_says_nothing_in_part_is_given_up_whole() {
let row = || {
Fitted::new(
vec![Span::raw("orb-7")],
vec![Span::raw("collected 10:22:14")],
vec![Span::raw("open")],
)
.title_or_nothing()
};
assert_eq!(drawn(row(), 31), "orb-7 collected 10:22:14 open");
assert_eq!(drawn(row(), 30), "orb-7 open");
}
#[test]
fn a_title_is_cut_like_any_other_block_unless_it_asks_not_to_be() {
let row = Fitted::new(
vec![Span::raw("orb-7")],
vec![Span::raw("collected 10:22:14")],
vec![Span::raw("open")],
);
assert_eq!(drawn(row, 30), "orb-7 collected 10:22:… open");
}
#[test]
fn a_state_that_says_nothing_in_part_is_given_up_whole() {
let row = || {
Fitted::new(
vec![Span::raw("orb-7")],
vec![Span::raw("a title")],
vec![Span::raw("Enter focus q quit")],
)
.state_or_nothing()
};
assert_eq!(drawn(row(), 27), "orb-7 Enter focus q quit");
assert_eq!(drawn(row(), 26), "orb-7 a title ");
}
#[test]
fn a_state_is_cut_like_any_other_block_unless_it_asks_not_to_be() {
let row = Fitted::new(
vec![Span::raw("orb-7")],
vec![Span::raw("a title")],
vec![Span::raw("Enter focus q quit")],
);
assert_eq!(drawn(row, 26), "orb-7 Enter focus q qu…");
}
#[test]
fn a_band_of_no_rows_is_left_alone() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
a_row().render(
Rect {
x: 0,
y: 1,
width: 20,
height: 0,
},
&mut buf,
);
assert_eq!(Painted::read(&buf).rows(), blank(20, 3));
}
#[test]
fn a_band_of_no_columns_is_left_alone() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
a_row().render(
Rect {
x: 0,
y: 1,
width: 0,
height: 1,
},
&mut buf,
);
assert_eq!(Painted::read(&buf).rows(), blank(20, 3));
}
#[test]
fn a_band_of_several_rows_is_given_one() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
a_row().render(Rect::new(0, 0, 20, 3), &mut buf);
assert_eq!(Painted::read(&buf).rows()[1..], blank(20, 2));
}
}