1use 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#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct LogLine {
20 pub time: String,
22 pub level: String,
23 pub source: String,
25 pub message: String,
26 pub job_id: Option<String>,
27}
28
29impl LogLine {
30 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 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
59pub fn short_target(target: &str) -> &str {
61 target.strip_prefix("studio_worker::").unwrap_or(target)
62}
63
64pub 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
74pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Role {
87 Time,
88 Level(Tone),
89 Source,
90 Message,
91 Job,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct Composed {
97 pub text: String,
98 pub spans: Vec<(Range<usize>, Role)>,
99}
100
101pub 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
131fn 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
157pub 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 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}