Skip to main content

pito_tui/
activity.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::fmt;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use crossterm::event::KeyEvent;
8use pito_footer::{Hint, InputBar, Key};
9use pito_header::Section;
10use pito_list::{Cell, Column, List, Mark, Row, Step};
11use ratatui::{
12    Frame,
13    layout::Rect,
14    style::{Color, Modifier, Style},
15    text::{Line, Span},
16    widgets::Paragraph,
17};
18
19use crate::chrome::message;
20use crate::log::{Lines, Log, Turn};
21use crate::palette::Palette;
22use crate::progress::{SPIN, bar, share, spinner};
23use crate::screen::{Cx, Screen};
24use crate::text::{cells, clip, fit};
25
26pub const LINGER: Duration = Duration::from_secs(3);
27const BAR: usize = 12;
28const GAUGE: usize = BAR + 6;
29const GAP: usize = 2;
30const NARROW: usize = 60;
31const SECOND: Duration = Duration::from_secs(1);
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum State {
36    #[default]
37    Running,
38    Waiting,
39    Done,
40    Failed,
41    Stopped,
42}
43
44impl State {
45    pub fn finished(self) -> bool {
46        matches!(self, State::Done | State::Failed | State::Stopped)
47    }
48}
49
50#[derive(Debug, Clone, PartialEq)]
51#[non_exhaustive]
52pub struct Activity {
53    pub id: u64,
54    pub label: String,
55    pub state: State,
56    pub progress: Option<f64>,
57    pub estimate: bool,
58    pub started: Instant,
59    pub ended: Option<Instant>,
60    pub status: String,
61    pub detail: Lines,
62}
63
64impl Activity {
65    pub fn new(id: u64, label: impl Into<String>, started: Instant) -> Self {
66        Activity {
67            id,
68            label: label.into(),
69            state: State::Running,
70            progress: None,
71            estimate: false,
72            started,
73            ended: None,
74            status: String::new(),
75            detail: Arc::new(Vec::new()),
76        }
77    }
78
79    pub fn state(mut self, state: State) -> Self {
80        self.state = state;
81        self
82    }
83
84    pub fn progress(mut self, fraction: impl Into<Option<f64>>) -> Self {
85        self.progress = fraction.into();
86        self
87    }
88
89    pub fn estimate(mut self, estimate: bool) -> Self {
90        self.estimate = estimate;
91        self
92    }
93
94    pub fn ended(mut self, at: Instant) -> Self {
95        self.ended = Some(at);
96        self
97    }
98
99    pub fn status(mut self, text: impl Into<String>) -> Self {
100        self.status = text.into();
101        self
102    }
103
104    pub fn detail(mut self, lines: impl IntoIterator<Item = Line<'static>>) -> Self {
105        self.detail = Arc::new(lines.into_iter().collect());
106        self
107    }
108
109    pub fn shared(mut self, lines: Lines) -> Self {
110        self.detail = lines;
111        self
112    }
113
114    pub fn took(&self, now: Instant) -> Duration {
115        self.ended
116            .unwrap_or(now)
117            .saturating_duration_since(self.started)
118    }
119
120    fn shown(&self, now: Instant) -> bool {
121        !self.state.finished() || self.ended.is_none_or(|ended| now < ended + LINGER)
122    }
123
124    fn wake(&self, now: Instant) -> Option<Instant> {
125        if self.state.finished() {
126            return self
127                .ended
128                .map(|ended| ended + LINGER)
129                .filter(|leaves| *leaves > now);
130        }
131        let step = if self.state == State::Running {
132            SPIN
133        } else {
134            SECOND
135        };
136        let gone = now.saturating_duration_since(self.started).as_nanos();
137        let steps = u32::try_from(gone / step.as_nanos() + 1).ok()?;
138        Some(self.started + step * steps)
139    }
140}
141
142type Count = Arc<dyn Fn(usize) -> String + Send + Sync>;
143type Elapsed = Arc<dyn Fn(Duration) -> String + Send + Sync>;
144type Facts = Arc<dyn Fn(&[Activity]) -> Vec<(String, Style)> + Send + Sync>;
145
146#[derive(Clone)]
147#[non_exhaustive]
148pub struct Activities {
149    pub(crate) section: Section,
150    pub(crate) keys: &'static [Key],
151    pub(crate) band: bool,
152    pub(crate) hints: Vec<Hint<'static>>,
153    pub(crate) detail_hints: Vec<Hint<'static>>,
154    pub(crate) log: Log,
155    pub(crate) empty: Cow<'static, str>,
156    pub(crate) more: Count,
157    pub(crate) elapsed: Elapsed,
158    pub(crate) facts: Facts,
159}
160
161impl Activities {
162    pub fn new(section: Section) -> Self {
163        Activities {
164            section,
165            keys: &[],
166            band: true,
167            hints: Vec::new(),
168            detail_hints: Vec::new(),
169            log: Log::new(""),
170            empty: Cow::Borrowed(""),
171            more: Arc::new(|_| String::new()),
172            elapsed: Arc::new(|_| String::new()),
173            facts: Arc::new(|_| Vec::new()),
174        }
175    }
176
177    pub fn keys(mut self, keys: &'static [Key]) -> Self {
178        self.keys = keys;
179        self
180    }
181
182    pub fn band(mut self, band: bool) -> Self {
183        self.band = band;
184        self
185    }
186
187    pub fn hints(mut self, hints: impl IntoIterator<Item = Hint<'static>>) -> Self {
188        self.hints = hints.into_iter().collect();
189        self
190    }
191
192    pub fn detail_hints(mut self, hints: impl IntoIterator<Item = Hint<'static>>) -> Self {
193        self.detail_hints = hints.into_iter().collect();
194        self
195    }
196
197    pub fn log(mut self, log: Log) -> Self {
198        self.log = log;
199        self
200    }
201
202    pub fn empty(mut self, text: impl Into<Cow<'static, str>>) -> Self {
203        self.empty = text.into();
204        self
205    }
206
207    pub fn more(mut self, words: impl Fn(usize) -> String + Send + Sync + 'static) -> Self {
208        self.more = Arc::new(words);
209        self
210    }
211
212    pub fn elapsed(mut self, words: impl Fn(Duration) -> String + Send + Sync + 'static) -> Self {
213        self.elapsed = Arc::new(words);
214        self
215    }
216
217    pub fn facts(
218        mut self,
219        facts: impl Fn(&[Activity]) -> Vec<(String, Style)> + Send + Sync + 'static,
220    ) -> Self {
221        self.facts = Arc::new(facts);
222        self
223    }
224}
225
226impl fmt::Debug for Activities {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        f.debug_struct("Activities")
229            .field("section", &self.section.name())
230            .field("keys", &self.keys)
231            .field("band", &self.band)
232            .field("empty", &self.empty)
233            .finish_non_exhaustive()
234    }
235}
236
237#[derive(Debug, Clone, PartialEq)]
238pub(crate) enum Change {
239    Put(Activity),
240    All(Vec<Activity>),
241    Forget(u64),
242    Band(bool),
243    Run(Activity),
244    Ran(Activity),
245    End(u64, State),
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249enum Owner {
250    Shell,
251    App,
252}
253
254pub(crate) struct Board {
255    pub(crate) words: Activities,
256    pub(crate) list: Vec<Activity>,
257    runs: HashMap<u64, Owner>,
258    rows: List,
259    ids: Vec<u64>,
260    open: Option<u64>,
261    log: Log,
262    now: Instant,
263    palette: Palette,
264}
265
266fn gauge(activity: &Activity) -> String {
267    match activity.progress.filter(|_| !activity.state.finished()) {
268        Some(fraction) => format!(
269            "{} {:>5}",
270            bar(fraction, BAR),
271            share(fraction, activity.estimate)
272        ),
273        None => String::new(),
274    }
275}
276
277fn mark(activity: &Activity, now: Instant, palette: &Palette) -> (&'static str, Style) {
278    match activity.state {
279        State::Running => (
280            spinner(now.saturating_duration_since(activity.started)),
281            palette.accent,
282        ),
283        State::Waiting => ("⧗", palette.muted),
284        State::Done => ("✓", palette.good),
285        State::Failed => ("✗", palette.bad),
286        State::Stopped => ("■", palette.muted),
287    }
288}
289
290fn metered(activity: &Activity, palette: &Palette) -> Style {
291    if activity.estimate {
292        palette.muted
293    } else {
294        palette.accent
295    }
296}
297
298fn tone(activity: &Activity, palette: &Palette) -> Style {
299    match activity.state {
300        State::Done => palette.good,
301        State::Failed => palette.bad,
302        _ => palette.muted,
303    }
304}
305
306fn newest(list: &[Activity]) -> Vec<&Activity> {
307    let mut sorted: Vec<&Activity> = list.iter().collect();
308    sorted.sort_by(|a, b| b.started.cmp(&a.started).then(b.id.cmp(&a.id)));
309    sorted
310}
311
312impl Board {
313    pub(crate) fn new(words: Activities, now: Instant) -> Self {
314        Board {
315            log: words.log.clone(),
316            words,
317            list: Vec::new(),
318            runs: HashMap::new(),
319            rows: List::new(),
320            ids: Vec::new(),
321            open: None,
322            now,
323            palette: Palette::new(Color::Reset),
324        }
325    }
326
327    fn kept(&self, id: u64) -> Option<usize> {
328        self.list.iter().position(|kept| kept.id == id)
329    }
330
331    fn stamp(&self, mut activity: Activity, now: Instant) -> Activity {
332        if activity.state.finished() && activity.ended.is_none() {
333            let before = self.kept(activity.id).and_then(|at| self.list[at].ended);
334            activity.ended = before.or(Some(now));
335        }
336        activity
337    }
338
339    fn place(&mut self, activity: Activity, now: Instant) {
340        let activity = self.stamp(activity, now);
341        match self.kept(activity.id) {
342            Some(at) => self.list[at] = activity,
343            None => self.list.push(activity),
344        }
345    }
346
347    fn put(&mut self, mut activity: Activity, now: Instant) {
348        if let Some(owner) = self.runs.get_mut(&activity.id) {
349            *owner = Owner::App;
350            if let Some(at) = self.kept(activity.id) {
351                activity.detail = Arc::clone(&self.list[at].detail);
352            }
353        }
354        self.place(activity, now);
355    }
356
357    fn run(&mut self, mut activity: Activity, now: Instant) {
358        let waiting = self
359            .kept(activity.id)
360            .map(|at| &self.list[at])
361            .filter(|kept| !kept.state.finished());
362        if let Some(kept) = waiting {
363            activity.started = kept.started;
364        }
365        self.runs.insert(activity.id, Owner::Shell);
366        self.place(activity, now);
367    }
368
369    fn ran(&mut self, activity: Activity) {
370        let (Some(at), Some(&owner)) = (self.kept(activity.id), self.runs.get(&activity.id)) else {
371            return;
372        };
373        let kept = &mut self.list[at];
374        kept.detail = activity.detail;
375        if owner == Owner::Shell {
376            kept.state = activity.state;
377            kept.progress = activity.progress;
378            kept.status = activity.status;
379            kept.ended = activity.ended;
380        }
381    }
382
383    fn end(&mut self, id: u64, state: State, now: Instant) {
384        let Some(at) = self.kept(id).filter(|_| self.runs.contains_key(&id)) else {
385            return;
386        };
387        let kept = &mut self.list[at];
388        if !kept.state.finished() {
389            kept.state = state;
390            kept.progress = None;
391            kept.ended = Some(now);
392        }
393    }
394
395    pub(crate) fn change(&mut self, change: Change, now: Instant) {
396        match change {
397            Change::Put(activity) => self.put(activity, now),
398            Change::All(list) => {
399                let runs = &self.runs;
400                self.list.retain(|kept| runs.contains_key(&kept.id));
401                for activity in list {
402                    self.put(activity, now);
403                }
404            }
405            Change::Forget(id) => {
406                self.list.retain(|kept| kept.id != id);
407                self.runs.remove(&id);
408            }
409            Change::Band(band) => self.words.band = band,
410            Change::Run(activity) => self.run(activity, now),
411            Change::Ran(activity) => self.ran(activity),
412            Change::End(id, state) => self.end(id, state, now),
413        }
414        if self
415            .open
416            .is_some_and(|id| self.list.iter().all(|kept| kept.id != id))
417        {
418            self.open = None;
419        }
420    }
421
422    fn opened(&self) -> Option<&Activity> {
423        let id = self.open?;
424        self.list.iter().find(|kept| kept.id == id)
425    }
426
427    pub(crate) fn banded(&self, now: Instant) -> Vec<&Activity> {
428        if !self.words.band {
429            return Vec::new();
430        }
431        let mut shown = newest(&self.list);
432        shown.retain(|activity| activity.shown(now));
433        shown
434    }
435
436    pub(crate) fn wake(&self, now: Instant, open: bool) -> Option<Instant> {
437        let shown = if open {
438            self.list.iter().collect()
439        } else {
440            self.banded(now)
441        };
442        shown
443            .into_iter()
444            .filter_map(|activity| activity.wake(now))
445            .min()
446    }
447
448    pub(crate) fn band(&self, frame: &mut Frame, area: Rect, palette: &Palette, now: Instant) {
449        let shown = self.banded(now);
450        let room = usize::from(area.height);
451        if shown.is_empty() || room == 0 {
452            return;
453        }
454        let hidden = shown.len().saturating_sub(room);
455        let more = if hidden > 0 {
456            (self.words.more)(hidden + 1)
457        } else {
458            String::new()
459        };
460        let kept = if more.is_empty() { room } else { room - 1 };
461        let shown = &shown[..shown.len().min(kept)];
462        let width = usize::from(area.width);
463        let label = shown
464            .iter()
465            .map(|activity| cells(&activity.label))
466            .max()
467            .unwrap_or(0)
468            .min(width / 3);
469        let took: Vec<String> = shown
470            .iter()
471            .map(|activity| (self.words.elapsed)(activity.took(now)))
472            .collect();
473        let time = took.iter().map(|text| cells(text)).max().unwrap_or(0);
474        let gauged = width >= NARROW
475            && shown
476                .iter()
477                .any(|activity| activity.progress.is_some() && !activity.state.finished());
478        let mut lines: Vec<Line> = shown
479            .iter()
480            .zip(&took)
481            .map(|(activity, took)| {
482                let (sign, sign_style) = mark(activity, now, palette);
483                let fade = |style: Style| {
484                    if activity.state.finished() {
485                        style.add_modifier(Modifier::DIM)
486                    } else {
487                        style
488                    }
489                };
490                let mut spans = vec![
491                    Span::styled(format!("{sign} "), fade(sign_style)),
492                    Span::styled(
493                        fit(&activity.label, label),
494                        fade(palette.ink.add_modifier(Modifier::BOLD)),
495                    ),
496                    Span::raw(" ".repeat(GAP)),
497                ];
498                let mut used = 2 + label + GAP;
499                if gauged {
500                    spans.push(Span::styled(
501                        fit(&gauge(activity), GAUGE),
502                        fade(metered(activity, palette)),
503                    ));
504                    spans.push(Span::raw(" ".repeat(GAP)));
505                    used += GAUGE + GAP;
506                }
507                let rest = width.saturating_sub(used + GAP + time);
508                spans.push(Span::styled(
509                    fit(&activity.status, rest),
510                    fade(tone(activity, palette)),
511                ));
512                spans.push(Span::styled(
513                    format!("{}{:>time$}", " ".repeat(GAP), clip(took, time)),
514                    fade(palette.muted),
515                ));
516                Line::from(spans)
517            })
518            .collect();
519        if !more.is_empty() {
520            lines.push(Line::styled(clip(&more, width), palette.muted));
521        }
522        frame.render_widget(Paragraph::new(lines), area);
523    }
524
525    fn sync(&mut self) {
526        let palette = &self.palette;
527        let selected = self
528            .rows
529            .selected()
530            .and_then(|index| self.ids.get(index))
531            .copied();
532        let sorted = newest(&self.list);
533        self.ids = sorted.iter().map(|activity| activity.id).collect();
534        let rows: Vec<Row> = sorted
535            .iter()
536            .map(|activity| {
537                let (sign, style) = mark(activity, self.now, palette);
538                Row::new([
539                    Cell::new(activity.label.clone()),
540                    Cell::new(gauge(activity)).style(metered(activity, palette)),
541                    Cell::new(activity.status.clone()).style(tone(activity, palette)),
542                    Cell::new((self.words.elapsed)(activity.took(self.now))).faint(),
543                ])
544                .mark(Mark::new(sign).style(style))
545            })
546            .collect();
547        self.rows.set_rows(rows);
548        if let Some(index) = selected.and_then(|id| self.ids.iter().position(|kept| *kept == id)) {
549            self.rows.select(index);
550        }
551    }
552}
553
554impl<E: Send + 'static> Screen<E> for Board {
555    fn draw(&mut self, frame: &mut Frame, area: Rect, palette: &Palette) {
556        self.palette = *palette;
557        if let Some(lines) = self.opened().map(|activity| Arc::clone(&activity.detail)) {
558            self.log.set_lines(lines);
559            self.log.draw(frame, area, palette);
560            return;
561        }
562        if self.list.is_empty() {
563            let line = Line::styled(self.words.empty.to_string(), palette.ink);
564            message(frame, area, line);
565            return;
566        }
567        self.sync();
568        let columns = [
569            Column::new("", 6, 0).fit(28).pinned(),
570            Column::new("", 0, 0).fit(18).priority(1),
571            Column::new("", 8, 0).flex(),
572            Column::new("", 2, 0).fit(12).right().pinned(),
573        ];
574        let view = palette.view(&mut self.rows, &columns).header(false);
575        frame.render_widget(view, area);
576    }
577
578    fn moment(&mut self, now: Instant) {
579        self.now = now;
580    }
581
582    fn key(&mut self, key: KeyEvent, cx: &mut Cx<'_, E>) {
583        if let Some(lines) = self.opened().map(|activity| Arc::clone(&activity.detail)) {
584            self.log.set_lines(lines);
585            if let Turn::Copy(text) = self.log.key(key) {
586                cx.copy(text);
587            }
588            return;
589        }
590        self.sync();
591        if let Step::Open(index) = self.rows.key(key.into()) {
592            self.open = self.ids.get(index).copied();
593            self.log = self.words.log.clone();
594        }
595    }
596
597    fn paste(&mut self, text: &str, _cx: &mut Cx<'_, E>) {
598        if self.open.is_some() {
599            self.log.paste(text);
600        }
601    }
602
603    fn typing(&self) -> bool {
604        self.open.is_some() && self.log.typing()
605    }
606
607    fn input(&self) -> Option<InputBar<'_>> {
608        self.open.and_then(|_| self.log.bar())
609    }
610
611    fn back(&mut self, _cx: &mut Cx<'_, E>) {
612        self.open = None;
613    }
614
615    fn crumb(&self) -> Option<String> {
616        self.opened().map(|activity| activity.label.clone())
617    }
618
619    fn selected(&self) -> usize {
620        self.rows.selected().unwrap_or(0)
621    }
622
623    fn hints(&self) -> Vec<Hint<'_>> {
624        if self.open.is_some() {
625            self.words.detail_hints.clone()
626        } else {
627            self.words.hints.clone()
628        }
629    }
630
631    fn facts(&self) -> Vec<(String, Style)> {
632        (self.words.facts)(&self.list)
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    fn kept(board: &Board, id: u64) -> &Activity {
641        board.list.iter().find(|kept| kept.id == id).unwrap()
642    }
643
644    fn ran(id: u64, status: &str, line: &str, now: Instant) -> Activity {
645        Activity::new(id, "sim", now)
646            .status(status)
647            .detail([Line::from(line.to_string())])
648    }
649
650    #[test]
651    fn an_app_that_posts_for_a_command_takes_its_band_and_the_command_keeps_its_detail() {
652        let now = Instant::now();
653        let later = now + SECOND;
654        let mut board = Board::new(Activities::new(Section::new("Jobs")), now);
655        board.change(
656            Change::Put(Activity::new(1, "sim", now).state(State::Waiting)),
657            now,
658        );
659        board.change(Change::Put(Activity::new(9, "mine", now)), now);
660        board.change(Change::Run(Activity::new(1, "sim", later)), later);
661        board.change(Change::Run(Activity::new(2, "sim", later)), later);
662        assert_eq!(kept(&board, 1).started, now);
663        assert_eq!(kept(&board, 2).started, later);
664
665        board.change(Change::Ran(ran(1, "build", "one", later)), later);
666        assert_eq!(kept(&board, 1).status, "build");
667
668        let mine = Activity::new(1, "sim", now).status("week 3").progress(0.25);
669        board.change(Change::Put(mine), later);
670        assert_eq!(kept(&board, 1).detail[0].to_string(), "one");
671        board.change(Change::Ran(ran(1, "run", "two", later)), later);
672        let shown = kept(&board, 1);
673        assert_eq!(
674            (shown.status.as_str(), shown.progress),
675            ("week 3", Some(0.25))
676        );
677        assert_eq!(shown.detail[0].to_string(), "two");
678
679        board.change(Change::All(vec![Activity::new(7, "other", now)]), later);
680        let mut ids: Vec<u64> = board.list.iter().map(|kept| kept.id).collect();
681        ids.sort_unstable();
682        assert_eq!(ids, [1, 2, 7]);
683
684        let stopped = Activity::new(1, "sim", now)
685            .state(State::Stopped)
686            .status("stopped from the lab");
687        board.change(Change::Put(stopped), later);
688        board.change(Change::End(1, State::Failed), later);
689        assert_eq!(kept(&board, 1).state, State::Stopped);
690        board.change(Change::Put(Activity::new(2, "sim", now)), later);
691        board.change(Change::End(2, State::Done), later);
692        assert_eq!(
693            (kept(&board, 2).state, kept(&board, 2).ended),
694            (State::Done, Some(later))
695        );
696
697        board.change(Change::Forget(1), later);
698        board.change(Change::Ran(ran(1, "run", "three", later)), later);
699        assert!(board.list.iter().all(|kept| kept.id != 1));
700    }
701}