Skip to main content

qframe/widgets/
log_buffer.rs

1//! The line store behind [`LogView`](super::LogView): a ring buffer that is cheap to share
2//! with the view every frame.
3
4use std::collections::VecDeque;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8/// Lines per shared chunk. Pushing copies at most one chunk that the last frame still shares.
9const CHUNK: usize = 256;
10
11/// Source of buffer identities, so views can keep per-buffer caches.
12static NEXT_ID: AtomicU64 = AtomicU64::new(1);
13
14/// How important a log line is.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub enum LogLevel {
17    /// Very detailed tracing.
18    Trace,
19    /// Diagnostic detail.
20    Debug,
21    /// Normal operation.
22    Info,
23    /// Something unexpected that did not fail.
24    Warn,
25    /// A failure.
26    Error,
27}
28
29impl LogLevel {
30    /// Every level, least important first.
31    pub const ALL: [Self; 5] = [Self::Trace, Self::Debug, Self::Info, Self::Warn, Self::Error];
32
33    /// A short lowercase name: `trace`, `debug`, `info`, `warn`, `error`.
34    #[must_use]
35    pub fn name(self) -> &'static str {
36        match self {
37            Self::Trace => "trace",
38            Self::Debug => "debug",
39            Self::Info => "info",
40            Self::Warn => "warn",
41            Self::Error => "error",
42        }
43    }
44}
45
46/// One line of a log.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct LogLine {
49    level: LogLevel,
50    time: Option<String>,
51    text: String,
52}
53
54impl LogLine {
55    /// A line with `level` and `text`.
56    ///
57    /// Text printed for a terminal is kept as the terminal would have shown it (see
58    /// [`printable`](crate::text::printable)): carriage returns, colour sequences and other
59    /// control characters never reach the screen, the search or a copy.
60    #[must_use]
61    pub fn new(level: LogLevel, text: impl Into<String>) -> Self {
62        let text = text.into();
63        let text = match crate::text::printable(&text) {
64            std::borrow::Cow::Borrowed(_) => text,
65            std::borrow::Cow::Owned(clean) => clean,
66        };
67        Self { level, time: None, text }
68    }
69
70    /// A timestamp drawn faint before the level, e.g. `"14:02:31.118"`.
71    #[must_use]
72    pub fn time(mut self, time: impl Into<String>) -> Self {
73        self.time = Some(time.into());
74        self
75    }
76
77    /// The level.
78    #[must_use]
79    pub fn level(&self) -> LogLevel {
80        self.level
81    }
82
83    /// The timestamp, if any.
84    #[must_use]
85    pub fn timestamp(&self) -> Option<&str> {
86        self.time.as_deref()
87    }
88
89    /// The message.
90    #[must_use]
91    pub fn text(&self) -> &str {
92        &self.text
93    }
94}
95
96/// A bounded store of log lines: when full, pushing drops the oldest line.
97///
98/// Cloning is cheap (lines live in shared chunks), so an application keeps one buffer in its
99/// state, pushes lines in `update` and hands it to a [`LogView`](super::LogView) every frame.
100/// Every line gets a number that never changes while it is in the buffer, which lets the view
101/// keep its scroll position and selection while old lines fall out.
102#[derive(Debug, Clone)]
103pub struct LogBuffer {
104    id: u64,
105    capacity: usize,
106    chunks: VecDeque<Arc<Vec<LogLine>>>,
107    /// Lines already dropped from the front of the first chunk.
108    skip: usize,
109    len: usize,
110    /// Lines removed from the buffer since it was created; the number of the first line.
111    dropped: u64,
112}
113
114impl LogBuffer {
115    /// An empty buffer keeping at most `capacity` lines (at least one).
116    #[must_use]
117    pub fn new(capacity: usize) -> Self {
118        Self {
119            id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
120            capacity: capacity.max(1),
121            chunks: VecDeque::new(),
122            skip: 0,
123            len: 0,
124            dropped: 0,
125        }
126    }
127
128    /// Adds a line at the end, dropping the oldest line when full.
129    pub fn push(&mut self, line: LogLine) {
130        match self.chunks.back_mut() {
131            Some(last) if last.len() < CHUNK => Arc::make_mut(last).push(line),
132            _ => {
133                let mut chunk = Vec::with_capacity(CHUNK);
134                chunk.push(line);
135                self.chunks.push_back(Arc::new(chunk));
136            }
137        }
138        self.len += 1;
139        while self.len > self.capacity {
140            self.skip += 1;
141            self.len -= 1;
142            self.dropped += 1;
143            if self.chunks.front().is_some_and(|first| self.skip >= first.len()) {
144                self.chunks.pop_front();
145                self.skip = 0;
146            }
147        }
148    }
149
150    /// Removes every line.
151    pub fn clear(&mut self) {
152        self.dropped += self.len as u64;
153        self.chunks.clear();
154        self.skip = 0;
155        self.len = 0;
156    }
157
158    /// How many lines the buffer holds.
159    #[must_use]
160    pub fn len(&self) -> usize {
161        self.len
162    }
163
164    /// Whether the buffer holds no lines.
165    #[must_use]
166    pub fn is_empty(&self) -> bool {
167        self.len == 0
168    }
169
170    /// The most lines the buffer keeps.
171    #[must_use]
172    pub fn capacity(&self) -> usize {
173        self.capacity
174    }
175
176    /// The line at `index`, counted from the oldest line kept.
177    #[must_use]
178    pub fn get(&self, index: usize) -> Option<&LogLine> {
179        if index >= self.len {
180            return None;
181        }
182        let first = self.chunks.front()?.len() - self.skip;
183        if index < first {
184            return self.chunks[0].get(self.skip + index);
185        }
186        let rest = index - first;
187        self.chunks.get(1 + rest / CHUNK)?.get(rest % CHUNK)
188    }
189
190    /// Every line, oldest first.
191    pub fn iter(&self) -> impl Iterator<Item = &LogLine> {
192        self.chunks.iter().enumerate().flat_map(|(i, chunk)| chunk[if i == 0 { self.skip } else { 0 }..].iter())
193    }
194
195    pub(crate) fn id(&self) -> u64 {
196        self.id
197    }
198
199    /// The permanent number of the oldest line kept.
200    pub(crate) fn first_number(&self) -> u64 {
201        self.dropped
202    }
203
204    /// The line with permanent number `number`, if it is still kept.
205    pub(crate) fn by_number(&self, number: u64) -> Option<&LogLine> {
206        let index = usize::try_from(number.checked_sub(self.dropped)?).ok()?;
207        self.get(index)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn line(n: usize) -> LogLine {
216        LogLine::new(LogLevel::Info, format!("line {n}"))
217    }
218
219    #[test]
220    fn drops_oldest_lines_when_full_and_keeps_numbers() {
221        let mut buffer = LogBuffer::new(600);
222        for n in 0..1000 {
223            buffer.push(line(n));
224        }
225        assert_eq!(buffer.len(), 600);
226        assert_eq!(buffer.get(0).map(LogLine::text), Some("line 400"));
227        assert_eq!(buffer.get(599).map(LogLine::text), Some("line 999"));
228        assert_eq!(buffer.get(600), None);
229        assert_eq!(buffer.first_number(), 400);
230        assert_eq!(buffer.by_number(512).map(LogLine::text), Some("line 512"));
231        assert_eq!(buffer.by_number(12), None);
232        assert_eq!(buffer.iter().count(), 600);
233        assert_eq!(buffer.iter().nth(300).map(LogLine::text), Some("line 700"));
234        buffer.clear();
235        assert!(buffer.is_empty());
236        assert_eq!(buffer.first_number(), 1000);
237    }
238
239    #[test]
240    fn clones_share_lines_until_one_changes() {
241        let mut buffer = LogBuffer::new(10_000);
242        for n in 0..CHUNK * 3 {
243            buffer.push(line(n));
244        }
245        let shared = buffer.clone();
246        buffer.push(line(9999));
247        assert_eq!(shared.len(), CHUNK * 3);
248        assert_eq!(buffer.len(), CHUNK * 3 + 1);
249        assert!(Arc::ptr_eq(&shared.chunks[0], &buffer.chunks[0]), "full chunks stay shared");
250        assert_eq!(shared.id(), buffer.id());
251    }
252}