Skip to main content

rmux_core/
transcript.rs

1//! Bounded pane transcript history and shared capture-range helpers.
2
3use std::collections::VecDeque;
4use std::ops::RangeInclusive;
5
6/// tmux-compatible capture bounds over history plus visible rows.
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
8pub struct ScreenCaptureRange {
9    /// Optional start value relative to the history size.
10    pub start: Option<i64>,
11    /// Optional end value relative to the history size.
12    pub end: Option<i64>,
13    /// Whether `start` used the `-` sentinel for absolute history start.
14    pub start_is_absolute: bool,
15    /// Whether `end` used the `-` sentinel for absolute capture end.
16    pub end_is_absolute: bool,
17}
18
19impl ScreenCaptureRange {
20    /// Creates a range with tmux-style relative defaults.
21    #[must_use]
22    pub const fn new(start: Option<i64>, end: Option<i64>) -> Self {
23        Self {
24            start,
25            end,
26            start_is_absolute: false,
27            end_is_absolute: false,
28        }
29    }
30}
31
32/// Per-pane line transcript bounded by the effective `history-limit`.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Transcript {
35    lines: VecDeque<String>,
36    limit: usize,
37}
38
39impl Transcript {
40    /// Creates an empty transcript bounded to `limit` retained lines.
41    #[must_use]
42    pub fn new(limit: usize) -> Self {
43        Self {
44            lines: VecDeque::new(),
45            limit,
46        }
47    }
48
49    /// Returns the current retained line limit.
50    #[must_use]
51    pub const fn limit(&self) -> usize {
52        self.limit
53    }
54
55    /// Returns the number of retained history lines.
56    #[must_use]
57    pub fn line_count(&self) -> usize {
58        self.lines.len()
59    }
60
61    /// Updates the retained line limit and evicts older lines if needed.
62    pub fn set_limit(&mut self, limit: usize) {
63        self.limit = limit;
64        self.enforce_limit();
65    }
66
67    /// Appends one complete logical line to the transcript.
68    pub fn append_line(&mut self, line: impl Into<String>) {
69        if self.limit == 0 {
70            return;
71        }
72
73        self.lines.push_back(line.into());
74        self.enforce_limit();
75    }
76
77    /// Returns the retained lines ordered from oldest to newest.
78    #[must_use]
79    pub const fn lines(&self) -> &VecDeque<String> {
80        &self.lines
81    }
82
83    /// Captures an inclusive range of retained lines as newline-delimited bytes.
84    ///
85    /// Non-negative bounds are zero-based indices into retained history.
86    /// Negative bounds count backward from the newest line, where `-1` is the
87    /// newest retained line. Missing bounds mean the first or last retained line.
88    /// Out-of-range values clamp to the retained transcript. Reversed ranges are
89    /// swapped to match tmux's buffer capture behavior.
90    #[must_use]
91    pub fn capture(&self, start: Option<i64>, end: Option<i64>) -> Vec<u8> {
92        let Some(range) = resolve_relative_capture_range(start, end, self.lines.len()) else {
93            return Vec::new();
94        };
95
96        let mut output = Vec::new();
97        for (index, line) in self.lines.iter().enumerate() {
98            if index < *range.start() {
99                continue;
100            }
101            if index > *range.end() {
102                break;
103            }
104            output.extend_from_slice(line.as_bytes());
105            output.push(b'\n');
106        }
107        output
108    }
109
110    /// Returns the retained history size in bytes including trailing newlines.
111    #[must_use]
112    pub fn byte_size(&self) -> usize {
113        self.lines.iter().map(|line| line.len() + 1).sum()
114    }
115
116    fn enforce_limit(&mut self) {
117        while self.lines.len() > self.limit {
118            self.lines.pop_front();
119        }
120    }
121}
122
123impl Default for Transcript {
124    fn default() -> Self {
125        Self::new(2000)
126    }
127}
128
129/// Captures tmux-style screen lines as newline-delimited bytes.
130#[cfg_attr(not(test), allow(dead_code))]
131#[must_use]
132pub fn capture_screen_lines<'a>(
133    lines: impl IntoIterator<Item = &'a str>,
134    line_count: usize,
135    history_size: usize,
136    range: ScreenCaptureRange,
137) -> Vec<u8> {
138    let Some(range) = resolve_screen_capture_range(range, history_size, line_count) else {
139        return Vec::new();
140    };
141
142    let mut output = Vec::new();
143    for (index, line) in lines.into_iter().enumerate() {
144        if index < *range.start() {
145            continue;
146        }
147        if index > *range.end() {
148            break;
149        }
150        output.extend_from_slice(line.as_bytes());
151        output.push(b'\n');
152    }
153    output
154}
155
156/// Resolves a tmux-compatible screen capture range.
157#[must_use]
158pub fn resolve_screen_capture_range(
159    range: ScreenCaptureRange,
160    history_size: usize,
161    total_lines: usize,
162) -> Option<RangeInclusive<usize>> {
163    if total_lines == 0 {
164        return None;
165    }
166
167    let last_line = total_lines - 1;
168    let default_top = history_size.min(last_line);
169    let mut top = if range.start_is_absolute {
170        0
171    } else {
172        resolve_screen_bound(range.start, default_top, history_size, last_line)
173    };
174    let mut bottom = if range.end_is_absolute {
175        last_line
176    } else {
177        resolve_screen_bound(range.end, last_line, history_size, last_line)
178    };
179    if bottom < top {
180        std::mem::swap(&mut top, &mut bottom);
181    }
182    Some(top..=bottom)
183}
184
185fn resolve_screen_bound(
186    bound: Option<i64>,
187    default: usize,
188    history_size: usize,
189    last_line: usize,
190) -> usize {
191    let Some(bound) = bound else {
192        return default;
193    };
194    if bound >= 0 {
195        return history_size
196            .saturating_add(usize::try_from(bound).unwrap_or(usize::MAX))
197            .min(last_line);
198    }
199
200    let magnitude = usize::try_from(bound.unsigned_abs()).unwrap_or(usize::MAX);
201    if magnitude > history_size {
202        0
203    } else {
204        history_size.saturating_sub(magnitude).min(last_line)
205    }
206}
207
208fn resolve_relative_capture_range(
209    start: Option<i64>,
210    end: Option<i64>,
211    len: usize,
212) -> Option<RangeInclusive<usize>> {
213    if len == 0 {
214        return None;
215    }
216
217    let mut start = resolve_relative_bound(start, len, 0)?;
218    let mut end = resolve_relative_bound(end, len, len.saturating_sub(1))?;
219    if start > end {
220        std::mem::swap(&mut start, &mut end);
221    }
222    Some(start..=end)
223}
224
225fn resolve_relative_bound(bound: Option<i64>, len: usize, default: usize) -> Option<usize> {
226    let bound = match bound {
227        Some(bound) => bound,
228        None => return Some(default),
229    };
230
231    if bound >= 0 {
232        return usize::try_from(bound).ok().map(|index| index.min(len - 1));
233    }
234
235    let from_newest = bound.unsigned_abs();
236    let len = u64::try_from(len).ok()?;
237    if from_newest > len {
238        Some(0)
239    } else {
240        usize::try_from(len - from_newest).ok()
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::{
247        capture_screen_lines, resolve_screen_capture_range, ScreenCaptureRange, Transcript,
248    };
249
250    #[test]
251    fn empty_transcript_captures_empty_output() {
252        let transcript = Transcript::new(10);
253
254        assert!(transcript.capture(None, None).is_empty());
255    }
256
257    #[test]
258    fn single_line_capture_includes_trailing_newline() {
259        let mut transcript = Transcript::new(10);
260        transcript.append_line("only");
261
262        assert_eq!(transcript.capture(None, None), b"only\n");
263    }
264
265    #[test]
266    fn capture_clamps_out_of_range_boundaries() {
267        let transcript = transcript(["zero", "one", "two"]);
268
269        assert_eq!(transcript.capture(Some(-99), Some(99)), b"zero\none\ntwo\n");
270    }
271
272    #[test]
273    fn negative_indices_count_back_from_newest_line() {
274        let transcript = transcript(["zero", "one", "two", "three"]);
275
276        assert_eq!(transcript.capture(Some(-2), Some(-1)), b"two\nthree\n");
277        assert_eq!(transcript.capture(Some(-3), Some(-2)), b"one\ntwo\n");
278    }
279
280    #[test]
281    fn reversed_relative_ranges_are_swapped() {
282        let transcript = transcript(["zero", "one", "two"]);
283
284        assert_eq!(transcript.capture(Some(2), Some(1)), b"one\ntwo\n");
285        assert_eq!(transcript.capture(Some(-1), Some(-2)), b"one\ntwo\n");
286    }
287
288    #[test]
289    fn exact_history_limit_evicts_oldest_lines() {
290        let mut transcript = Transcript::new(3);
291        transcript.append_line("zero");
292        transcript.append_line("one");
293        transcript.append_line("two");
294        transcript.append_line("three");
295
296        assert_eq!(
297            transcript.lines().iter().collect::<Vec<_>>(),
298            vec!["one", "two", "three"]
299        );
300        assert_eq!(transcript.capture(None, None), b"one\ntwo\nthree\n");
301    }
302
303    #[test]
304    fn screen_range_defaults_to_visible_rows() {
305        let lines = ["h0", "h1", "v0", "v1"];
306        let range = ScreenCaptureRange::default();
307
308        assert_eq!(
309            capture_screen_lines(lines.iter().copied(), lines.len(), 2, range),
310            b"v0\nv1\n"
311        );
312    }
313
314    #[test]
315    fn screen_range_dash_captures_full_history_and_visible_rows() {
316        let lines = ["h0", "h1", "v0", "v1"];
317        let range = ScreenCaptureRange {
318            start_is_absolute: true,
319            end_is_absolute: true,
320            ..ScreenCaptureRange::default()
321        };
322
323        assert_eq!(
324            capture_screen_lines(lines.iter().copied(), lines.len(), 2, range),
325            b"h0\nh1\nv0\nv1\n"
326        );
327    }
328
329    #[test]
330    fn screen_range_negative_values_are_relative_to_history_size() {
331        let range = resolve_screen_capture_range(ScreenCaptureRange::new(Some(-1), Some(0)), 2, 4)
332            .expect("range exists");
333        assert_eq!(range, 1..=2);
334    }
335
336    #[test]
337    fn screen_range_swaps_reversed_bounds() {
338        let range = resolve_screen_capture_range(ScreenCaptureRange::new(Some(1), Some(-1)), 2, 4)
339            .expect("range exists");
340        assert_eq!(range, 1..=3);
341    }
342
343    fn transcript(lines: impl IntoIterator<Item = &'static str>) -> Transcript {
344        let mut transcript = Transcript::new(10);
345        for line in lines {
346            transcript.append_line(line);
347        }
348        transcript
349    }
350}