返回 CodeWhale
toast.rs
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
383 lines RUST