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    #[must_use]
57    pub fn new(level: LogLevel, text: impl Into<String>) -> Self {
58        Self { level, time: None, text: text.into() }
59    }
60
61    /// A timestamp drawn faint before the level, e.g. `"14:02:31.118"`.
62    #[must_use]
63    pub fn time(mut self, time: impl Into<String>) -> Self {
64        self.time = Some(time.into());
65        self
66    }
67
68    /// The level.
69    #[must_use]
70    pub fn level(&self) -> LogLevel {
71        self.level
72    }
73
74    /// The timestamp, if any.
75    #[must_use]
76    pub fn timestamp(&self) -> Option<&str> {
77        self.time.as_deref()
78    }
79
80    /// The message.
81    #[must_use]
82    pub fn text(&self) -> &str {
83        &self.text
84    }
85}
86
87/// A bounded store of log lines: when full, pushing drops the oldest line.
88///
89/// Cloning is cheap (lines live in shared chunks), so an application keeps one buffer in its
90/// state, pushes lines in `update` and hands it to a [`LogView`](super::LogView) every frame.
91/// Every line gets a number that never changes while it is in the buffer, which lets the view
92/// keep its scroll position and selection while old lines fall out.
93#[derive(Debug, Clone)]
94pub struct LogBuffer {
95    id: u64,
96    capacity: usize,
97    chunks: VecDeque<Arc<Vec<LogLine>>>,
98    /// Lines already dropped from the front of the first chunk.
99    skip: usize,
100    len: usize,
101    /// Lines removed from the buffer since it was created; the number of the first line.
102    dropped: u64,
103}
104
105impl LogBuffer {
106    /// An empty buffer keeping at most `capacity` lines (at least one).
107    #[must_use]
108    pub fn new(capacity: usize) -> Self {
109        Self {
110            id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
111            capacity: capacity.max(1),
112            chunks: VecDeque::new(),
113            skip: 0,
114            len: 0,
115            dropped: 0,
116        }
117    }
118
119    /// Adds a line at the end, dropping the oldest line when full.
120    pub fn push(&mut self, line: LogLine) {
121        match self.chunks.back_mut() {
122            Some(last) if last.len() < CHUNK => Arc::make_mut(last).push(line),
123            _ => {
124                let mut chunk = Vec::with_capacity(CHUNK);
125                chunk.push(line);
126                self.chunks.push_back(Arc::new(chunk));
127            }
128        }
129        self.len += 1;
130        while self.len > self.capacity {
131            self.skip += 1;
132            self.len -= 1;
133            self.dropped += 1;
134            if self.chunks.front().is_some_and(|first| self.skip >= first.len()) {
135                self.chunks.pop_front();
136                self.skip = 0;
137            }
138        }
139    }
140
141    /// Removes every line.
142    pub fn clear(&mut self) {
143        self.dropped += self.len as u64;
144        self.chunks.clear();
145        self.skip = 0;
146        self.len = 0;
147    }
148
149    /// How many lines the buffer holds.
150    #[must_use]
151    pub fn len(&self) -> usize {
152        self.len
153    }
154
155    /// Whether the buffer holds no lines.
156    #[must_use]
157    pub fn is_empty(&self) -> bool {
158        self.len == 0
159    }
160
161    /// The most lines the buffer keeps.
162    #[must_use]
163    pub fn capacity(&self) -> usize {
164        self.capacity
165    }
166
167    /// The line at `index`, counted from the oldest line kept.
168    #[must_use]
169    pub fn get(&self, index: usize) -> Option<&LogLine> {
170        if index >= self.len {
171            return None;
172        }
173        let first = self.chunks.front()?.len() - self.skip;
174        if index < first {
175            return self.chunks[0].get(self.skip + index);
176        }
177        let rest = index - first;
178        self.chunks.get(1 + rest / CHUNK)?.get(rest % CHUNK)
179    }
180
181    /// Every line, oldest first.
182    pub fn iter(&self) -> impl Iterator<Item = &LogLine> {
183        self.chunks.iter().enumerate().flat_map(|(i, chunk)| chunk[if i == 0 { self.skip } else { 0 }..].iter())
184    }
185
186    pub(crate) fn id(&self) -> u64 {
187        self.id
188    }
189
190    /// The permanent number of the oldest line kept.
191    pub(crate) fn first_number(&self) -> u64 {
192        self.dropped
193    }
194
195    /// The line with permanent number `number`, if it is still kept.
196    pub(crate) fn by_number(&self, number: u64) -> Option<&LogLine> {
197        let index = usize::try_from(number.checked_sub(self.dropped)?).ok()?;
198        self.get(index)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn line(n: usize) -> LogLine {
207        LogLine::new(LogLevel::Info, format!("line {n}"))
208    }
209
210    #[test]
211    fn drops_oldest_lines_when_full_and_keeps_numbers() {
212        let mut buffer = LogBuffer::new(600);
213        for n in 0..1000 {
214            buffer.push(line(n));
215        }
216        assert_eq!(buffer.len(), 600);
217        assert_eq!(buffer.get(0).map(LogLine::text), Some("line 400"));
218        assert_eq!(buffer.get(599).map(LogLine::text), Some("line 999"));
219        assert_eq!(buffer.get(600), None);
220        assert_eq!(buffer.first_number(), 400);
221        assert_eq!(buffer.by_number(512).map(LogLine::text), Some("line 512"));
222        assert_eq!(buffer.by_number(12), None);
223        assert_eq!(buffer.iter().count(), 600);
224        assert_eq!(buffer.iter().nth(300).map(LogLine::text), Some("line 700"));
225        buffer.clear();
226        assert!(buffer.is_empty());
227        assert_eq!(buffer.first_number(), 1000);
228    }
229
230    #[test]
231    fn clones_share_lines_until_one_changes() {
232        let mut buffer = LogBuffer::new(10_000);
233        for n in 0..CHUNK * 3 {
234            buffer.push(line(n));
235        }
236        let shared = buffer.clone();
237        buffer.push(line(9999));
238        assert_eq!(shared.len(), CHUNK * 3);
239        assert_eq!(buffer.len(), CHUNK * 3 + 1);
240        assert!(Arc::ptr_eq(&shared.chunks[0], &buffer.chunks[0]), "full chunks stay shared");
241        assert_eq!(shared.id(), buffer.id());
242    }
243}