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