1use std::sync::{Arc, Mutex};
23
24use khora_sdk::editor_ui::*;
25use khora_tool_ui::widgets::{
26 self, empty_state, filter_pill,
27 paint::{fill, icon, mono, text},
28 search_field, Pill, Tone,
29};
30
31const HEADER_H: f32 = 34.0;
32const FILTER_H: f32 = 30.0;
33const ROW_H: f32 = 17.0;
34
35const COL_TIME: f32 = 58.0;
39const COL_LEVEL: f32 = 16.0;
40const COL_SOURCE: f32 = 122.0;
41const GAP: f32 = 9.0;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct LevelFilter {
49 pub info: bool,
51 pub warn: bool,
53 pub error: bool,
55 pub debug: bool,
57}
58
59impl Default for LevelFilter {
60 fn default() -> Self {
63 Self {
64 info: true,
65 warn: true,
66 error: true,
67 debug: false,
68 }
69 }
70}
71
72impl LevelFilter {
73 pub fn admits(&self, level: LogLevel) -> bool {
75 match level {
76 LogLevel::Error => self.error,
77 LogLevel::Warn => self.warn,
78 LogLevel::Info => self.info,
79 LogLevel::Debug | LogLevel::Trace => self.debug,
80 }
81 }
82
83 pub fn toggle(&mut self, level: LogLevel) {
85 let slot = match level {
86 LogLevel::Error => &mut self.error,
87 LogLevel::Warn => &mut self.warn,
88 LogLevel::Info => &mut self.info,
89 LogLevel::Debug | LogLevel::Trace => &mut self.debug,
90 };
91 *slot = !*slot;
92 }
93}
94
95#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
97struct Counts {
98 info: usize,
99 warn: usize,
100 error: usize,
101 debug: usize,
102}
103
104impl Counts {
105 fn of(entries: &[LogEntry]) -> Self {
106 let mut c = Self::default();
107 for e in entries {
108 match e.level {
109 LogLevel::Error => c.error += 1,
110 LogLevel::Warn => c.warn += 1,
111 LogLevel::Info => c.info += 1,
112 LogLevel::Debug | LogLevel::Trace => c.debug += 1,
113 }
114 }
115 c
116 }
117}
118
119pub struct ConsolePanel {
121 state: Arc<Mutex<EditorState>>,
122 theme: UiTheme,
123 filter: LevelFilter,
124 search: String,
125 scroll: widgets::ScrollState,
128}
129
130impl ConsolePanel {
131 pub fn new(state: Arc<Mutex<EditorState>>, theme: UiTheme) -> Self {
133 Self {
134 state,
135 theme,
136 filter: LevelFilter::default(),
137 search: String::new(),
138 scroll: widgets::ScrollState::default(),
139 }
140 }
141}
142
143fn level_style(level: LogLevel, t: &UiTheme) -> ([f32; 4], Icon) {
145 match level {
146 LogLevel::Error => (t.error, Icon::Error),
147 LogLevel::Warn => (t.warning, Icon::Warn),
148 LogLevel::Info => (t.accent_b, Icon::Info),
149 LogLevel::Debug | LogLevel::Trace => (t.text_muted, Icon::Code),
150 }
151}
152
153impl EditorPanel for ConsolePanel {
154 fn id(&self) -> &str {
155 "khora.editor.console"
156 }
157
158 fn title(&self) -> &str {
159 "Console"
160 }
161
162 fn ui(&mut self, ui: &mut dyn UiBuilder) {
163 let t = self.theme.clone();
164 let r = ui.panel_rect();
165
166 let entries = self
167 .state
168 .lock()
169 .ok()
170 .map(|s| s.log_entries.clone())
171 .unwrap_or_default();
172 let counts = Counts::of(&entries);
173
174 ui.paint_rect_filled([r[0], r[1]], [r[2], HEADER_H], t.surface, 0.0);
176 ui.paint_line(
177 [r[0], r[1] + HEADER_H],
178 [widgets::right(r), r[1] + HEADER_H],
179 t.separator,
180 1.0,
181 );
182 let fy = r[1] + HEADER_H + 5.0;
186 let mut x = r[0] + 10.0;
187
188 let pills: [(&str, Tone, usize, LogLevel, &str); 4] = [
189 ("Info", Tone::Info, counts.info, LogLevel::Info, "c-f-info"),
190 (
191 "Warn",
192 Tone::Warning,
193 counts.warn,
194 LogLevel::Warn,
195 "c-f-warn",
196 ),
197 (
198 "Error",
199 Tone::Error,
200 counts.error,
201 LogLevel::Error,
202 "c-f-err",
203 ),
204 (
205 "Debug",
206 Tone::Neutral,
207 counts.debug,
208 LogLevel::Debug,
209 "c-f-dbg",
210 ),
211 ];
212
213 let mut toggled: Option<LogLevel> = None;
214 for (label, tone, count, level, salt) in pills {
215 let w = 28.0
216 + ui.measure_text(label, t.font_size_caption, FontFamilyHint::Proportional)[0]
217 + ui.measure_text(
218 &count.to_string(),
219 t.font_size_caption,
220 FontFamilyHint::Monospace,
221 )[0]
222 + 12.0;
223
224 if filter_pill(
225 ui,
226 &t,
227 [x, fy, w, 20.0],
228 salt,
229 Pill::new(label, tone)
230 .count(count)
231 .on(self.filter.admits(level)),
232 )
233 .clicked
234 {
235 toggled = Some(level);
236 }
237 x += w + 6.0;
238 }
239 if let Some(level) = toggled {
240 self.filter.toggle(level);
241 }
242
243 let sw = 160.0_f32.min(r[2] * 0.32);
245 let sx = widgets::right(r) - sw - 10.0;
246 if sx > x {
247 search_field(
248 ui,
249 &t,
250 [sx, fy, sw, 20.0],
251 "c-search",
252 &self.search,
253 "Filter…",
254 );
255 let sref = &mut self.search;
256 ui.region_at(
257 "console-search",
258 [sx + 22.0, fy + 2.0, sw - 30.0, 16.0],
259 &mut |ui| {
260 ui.text_edit_singleline(sref);
261 },
262 );
263 }
264
265 let body_y = r[1] + HEADER_H + FILTER_H + 2.0;
267 let body = [r[0], body_y, r[2], (widgets::bottom(r) - body_y).max(0.0)];
268
269 let needle = self.search.to_lowercase();
270 let visible: Vec<&LogEntry> = entries
271 .iter()
272 .rev()
273 .filter(|e| self.filter.admits(e.level))
274 .filter(|e| {
275 needle.is_empty()
276 || e.message.to_lowercase().contains(&needle)
277 || e.target.to_lowercase().contains(&needle)
278 })
279 .take(500)
280 .collect();
281
282 if visible.is_empty() {
283 let w = 260.0_f32.min(body[2] - 24.0);
284 let h = 110.0_f32.min(body[3] - 12.0);
285 if w > 0.0 && h > 0.0 {
286 empty_state(
287 ui,
288 &t,
289 [body[0] + (body[2] - w) * 0.5, body[1] + 8.0, w, h],
290 Icon::Terminal,
291 if entries.is_empty() {
292 "Console is clear"
293 } else {
294 "Nothing matches"
295 },
296 if entries.is_empty() {
297 "Engine logs will appear here."
298 } else {
299 "Try a different filter."
300 },
301 );
302 }
303 return;
304 }
305
306 let msg_x = body[0] + 12.0 + COL_TIME + GAP + COL_LEVEL + GAP + COL_SOURCE + GAP;
307 let msg_w = (widgets::right(body) - 12.0 - msg_x).max(40.0);
308 let size = t.font_size_caption;
309
310 let content_h = visible.len() as f32 * ROW_H + 4.0;
314 self.scroll.update(ui, body, content_h);
315 ui.push_clip_rect(body);
316
317 let mut y = body[1] + 2.0 - self.scroll.offset();
318 for (row_index, e) in visible.iter().enumerate() {
319 if y + ROW_H < body[1] {
323 y += ROW_H;
324 continue;
325 }
326 if y > widgets::bottom(body) {
327 break;
328 }
329 let row = [body[0], y, body[2], ROW_H];
330 let (color, glyph) = level_style(e.level, &t);
331
332 match e.level {
334 LogLevel::Error => fill(ui, row, widgets::tint(t.error, 0.09), 0.0),
335 LogLevel::Warn => fill(ui, row, widgets::tint(t.warning, 0.07), 0.0),
336 _ => {}
337 }
338
339 let ty = widgets::text_y(row, size);
340 let mut cx = body[0] + 12.0;
341
342 mono(ui, [cx, ty], &e.time, size, t.text_disabled);
343 cx += COL_TIME + GAP;
344
345 icon(ui, [cx, ty], glyph, size + 1.0, color);
346 cx += COL_LEVEL + GAP;
347
348 mono(ui, [cx, ty], &truncate(&e.target, 18), size, t.text_muted);
349 cx += COL_SOURCE + GAP;
350
351 ui.region_at(
354 &format!("console-msg-{row_index}"),
355 [cx, y, msg_w, ROW_H],
356 &mut |ui| {
357 text(ui, [cx, ty], &e.message, size + 1.0, t.text_dim);
358 },
359 );
360
361 y += ROW_H;
362 }
363
364 ui.pop_clip_rect();
365 widgets::scrollbar(ui, &t, body, content_h, &mut self.scroll, "console-scroll");
366 }
367}
368
369fn truncate(s: &str, max: usize) -> String {
372 if s.chars().count() <= max {
373 return s.to_owned();
374 }
375 let tail: String = s.chars().skip(s.chars().count() - (max - 1)).collect();
376 format!("…{tail}")
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 fn entry(level: LogLevel, target: &str, message: &str) -> LogEntry {
384 LogEntry {
385 level,
386 message: message.to_owned(),
387 target: target.to_owned(),
388 time: "12:00:00".to_owned(),
389 }
390 }
391
392 #[test]
393 fn default_filter_hides_debug_but_shows_the_rest() {
394 let f = LevelFilter::default();
395 assert!(f.admits(LogLevel::Error));
396 assert!(f.admits(LogLevel::Warn));
397 assert!(f.admits(LogLevel::Info));
398 assert!(!f.admits(LogLevel::Debug));
399 assert!(!f.admits(LogLevel::Trace));
400 }
401
402 #[test]
403 fn toggling_a_level_flips_only_that_level() {
404 let mut f = LevelFilter::default();
405 f.toggle(LogLevel::Warn);
406 assert!(!f.admits(LogLevel::Warn));
407 assert!(f.admits(LogLevel::Error), "error must be untouched");
408 assert!(f.admits(LogLevel::Info), "info must be untouched");
409
410 f.toggle(LogLevel::Warn);
411 assert!(f.admits(LogLevel::Warn), "toggling twice restores it");
412 }
413
414 #[test]
416 fn debug_and_trace_share_a_single_toggle() {
417 let mut f = LevelFilter::default();
418 f.toggle(LogLevel::Trace);
419 assert!(f.admits(LogLevel::Debug));
420 assert!(f.admits(LogLevel::Trace));
421 }
422
423 #[test]
424 fn counts_bucket_every_level_and_fold_trace_into_debug() {
425 let entries = vec![
426 entry(LogLevel::Info, "a", "1"),
427 entry(LogLevel::Info, "b", "2"),
428 entry(LogLevel::Warn, "c", "3"),
429 entry(LogLevel::Error, "d", "4"),
430 entry(LogLevel::Debug, "e", "5"),
431 entry(LogLevel::Trace, "f", "6"),
432 ];
433 let c = Counts::of(&entries);
434 assert_eq!(c.info, 2);
435 assert_eq!(c.warn, 1);
436 assert_eq!(c.error, 1);
437 assert_eq!(c.debug, 2, "trace counts toward the debug pill");
438 }
439
440 #[test]
441 fn counts_of_nothing_are_zero() {
442 assert_eq!(Counts::of(&[]), Counts::default());
443 }
444
445 #[test]
446 fn truncate_keeps_the_distinguishing_tail() {
447 assert_eq!(truncate("khora_control", 18), "khora_control");
448 let long = truncate("khora_infra::graphics::wgpu::device", 18);
449 assert_eq!(long.chars().count(), 18);
450 assert!(long.starts_with('…'));
451 assert!(
452 long.ends_with("device"),
453 "the tail is what tells them apart"
454 );
455 }
456}