| 1 | //! Toasts: one line, a mark and a sentence, stacked at the bottom right. |
| 2 | //! |
| 3 | //! The rendering half of the engine's `StatusToast` (`crates/tui/src/tui/ |
| 4 | //! app/status.rs`, `Hmbown/CodeWhale` `58b1dd3dd`), plus the clock the engine |
| 5 | //! keeps beside it: a [`Ttl`] and a `born` instant the caller supplies, so |
| 6 | //! nothing here reads the time. Painting is a pure function of what the host |
| 7 | //! passes in ([`Toasts::at`]); expiry is [`Toasts::retain_live`], called by |
| 8 | //! the host with its own `now`. Failures and anything that needs the person |
| 9 | //! stay until they have been seen ([`Ttl::UntilSeen`]); a stack caps at |
| 10 | //! [`Toasts::max_visible`] and says `+n more` with the real n. |
| 11 | //! |
| 12 | //! A toast arrives and leaves in steps of ink (`Dim`, `Hint`, `Muted`, then |
| 13 | //! `Foreground`), never by moving, and only under [`MotionMode::Full`]: |
| 14 | //! reduced and still motion show it at full ink at once. |
| 15 | |
| 16 | use std::{ |
| 17 | borrow::Cow, |
| 18 | time::{Duration, Instant}, |
| 19 | }; |
| 20 | |
| 21 | use ratatui::{ |
| 22 | buffer::Buffer, |
| 23 | layout::Rect, |
| 24 | text::{Line, Span}, |
| 25 | widgets::Widget, |
| 26 | }; |
| 27 | |
| 28 | use crate::{MotionMode, Paint, Role, State, StatusMark, Theme, text}; |
| 29 | |
| 30 | /// How long a toast lives. |
| 31 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 32 | pub enum Ttl { |
| 33 | /// This many seconds after it was born. |
| 34 | Seconds(u16), |
| 35 | /// Until the person has seen it ([`Toasts::mark_seen`]), then |
| 36 | /// [`TOAST_SEEN_LINGER`] more: a failure must not vanish unread. |
| 37 | UntilSeen, |
| 38 | } |
| 39 | |
| 40 | impl Ttl { |
| 41 | /// What [`Toast::new`] gives a state: failures and things that need the |
| 42 | /// person stay until seen, everything else lasts five seconds. |
| 43 | #[must_use] |
| 44 | pub const fn for_state(state: State) -> Self { |
| 45 | match state { |
| 46 | State::Failed | State::NeedsYou => Self::UntilSeen, |
| 47 | _ => Self::Seconds(5), |
| 48 | } |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | /// How long a seen [`Ttl::UntilSeen`] toast stays before it goes. |
| 53 | pub const TOAST_SEEN_LINGER: Duration = Duration::from_secs(2); |
| 54 | /// A toast arrives over this long, in three steps of ink. |
| 55 | pub const TOAST_FADE_IN: Duration = Duration::from_millis(180); |
| 56 | /// A toast leaves over this long, in three steps of ink. |
| 57 | pub const TOAST_FADE_OUT: Duration = Duration::from_millis(300); |
| 58 | |
| 59 | /// One notice: `✓ Theme set to Shoreline`. |
| 60 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 61 | pub struct Toast { |
| 62 | pub state: State, |
| 63 | pub text: Cow<'static, str>, |
| 64 | /// Draws `→`: there is more to open. |
| 65 | pub opens: bool, |
| 66 | pub ttl: Ttl, |
| 67 | /// When the host raised it. `None` never expires by time and never fades. |
| 68 | pub born: Option<Instant>, |
| 69 | /// When the person saw it, for [`Ttl::UntilSeen`]. |
| 70 | pub seen: Option<Instant>, |
| 71 | } |
| 72 | |
| 73 | impl Toast { |
| 74 | #[must_use] |
| 75 | pub fn new(state: State, text: impl Into<Cow<'static, str>>) -> Self { |
| 76 | Self { |
| 77 | state, |
| 78 | text: text.into(), |
| 79 | opens: false, |
| 80 | ttl: Ttl::for_state(state), |
| 81 | born: None, |
| 82 | seen: None, |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | #[must_use] |
| 87 | pub fn opens(mut self) -> Self { |
| 88 | self.opens = true; |
| 89 | self |
| 90 | } |
| 91 | |
| 92 | #[must_use] |
| 93 | pub fn ttl(mut self, ttl: Ttl) -> Self { |
| 94 | self.ttl = ttl; |
| 95 | self |
| 96 | } |
| 97 | |
| 98 | /// When the host raised this toast. Painting never reads a clock; the |
| 99 | /// host passes the instant it measured. |
| 100 | #[must_use] |
| 101 | pub fn born(mut self, born: Instant) -> Self { |
| 102 | self.born = Some(born); |
| 103 | self |
| 104 | } |
| 105 | |
| 106 | /// When this toast goes, if it is known yet: `born` plus the seconds, or |
| 107 | /// the moment it was seen plus [`TOAST_SEEN_LINGER`]. `None` means not yet |
| 108 | /// (unborn, or not seen). |
| 109 | #[must_use] |
| 110 | pub fn expires_at(&self) -> Option<Instant> { |
| 111 | match self.ttl { |
| 112 | Ttl::Seconds(s) => self.born.map(|b| b + Duration::from_secs(u64::from(s))), |
| 113 | Ttl::UntilSeen => self.seen.map(|s| s + TOAST_SEEN_LINGER), |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | /// Whether the toast still shows at `now`. |
| 118 | #[must_use] |
| 119 | pub fn is_live(&self, now: Instant) -> bool { |
| 120 | self.expires_at().is_none_or(|end| now < end) |
| 121 | } |
| 122 | |
| 123 | /// The ink the sentence takes at `now`: `Foreground` once settled, and |
| 124 | /// `Dim`, `Hint` or `Muted` while it arrives or leaves. `Foreground` |
| 125 | /// always unless `motion` is [`MotionMode::Full`] and the toast has a |
| 126 | /// `born`. |
| 127 | #[must_use] |
| 128 | pub fn ink(&self, now: Instant, motion: MotionMode) -> Role { |
| 129 | const STEPS: [Role; 3] = [Role::Dim, Role::Hint, Role::Muted]; |
| 130 | if !motion.animates() { |
| 131 | return Role::Foreground; |
| 132 | } |
| 133 | let mut step = 3usize; |
| 134 | if let Some(born) = self.born { |
| 135 | let age = now.saturating_duration_since(born); |
| 136 | if age < TOAST_FADE_IN { |
| 137 | step = step.min((age.as_millis() / 60) as usize); |
| 138 | } |
| 139 | } |
| 140 | if let Some(end) = self.expires_at() |
| 141 | && let Some(left) = end.checked_duration_since(now) |
| 142 | && left < TOAST_FADE_OUT |
| 143 | { |
| 144 | step = step.min((left.as_millis() / 100) as usize); |
| 145 | } |
| 146 | STEPS.get(step).copied().unwrap_or(Role::Foreground) |
| 147 | } |
| 148 | |
| 149 | fn line(&self, max: usize, ink: Role, theme: &Theme) -> Line<'static> { |
| 150 | let mark = StatusMark::new(self.state); |
| 151 | let glyph = mark.glyph(theme); |
| 152 | let arrow = if self.opens { |
| 153 | if theme.ascii() { " >" } else { " →" } |
| 154 | } else { |
| 155 | "" |
| 156 | }; |
| 157 | let fixed = 1 + text::width(glyph) + 1 + text::width(arrow) + 1; |
| 158 | let body = text::display_safe(&self.text); |
| 159 | let body = |
| 160 | text::truncate_words(&body, max.saturating_sub(fixed), theme.ascii()).into_owned(); |
| 161 | let mut spans = vec![ |
| 162 | Span::raw(" "), |
| 163 | Span::styled(glyph, theme.fg(self.state.role())), |
| 164 | Span::raw(" "), |
| 165 | Span::styled(body, theme.fg(ink)), |
| 166 | ]; |
| 167 | if !arrow.is_empty() { |
| 168 | spans.push(Span::styled(arrow, theme.fg(Role::Muted))); |
| 169 | } |
| 170 | spans.push(Span::raw(" ")); |
| 171 | Line::from(spans) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | /// Words a toast stack prints. `Default` is English. |
| 176 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 177 | pub struct ToastWords { |
| 178 | /// `+2 more`. |
| 179 | pub more: Cow<'static, str>, |
| 180 | } |
| 181 | |
| 182 | impl Default for ToastWords { |
| 183 | fn default() -> Self { |
| 184 | Self { |
| 185 | more: Cow::Borrowed("more"), |
| 186 | } |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | /// How many toasts a stack shows before it says `+n more`. |
| 191 | pub const TOAST_MAX_VISIBLE: usize = 3; |
| 192 | |
| 193 | /// A stack of toasts anchored to the bottom right of an area, newest last |
| 194 | /// (nearest the horizon). At most [`Toasts::max_visible`] show, and the rows |
| 195 | /// available cap that further; older ones fold into a `+n more` line above |
| 196 | /// the stack, with n the real number hidden. |
| 197 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 198 | pub struct Toasts { |
| 199 | pub items: Vec<Toast>, |
| 200 | pub max_visible: usize, |
| 201 | /// The host's clock for this frame; with [`MotionMode::Full`] it lets |
| 202 | /// toasts fade. `None` paints every toast settled. |
| 203 | pub now: Option<Instant>, |
| 204 | pub motion: MotionMode, |
| 205 | pub words: ToastWords, |
| 206 | } |
| 207 | |
| 208 | impl Default for Toasts { |
| 209 | fn default() -> Self { |
| 210 | Self::new(Vec::new()) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | impl Toasts { |
| 215 | #[must_use] |
| 216 | pub fn new(items: Vec<Toast>) -> Self { |
| 217 | Self { |
| 218 | items, |
| 219 | max_visible: TOAST_MAX_VISIBLE, |
| 220 | now: None, |
| 221 | motion: MotionMode::Full, |
| 222 | words: ToastWords::default(), |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | #[must_use] |
| 227 | pub fn max_visible(mut self, max_visible: usize) -> Self { |
| 228 | self.max_visible = max_visible; |
| 229 | self |
| 230 | } |
| 231 | |
| 232 | /// Paint as of `now`, the host's clock for this frame. |
| 233 | #[must_use] |
| 234 | pub fn at(mut self, now: Instant) -> Self { |
| 235 | self.now = Some(now); |
| 236 | self |
| 237 | } |
| 238 | |
| 239 | #[must_use] |
| 240 | pub fn motion(mut self, motion: MotionMode) -> Self { |
| 241 | self.motion = motion; |
| 242 | self |
| 243 | } |
| 244 | |
| 245 | #[must_use] |
| 246 | pub fn words(mut self, words: ToastWords) -> Self { |
| 247 | self.words = words; |
| 248 | self |
| 249 | } |
| 250 | |
| 251 | /// `(shown, hidden, more_row)` for a stack `rows` tall: the newest |
| 252 | /// `shown` toasts draw, `hidden` older ones are folded, and `more_row` |
| 253 | /// says whether the `+n more` line has a row. With one row the newest |
| 254 | /// toast gets it and the count goes unspoken. |
| 255 | #[must_use] |
| 256 | pub fn window(&self, rows: u16) -> (usize, usize, bool) { |
| 257 | let rows = usize::from(rows); |
| 258 | let len = self.items.len(); |
| 259 | if len == 0 || rows == 0 { |
| 260 | return (0, 0, false); |
| 261 | } |
| 262 | if len <= self.max_visible.min(rows) { |
| 263 | return (len, 0, false); |
| 264 | } |
| 265 | if rows == 1 { |
| 266 | return (1, len - 1, false); |
| 267 | } |
| 268 | let shown = self.max_visible.min(rows - 1); |
| 269 | (shown, len - shown, true) |
| 270 | } |
| 271 | |
| 272 | /// Drop the toasts that have expired at `now`, and say how many went. |
| 273 | /// Failures stay until [`Toasts::mark_seen`] has run for them. |
| 274 | pub fn retain_live(&mut self, now: Instant) -> usize { |
| 275 | let before = self.items.len(); |
| 276 | self.items.retain(|t| t.is_live(now)); |
| 277 | before - self.items.len() |
| 278 | } |
| 279 | |
| 280 | /// Record that the person saw the toasts a stack `rows` tall shows, so |
| 281 | /// their [`Ttl::UntilSeen`] clocks start. Call it when the stack was |
| 282 | /// really on screen. |
| 283 | pub fn mark_seen(&mut self, now: Instant, rows: u16) { |
| 284 | let (shown, _, _) = self.window(rows); |
| 285 | let first = self.items.len() - shown; |
| 286 | for toast in &mut self.items[first..] { |
| 287 | toast.seen.get_or_insert(now); |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | /// How long until anything about the stack changes (a toast expires or |
| 292 | /// moves a step of ink), or `None` when nothing will: the host schedules |
| 293 | /// its next redraw from this instead of polling. |
| 294 | #[must_use] |
| 295 | pub fn next_change_in(&self, now: Instant) -> Option<Duration> { |
| 296 | let mut soonest: Option<Duration> = None; |
| 297 | let mut consider = |at: Instant| { |
| 298 | if let Some(after) = at.checked_duration_since(now) |
| 299 | && !after.is_zero() |
| 300 | { |
| 301 | soonest = Some(soonest.map_or(after, |s| s.min(after))); |
| 302 | } |
| 303 | }; |
| 304 | for toast in &self.items { |
| 305 | let end = toast.expires_at(); |
| 306 | if let Some(end) = end { |
| 307 | consider(end); |
| 308 | } |
| 309 | if self.motion.animates() { |
| 310 | if let Some(born) = toast.born { |
| 311 | for ms in [60, 120, 180] { |
| 312 | consider(born + Duration::from_millis(ms)); |
| 313 | } |
| 314 | } |
| 315 | if let Some(end) = end { |
| 316 | for ms in [300, 200, 100] { |
| 317 | if let Some(at) = end.checked_sub(Duration::from_millis(ms)) { |
| 318 | consider(at); |
| 319 | } |
| 320 | } |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | soonest |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | impl Paint for Toasts { |
| 329 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 330 | let area = area.intersection(buf.area); |
| 331 | if area.is_empty() { |
| 332 | return; |
| 333 | } |
| 334 | let max = usize::from(area.width); |
| 335 | let (shown, hidden, more_row) = self.window(area.height); |
| 336 | let first = self.items.len() - shown; |
| 337 | let top = area.bottom() - shown as u16 - u16::from(more_row); |
| 338 | if more_row { |
| 339 | let more = format!(" +{hidden} {} ", text::display_safe(&self.words.more)); |
| 340 | let more = text::truncate(&more, max, theme.ascii()).into_owned(); |
| 341 | let w = u16::try_from(text::width(&more)) |
| 342 | .unwrap_or(u16::MAX) |
| 343 | .min(area.width); |
| 344 | let rect = Rect { |
| 345 | x: area.right() - w, |
| 346 | y: top, |
| 347 | width: w, |
| 348 | height: 1, |
| 349 | }; |
| 350 | buf.set_style(rect, theme.bg(Role::Surface)); |
| 351 | Line::from(Span::styled(more, theme.fg(Role::Muted))).render(rect, buf); |
| 352 | } |
| 353 | for (row, toast) in self.items[first..].iter().enumerate() { |
| 354 | let ink = match self.now { |
| 355 | Some(now) => toast.ink(now, self.motion), |
| 356 | None => Role::Foreground, |
| 357 | }; |
| 358 | let line = toast.line(max, ink, theme); |
| 359 | let w = u16::try_from(line.width()) |
| 360 | .unwrap_or(u16::MAX) |
| 361 | .min(area.width); |
| 362 | let rect = Rect { |
| 363 | x: area.right() - w, |
| 364 | y: top + u16::from(more_row) + row as u16, |
| 365 | width: w, |
| 366 | height: 1, |
| 367 | }; |
| 368 | buf.set_style(rect, theme.bg(Role::Surface)); |
| 369 | line.render(rect, buf); |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | fn height(&self, _width: u16, _theme: &Theme) -> u16 { |
| 374 | let len = self.items.len(); |
| 375 | let rows = if len <= self.max_visible { |
| 376 | len |
| 377 | } else { |
| 378 | self.max_visible + 1 |
| 379 | }; |
| 380 | u16::try_from(rows).unwrap_or(u16::MAX) |
| 381 | } |
| 382 | } |
| 383 |