fast_rich/
console.rs

1//! Console abstraction for terminal output.
2//!
3//! The `Console` type is the main entry point for rich terminal output.
4//! It handles styled printing, word wrapping, and terminal capabilities.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use fast_rich::Console;
10//!
11//! let console = Console::new();
12//! console.print("Hello, [bold magenta]World[/]!");
13//! ```
14
15use crate::markup;
16use crate::renderable::{Renderable, Segment};
17use crate::text::{Span, Text};
18
19use crossterm::{
20    execute,
21    style::{Attribute, Print, SetAttribute, SetBackgroundColor, SetForegroundColor},
22    terminal,
23};
24use std::io::{self, Write};
25
26/// Escape HTML special characters.
27fn html_escape(s: &str) -> String {
28    s.replace('&', "&")
29        .replace('<', "&lt;")
30        .replace('>', "&gt;")
31        .replace('"', "&quot;")
32}
33
34/// Escape SVG special characters.
35fn svg_escape(s: &str) -> String {
36    s.replace('&', "&amp;")
37        .replace('<', "&lt;")
38        .replace('>', "&gt;")
39}
40
41/// Rendering context passed to Renderable objects.
42#[derive(Debug, Clone)]
43pub struct RenderContext {
44    /// Available width for rendering.
45    pub width: usize,
46    /// Available height for rendering (optional).
47    pub height: Option<usize>,
48}
49
50impl Default for RenderContext {
51    fn default() -> Self {
52        RenderContext {
53            width: 80,
54            height: None,
55        }
56    }
57}
58
59/// Color system capabilities.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum ColorSystem {
62    /// No color support
63    NoColor,
64    /// Standard 8/16 colors
65    #[default]
66    Standard,
67    /// 256 colors
68    EightBit,
69    /// True color (16 million colors)
70    TrueColor,
71    /// Windows legacy console (mapped to Standard for ANSI output)
72    Windows,
73}
74
75/// The main console type for rich terminal output.
76#[derive(Debug)]
77pub struct Console {
78    /// Output stream (stdout or stderr)
79    output: ConsoleOutput,
80    /// Terminal width (cached or forced)
81    width: Option<usize>,
82    /// Whether to force color output
83    force_color: bool,
84    /// Whether color is enabled
85    color_enabled: bool,
86    /// The detected or forced color system
87    color_system: ColorSystem,
88    /// Whether to use markup parsing
89    markup: bool,
90    /// Whether to translate emoji shortcodes
91    emoji: bool,
92    /// Soft wrap text at terminal width
93    soft_wrap: bool,
94    /// Whether recording is enabled
95    record: std::sync::Arc<std::sync::atomic::AtomicBool>,
96    /// Buffer for recorded segments
97    recording: std::sync::Arc<std::sync::Mutex<Vec<Segment>>>,
98}
99
100#[derive(Debug, Clone)]
101enum ConsoleOutput {
102    Stdout,
103    Stderr,
104    Buffer(std::sync::Arc<std::sync::Mutex<Vec<u8>>>),
105}
106
107struct BufferWriter {
108    buffer: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
109}
110
111impl Write for BufferWriter {
112    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
113        let mut lock = self
114            .buffer
115            .lock()
116            .map_err(|e| io::Error::other(e.to_string()))?;
117        lock.extend_from_slice(buf);
118        Ok(buf.len())
119    }
120
121    fn flush(&mut self) -> io::Result<()> {
122        Ok(())
123    }
124}
125
126impl Default for Console {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132impl Console {
133    /// Create a new Console writing to stdout.
134    pub fn new() -> Self {
135        let (color_enabled, color_system) = Self::detect_color_system();
136        Console {
137            output: ConsoleOutput::Stdout,
138            width: None,
139            force_color: false,
140            color_enabled,
141            color_system,
142            markup: true,
143            emoji: true,
144            soft_wrap: true,
145            record: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
146            recording: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
147        }
148    }
149
150    /// Create a new Console writing to stderr.
151    pub fn stderr() -> Self {
152        let (color_enabled, color_system) = Self::detect_color_system();
153        Console {
154            output: ConsoleOutput::Stderr,
155            width: None,
156            force_color: false,
157            color_enabled,
158            color_system,
159            markup: true,
160            emoji: true,
161            soft_wrap: true,
162            record: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
163            recording: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
164        }
165    }
166
167    /// Create a new Console that captures output to memory.
168    ///
169    /// Useful for testing output verification.
170    pub fn capture() -> Self {
171        Console {
172            output: ConsoleOutput::Buffer(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))),
173            width: Some(80),   // Default width for tests
174            force_color: true, // Force color for tests
175            color_enabled: true,
176            color_system: ColorSystem::TrueColor, // Capture assumes good capabilities
177            markup: true,
178            emoji: true,
179            soft_wrap: true,
180            record: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
181            recording: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
182        }
183    }
184
185    /// Get the captured output as a string (if using capture mode).
186    pub fn get_captured_output(&self) -> String {
187        match &self.output {
188            ConsoleOutput::Buffer(buf) => {
189                let lock = buf.lock().unwrap();
190                String::from_utf8(lock.clone()).unwrap_or_default()
191            }
192            _ => String::new(),
193        }
194    }
195
196    /// Set a fixed terminal width.
197    pub fn width(mut self, width: usize) -> Self {
198        self.width = Some(width);
199        self
200    }
201
202    /// Force color output even when not detected.
203    pub fn force_color(mut self, force: bool) -> Self {
204        self.force_color = force;
205        if force {
206            self.color_enabled = true;
207            // If forcing color and we were previously NoColor, assume Standard
208            if self.color_system == ColorSystem::NoColor {
209                self.color_system = ColorSystem::Standard;
210            }
211        }
212        self
213    }
214
215    /// Set the color system explicitly.
216    pub fn color_system(mut self, system: ColorSystem) -> Self {
217        self.color_system = system;
218        // If explicitly setting a color system (other than NoColor), enable color
219        self.color_enabled = system != ColorSystem::NoColor;
220        self
221    }
222
223    /// Enable or disable markup parsing.
224    pub fn markup(mut self, enabled: bool) -> Self {
225        self.markup = enabled;
226        self
227    }
228
229    /// Enable or disable emoji shortcode translation.
230    pub fn emoji(mut self, enabled: bool) -> Self {
231        self.emoji = enabled;
232        self
233    }
234
235    /// Enable or disable soft word wrapping.
236    pub fn soft_wrap(mut self, enabled: bool) -> Self {
237        self.soft_wrap = enabled;
238        self
239    }
240
241    /// Enable or disable recording of output.
242    pub fn record(self, enabled: bool) -> Self {
243        self.record
244            .store(enabled, std::sync::atomic::Ordering::Relaxed);
245        self
246    }
247
248    /// Start recording output.
249    pub fn start_recording(&self) {
250        self.record
251            .store(true, std::sync::atomic::Ordering::Relaxed);
252        if let Ok(mut lock) = self.recording.lock() {
253            lock.clear();
254        }
255    }
256
257    /// Stop recording output.
258    pub fn stop_recording(&self) {
259        self.record
260            .store(false, std::sync::atomic::Ordering::Relaxed);
261    }
262
263    /// Get the current terminal width.
264    pub fn get_width(&self) -> usize {
265        self.width
266            .unwrap_or_else(|| terminal::size().map(|(w, _)| w as usize).unwrap_or(80))
267    }
268
269    /// Detect color support and system.
270    fn detect_color_system() -> (bool, ColorSystem) {
271        // Check common environment variables
272        if std::env::var("NO_COLOR").is_ok() {
273            return (false, ColorSystem::NoColor);
274        }
275
276        if std::env::var("FORCE_COLOR").is_ok() {
277            // Default to Standard if forced, can be upgraded by other checks if we were smarter,
278            // but for now FORCE_COLOR just ensures we have *some* color.
279            return (true, ColorSystem::Standard);
280        }
281
282        // Check COLORTERM for truecolor
283        if let Ok(colorterm) = std::env::var("COLORTERM") {
284            if colorterm.contains("truecolor") || colorterm.contains("24bit") {
285                return (true, ColorSystem::TrueColor);
286            }
287        }
288
289        // Check TERM for 256 colors
290        if let Ok(term) = std::env::var("TERM") {
291            if term.contains("256color") {
292                return (true, ColorSystem::EightBit);
293            }
294        }
295
296        // Fallback to Standard color if TTY (simplified)
297        // In a real app we'd check is_tty
298        (true, ColorSystem::Standard)
299    }
300
301    /// Print a string with markup support.
302    pub fn print(&self, content: &str) {
303        let text = if self.markup {
304            markup::parse(content)
305        } else {
306            Text::plain(content.to_string())
307        };
308
309        self.print_renderable(&text);
310    }
311
312    /// Print any renderable object.
313    pub fn print_renderable(&self, renderable: &dyn Renderable) {
314        let context = RenderContext {
315            width: self.get_width(),
316            height: None,
317        };
318
319        let segments = renderable.render(&context);
320        self.write_segments(&segments);
321    }
322
323    /// Print a line (with newline at the end).
324    pub fn println(&self, content: &str) {
325        self.print(content);
326        self.newline();
327    }
328
329    /// Print a string without markup parsing.
330    ///
331    /// Use this when printing content that may contain brackets `[...]`
332    /// that should NOT be interpreted as markup (e.g., debug output).
333    pub fn print_raw(&self, content: &str) {
334        let text = Text::plain(content.to_string());
335        self.print_renderable(&text);
336    }
337
338    /// Print a line without markup parsing (with newline at the end).
339    ///
340    /// Use this when printing content that may contain brackets `[...]`
341    /// that should NOT be interpreted as markup (e.g., debug output).
342    pub fn println_raw(&self, content: &str) {
343        self.print_raw(content);
344        self.newline();
345    }
346
347    /// Print an empty line.
348    pub fn newline(&self) {
349        let _ = self.write_raw("\n");
350        // Record newline segment if recording
351        if self.record.load(std::sync::atomic::Ordering::Relaxed) {
352            if let Ok(mut lock) = self.recording.lock() {
353                lock.push(Segment::empty_line());
354            }
355        }
356    }
357
358    /// Write segments to the output.
359    pub(crate) fn write_segments(&self, segments: &[Segment]) {
360        if self.record.load(std::sync::atomic::Ordering::Relaxed) {
361            if let Ok(mut lock) = self.recording.lock() {
362                lock.extend_from_slice(segments);
363            }
364        }
365
366        for segment in segments {
367            for span in &segment.spans {
368                self.write_span(span);
369            }
370            if segment.newline {
371                let _ = self.write_raw("\n");
372            }
373        }
374        let _ = self.flush();
375    }
376
377    /// Write a single span with styling.
378    fn write_span(&self, span: &Span) {
379        if !self.color_enabled || self.color_system == ColorSystem::NoColor || span.style.is_empty()
380        {
381            let _ = self.write_raw(&span.text);
382            return;
383        }
384
385        let mut writer = self.get_writer();
386
387        // Helper to downsample colors based on system
388        let process_color = |color: crate::style::Color| -> crossterm::style::Color {
389            match self.color_system {
390                ColorSystem::Standard | ColorSystem::Windows => color.to_standard().to_crossterm(),
391                ColorSystem::EightBit => color.to_ansi256().to_crossterm(),
392                ColorSystem::TrueColor => color.to_crossterm(),
393                ColorSystem::NoColor => crossterm::style::Color::Reset, // Should be handled by early return
394            }
395        };
396
397        // Set foreground color
398        if let Some(color) = span.style.foreground {
399            if matches!(
400                self.color_system,
401                ColorSystem::Standard | ColorSystem::Windows
402            ) {
403                let std_color = color.to_standard();
404                let sgr = std_color.to_sgr_fg();
405                if !sgr.is_empty() {
406                    let _ = self.write_raw(&sgr);
407                } else {
408                    let _ = execute!(writer, SetForegroundColor(std_color.to_crossterm()));
409                }
410            } else {
411                let _ = execute!(writer, SetForegroundColor(process_color(color)));
412            }
413        }
414
415        // Set background color
416        if let Some(color) = span.style.background {
417            if matches!(
418                self.color_system,
419                ColorSystem::Standard | ColorSystem::Windows
420            ) {
421                let std_color = color.to_standard();
422                let sgr = std_color.to_sgr_bg();
423                if !sgr.is_empty() {
424                    let _ = self.write_raw(&sgr);
425                } else {
426                    let _ = execute!(writer, SetBackgroundColor(std_color.to_crossterm()));
427                }
428            } else {
429                let _ = execute!(writer, SetBackgroundColor(process_color(color)));
430            }
431        }
432
433        // Set attributes
434        if span.style.bold {
435            let _ = execute!(writer, SetAttribute(Attribute::Bold));
436        }
437        if span.style.dim {
438            let _ = execute!(writer, SetAttribute(Attribute::Dim));
439        }
440        if span.style.italic {
441            let _ = execute!(writer, SetAttribute(Attribute::Italic));
442        }
443        if span.style.underline {
444            let _ = execute!(writer, SetAttribute(Attribute::Underlined));
445        }
446        if span.style.blink {
447            let _ = execute!(writer, SetAttribute(Attribute::SlowBlink));
448        }
449        if span.style.reverse {
450            let _ = execute!(writer, SetAttribute(Attribute::Reverse));
451        }
452        if span.style.hidden {
453            let _ = execute!(writer, SetAttribute(Attribute::Hidden));
454        }
455        if span.style.strikethrough {
456            let _ = execute!(writer, SetAttribute(Attribute::CrossedOut));
457        }
458
459        // Write the text
460        let _ = execute!(writer, Print(&span.text));
461
462        // Reset all attributes (SGR 0 includes color reset)
463        let _ = execute!(writer, SetAttribute(Attribute::Reset));
464    }
465
466    /// Get the writer for this console.
467    fn get_writer(&self) -> Box<dyn Write> {
468        match &self.output {
469            ConsoleOutput::Stdout => Box::new(io::stdout()),
470            ConsoleOutput::Stderr => Box::new(io::stderr()),
471            ConsoleOutput::Buffer(buf) => Box::new(BufferWriter {
472                buffer: buf.clone(),
473            }),
474        }
475    }
476
477    /// Write raw string to output.
478    fn write_raw(&self, s: &str) -> io::Result<()> {
479        match &self.output {
480            ConsoleOutput::Stdout => {
481                let mut stdout = io::stdout();
482                stdout.write_all(s.as_bytes())
483            }
484            ConsoleOutput::Stderr => {
485                let mut stderr = io::stderr();
486                stderr.write_all(s.as_bytes())
487            }
488            ConsoleOutput::Buffer(buf) => {
489                let mut lock = buf.lock().map_err(|e| io::Error::other(e.to_string()))?;
490                lock.extend_from_slice(s.as_bytes());
491                Ok(())
492            }
493        }
494    }
495
496    /// Flush the output.
497    fn flush(&self) -> io::Result<()> {
498        match &self.output {
499            ConsoleOutput::Stdout => io::stdout().flush(),
500            ConsoleOutput::Stderr => io::stderr().flush(),
501            ConsoleOutput::Buffer(_) => Ok(()),
502        }
503    }
504
505    /// Clear the screen.
506    pub fn clear(&self) {
507        let mut writer = self.get_writer();
508        let _ = execute!(
509            writer,
510            crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
511            crossterm::cursor::MoveTo(0, 0)
512        );
513    }
514
515    /// Show or hide the cursor.
516    pub fn show_cursor(&self, show: bool) {
517        let mut writer = self.get_writer();
518        if show {
519            let _ = execute!(writer, crossterm::cursor::Show);
520        } else {
521            let _ = execute!(writer, crossterm::cursor::Hide);
522        }
523    }
524
525    /// Move the cursor up by `n` lines.
526    pub fn move_cursor_up(&self, n: u16) {
527        if n > 0 {
528            let mut writer = self.get_writer();
529            let _ = execute!(writer, crossterm::cursor::MoveUp(n));
530        }
531    }
532
533    /// Move the cursor down by `n` lines.
534    pub fn move_cursor_down(&self, n: u16) {
535        if n > 0 {
536            let mut writer = self.get_writer();
537            let _ = execute!(writer, crossterm::cursor::MoveDown(n));
538        }
539    }
540
541    /// Clear the current line.
542    pub fn clear_line(&self) {
543        let mut writer = self.get_writer();
544        let _ = execute!(
545            writer,
546            crossterm::terminal::Clear(crossterm::terminal::ClearType::CurrentLine),
547            crossterm::cursor::MoveToColumn(0)
548        );
549    }
550
551    /// Show a rule (horizontal line).
552    pub fn rule(&self, title: &str) {
553        let _width = self.get_width();
554        let rule = crate::rule::Rule::new(title);
555        self.print_renderable(&rule);
556        self.newline();
557    }
558
559    /// Print JSON with syntax highlighting.
560    ///
561    /// This method prints a JSON string with automatic syntax highlighting.
562    /// The input should be a valid JSON string.
563    #[cfg(feature = "syntax")]
564    pub fn print_json(&self, json_str: &str) {
565        let syntax = crate::syntax::Syntax::new(json_str, "json");
566        self.print_renderable(&syntax);
567        self.newline();
568    }
569
570    /// Pretty print a debug-printable object.
571    ///
572    /// Uses syntax highlighting if the `syntax` feature is enabled.
573    pub fn print_debug<T: std::fmt::Debug>(&self, obj: &T) {
574        let content = format!("{:#?}", obj);
575
576        #[cfg(feature = "syntax")]
577        {
578            let syntax = crate::syntax::Syntax::new(&content, "rust");
579            self.print_renderable(&syntax);
580        }
581
582        #[cfg(not(feature = "syntax"))]
583        {
584            // Use Text::plain to avoid parsing brackets as markup
585            let text = Text::plain(content);
586            self.print_renderable(&text);
587        }
588
589        self.newline();
590    }
591
592    /// Export a renderable as plain text.
593    ///
594    /// Returns the plain text representation without any ANSI codes.
595    pub fn export_text(&self, renderable: &dyn Renderable) -> String {
596        let context = RenderContext {
597            width: self.get_width(),
598            height: None,
599        };
600        let segments = renderable.render(&context);
601        self.segments_to_text(&segments)
602    }
603
604    fn segments_to_text(&self, segments: &[Segment]) -> String {
605        let mut result = String::new();
606        for segment in segments {
607            result.push_str(&segment.plain_text());
608            if segment.newline {
609                result.push('\n');
610            }
611        }
612        result
613    }
614
615    /// Export a renderable as HTML with inline styles.
616    ///
617    /// Returns an HTML string with styled `<span>` elements.
618    pub fn export_html(&self, renderable: &dyn Renderable) -> String {
619        let context = RenderContext {
620            width: self.get_width(),
621            height: None,
622        };
623        let segments = renderable.render(&context);
624        self.segments_to_html(&segments)
625    }
626
627    /// Save the recorded output as HTML.
628    pub fn save_html(&self, path: &str) -> io::Result<()> {
629        let segments = self.recording.lock().unwrap();
630        let html = self.segments_to_html(&segments);
631        std::fs::write(path, html)
632    }
633
634    fn segments_to_html(&self, segments: &[Segment]) -> String {
635        let mut html = String::from("<pre style=\"font-family: monospace; background: #1e1e1e; color: #d4d4d4; padding: 1em;\">\n");
636
637        for segment in segments {
638            for span in &segment.spans {
639                let style_css = span.style.to_css();
640                if style_css.is_empty() {
641                    html.push_str(&html_escape(&span.text));
642                } else {
643                    html.push_str(&format!(
644                        "<span style=\"{}\">{}</span>",
645                        style_css,
646                        html_escape(&span.text)
647                    ));
648                }
649            }
650            if segment.newline {
651                html.push('\n');
652            }
653        }
654
655        html.push_str("</pre>");
656        html
657    }
658
659    /// Export a renderable as SVG.
660    ///
661    /// Returns an SVG string with text elements.
662    pub fn export_svg(&self, renderable: &dyn Renderable) -> String {
663        let context = RenderContext {
664            width: self.get_width(),
665            height: None,
666        };
667        let segments = renderable.render(&context);
668        self.segments_to_svg(&segments)
669    }
670
671    /// Save the recorded output as SVG.
672    pub fn save_svg(&self, path: &str) -> io::Result<()> {
673        let segments = self.recording.lock().unwrap();
674        let svg = self.segments_to_svg(&segments);
675        std::fs::write(path, svg)
676    }
677
678    fn segments_to_svg(&self, segments: &[Segment]) -> String {
679        let char_width = 9.6; // Approximate monospace character width
680        let line_height = 20.0;
681        let padding = 10.0;
682
683        let mut lines: Vec<String> = Vec::new();
684        let mut current_line = String::new();
685
686        for segment in segments {
687            for span in &segment.spans {
688                current_line.push_str(&span.text);
689            }
690            if segment.newline {
691                lines.push(std::mem::take(&mut current_line));
692            }
693        }
694        if !current_line.is_empty() {
695            lines.push(current_line);
696        }
697
698        let max_chars = lines.iter().map(|l| l.len()).max().unwrap_or(80);
699        let width = (max_chars as f64 * char_width) + padding * 2.0;
700        let height = (lines.len() as f64 * line_height) + padding * 2.0;
701
702        let mut svg = format!(
703            "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {:.0} {:.0}\">\n",
704            width, height
705        );
706        svg.push_str("  <rect width=\"100%\" height=\"100%\" fill=\"#1e1e1e\"/>\n");
707        svg.push_str("  <text font-family=\"monospace\" font-size=\"14\" fill=\"#d4d4d4\">\n");
708
709        for (i, line) in lines.iter().enumerate() {
710            let y = padding + (i as f64 + 1.0) * line_height;
711            svg.push_str(&format!(
712                "    <tspan x=\"{}\" y=\"{:.1}\">{}</tspan>\n",
713                padding,
714                y,
715                svg_escape(line)
716            ));
717        }
718
719        svg.push_str("  </text>\n</svg>");
720        svg
721    }
722}
723
724/// A guard that captures output for testing.
725#[derive(Debug)]
726pub struct CapturedOutput {
727    segments: Vec<Segment>,
728}
729
730impl CapturedOutput {
731    /// Create a new capture.
732    pub fn new() -> Self {
733        CapturedOutput {
734            segments: Vec::new(),
735        }
736    }
737
738    /// Get the plain text output.
739    pub fn plain_text(&self) -> String {
740        let mut result = String::new();
741        for segment in &self.segments {
742            result.push_str(&segment.plain_text());
743            if segment.newline {
744                result.push('\n');
745            }
746        }
747        result
748    }
749}
750
751impl Default for CapturedOutput {
752    fn default() -> Self {
753        Self::new()
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    #[test]
762    fn test_console_default_width() {
763        let console = Console::new().width(80);
764        assert_eq!(console.get_width(), 80);
765    }
766
767    #[test]
768    fn test_render_context_default() {
769        let context = RenderContext::default();
770        assert_eq!(context.width, 80);
771    }
772
773    #[test]
774    fn test_force_color() {
775        let console = Console::new().force_color(true);
776        assert!(console.force_color);
777        assert!(console.color_enabled);
778    }
779}