Skip to main content

artifactize_core/monitor/
session.rs

1//! Selected-session jobs. Geometry/input are pure; the driver alone starts bounded reader jobs.
2//! A UI sizes the window in text columns and rows, whether a terminal or the desktop app.
3use crate::agent::session::{
4    document::{Anchor, Mode, Move, Position, Scroll},
5    live::{Reader, Source, Window},
6};
7
8pub struct Live {
9    pub serial: u64,
10    pub source: Source,
11    pub reader: Option<Reader>,
12    pub scroll: Scroll,
13    pub window: Window,
14    width: usize,
15    height: usize,
16    anchor: Anchor,
17    position: Position,
18    revision: u64,
19    rendered_width: usize,
20    dirty: bool,
21    expanded: std::collections::BTreeSet<crate::agent::session::transcript::BlockId>,
22}
23
24pub struct Job {
25    pub serial: u64,
26    pub revision: u64,
27    pub width: usize,
28    pub height: usize,
29    pub position: Position,
30    pub reader: Reader,
31    pub expanded: std::collections::BTreeSet<crate::agent::session::transcript::BlockId>,
32}
33impl Live {
34    pub fn new(serial: u64, source: Source) -> Self {
35        Self {
36            serial,
37            source: source.clone(),
38            reader: Some(Reader::new(source)),
39            scroll: Scroll::default(),
40            window: Window::default(),
41            width: 0,
42            height: 0,
43            anchor: Anchor::default(),
44            position: Position::Bottom,
45            revision: 0,
46            rendered_width: 0,
47            dirty: true,
48            expanded: std::collections::BTreeSet::new(),
49        }
50    }
51    pub fn geometry(&mut self, width: usize, height: usize) {
52        if self.width != width || self.height != height {
53            self.width = width;
54            self.height = height;
55            // Preserve pending Row/Top intent. The reader converts an old-layout row to
56            // its logical anchor before changing width; committed positions already anchor.
57            self.revision += 1;
58            self.dirty = true;
59        }
60    }
61    pub fn invalidate(&mut self) {
62        self.dirty = true;
63    }
64
65    /// Point this reader at a freshly resolved `source`. Of the same session it keeps its
66    /// position, follow state and expanded groups and reads again (`true`); another session
67    /// is not this reader's (`false`, nothing changed).
68    pub fn refresh(&mut self, source: &Source) -> bool {
69        if self.source.reference != source.reference {
70            return false;
71        }
72        self.source = source.clone();
73        if let Some(reader) = &mut self.reader {
74            reader.source = source.clone();
75        }
76        self.invalidate();
77        true
78    }
79    #[cfg(any(test, feature = "test-support"))]
80    pub fn expanded(&self, id: crate::agent::session::transcript::BlockId) -> bool {
81        self.expanded.contains(&id)
82    }
83    pub fn toggle(&mut self, id: crate::agent::session::transcript::BlockId) {
84        if !self.expanded.remove(&id) {
85            self.expanded.insert(id);
86        }
87        self.revision += 1;
88        self.dirty = true;
89    }
90    pub fn toggle_visible(&mut self) {
91        if let Some((_, id)) = self.window.groups.first().copied() {
92            self.toggle(id);
93        }
94    }
95    pub fn movement(&mut self, movement: Move) {
96        let before = self.scroll.top;
97        self.scroll.apply(movement);
98        let delta = self.scroll.top as isize - before as isize;
99        self.position = match movement {
100            Move::Bottom => Position::Bottom,
101            Move::Top => Position::Anchor(Anchor::default()),
102            Move::Up(_) | Move::Down(_) if self.scroll.mode == Mode::Following => Position::Bottom,
103            Move::Up(_) | Move::Down(_) => match self.position {
104                Position::Anchor(anchor) => Position::Relative {
105                    anchor,
106                    rows: delta,
107                    width: self.rendered_width.max(1),
108                },
109                Position::Relative {
110                    anchor,
111                    rows,
112                    width,
113                } => Position::Relative {
114                    anchor,
115                    rows: rows.saturating_add(delta),
116                    width,
117                },
118                _ => Position::Relative {
119                    anchor: self.anchor,
120                    rows: self.scroll.top as isize - self.window.top as isize,
121                    width: self.rendered_width.max(1),
122                },
123            },
124        };
125        self.revision += 1;
126        self.dirty = true;
127    }
128    pub fn job(&mut self) -> Option<Job> {
129        if !self.dirty || self.width == 0 || self.height == 0 {
130            return None;
131        }
132        let reader = self.reader.take()?;
133        self.dirty = false;
134        Some(Job {
135            serial: self.serial,
136            revision: self.revision,
137            width: self.width,
138            height: self.height,
139            position: self.position,
140            reader,
141            expanded: self.expanded.clone(),
142        })
143    }
144    pub fn finish(&mut self, job: Job, window: Window) {
145        let mut reader = job.reader;
146        reader.source = self.source.clone();
147        self.reader = Some(reader);
148        // A replacement creates a different document even if its visible result is stale.
149        if window.reset {
150            self.scroll = Scroll::default();
151            self.position = Position::Bottom;
152            self.dirty = true;
153        }
154        if self.revision != job.revision {
155            self.dirty = true;
156            return;
157        }
158        if window.loading && window.rows.is_empty() && window.status.is_none() {
159            // Keep the old visible text during bounded relayout, with an honest loading label.
160            self.window.loading = true;
161            self.position = window.position;
162        } else {
163            self.scroll
164                .update(window.total, self.height, Some(window.top));
165            self.anchor = window.anchor;
166            self.rendered_width = job.width;
167            if let Some(reader) = &mut self.reader {
168                reader.commit(job.width);
169            }
170            self.position = match self.scroll.mode {
171                Mode::Following => Position::Bottom,
172                Mode::Paused => Position::Anchor(self.anchor),
173            };
174            self.window = window;
175        }
176        self.dirty |= self.window.loading;
177    }
178    pub fn indicator(&self) -> String {
179        let position = if self.scroll.total == 0 {
180            "0/0".into()
181        } else {
182            format!("{}/{}", self.scroll.top + 1, self.scroll.total)
183        };
184        format!(
185            "{position} {} {}{}",
186            if self.scroll.at_bottom() {
187                "bottom"
188            } else {
189                "history"
190            },
191            if self.scroll.mode == Mode::Following {
192                "following"
193            } else {
194                "paused"
195            },
196            if self.window.loading {
197                " · indexing…"
198            } else {
199                ""
200            }
201        )
202    }
203}