Skip to main content

artifactize_core/agent/session/
document.rs

1//! Pure session layout and scrolling, shared by terminal and future graphical viewers.
2//! Rows are indexed by logical UTF-8 offsets, not terminal-sized global scroll coordinates.
3use std::ops::Range;
4use unicode_width::UnicodeWidthStr;
5
6/// Terminal cells of one grapheme, as ratatui's renderer counts them: `unicode-width`, plus
7/// the halfwidth dakuten marks (U+FF9E, U+FF9F), which terminals draw in a cell of their own.
8pub(crate) fn cell_width(grapheme: &str) -> u16 {
9    if grapheme.len() == 1 {
10        return 1;
11    }
12    let marks = grapheme
13        .chars()
14        .filter(|c| matches!(c, '\u{FF9E}' | '\u{FF9F}'))
15        .count() as u16;
16    (grapheme.width() as u16).saturating_add(marks)
17}
18use unicode_segmentation::UnicodeSegmentation;
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub struct Anchor {
22    pub event: usize,
23    pub byte: usize,
24}
25
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
27pub enum Position {
28    #[default]
29    Bottom,
30    Row(usize),
31    /// Movement is relative to the displayed logical anchor and its wrapping width.
32    Relative {
33        anchor: Anchor,
34        rows: isize,
35        width: usize,
36    },
37    Anchor(Anchor),
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Move {
42    Up(usize),
43    Down(usize),
44    Top,
45    Bottom,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Mode {
50    Following,
51    Paused,
52}
53
54/// Input sources all use the same clamping and follow transition rules.
55#[derive(Debug, Clone)]
56pub struct Scroll {
57    pub top: usize,
58    pub total: usize,
59    pub height: usize,
60    pub mode: Mode,
61}
62impl Default for Scroll {
63    fn default() -> Self {
64        Self {
65            top: 0,
66            total: 0,
67            height: 0,
68            mode: Mode::Following,
69        }
70    }
71}
72impl Scroll {
73    pub fn bottom(&self) -> usize {
74        self.total.saturating_sub(self.height)
75    }
76    pub fn at_bottom(&self) -> bool {
77        self.top == self.bottom()
78    }
79    pub fn update(&mut self, total: usize, height: usize, anchor_row: Option<usize>) {
80        self.total = total;
81        self.height = height;
82        self.top = match self.mode {
83            Mode::Following => self.bottom(),
84            Mode::Paused => anchor_row.unwrap_or(self.top).min(self.bottom()),
85        };
86        // Resizing/short content reaching the bottom is also a return to bottom.
87        if self.at_bottom() {
88            self.mode = Mode::Following;
89        }
90    }
91    pub fn apply(&mut self, movement: Move) -> Position {
92        self.top = match movement {
93            Move::Up(rows) => self.top.saturating_sub(rows),
94            Move::Down(rows) => self.top.saturating_add(rows).min(self.bottom()),
95            Move::Top => 0,
96            Move::Bottom => self.bottom(),
97        };
98        self.mode = if self.at_bottom() {
99            Mode::Following
100        } else {
101            Mode::Paused
102        };
103        match self.mode {
104            Mode::Following => Position::Bottom,
105            Mode::Paused => Position::Row(self.top),
106        }
107    }
108}
109
110/// Character wrapping is explicit: these exact rows render without a second wrapping pass.
111/// Grapheme boundaries keep CJK, combining characters and emoji intact. Control characters
112/// are omitted just as ratatui's text renderer omits them; an over-wide grapheme is skipped.
113pub fn rows(text: &str, width: usize) -> Vec<Range<usize>> {
114    if text.is_empty() || width == 0 {
115        return Vec::new();
116    }
117    let mut rows = Vec::new();
118    let mut start = 0;
119    let mut columns = 0;
120    for (byte, grapheme) in text.grapheme_indices(true) {
121        if grapheme.contains('\n') {
122            rows.push(start..byte);
123            start = byte + grapheme.len();
124            columns = 0;
125        } else if !grapheme.contains(char::is_control) {
126            let size = usize::from(cell_width(grapheme));
127            if size <= width {
128                if columns + size > width {
129                    rows.push(start..byte);
130                    start = byte;
131                    columns = 0;
132                }
133                columns += size;
134            }
135        }
136    }
137    if start < text.len() {
138        rows.push(start..text.len());
139    }
140    rows
141}
142
143pub fn row(text: &str, range: &Range<usize>, width: usize) -> String {
144    text[range.clone()]
145        .graphemes(true)
146        .filter(|grapheme| {
147            !grapheme.contains(char::is_control) && usize::from(cell_width(grapheme)) <= width
148        })
149        .collect()
150}