Skip to main content

studio_worker/ui/
log_view.rs

1//! A log as the UI shows it: monospace, the level coloured, wrapping,
2//! selectable and copyable.  The job detail pane and the Logs page share it.
3//!
4//! [`compose`] turns lines into one text plus the role of each span (pure,
5//! tested); [`show`] lays that text out read-only with the spans coloured.
6
7use std::ops::Range;
8
9use chrono::{DateTime, TimeZone};
10use eframe::egui::{self, text::LayoutJob, CornerRadius, Frame, Margin, TextFormat};
11
12use crate::job_log::JobLogLine;
13use crate::types::LogEntry;
14
15use super::theme::{Palette, Tone, CONTROL_RADIUS};
16
17/// One line of a log, normalised from a job log or the worker log.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct LogLine {
20    /// `HH:MM:SS`, local time.
21    pub time: String,
22    pub level: String,
23    /// Where it came from: a category or a shortened tracing target.
24    pub source: String,
25    pub message: String,
26    pub job_id: Option<String>,
27}
28
29impl LogLine {
30    /// A job log line, its time in `tz`.
31    pub fn from_job_line<Tz: TimeZone>(line: &JobLogLine, tz: &Tz) -> Self
32    where
33        Tz::Offset: std::fmt::Display,
34    {
35        Self {
36            time: line.ts.with_timezone(tz).format("%H:%M:%S").to_string(),
37            level: line.level.clone(),
38            source: short_target(&line.target).to_string(),
39            message: line.message.clone(),
40            job_id: None,
41        }
42    }
43
44    /// A worker log entry, its time in `tz` when it parses.
45    pub fn from_entry<Tz: TimeZone>(entry: &LogEntry, tz: &Tz) -> Self
46    where
47        Tz::Offset: std::fmt::Display,
48    {
49        Self {
50            time: local_time(&entry.ts, tz),
51            level: entry.level.clone(),
52            source: entry.category.clone(),
53            message: entry.message.clone(),
54            job_id: entry.job_id.clone(),
55        }
56    }
57}
58
59/// A tracing target without the crate prefix: `engine::sdcpp`.
60pub fn short_target(target: &str) -> &str {
61    target.strip_prefix("studio_worker::").unwrap_or(target)
62}
63
64/// An RFC 3339 timestamp as local `HH:MM:SS`; anything else unchanged.
65pub fn local_time<Tz: TimeZone>(ts: &str, tz: &Tz) -> String
66where
67    Tz::Offset: std::fmt::Display,
68{
69    DateTime::parse_from_rfc3339(ts)
70        .map(|t| t.with_timezone(tz).format("%H:%M:%S").to_string())
71        .unwrap_or_else(|_| ts.to_string())
72}
73
74/// The colour a level is shown in.
75pub fn level_tone(level: &str) -> Tone {
76    match level.to_ascii_lowercase().as_str() {
77        "error" => Tone::Bad,
78        "warn" | "warning" => Tone::Busy,
79        "info" => Tone::Info,
80        _ => Tone::Neutral,
81    }
82}
83
84/// What a span of a composed log is.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Role {
87    Time,
88    Level(Tone),
89    Source,
90    Message,
91    Job,
92}
93
94/// A log as one text and the role of each of its spans.
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct Composed {
97    pub text: String,
98    pub spans: Vec<(Range<usize>, Role)>,
99}
100
101/// Lay `lines` out as `time  LEVEL  source  message  ยท job`, one per line.
102pub fn compose(lines: &[LogLine]) -> Composed {
103    let mut out = Composed::default();
104    for (i, line) in lines.iter().enumerate() {
105        if i > 0 {
106            out.text.push('\n');
107        }
108        push(&mut out, &line.time, Role::Time);
109        out.text.push_str("  ");
110        let level = format!("{:<5}", line.level.to_ascii_uppercase());
111        push(&mut out, &level, Role::Level(level_tone(&line.level)));
112        out.text.push_str("  ");
113        if !line.source.is_empty() {
114            push(&mut out, &line.source, Role::Source);
115            out.text.push_str("  ");
116        }
117        push(&mut out, &line.message, Role::Message);
118        if let Some(job) = &line.job_id {
119            push(&mut out, &format!("  \u{00b7} {job}"), Role::Job);
120        }
121    }
122    out
123}
124
125fn push(out: &mut Composed, text: &str, role: Role) {
126    let start = out.text.len();
127    out.text.push_str(text);
128    out.spans.push((start..out.text.len(), role));
129}
130
131/// The coloured layout of `composed` for `text` (the same string).
132fn layout_job(text: &str, composed: &Composed, p: &Palette, font: egui::FontId) -> LayoutJob {
133    let mut job = LayoutJob::default();
134    let format = |colour| TextFormat::simple(font.clone(), colour);
135    let mut at = 0;
136    for (range, role) in &composed.spans {
137        if range.end > text.len() {
138            break;
139        }
140        if range.start > at {
141            job.append(&text[at..range.start], 0.0, format(p.text));
142        }
143        let colour = match role {
144            Role::Time | Role::Source | Role::Job => p.muted,
145            Role::Level(tone) => p.tone(*tone),
146            Role::Message => p.text,
147        };
148        job.append(&text[range.clone()], 0.0, format(colour));
149        at = range.end;
150    }
151    if at < text.len() {
152        job.append(&text[at..], 0.0, format(p.text));
153    }
154    job
155}
156
157/// Show `composed` read-only: selectable, copyable, wrapping, in a sunken
158/// panel of `height` points (the panel keeps its height whatever the log
159/// holds).  `follow` keeps the newest line in view.
160pub fn show(
161    ui: &mut egui::Ui,
162    id_salt: impl std::hash::Hash,
163    composed: &Composed,
164    follow: bool,
165    height: f32,
166) {
167    let p = Palette::of_ui(ui);
168    let font = egui::TextStyle::Monospace.resolve(ui.style());
169    Frame::new()
170        .fill(p.inset)
171        .corner_radius(CornerRadius::same(CONTROL_RADIUS))
172        .inner_margin(Margin::same(10))
173        .show(ui, |ui| {
174            ui.set_width(ui.available_width());
175            egui::ScrollArea::vertical()
176                .id_salt(id_salt)
177                .stick_to_bottom(follow)
178                .auto_shrink([false, false])
179                .min_scrolled_height(height)
180                .max_height(height)
181                .show(ui, |ui| {
182                    let mut layouter =
183                        |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
184                            let mut job = layout_job(buf.as_str(), composed, p, font.clone());
185                            job.wrap.max_width = wrap_width;
186                            ui.ctx().fonts_mut(|f| f.layout_job(job))
187                        };
188                    let mut text = composed.text.as_str();
189                    ui.add(
190                        egui::TextEdit::multiline(&mut text)
191                            .font(egui::TextStyle::Monospace)
192                            .desired_width(f32::INFINITY)
193                            .desired_rows(1)
194                            .frame(egui::Frame::NONE)
195                            .layouter(&mut layouter),
196                    );
197                });
198        });
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use chrono::Utc;
205
206    fn line(level: &str, message: &str, job: Option<&str>) -> LogLine {
207        LogLine {
208            time: "03:04:05".into(),
209            level: level.into(),
210            source: "engine".into(),
211            message: message.into(),
212            job_id: job.map(str::to_string),
213        }
214    }
215
216    #[test]
217    fn a_line_reads_time_level_source_message_and_job() {
218        let composed = compose(&[line("warn", "slow download", Some("j-1"))]);
219        assert_eq!(
220            composed.text,
221            "03:04:05  WARN   engine  slow download  \u{00b7} j-1"
222        );
223        let roles: Vec<Role> = composed.spans.iter().map(|(_, r)| *r).collect();
224        assert_eq!(
225            roles,
226            [
227                Role::Time,
228                Role::Level(Tone::Busy),
229                Role::Source,
230                Role::Message,
231                Role::Job
232            ]
233        );
234        for (range, _) in &composed.spans {
235            assert!(composed.text.get(range.clone()).is_some(), "on char bounds");
236        }
237    }
238
239    #[test]
240    fn lines_are_joined_without_a_trailing_newline_and_an_empty_log_is_empty() {
241        let composed = compose(&[line("info", "a", None), line("error", "b", None)]);
242        assert_eq!(composed.text.lines().count(), 2);
243        assert!(!composed.text.ends_with('\n'));
244        assert_eq!(compose(&[]), Composed::default());
245    }
246
247    #[test]
248    fn levels_have_their_colours() {
249        assert_eq!(level_tone("error"), Tone::Bad);
250        assert_eq!(level_tone("WARN"), Tone::Busy);
251        assert_eq!(level_tone("info"), Tone::Info);
252        assert_eq!(level_tone("debug"), Tone::Neutral);
253    }
254
255    #[test]
256    fn job_lines_and_entries_normalise_their_time_and_source() {
257        let job_line = JobLogLine {
258            ts: "2026-01-02T03:04:05Z".parse().unwrap(),
259            level: "info".into(),
260            target: "studio_worker::engine::sdcpp".into(),
261            message: "loaded".into(),
262        };
263        let l = LogLine::from_job_line(&job_line, &Utc);
264        assert_eq!(
265            (l.time.as_str(), l.source.as_str()),
266            ("03:04:05", "engine::sdcpp")
267        );
268
269        let entry = LogEntry {
270            ts: "2026-01-02T03:04:05Z".into(),
271            level: "warn".into(),
272            category: "heartbeat".into(),
273            message: "late".into(),
274            job_id: Some("j-9".into()),
275        };
276        let l = LogLine::from_entry(&entry, &Utc);
277        assert_eq!(l.time, "03:04:05");
278        assert_eq!(l.job_id.as_deref(), Some("j-9"));
279        assert_eq!(local_time("not a time", &Utc), "not a time");
280        assert_eq!(short_target("other::x"), "other::x");
281    }
282
283    #[test]
284    fn an_empty_source_is_left_out() {
285        let mut l = line("info", "m", None);
286        l.source.clear();
287        assert_eq!(compose(&[l]).text, "03:04:05  INFO   m");
288    }
289
290    #[test]
291    fn the_layout_colours_every_span_and_survives_a_stale_buffer() {
292        let composed = compose(&[line("error", "boom", Some("j"))]);
293        let font = egui::FontId::monospace(13.0);
294        let job = layout_job(&composed.text, &composed, &Palette::DARK, font.clone());
295        assert_eq!(job.text, composed.text);
296        assert!(job
297            .sections
298            .iter()
299            .any(|s| s.format.color == Palette::DARK.bad));
300        // A buffer shorter than the spans still lays out what it has.
301        let job = layout_job("03:04", &composed, &Palette::DARK, font);
302        assert_eq!(job.text, "03:04");
303    }
304
305    #[test]
306    fn the_log_shows_without_panicking() {
307        let composed = compose(&[line("info", "hello", None)]);
308        egui::__run_test_ui(|ui| show(ui, "log", &composed, true, 120.0));
309    }
310}