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