Skip to main content

katui_rt/
session.rs

1//! The alternate-screen session guard: enter on construction, leave on
2//! drop, including unwinds. A session owns its writer; drawing through
3//! katui happens beside it, on the same stdout.
4
5use std::io;
6use std::time::{Duration, Instant};
7
8use katui_present::cells;
9use katui_present::session;
10use katui_proto::kitty;
11
12use crate::RtError;
13
14/// An alternate-screen session. Drop leaves the screen, whatever ended
15/// it: normal return, error, panic.
16pub struct Session<W: io::Write> {
17    out: Option<W>,
18}
19
20impl<W: io::Write> Session<W> {
21    /// Enter the alternate screen on `out`.
22    pub fn enter(out: W) -> Result<Self, RtError> {
23        let mut session = Self { out: Some(out) };
24        if let Some(out) = session.out.as_mut() {
25            session::enter(out)?;
26        }
27        Ok(session)
28    }
29
30    /// Leave the screen and return the writer.
31    pub fn leave(mut self) -> io::Result<W> {
32        let out = self.out.take();
33        let Some(mut out) = out else {
34            unreachable!("only leave takes the writer and it runs once");
35        };
36        session::leave(&mut out)?;
37        Ok(out)
38    }
39}
40
41impl<W: io::Write> Drop for Session<W> {
42    fn drop(&mut self) {
43        // drop cannot report, so the leave is best effort
44        if let Some(out) = &mut self.out {
45            let _ = session::leave(out);
46        }
47    }
48}
49
50/// What a [`Stage`] run owes the terminal when it ends: which teardown
51/// pieces to send after the always-on resets.
52#[derive(Debug, Clone, Copy, Default)]
53pub struct Teardown {
54    /// Delete every visible kitty image; runs that transmitted any.
55    pub graphics: bool,
56    /// Home the cursor; runs that kept painting the screen.
57    pub home: bool,
58    /// Clear the whole screen after homing.
59    pub clear: bool,
60}
61
62impl Teardown {
63    /// Send the restore sequence: leave sync mode, reset styles, show
64    /// the cursor, then whatever this teardown still owes.
65    pub(crate) fn finish<W: io::Write>(self, out: &mut W) -> io::Result<()> {
66        out.write_all(b"\x1b[?2026l\x1b[0m")?;
67        cells::show_cursor(out)?;
68        if self.graphics {
69            out.write_all(&kitty::Delete::AllVisible.encode())?;
70        }
71        if self.home {
72            out.write_all(b"\x1b[H")?;
73        }
74        if self.clear {
75            out.write_all(b"\x1b[2J")?;
76        }
77        out.flush()
78    }
79}
80
81/// The main-screen stage: hides the cursor on enter and restores the
82/// terminal on [`Stage::leave`] or drop. Unlike [`Session`] it never
83/// switches to the alternate screen: a run draws beside it, through
84/// [`Stage::writer`], on the same stdout.
85pub struct Stage<W: io::Write> {
86    out: Option<W>,
87    teardown: Teardown,
88}
89
90/// What a [`ResizeWatch`] noticed.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum ResizeEvent {
93    /// The terminal has a new size; rebuild for it.
94    Resized(katui_scene::CellSize),
95    /// The size has held for a while; repaint, because the terminal may
96    /// have dropped images while it was resizing.
97    Settled,
98}
99
100/// How long after the last size change a watch asks for each repaint.
101/// Kitty debounces its own resize for up to half a second, so the second
102/// repaint lands after that.
103const SETTLE_REPAINTS: [Duration; 2] = [Duration::from_millis(250), Duration::from_millis(1000)];
104
105/// Turns the per-frame terminal size into resize and settle events.
106#[derive(Debug)]
107pub struct ResizeWatch {
108    size: katui_scene::CellSize,
109    changed_at: Option<Instant>,
110    repaints_done: usize,
111}
112
113impl ResizeWatch {
114    /// A watch starting from `size`.
115    #[must_use]
116    pub fn new(size: katui_scene::CellSize) -> Self {
117        Self {
118            size,
119            changed_at: None,
120            repaints_done: 0,
121        }
122    }
123
124    /// Report what changed given the size read at `at`.
125    pub fn observe(
126        &mut self,
127        size: Option<katui_scene::CellSize>,
128        at: Instant,
129    ) -> Option<ResizeEvent> {
130        if let Some(new_size) = size
131            && (new_size.columns, new_size.rows) != (self.size.columns, self.size.rows)
132            && new_size.columns > 0
133            && new_size.rows > 0
134        {
135            self.size = new_size;
136            self.changed_at = Some(at);
137            self.repaints_done = 0;
138            return Some(ResizeEvent::Resized(new_size));
139        }
140        let waited = at.duration_since(self.changed_at?);
141        let due = *SETTLE_REPAINTS.get(self.repaints_done)?;
142        if waited < due {
143            return None;
144        }
145        self.repaints_done += 1;
146        Some(ResizeEvent::Settled)
147    }
148}
149
150impl<W: io::Write> Stage<W> {
151    /// Hide the cursor and hold `out` for the run.
152    pub fn enter(mut out: W, teardown: Teardown) -> Result<Self, RtError> {
153        cells::hide_cursor(&mut out)?;
154        Ok(Self {
155            out: Some(out),
156            teardown,
157        })
158    }
159
160    /// The writer the run draws through.
161    pub fn writer(&mut self) -> &mut W {
162        let Some(out) = self.out.as_mut() else {
163            unreachable!("only leave takes the writer and it runs once");
164        };
165        out
166    }
167
168    /// Restore the terminal and return the writer.
169    pub fn leave(mut self) -> io::Result<W> {
170        let teardown = self.teardown;
171        let Some(mut out) = self.out.take() else {
172            unreachable!("only leave takes the writer and it runs once");
173        };
174        teardown.finish(&mut out)?;
175        Ok(out)
176    }
177}
178
179impl<W: io::Write> Drop for Stage<W> {
180    fn drop(&mut self) {
181        // drop cannot report, so the restore is best effort; it only
182        // runs when leave never did, on a panic for example
183        if let Some(out) = &mut self.out {
184            let _ = self.teardown.finish(out);
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use std::io;
192
193    use std::time::{Duration, Instant};
194
195    use super::{ResizeEvent, ResizeWatch, Session, Stage, Teardown};
196    /// A writer that records into shared storage, so bytes survive the
197    /// session's drop.
198    #[derive(Clone)]
199    struct Recorder(std::rc::Rc<std::cell::RefCell<Vec<u8>>>);
200
201    impl io::Write for Recorder {
202        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
203            self.0.borrow_mut().extend_from_slice(bytes);
204            Ok(bytes.len())
205        }
206
207        fn flush(&mut self) -> io::Result<()> {
208            Ok(())
209        }
210    }
211
212    #[test]
213    fn a_session_leaves_on_drop() {
214        let log = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
215        let session = Session::enter(Recorder(log.clone())).expect("recorder cannot fail");
216        drop(session);
217        assert_eq!(
218            String::from_utf8_lossy(&log.borrow()),
219            "\x1b[?1049h\x1b[?25l\x1b[?25h\x1b[?1049l",
220            "drop leaves the screen"
221        );
222    }
223
224    #[test]
225    fn leave_after_use_returns_the_writer_with_the_leave_bytes() {
226        let session = Session::enter(Vec::new()).expect("writing to a Vec cannot fail");
227        let bytes = session.leave().expect("writing to a Vec cannot fail");
228        assert_eq!(
229            String::from_utf8_lossy(&bytes),
230            "\x1b[?1049h\x1b[?25l\x1b[?25h\x1b[?1049l"
231        );
232    }
233
234    #[test]
235    fn a_failing_writer_fails_the_enter() {
236        struct Broken;
237        impl io::Write for Broken {
238            fn write(&mut self, _: &[u8]) -> io::Result<usize> {
239                Err(io::Error::other("broken"))
240            }
241            fn flush(&mut self) -> io::Result<()> {
242                Ok(())
243            }
244        }
245        assert!(Session::enter(Broken).is_err());
246    }
247
248    #[test]
249    fn a_full_stage_restores_everything_it_owes() {
250        let stage = Stage::enter(
251            Vec::new(),
252            Teardown {
253                graphics: true,
254                home: true,
255                clear: true,
256            },
257        )
258        .expect("writing to a Vec cannot fail");
259        let bytes = stage.leave().expect("writing to a Vec cannot fail");
260        assert_eq!(
261            String::from_utf8_lossy(&bytes),
262            "\x1b[?25l\x1b[?2026l\x1b[0m\x1b[?25h\x1b_Ga=d,d=a,q=2\x1b\\\x1b[H\x1b[2J",
263            "hide on enter; sync off, styles, cursor, graphics, home, clear on leave"
264        );
265    }
266
267    #[test]
268    fn a_bare_stage_only_resets_the_basics() {
269        let stage =
270            Stage::enter(Vec::new(), Teardown::default()).expect("writing to a Vec cannot fail");
271        let bytes = stage.leave().expect("writing to a Vec cannot fail");
272        assert_eq!(
273            String::from_utf8_lossy(&bytes),
274            "\x1b[?25l\x1b[?2026l\x1b[0m\x1b[?25h",
275            "no graphics, home or clear owed"
276        );
277    }
278
279    #[test]
280    fn a_stage_left_unleft_still_restores_on_drop() {
281        let log = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
282        let stage =
283            Stage::enter(Recorder(log.clone()), Teardown::default()).expect("recorder cannot fail");
284        drop(stage);
285        assert_eq!(
286            String::from_utf8_lossy(&log.borrow()),
287            "\x1b[?25l\x1b[?2026l\x1b[0m\x1b[?25h",
288            "drop restores when leave never ran"
289        );
290    }
291
292    fn size(columns: u16, rows: u16) -> katui_scene::CellSize {
293        katui_scene::CellSize { columns, rows }
294    }
295
296    #[test]
297    fn a_resize_reports_once_then_repaints_as_it_settles() {
298        let start = Instant::now();
299        let mut watch = ResizeWatch::new(size(80, 24));
300        assert_eq!(watch.observe(Some(size(80, 24)), start), None);
301        let later = |millis| start + Duration::from_millis(millis);
302        assert_eq!(
303            watch.observe(Some(size(100, 30)), start),
304            Some(ResizeEvent::Resized(size(100, 30)))
305        );
306        assert_eq!(watch.observe(Some(size(100, 30)), later(100)), None);
307        assert_eq!(
308            watch.observe(Some(size(100, 30)), later(300)),
309            Some(ResizeEvent::Settled)
310        );
311        assert_eq!(watch.observe(Some(size(100, 30)), later(400)), None);
312        assert_eq!(
313            watch.observe(Some(size(100, 30)), later(1100)),
314            Some(ResizeEvent::Settled)
315        );
316        assert_eq!(watch.observe(Some(size(100, 30)), later(5000)), None);
317    }
318
319    #[test]
320    fn a_new_resize_restarts_the_settling() {
321        let start = Instant::now();
322        let mut watch = ResizeWatch::new(size(80, 24));
323        watch.observe(Some(size(100, 30)), start);
324        let dragged = start + Duration::from_millis(200);
325        watch.observe(Some(size(110, 30)), dragged);
326        assert_eq!(
327            watch.observe(Some(size(110, 30)), start + Duration::from_millis(300)),
328            None,
329            "the clock runs from the last change"
330        );
331        assert_eq!(
332            watch.observe(Some(size(110, 30)), start + Duration::from_millis(500)),
333            Some(ResizeEvent::Settled)
334        );
335    }
336
337    #[test]
338    fn a_zero_or_unreadable_size_is_ignored() {
339        let mut watch = ResizeWatch::new(size(80, 24));
340        let now = Instant::now();
341        assert_eq!(watch.observe(None, now), None);
342        assert_eq!(watch.observe(Some(size(0, 0)), now), None);
343    }
344}