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}