Skip to main content

khora_editor/panels/
console.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Console — the engine talking.
16//!
17//! Rows are a fixed grid (time · level · source · message) so the eye can scan
18//! one column without reading the others. Warn and error rows are tinted, but
19//! the level is *also* an icon: colour alone would leave the severity invisible
20//! to a colour-blind reader.
21
22use 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
35/// Column geometry. The message column takes whatever is left and *wraps*;
36/// it never widens the row, because a console that scrolls sideways is a
37/// console you cannot read.
38const COL_TIME: f32 = 58.0;
39const COL_LEVEL: f32 = 16.0;
40const COL_SOURCE: f32 = 122.0;
41const GAP: f32 = 9.0;
42
43/// Which log levels the console is currently showing.
44///
45/// A plain value type, separate from the panel, so the toggle logic can be
46/// tested without a UI at all.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct LevelFilter {
49    /// Show `Info` entries.
50    pub info: bool,
51    /// Show `Warn` entries.
52    pub warn: bool,
53    /// Show `Error` entries.
54    pub error: bool,
55    /// Show `Debug` and `Trace` entries.
56    pub debug: bool,
57}
58
59impl Default for LevelFilter {
60    /// Errors, warnings and info on; debug off. Debug is noise until you go
61    /// looking for it.
62    fn default() -> Self {
63        Self {
64            info: true,
65            warn: true,
66            error: true,
67            debug: false,
68        }
69    }
70}
71
72impl LevelFilter {
73    /// Whether an entry of this level passes the filter.
74    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    /// Flips one level on or off.
84    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/// Counts per level, for the pill badges.
96#[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
119/// The console panel.
120pub struct ConsolePanel {
121    state: Arc<Mutex<EditorState>>,
122    theme: UiTheme,
123    filter: LevelFilter,
124    search: String,
125    /// How far the log is scrolled. Owned by the panel because the rows are
126    /// painted in absolute coordinates — see `khora_tool_ui::widgets::scroll`.
127    scroll: widgets::ScrollState,
128}
129
130impl ConsolePanel {
131    /// Creates a new console.
132    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
143/// The colour and glyph that stand for a level.
144fn 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        // ── Header: the dock tabs ──
175        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        // No title chip: the dock tab above already names this panel.
183
184        // ── Filters ──
185        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        // Search, right-aligned.
244        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        // ── Rows ──
266        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        // Scrolling, the whole of it: take the wheel, offset the cursor, clip.
311        // The rows below still paint in absolute coordinates — they just start
312        // higher up, and anything outside `body` is clipped away.
313        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            // Skip rows scrolled off either edge instead of painting them under
320            // the clip: a 2000-line buffer would otherwise cost 2000 paint
321            // calls a frame to show forty.
322            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            // Tint the row for the two levels that mean "look at me".
333            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            // The message is clipped, never wrapped into a taller row: rows
352            // must stay on the grid for the columns to mean anything.
353            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
369/// Clips a source path to fit its column, keeping the tail (the part that
370/// actually distinguishes `khora_control` from `khora_data`).
371fn 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    /// Debug and Trace share one pill, so toggling either must move both.
415    #[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}