Skip to main content

proof_engine/debug/
console.rs

1//! In-engine debug console with command registration, tab completion, and history.
2//!
3//! The console renders as an overlay on top of the scene using glyph rendering.
4//! It supports:
5//! - Command registration with argument schemas and help text
6//! - Tab completion for commands and arguments
7//! - Command history (up/down arrow navigation)
8//! - Built-in commands: help, list, set, get, clear, echo, quit, reload, time, fps
9//! - Structured output log with severity levels and scroll
10//! - Lua-style expression evaluation stubs for future scripting integration
11//!
12//! ## Quick Start
13//! ```rust,no_run
14//! use proof_engine::debug::console::Console;
15//! let mut console = Console::new();
16//! console.register_command(proof_engine::debug::console::Command {
17//!     name:     "say".to_owned(),
18//!     help:     "Print a message".to_owned(),
19//!     args:     vec![proof_engine::debug::console::ArgSpec::required("text")],
20//!     handler:  Box::new(|args, out| {
21//!         out.push(proof_engine::debug::console::LogLine::info(args.join(" ")));
22//!     }),
23//!     aliases:  Vec::new(),
24//!     hidden:   false,
25//! });
26//! for c in "say hello world".chars() {
27//!     console.type_char(c);
28//! }
29//! console.submit();
30//! ```
31
32use std::collections::VecDeque;
33use glam::{Vec3, Vec4};
34
35// ── LogLine ───────────────────────────────────────────────────────────────────
36
37#[derive(Debug, Clone, PartialEq)]
38pub enum LogLevel { Info, Warn, Error, Success, Debug, Command }
39
40#[derive(Debug, Clone)]
41pub struct LogLine {
42    pub level:   LogLevel,
43    pub text:    String,
44    pub color:   Vec4,
45}
46
47impl LogLine {
48    pub fn new(level: LogLevel, text: impl Into<String>) -> Self {
49        let color = match level {
50            LogLevel::Info    => Vec4::new(0.9, 0.9, 0.9, 1.0),
51            LogLevel::Warn    => Vec4::new(1.0, 0.85, 0.2, 1.0),
52            LogLevel::Error   => Vec4::new(1.0, 0.3, 0.3, 1.0),
53            LogLevel::Success => Vec4::new(0.3, 1.0, 0.5, 1.0),
54            LogLevel::Debug   => Vec4::new(0.6, 0.6, 1.0, 1.0),
55            LogLevel::Command => Vec4::new(0.5, 0.8, 1.0, 1.0),
56        };
57        Self { level, text: text.into(), color }
58    }
59
60    pub fn info(text: impl Into<String>)    -> Self { Self::new(LogLevel::Info,    text) }
61    pub fn warn(text: impl Into<String>)    -> Self { Self::new(LogLevel::Warn,    text) }
62    pub fn error(text: impl Into<String>)   -> Self { Self::new(LogLevel::Error,   text) }
63    pub fn success(text: impl Into<String>) -> Self { Self::new(LogLevel::Success, text) }
64    pub fn debug(text: impl Into<String>)   -> Self { Self::new(LogLevel::Debug,   text) }
65    pub fn command(text: impl Into<String>) -> Self { Self::new(LogLevel::Command, text) }
66}
67
68// ── ArgSpec ───────────────────────────────────────────────────────────────────
69
70#[derive(Debug, Clone)]
71pub struct ArgSpec {
72    pub name:        String,
73    pub required:    bool,
74    pub default:     Option<String>,
75    pub completions: Vec<String>,
76    pub description: String,
77}
78
79impl ArgSpec {
80    pub fn required(name: impl Into<String>) -> Self {
81        Self {
82            name: name.into(),
83            required: true,
84            default: None,
85            completions: Vec::new(),
86            description: String::new(),
87        }
88    }
89
90    pub fn optional(name: impl Into<String>, default: impl Into<String>) -> Self {
91        Self {
92            name: name.into(),
93            required: false,
94            default: Some(default.into()),
95            completions: Vec::new(),
96            description: String::new(),
97        }
98    }
99
100    pub fn with_completions(mut self, opts: Vec<String>) -> Self {
101        self.completions = opts;
102        self
103    }
104
105    pub fn with_description(mut self, d: impl Into<String>) -> Self {
106        self.description = d.into();
107        self
108    }
109}
110
111// ── Command ───────────────────────────────────────────────────────────────────
112
113pub type HandlerFn = Box<dyn Fn(&[&str], &mut Vec<LogLine>) + Send + Sync>;
114
115pub struct Command {
116    pub name:    String,
117    pub help:    String,
118    pub args:    Vec<ArgSpec>,
119    pub handler: HandlerFn,
120    pub aliases: Vec<String>,
121    pub hidden:  bool,
122}
123
124impl Command {
125    pub fn new(
126        name:    impl Into<String>,
127        help:    impl Into<String>,
128        handler: HandlerFn,
129    ) -> Self {
130        Self {
131            name: name.into(),
132            help: help.into(),
133            args: Vec::new(),
134            handler,
135            aliases: Vec::new(),
136            hidden: false,
137        }
138    }
139
140    pub fn with_args(mut self, args: Vec<ArgSpec>) -> Self { self.args = args; self }
141    pub fn with_alias(mut self, alias: impl Into<String>) -> Self { self.aliases.push(alias.into()); self }
142    pub fn hidden(mut self) -> Self { self.hidden = true; self }
143
144    pub fn usage(&self) -> String {
145        let mut s = self.name.clone();
146        for arg in &self.args {
147            if arg.required {
148                s.push_str(&format!(" <{}>", arg.name));
149            } else {
150                let def = arg.default.as_deref().unwrap_or("...");
151                s.push_str(&format!(" [{}={}]", arg.name, def));
152            }
153        }
154        s
155    }
156}
157
158// ── CompletionResult ──────────────────────────────────────────────────────────
159
160#[derive(Debug, Clone)]
161pub struct CompletionResult {
162    pub completions:   Vec<String>,
163    pub common_prefix: String,
164}
165
166impl CompletionResult {
167    pub fn empty() -> Self { Self { completions: Vec::new(), common_prefix: String::new() } }
168
169    fn from_list(prefix: &str, candidates: &[impl AsRef<str>]) -> Self {
170        let matching: Vec<String> = candidates.iter()
171            .filter(|c| c.as_ref().starts_with(prefix))
172            .map(|c| c.as_ref().to_owned())
173            .collect();
174        if matching.is_empty() {
175            return Self::empty();
176        }
177        let common = common_prefix(&matching);
178        Self { completions: matching, common_prefix: common }
179    }
180}
181
182fn common_prefix(strings: &[String]) -> String {
183    if strings.is_empty() { return String::new(); }
184    let first = &strings[0];
185    let mut len = first.len();
186    for s in &strings[1..] {
187        len = len.min(s.len());
188        len = first.chars().zip(s.chars()).take(len).take_while(|(a, b)| a == b).count();
189    }
190    first[..len].to_owned()
191}
192
193// ── Console ───────────────────────────────────────────────────────────────────
194
195/// Maximum lines kept in the output log.
196const MAX_LOG_LINES:     usize = 1024;
197/// Maximum history entries.
198const MAX_HISTORY:       usize = 200;
199/// Number of visible log lines in the console window.
200const VISIBLE_LOG_LINES: usize = 20;
201/// Maximum input line length.
202const MAX_INPUT_LEN:     usize = 512;
203
204pub struct Console {
205    /// Whether the console window is visible.
206    pub visible:          bool,
207    /// Current input buffer.
208    pub input:            String,
209    /// Cursor position in the input buffer (byte offset).
210    pub cursor:           usize,
211    /// Output log (newest last).
212    pub log:              VecDeque<LogLine>,
213    /// Scroll offset: 0 = bottom (newest), N = scroll N lines up.
214    pub scroll_offset:    usize,
215    /// Command history, oldest first.
216    history:              VecDeque<String>,
217    /// Current history navigation index. None = not navigating.
218    history_index:        Option<usize>,
219    /// Saved input line while browsing history.
220    history_saved:        String,
221    /// Registered commands.
222    commands:             Vec<Command>,
223    /// Pending tab completion results.
224    pending_completion:   Option<CompletionResult>,
225    /// Ambient variable store (set/get).
226    vars:                 std::collections::HashMap<String, String>,
227}
228
229impl Console {
230    pub fn new() -> Self {
231        let mut console = Self {
232            visible:           false,
233            input:             String::new(),
234            cursor:            0,
235            log:               VecDeque::new(),
236            scroll_offset:     0,
237            history:           VecDeque::new(),
238            history_index:     None,
239            history_saved:     String::new(),
240            commands:          Vec::new(),
241            pending_completion: None,
242            vars:              std::collections::HashMap::new(),
243        };
244        console.register_builtins();
245        console
246    }
247
248    // ── Registration ─────────────────────────────────────────────────────────
249
250    pub fn register_command(&mut self, cmd: Command) {
251        self.commands.push(cmd);
252    }
253
254    fn register_builtins(&mut self) {
255        self.commands.push(Command::new("help", "Show help for a command or list all commands",
256            Box::new(|args, out| {
257                if args.is_empty() {
258                    out.push(LogLine::info("Type 'list' to see all commands. 'help <command>' for details."));
259                } else {
260                    out.push(LogLine::info(format!("Help for '{}': (use 'list' to see all)", args[0])));
261                }
262            })
263        ).with_args(vec![ArgSpec::optional("command", "")]));
264
265        self.commands.push(Command::new("list", "List all registered commands",
266            Box::new(|_args, out| {
267                out.push(LogLine::info("Available commands: help, list, set, get, clear, echo, history, reload, version"));
268            })
269        ));
270
271        self.commands.push(Command::new("clear", "Clear the console output log",
272            Box::new(|_args, out| {
273                out.push(LogLine::new(LogLevel::Debug, "CLEAR"));
274            })
275        ));
276
277        self.commands.push(Command::new("echo", "Print arguments to the console",
278            Box::new(|args, out| {
279                out.push(LogLine::info(args.join(" ")));
280            })
281        ).with_args(vec![ArgSpec::required("text")]));
282
283        self.commands.push(Command::new("set", "Set a console variable: set <name> <value>",
284            Box::new(|args, out| {
285                if args.len() < 2 {
286                    out.push(LogLine::warn("Usage: set <name> <value>"));
287                } else {
288                    out.push(LogLine::success(format!("SET {} = {}", args[0], args[1..]
289                        .join(" "))));
290                }
291            })
292        ).with_args(vec![ArgSpec::required("name"), ArgSpec::required("value")]));
293
294        self.commands.push(Command::new("get", "Get a console variable: get <name>",
295            Box::new(|args, out| {
296                if args.is_empty() {
297                    out.push(LogLine::warn("Usage: get <name>"));
298                } else {
299                    out.push(LogLine::info(format!("GET {}", args[0])));
300                }
301            })
302        ).with_args(vec![ArgSpec::required("name")]));
303
304        self.commands.push(Command::new("history", "Show command history",
305            Box::new(|_args, out| {
306                out.push(LogLine::info("--- command history ---"));
307            })
308        ));
309
310        self.commands.push(Command::new("version", "Show engine version",
311            Box::new(|_args, out| {
312                out.push(LogLine::info("Proof Engine -- mathematical rendering engine for Rust"));
313            })
314        ));
315
316        self.commands.push(Command::new("reload", "Reload engine config from disk",
317            Box::new(|_args, out| {
318                out.push(LogLine::info("Reloading config..."));
319            })
320        ));
321
322        self.commands.push(Command::new("quit", "Quit the engine",
323            Box::new(|_args, out| {
324                out.push(LogLine::warn("Quit requested."));
325            })
326        ).with_alias("exit").with_alias("q"));
327    }
328
329    // ── Input handling ────────────────────────────────────────────────────────
330
331    /// Insert a character at the cursor position.
332    pub fn type_char(&mut self, c: char) {
333        if self.input.len() >= MAX_INPUT_LEN { return; }
334        self.input.insert(self.cursor, c);
335        self.cursor += c.len_utf8();
336        self.pending_completion = None;
337        self.history_index = None;
338    }
339
340    /// Delete the character before the cursor (Backspace).
341    pub fn backspace(&mut self) {
342        if self.cursor == 0 { return; }
343        // Step back one char boundary
344        let before = &self.input[..self.cursor];
345        if let Some(c) = before.chars().next_back() {
346            let len = c.len_utf8();
347            self.input.remove(self.cursor - len);
348            self.cursor -= len;
349        }
350        self.pending_completion = None;
351    }
352
353    /// Delete the character at the cursor (Delete key).
354    pub fn delete_forward(&mut self) {
355        if self.cursor >= self.input.len() { return; }
356        self.input.remove(self.cursor);
357        self.pending_completion = None;
358    }
359
360    /// Move cursor left by one character.
361    pub fn cursor_left(&mut self) {
362        if self.cursor == 0 { return; }
363        let before = &self.input[..self.cursor];
364        if let Some(c) = before.chars().next_back() {
365            self.cursor -= c.len_utf8();
366        }
367    }
368
369    /// Move cursor right by one character.
370    pub fn cursor_right(&mut self) {
371        if self.cursor >= self.input.len() { return; }
372        let c = self.input[self.cursor..].chars().next().unwrap();
373        self.cursor += c.len_utf8();
374    }
375
376    /// Move cursor to start of input.
377    pub fn cursor_home(&mut self) { self.cursor = 0; }
378
379    /// Move cursor to end of input.
380    pub fn cursor_end(&mut self) { self.cursor = self.input.len(); }
381
382    /// Clear the entire input line.
383    pub fn clear_input(&mut self) {
384        self.input.clear();
385        self.cursor = 0;
386        self.history_index = None;
387    }
388
389    // ── History navigation ────────────────────────────────────────────────────
390
391    /// Navigate to the previous command in history (up arrow).
392    pub fn history_prev(&mut self) {
393        if self.history.is_empty() { return; }
394        match self.history_index {
395            None => {
396                self.history_saved = self.input.clone();
397                self.history_index = Some(self.history.len() - 1);
398            }
399            Some(0) => return,
400            Some(ref mut i) => *i -= 1,
401        }
402        if let Some(idx) = self.history_index {
403            self.input  = self.history[idx].clone();
404            self.cursor = self.input.len();
405        }
406    }
407
408    /// Navigate to the next command in history (down arrow).
409    pub fn history_next(&mut self) {
410        match self.history_index {
411            None => return,
412            Some(i) if i + 1 >= self.history.len() => {
413                self.history_index = None;
414                self.input  = self.history_saved.clone();
415                self.cursor = self.input.len();
416            }
417            Some(ref mut i) => {
418                *i += 1;
419                let idx = *i;
420                self.input  = self.history[idx].clone();
421                self.cursor = self.input.len();
422            }
423        }
424    }
425
426    // ── Tab completion ────────────────────────────────────────────────────────
427
428    /// Attempt tab completion on the current input.
429    pub fn tab_complete(&mut self) {
430        let input = self.input.trim_start().to_owned();
431        if input.is_empty() {
432            // Show all commands
433            let names: Vec<String> = self.commands.iter()
434                .filter(|c| !c.hidden)
435                .map(|c| c.name.clone())
436                .collect();
437            for name in &names { self.log.push_back(LogLine::debug(name.clone())); }
438            self.trim_log();
439            return;
440        }
441
442        let parts: Vec<&str> = input.splitn(2, ' ').collect();
443        let command_word = parts[0];
444
445        if parts.len() == 1 {
446            // Complete command name
447            let all_names: Vec<String> = self.commands.iter()
448                .flat_map(|c| std::iter::once(c.name.clone()).chain(c.aliases.iter().cloned()))
449                .filter(|n| !n.is_empty())
450                .collect();
451            let result = CompletionResult::from_list(command_word, &all_names);
452            if result.completions.len() == 1 {
453                self.input  = result.completions[0].clone() + " ";
454                self.cursor = self.input.len();
455            } else if result.completions.len() > 1 {
456                if result.common_prefix.len() > command_word.len() {
457                    self.input  = result.common_prefix.clone();
458                    self.cursor = self.input.len();
459                }
460                for c in &result.completions {
461                    self.log.push_back(LogLine::debug(c.clone()));
462                }
463                self.trim_log();
464            }
465            self.pending_completion = Some(result);
466        } else {
467            // Complete argument
468            let partial_arg = parts[1];
469            if let Some(cmd) = self.commands.iter().find(|c| c.name == command_word || c.aliases.contains(&command_word.to_owned())) {
470                let completions: Vec<String> = cmd.args.iter()
471                    .flat_map(|a| a.completions.iter().cloned())
472                    .collect();
473                let result = CompletionResult::from_list(partial_arg, &completions);
474                if result.completions.len() == 1 {
475                    self.input  = format!("{} {}", command_word, result.completions[0]);
476                    self.cursor = self.input.len();
477                } else if result.completions.len() > 1 {
478                    for c in &result.completions {
479                        self.log.push_back(LogLine::debug(c.clone()));
480                    }
481                    self.trim_log();
482                }
483                self.pending_completion = Some(result);
484            }
485        }
486    }
487
488    // ── Submission ────────────────────────────────────────────────────────────
489
490    /// Submit the current input line. Returns any special engine actions.
491    pub fn submit(&mut self) -> ConsoleAction {
492        let line = self.input.trim().to_owned();
493        if line.is_empty() { return ConsoleAction::None; }
494
495        // Push to history
496        if self.history.back().map(|l| l.as_str()) != Some(&line) {
497            self.history.push_back(line.clone());
498            if self.history.len() > MAX_HISTORY {
499                self.history.pop_front();
500            }
501        }
502        self.history_index = None;
503        self.history_saved.clear();
504
505        self.log.push_back(LogLine::command(format!("> {}", line)));
506        self.input.clear();
507        self.cursor = 0;
508        self.scroll_offset = 0;
509
510        self.execute_line(&line)
511    }
512
513    fn execute_line(&mut self, line: &str) -> ConsoleAction {
514        let mut parts = tokenize(line);
515        if parts.is_empty() { return ConsoleAction::None; }
516
517        let cmd_name = parts.remove(0);
518        let args: Vec<&str> = parts.iter().map(|s| s.as_str()).collect();
519
520        // Special built-in actions that need ConsoleAction return
521        match cmd_name.as_str() {
522            "quit" | "exit" | "q" => {
523                self.log.push_back(LogLine::warn("Quitting..."));
524                self.trim_log();
525                return ConsoleAction::Quit;
526            }
527            "clear" => {
528                self.log.clear();
529                self.scroll_offset = 0;
530                return ConsoleAction::None;
531            }
532            "history" => {
533                for (i, h) in self.history.iter().enumerate() {
534                    self.log.push_back(LogLine::debug(format!("{:4}: {}", i + 1, h)));
535                }
536                self.trim_log();
537                return ConsoleAction::None;
538            }
539            "set" if args.len() >= 2 => {
540                let val = args[1..].join(" ");
541                self.vars.insert(args[0].to_owned(), val.clone());
542                self.log.push_back(LogLine::success(format!("{} = {}", args[0], val)));
543                self.trim_log();
544                return ConsoleAction::None;
545            }
546            "get" if !args.is_empty() => {
547                let val = self.vars.get(args[0]).cloned().unwrap_or_else(|| "<undefined>".into());
548                self.log.push_back(LogLine::info(format!("{} = {}", args[0], val)));
549                self.trim_log();
550                return ConsoleAction::None;
551            }
552            _ => {}
553        }
554
555        // Find and execute registered command
556        let found = self.commands.iter().any(|c| {
557            c.name == cmd_name || c.aliases.contains(&cmd_name)
558        });
559
560        if found {
561            let mut output: Vec<LogLine> = Vec::new();
562            // Invoke handler (borrow-safe: collect output then push)
563            for cmd in &self.commands {
564                if cmd.name == cmd_name || cmd.aliases.contains(&cmd_name) {
565                    (cmd.handler)(&args, &mut output);
566                    break;
567                }
568            }
569            for line in output {
570                self.log.push_back(line);
571            }
572            // Check for "help" command result containing a request to show help
573            if cmd_name == "help" && !args.is_empty() {
574                let target = args[0];
575                if let Some(cmd) = self.commands.iter().find(|c| c.name == target) {
576                    self.log.push_back(LogLine::info(format!("  {}", cmd.usage())));
577                    self.log.push_back(LogLine::info(format!("  {}", cmd.help)));
578                } else {
579                    self.log.push_back(LogLine::warn(format!("Unknown command: '{}'", target)));
580                }
581            }
582        } else {
583            self.log.push_back(LogLine::error(format!("Unknown command: '{}'. Type 'list' for help.", cmd_name)));
584        }
585
586        self.trim_log();
587        ConsoleAction::None
588    }
589
590    fn trim_log(&mut self) {
591        while self.log.len() > MAX_LOG_LINES {
592            self.log.pop_front();
593            if self.scroll_offset > 0 {
594                self.scroll_offset = self.scroll_offset.saturating_sub(1);
595            }
596        }
597    }
598
599    // ── Scrolling ─────────────────────────────────────────────────────────────
600
601    pub fn scroll_up(&mut self, lines: usize) {
602        let max_scroll = self.log.len().saturating_sub(VISIBLE_LOG_LINES);
603        self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
604    }
605
606    pub fn scroll_down(&mut self, lines: usize) {
607        self.scroll_offset = self.scroll_offset.saturating_sub(lines);
608    }
609
610    pub fn scroll_to_bottom(&mut self) { self.scroll_offset = 0; }
611    pub fn scroll_to_top(&mut self) {
612        self.scroll_offset = self.log.len().saturating_sub(VISIBLE_LOG_LINES);
613    }
614
615    // ── Rendering helpers ─────────────────────────────────────────────────────
616
617    /// Get the slice of log lines currently visible in the console window.
618    pub fn visible_lines(&self) -> impl Iterator<Item = &LogLine> {
619        let total = self.log.len();
620        let start = if self.scroll_offset + VISIBLE_LOG_LINES > total {
621            0
622        } else {
623            total - VISIBLE_LOG_LINES - self.scroll_offset
624        };
625        let end = (total - self.scroll_offset).min(total);
626        self.log.range(start..end)
627    }
628
629    /// The input line split at the cursor for rendering a blinking cursor.
630    pub fn input_before_cursor(&self) -> &str { &self.input[..self.cursor] }
631    pub fn input_after_cursor(&self)  -> &str { &self.input[self.cursor..] }
632
633    /// Toggle console visibility.
634    pub fn toggle(&mut self) { self.visible = !self.visible; }
635
636    /// Push a log line from external code.
637    pub fn print(&mut self, line: LogLine) {
638        self.log.push_back(line);
639        self.trim_log();
640    }
641
642    pub fn println(&mut self, text: impl Into<String>) {
643        self.print(LogLine::info(text));
644    }
645
646    pub fn print_warn(&mut self, text: impl Into<String>) {
647        self.print(LogLine::warn(text));
648    }
649
650    pub fn print_error(&mut self, text: impl Into<String>) {
651        self.print(LogLine::error(text));
652    }
653
654    pub fn print_success(&mut self, text: impl Into<String>) {
655        self.print(LogLine::success(text));
656    }
657}
658
659impl Default for Console {
660    fn default() -> Self { Self::new() }
661}
662
663// ── ConsoleAction ─────────────────────────────────────────────────────────────
664
665/// Returned by submit() to signal special engine actions.
666#[derive(Debug, Clone, PartialEq)]
667pub enum ConsoleAction {
668    None,
669    Quit,
670    Reload,
671    RunScript(String),
672    SetVar { name: String, value: String },
673}
674
675// ── tokenize ──────────────────────────────────────────────────────────────────
676
677/// Split a command line into tokens, respecting quoted strings.
678fn tokenize(line: &str) -> Vec<String> {
679    let mut tokens = Vec::new();
680    let mut current = String::new();
681    let mut in_quotes = false;
682    let mut quote_char = '"';
683
684    for c in line.chars() {
685        match c {
686            '"' | '\'' if !in_quotes => { in_quotes = true;  quote_char = c; }
687            c if in_quotes && c == quote_char => { in_quotes = false; }
688            ' ' | '\t' if !in_quotes => {
689                if !current.is_empty() {
690                    tokens.push(current.clone());
691                    current.clear();
692                }
693            }
694            _ => current.push(c),
695        }
696    }
697    if !current.is_empty() { tokens.push(current); }
698    tokens
699}
700
701// ── ConsoleSink ───────────────────────────────────────────────────────────────
702
703/// A lightweight handle for writing to the console from other systems.
704/// Clone it and pass it around; it queues lines to be pushed on the next tick.
705#[derive(Debug, Clone, Default)]
706pub struct ConsoleSink {
707    pub pending: Vec<LogLine>,
708}
709
710impl ConsoleSink {
711    pub fn new() -> Self { Self::default() }
712
713    pub fn info(&mut self,    text: impl Into<String>) { self.pending.push(LogLine::info(text));    }
714    pub fn warn(&mut self,    text: impl Into<String>) { self.pending.push(LogLine::warn(text));    }
715    pub fn error(&mut self,   text: impl Into<String>) { self.pending.push(LogLine::error(text));   }
716    pub fn success(&mut self, text: impl Into<String>) { self.pending.push(LogLine::success(text)); }
717
718    /// Drain pending lines into the console.
719    pub fn flush(&mut self, console: &mut Console) {
720        for line in self.pending.drain(..) {
721            console.print(line);
722        }
723    }
724}