Skip to main content

rich_ext/live/
mod.rs

1//! Synchronous single-writer coordination for inline terminal regions.
2//!
3//! Content carries no terminal controls. Graphics (micro assets drawn with
4//! Kitty, iTerm2 or Sixel) come through a [`PlacementSource`] given with
5//! [`LiveCoordinator::with_graphics`]: each row's placements are drawn after
6//! its cells, redrawn when the cells under them are repainted, and released
7//! when the coordinator finishes. Without a terminal no graphics are drawn.
8mod region;
9use crate::{
10    frame::{Change, Frame, Placement, PlacementSource, plan_graphics},
11    layout::{OverflowPolicy, fit_segments},
12    target::RenderTarget,
13};
14use region::Region;
15pub use region::RegionId;
16use rich::{
17    Console, Segment,
18    control::{Control, ControlType},
19    protocol::RenderEnvironment,
20};
21use std::{io::Write, sync::Arc};
22#[derive(Debug)]
23pub enum LiveError {
24    InvalidRegion,
25    ExhaustedIds,
26    UnsupportedControl,
27    Closed,
28    Io(std::io::Error),
29}
30impl std::fmt::Display for LiveError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::Io(e) => write!(f, "live output: {e}"),
34            other => write!(f, "live region error: {other:?}"),
35        }
36    }
37}
38impl std::error::Error for LiveError {
39    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
40        if let Self::Io(e) = self {
41            Some(e)
42        } else {
43            None
44        }
45    }
46}
47impl From<std::io::Error> for LiveError {
48    fn from(e: std::io::Error) -> Self {
49        Self::Io(e)
50    }
51}
52/// A painted row: its encoded text, the frame the next paint diffs
53/// against (with the graphics placed on it), and whether terminals may
54/// disagree about where its cells fall.
55#[derive(Debug)]
56struct Row {
57    text: String,
58    frame: Frame,
59    uncertain: bool,
60}
61
62impl Row {
63    fn placements(&self) -> &[Placement] {
64        self.frame.placements()
65    }
66}
67
68/// Whether terminals disagree about the width of something in `text`, so a
69/// column computed from rich's widths may not be where the terminal put a
70/// cell: emoji presentation and text selectors, zero-width joiners, regional
71/// indicators (flags), skin-tone modifiers, keycaps, tag sequences, and East
72/// Asian ambiguous-width characters. Such a row is repainted whole.
73///
74/// Box-drawing and block characters are ambiguous too, but every table and
75/// bar is drawn with them and terminals draw them one cell wide unless told
76/// to treat ambiguous characters as wide; they keep cell repaints.
77fn uncertain(text: &str) -> bool {
78    use unicode_width::UnicodeWidthChar;
79    text.chars().any(|c| {
80        matches!(
81            c,
82            '\u{fe0e}' | '\u{fe0f}'
83                | '\u{200d}'
84                | '\u{20e3}'
85                | '\u{1f1e6}'..='\u{1f1ff}'
86                | '\u{1f3fb}'..='\u{1f3ff}'
87                | '\u{e0020}'..='\u{e007f}'
88        ) || (c.width() != c.width_cjk() && !matches!(c, '\u{2500}'..='\u{259f}'))
89    })
90}
91impl PartialEq for Row {
92    fn eq(&self, other: &Row) -> bool {
93        self.text == other.text
94            && self.placements().len() == other.placements().len()
95            && self
96                .placements()
97                .iter()
98                .zip(other.placements())
99                .all(|(a, b)| a.same(b))
100    }
101}
102pub struct LiveCoordinator<W: Write> {
103    writer: W,
104    target: RenderTarget,
105    owner: Arc<()>,
106    next: u64,
107    regions: Vec<Region>,
108    width: usize,
109    height: usize,
110    painted: Vec<Row>,
111    hidden: bool,
112    closed: bool,
113    graphics: Option<Arc<dyn PlacementSource>>,
114    /// Graphics drawn in printed output, which stays on screen.
115    retained: Vec<Placement>,
116}
117impl<W: Write> LiveCoordinator<W> {
118    pub fn new(writer: W, target: RenderTarget) -> Self {
119        let c = target.capabilities();
120        Self {
121            writer,
122            target,
123            owner: Arc::new(()),
124            next: 0,
125            regions: Vec::new(),
126            width: c.width,
127            height: c.height,
128            painted: Vec::new(),
129            hidden: false,
130            closed: false,
131            graphics: None,
132            retained: Vec::new(),
133        }
134    }
135    /// Draw the graphics `source` places on the content's cells (on an
136    /// interactive terminal only).
137    #[must_use]
138    pub fn with_graphics(mut self, source: Arc<dyn PlacementSource>) -> Self {
139        self.graphics = Some(source);
140        self
141    }
142    /// The graphics source, when interactive.
143    fn source(&self) -> Option<&Arc<dyn PlacementSource>> {
144        self.graphics
145            .as_ref()
146            .filter(|_| self.target.capabilities().interactive)
147    }
148    fn check(&self) -> Result<(), LiveError> {
149        if self.closed {
150            Err(LiveError::Closed)
151        } else {
152            Ok(())
153        }
154    }
155    pub fn add(&mut self, content: Vec<Segment>) -> Result<RegionId, LiveError> {
156        self.check()?;
157        validate(&content)?;
158        let serial = self.next;
159        self.next = self.next.checked_add(1).ok_or(LiveError::ExhaustedIds)?;
160        let id = RegionId {
161            owner: self.owner.clone(),
162            serial,
163        };
164        self.regions.push(Region {
165            id: id.clone(),
166            content,
167        });
168        Ok(id)
169    }
170    fn index(&self, id: &RegionId) -> Result<usize, LiveError> {
171        if !Arc::ptr_eq(&self.owner, &id.owner) {
172            return Err(LiveError::InvalidRegion);
173        }
174        self.regions
175            .iter()
176            .position(|r| r.id.serial == id.serial)
177            .ok_or(LiveError::InvalidRegion)
178    }
179    pub fn update(&mut self, id: RegionId, content: Vec<Segment>) -> Result<(), LiveError> {
180        self.check()?;
181        validate(&content)?;
182        let index = self.index(&id)?;
183        self.regions[index].content = content;
184        Ok(())
185    }
186    pub fn remove(&mut self, id: RegionId) -> Result<(), LiveError> {
187        self.check()?;
188        let index = self.index(&id)?;
189        self.regions.remove(index);
190        Ok(())
191    }
192    pub fn handle(&mut self) -> LiveHandle<'_, W> {
193        LiveHandle { live: self }
194    }
195    fn dynamic(&self) -> bool {
196        self.target.capabilities().interactive && self.width > 1 && self.height > 1
197    }
198    fn control(&mut self, codes: &[ControlType]) -> std::io::Result<()> {
199        self.writer
200            .write_all(Control::new(codes).as_str().as_bytes())
201    }
202    fn row(&self, console: &Console, mut row: Vec<Segment>) -> Row {
203        if let Some(source) = self.source() {
204            row = source.prepare(row);
205            // Whatever the source swapped in is content too.
206            if validate(&row).is_err() {
207                row.retain(|segment| !segment.control);
208            }
209        }
210        if !self.target.capabilities().hyperlinks {
211            for segment in &mut row {
212                segment.style = segment.style.as_ref().map(|style| style.update_link(None));
213            }
214        }
215        let text = console.segments_to_string(&row);
216        let mut frame = Frame::from_segments(&row);
217        if let Some(source) = self.source() {
218            // A row is one line: only one-row placements on it fit.
219            let placements = source
220                .placements(&frame)
221                .into_iter()
222                .filter(|p| p.row == 0 && p.rows == 1)
223                .collect();
224            frame.set_placements(placements);
225        }
226        Row {
227            uncertain: row.iter().any(|segment| uncertain(&segment.text)),
228            text,
229            frame,
230        }
231    }
232    /// The bytes that draw `new`'s graphics on the current line after its
233    /// cells were painted: `changes` (or the whole row, when `whole`) went
234    /// to the terminal, against `old`'s graphics.
235    fn graphics(&self, new: &Row, old: Option<&Row>, whole: bool) -> Vec<u8> {
236        if self.source().is_none() {
237            return Vec::new();
238        }
239        let width = new
240            .frame
241            .row_width(0)
242            .max(old.map_or(0, |o| o.frame.row_width(0)));
243        let changes = match old {
244            Some(old) if !whole => new.frame.diff(&old.frame),
245            _ => vec![Change {
246                row: 0,
247                columns: 0..width.max(1),
248            }],
249        };
250        let previous = old.map_or(&[][..], Row::placements);
251        let plan = plan_graphics(previous, new.placements(), &changes);
252        let console = self.target.console();
253        let (system, no_color) = (console.color_system(), console.no_color());
254        let mut out = String::new();
255        for (_, columns) in &plan.repaint {
256            let end = columns.end.min(new.frame.row_width(0));
257            out.push_str(
258                Control::new(&[ControlType::CursorMoveToColumn(columns.start as u32)]).as_str(),
259            );
260            if columns.start < end && new.frame.height() > 0 {
261                out.push_str(
262                    &new.frame
263                        .encode_span(0, columns.start..end, system, no_color),
264                );
265            }
266        }
267        for placement in &plan.draw {
268            out.push_str(
269                Control::new(&[ControlType::CursorMoveToColumn(placement.column as u32)]).as_str(),
270            );
271            out.push_str(&placement.graphic.draw(placement.frame));
272        }
273        out.into_bytes()
274    }
275    fn rows(&self, width: usize, height: usize) -> Vec<Row> {
276        let console = self.target.console();
277        let mut result = Vec::new();
278        for region in &self.regions {
279            for row in fit_segments(&region.content, width, OverflowPolicy::Crop) {
280                if result.len() == height {
281                    return result;
282                }
283                result.push(self.row(&console, row));
284            }
285        }
286        result
287    }
288    /// The bytes that bring `old` up to `new` on the current line: only the
289    /// changed cells, each after a column move, or the whole row when that is
290    /// shorter or either row holds characters of [`uncertain`] width.
291    fn repaint(&self, new: &Row, old: &Row) -> (Vec<u8>, bool) {
292        let console = self.target.console();
293        let (system, no_color) = (console.color_system(), console.no_color());
294        let width = if new.frame.height() == 0 {
295            0
296        } else {
297            new.frame.row_width(0)
298        };
299        let mut cells = String::new();
300        if new.frame.height() > 0 && old.frame.height() > 0 && !new.uncertain && !old.uncertain {
301            for change in new.frame.diff(&old.frame) {
302                let column = change.columns.start.min(u32::MAX as usize) as u32;
303                cells.push_str(Control::new(&[ControlType::CursorMoveToColumn(column)]).as_str());
304                if change.columns.start < width {
305                    cells.push_str(&new.frame.encode_span(
306                        0,
307                        change.columns.start..change.columns.end.min(width),
308                        system,
309                        no_color,
310                    ));
311                }
312                if change.columns.end > width {
313                    cells.push_str(Control::new(&[ControlType::EraseInLine(0)]).as_str());
314                }
315            }
316        }
317        let mut whole = Control::new(&[ControlType::EraseInLine(2)])
318            .as_str()
319            .to_string();
320        whole.push_str(&new.text);
321        if new.text == old.text {
322            // Only the graphics differ.
323            (Vec::new(), false)
324        } else if !cells.is_empty() && cells.len() < whole.len() {
325            (cells.into_bytes(), false)
326        } else {
327            (whole.into_bytes(), true)
328        }
329    }
330    fn clear(&mut self) -> std::io::Result<()> {
331        let count = self
332            .painted
333            .len()
334            .min(self.height.saturating_sub(1))
335            .min(u32::MAX as usize);
336        if count == 0 {
337            self.painted.clear();
338            return Ok(());
339        }
340        self.control(&[
341            ControlType::CarriageReturn,
342            ControlType::CursorUp(count as u32),
343        ])?;
344        for _ in 0..count {
345            self.control(&[ControlType::EraseInLine(2)])?;
346            self.writer.write_all(b"\n\r")?;
347        }
348        self.control(&[ControlType::CursorUp(count as u32)])?;
349        self.painted.clear();
350        Ok(())
351    }
352    fn paint(&mut self) -> std::io::Result<()> {
353        if !self.dynamic() {
354            return Ok(());
355        }
356        let rows = self.rows(self.width - 1, (self.height - 1).min(u32::MAX as usize));
357        if rows == self.painted {
358            return Ok(());
359        }
360        if !self.hidden && !rows.is_empty() {
361            self.hidden = true;
362            self.control(&[ControlType::HideCursor])?;
363        }
364        if rows.len() == self.painted.len() {
365            for (i, row) in rows.iter().enumerate() {
366                if row == &self.painted[i] {
367                    continue;
368                }
369                let distance = (rows.len() - i) as u32;
370                let (bytes, whole) = self.repaint(row, &self.painted[i]);
371                let graphics = self.graphics(row, Some(&self.painted[i]), whole);
372                self.control(&[ControlType::CarriageReturn, ControlType::CursorUp(distance)])?;
373                self.writer.write_all(&bytes)?;
374                self.writer.write_all(&graphics)?;
375                self.control(&[
376                    ControlType::CarriageReturn,
377                    ControlType::CursorDown(distance),
378                ])?;
379            }
380        } else {
381            self.clear()?;
382            for row in &rows {
383                self.control(&[ControlType::CarriageReturn, ControlType::EraseInLine(2)])?;
384                self.writer.write_all(row.text.as_bytes())?;
385                let graphics = self.graphics(row, None, true);
386                self.writer.write_all(&graphics)?;
387                self.writer.write_all(b"\n\r")?;
388            }
389        }
390        if rows.is_empty() && self.hidden {
391            self.hidden = false;
392            self.control(&[ControlType::ShowCursor])?;
393        }
394        self.painted = rows;
395        self.writer.flush()
396    }
397    fn fail(&mut self, error: std::io::Error) -> LiveError {
398        let _ = self.finish();
399        LiveError::Io(error)
400    }
401    pub fn refresh(&mut self) -> Result<(), LiveError> {
402        self.check()?;
403        self.paint().map_err(|e| self.fail(e))
404    }
405    pub fn resize(&mut self, width: usize, height: usize) -> Result<(), LiveError> {
406        self.check()?;
407        self.width = width;
408        self.height = height;
409        let result = (|| {
410            if !self.dynamic() {
411                self.painted.clear();
412                if self.hidden {
413                    self.hidden = false;
414                    self.control(&[ControlType::ShowCursor])?;
415                }
416                return self.writer.flush();
417            }
418            self.clear()?;
419            self.paint()
420        })();
421        result.map_err(|e| self.fail(e))
422    }
423    pub fn print(&mut self, content: &[Segment]) -> Result<(), LiveError> {
424        self.check()?;
425        validate(content)?;
426        let result = (|| {
427            self.clear()?;
428            let interactive = self.target.capabilities().interactive;
429            // Tiny viewports suspend dynamic regions, but ordinary writes must
430            // still reach the terminal: fold to at least one column.
431            let width = if interactive {
432                self.width.saturating_sub(1)
433            } else {
434                self.width
435            }
436            .max(1);
437            let console = self.target.console();
438            for row in fit_segments(content, width, OverflowPolicy::Fold) {
439                let row = self.row(&console, row);
440                self.writer.write_all(row.text.as_bytes())?;
441                let graphics = self.graphics(&row, None, true);
442                self.writer.write_all(&graphics)?;
443                self.retained.extend(row.placements().iter().cloned());
444                self.writer
445                    .write_all(if interactive { b"\n\r" } else { b"\n" })?;
446            }
447            self.paint()
448        })();
449        result.map_err(|e| self.fail(e))
450    }
451    pub fn finish(&mut self) -> Result<(), LiveError> {
452        if self.closed {
453            return Ok(());
454        }
455        self.closed = true;
456        let mut error = None;
457        if !self.target.capabilities().interactive {
458            // A pipe or CI log scrolls: the final snapshot is written whole,
459            // not cut to the height a terminal would show.
460            for row in self.rows(self.width, usize::MAX) {
461                if let Err(e) = writeln!(self.writer, "{}", row.text) {
462                    error = Some(e);
463                    break;
464                }
465            }
466        } else if let Err(e) = self.clear() {
467            error = Some(e);
468        } else if let Some(source) = self.source() {
469            let release = source.release(&self.retained);
470            if let Err(e) = self.writer.write_all(release.as_bytes()) {
471                error = Some(e);
472            }
473        }
474        if self.hidden {
475            self.hidden = false;
476            if let Err(e) = self.control(&[ControlType::ShowCursor])
477                && error.is_none()
478            {
479                error = Some(e);
480            }
481        }
482        if let Err(e) = self.writer.flush()
483            && error.is_none()
484        {
485            error = Some(e);
486        }
487        error.map_or(Ok(()), |e| Err(LiveError::Io(e)))
488    }
489}
490impl<W: Write> Drop for LiveCoordinator<W> {
491    fn drop(&mut self) {
492        let _ = self.finish();
493    }
494}
495pub struct LiveHandle<'a, W: Write> {
496    live: &'a mut LiveCoordinator<W>,
497}
498impl<W: Write> LiveHandle<'_, W> {
499    pub fn print(&mut self, content: &[Segment]) -> Result<(), LiveError> {
500        self.live.print(content)
501    }
502    pub fn update(&mut self, id: RegionId, content: Vec<Segment>) -> Result<(), LiveError> {
503        self.live.update(id, content)
504    }
505    pub fn refresh(&mut self) -> Result<(), LiveError> {
506        self.live.refresh()
507    }
508}
509/// Content must not carry terminal controls: not as control segments, not in
510/// its text, and not in a link, which is written inside an OSC 8 sequence
511/// where a BEL or ESC ends it early and starts whatever follows.
512fn validate(content: &[Segment]) -> Result<(), LiveError> {
513    if content.iter().any(|s| {
514        s.control
515            || s.text
516                .chars()
517                .any(|c| c.is_control() && c != '\n' && c != '\t')
518            || s.style
519                .as_ref()
520                .and_then(|style| style.link())
521                .is_some_and(|link| link.chars().any(char::is_control))
522    }) {
523        Err(LiveError::UnsupportedControl)
524    } else {
525        Ok(())
526    }
527}