use super::Button;
use super::cells;
use super::toast::ToastKind;
use crate::geometry::{Rect, Size, clamp_u16};
use crate::style::CellStyle;
use crate::text;
use crate::widget::{MeasureCx, Node, PaintCx, Widget};
const ACTION_GAP: u16 = 2;
fn indent_cells(sign_width: u16) -> u16 {
cells::sum([sign_width, 1])
}
struct Plan {
width: u16,
lines: Vec<String>,
below: bool,
}
fn plan(text: &str, width: u16, indent: u16, action: u16) -> Plan {
let gap = if action == 0 { 0 } else { ACTION_GAP };
let wanted = cells::sum([indent, text::width(text), gap, action]);
if wanted <= width {
return Plan { width: wanted, lines: vec![text.to_owned()], below: false };
}
Plan { width, lines: text::wrap(text, width.saturating_sub(indent).max(1)), below: true }
}
pub struct StatusLine<Msg> {
text: String,
tone: ToastKind,
action: Vec<Node<Msg>>,
}
impl<Msg: Clone + 'static> StatusLine<Msg> {
#[must_use]
pub fn new(text: impl Into<String>) -> Self {
Self { text: text.into(), tone: ToastKind::default(), action: Vec::new() }
}
#[must_use]
pub fn tone(mut self, tone: ToastKind) -> Self {
self.tone = tone;
self
}
#[must_use]
pub fn action(mut self, button: Button<Msg>) -> Self {
self.action = vec![Node::new(button, 0)];
self
}
}
impl<Msg: Clone + 'static> Widget<Msg> for StatusLine<Msg> {
fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
let sign = text::width(&cx.env().icons().glyph(self.tone.icon()));
let action = self.action.first().map_or(0, |node| cx.measure_child(node, Size::new(available.width, 1)).width);
let plan = plan(&self.text, available.width, indent_cells(sign), action);
let lines = clamp_u16(i32::try_from(plan.lines.len()).unwrap_or(i32::MAX));
Size::new(plan.width, cells::sum([lines, u16::from(plan.below)])).min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
if area.is_empty() {
return;
}
let sign = cx.env().icons().glyph(self.tone.icon()).into_owned();
let sign_width = text::width(&sign);
let indent = indent_cells(sign_width);
let action = self.action.first().map(|node| cx.measure_child(node, Size::new(area.width, 1)));
let plan = plan(&self.text, area.width, indent, action.map_or(0, |size| size.width));
let tone = cx.color(self.tone.name());
let mut style = cx.style("status-line", Some(self.tone.name()), &[]).text();
style.bg = None;
style.fg = style.fg.or(Some(tone));
cx.text(area.x, area.y, &sign, CellStyle { fg: Some(tone), ..CellStyle::default() }, sign_width);
let sentence_x = area.x.saturating_add(i32::from(indent));
let room = clamp_u16(area.right().saturating_sub(sentence_x));
for (row, line) in plan.lines.iter().enumerate() {
let y = area.y.saturating_add(i32::try_from(row).unwrap_or(i32::MAX));
cx.text(sentence_x, y, line, style, room);
}
let (Some(node), Some(size)) = (self.action.first(), action) else { return };
let (x, y) = if plan.below {
(area.x, area.y.saturating_add(i32::try_from(plan.lines.len()).unwrap_or(i32::MAX)))
} else {
(
sentence_x.saturating_add(i32::from(text::width(&self.text))).saturating_add(i32::from(ACTION_GAP)),
area.y,
)
};
let rect = Rect::new(x, y, size.width.min(clamp_u16(area.right().saturating_sub(x))), 1);
if area.contains(rect.x, rect.y) {
cx.paint_child(node, rect);
}
}
fn children(&self) -> &[Node<Msg>] {
&self.action
}
fn children_mut(&mut self) -> &mut [Node<Msg>] {
&mut self.action
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::icons::GlyphMode;
use crate::runtime::{App, Command, Harness};
use crate::widget::View;
const REPORTED: [(ToastKind, &str); 4] = [
(ToastKind::Success, "Deployed api-gateway"),
(ToastKind::Warning, "Disk is almost full"),
(ToastKind::Danger, "Build failed on web-frontend"),
(ToastKind::Info, "Release 2.14.0 is live"),
];
struct Kinds;
struct Reported {
saved: u32,
}
#[derive(Clone)]
enum Msg {
Save,
}
impl App for Kinds {
type Msg = Msg;
fn update(&mut self, _: Msg) -> Command<Msg> {
Command::none()
}
fn view(&self, ui: &mut View<'_, Msg>) {
ui.column(|ui| {
for (kind, sentence) in REPORTED {
ui.add(StatusLine::new(sentence).tone(kind));
}
});
}
}
impl App for Reported {
type Msg = Msg;
fn update(&mut self, _: Msg) -> Command<Msg> {
self.saved += 1;
Command::none()
}
fn view(&self, ui: &mut View<'_, Msg>) {
ui.column(|ui| {
ui.add(StatusLine::new("3 changes are not saved").tone(ToastKind::Warning));
ui.add(
StatusLine::new("The build of web-frontend failed")
.tone(ToastKind::Danger)
.action(Button::new("Retry").on_press(Msg::Save)),
);
});
}
}
fn at<A: App>(h: &Harness<A>, text: &str) -> (i32, usize) {
let (x, y) = h.find(text).unwrap_or_else(|| panic!("`{text}` is not on screen:\n{}", h.screen()));
(y, x.max(0) as usize)
}
fn column<A: App>(h: &Harness<A>, row: i32, text: &str) -> usize {
let line = h.screen().lines().nth(usize::try_from(row).unwrap_or(0)).unwrap_or_default().to_owned();
line.find(text)
.map(|at| line[..at].chars().count())
.unwrap_or_else(|| panic!("`{text}` is not on row {row}:\n{line}"))
}
#[test]
fn the_sign_stands_in_every_glyph_mode_and_the_sentence_takes_the_tone_colour() {
for mode in [GlyphMode::Unicode, GlyphMode::Ascii, GlyphMode::Nerd] {
let mut h = Harness::new(Kinds, 44, 4);
h.set_glyph_mode(mode);
let theme = h.env().theme();
for (row, (kind, sentence)) in REPORTED.into_iter().enumerate() {
let y = u16::try_from(row).unwrap_or(0);
let sign = h.screen().lines().nth(row).unwrap_or_default().chars().next().unwrap_or(' ');
assert_eq!(sign.to_string(), h.env().icons().glyph(kind.icon()).to_string(), "{mode:?} row {row}");
assert!(!sign.is_whitespace(), "{mode:?} row {row}: the sign is never a blank cell");
let tone = theme.color(kind.name()).expect("every theme has the four status colours");
assert_eq!(h.fg(0, y), Some(tone), "{mode:?} row {row}: the sign carries the tone");
let (at, column) = at(&h, sentence);
assert_eq!(at, i32::try_from(row).unwrap_or(0), "{mode:?} row {row}: the sentence is on its own row");
assert!(column > 1, "{mode:?} row {row}: a space stands between the sign and the sentence");
assert_eq!(
h.fg(u16::try_from(column).unwrap_or(0), y),
Some(tone),
"{mode:?} row {row}: and so does the sentence"
);
}
}
}
#[test]
fn the_button_at_the_end_of_a_line_is_a_button() {
let mut h = Harness::new(Reported { saved: 0 }, 50, 2);
let (sentence, button) = (h.find("The build of"), h.find("Retry"));
let (sentence, button) = (sentence.expect("the sentence"), button.expect("the button"));
assert_eq!(sentence.1, button.1, "one row while there is room: {}", h.screen());
assert!(button.0 > sentence.0, "the button stands after the sentence: {}", h.screen());
h.click(sentence.0, sentence.1).click_text("Retry");
assert_eq!(h.app().saved, 1, "the sentence is not a target, the button is");
h.press("tab").press("enter");
assert_eq!(h.app().saved, 2, "and it takes focus like any other button");
}
#[test]
fn a_narrow_area_wraps_the_sentence_under_itself_and_moves_the_button_below() {
let h = Harness::new(Reported { saved: 0 }, 20, 7);
let screen = h.screen();
for word in "The build of web-frontend failed".split(' ') {
assert!(screen.contains(word), "`{word}` is nowhere: {screen}");
}
let (first, sentence_column) = at(&h, "The build of");
let (rest, wrapped) = at(&h, "web-frontend");
let (button, _) = at(&h, "Retry");
assert!(rest > first, "the sentence wraps under itself: {screen}");
assert_eq!(sentence_column, wrapped, "and keeps its column: {screen}");
assert!(
column(&h, first, &h.env().icons().glyph(ToastKind::Danger.icon())) < sentence_column,
"never under the sign: {screen}"
);
assert!(button > rest, "the button moves below: {screen}");
}
}