Skip to main content

rich_rs/
console.rs

1//! Console: the main API for rendering to the terminal.
2//!
3//! The Console is the central orchestrator for all Rich output. It handles:
4//! - Terminal capabilities detection
5//! - Rendering renderables to segments
6//! - Writing styled output to the terminal
7//! - Alternate screen mode
8//! - Output capture for testing
9
10use std::collections::HashMap;
11use std::env;
12use std::fs::OpenOptions;
13use std::io::{self, Stdout, Write};
14use std::sync::{Arc, Mutex, OnceLock};
15
16use crossterm::terminal::{self, ClearType};
17use crossterm::{cursor, execute, terminal as ct};
18
19use crate::Renderable;
20use crate::cells::cell_len;
21use crate::color::{ColorSystem, ColorTriplet, SimpleColor};
22use crate::emoji::Emoji;
23use crate::export_format::{CONSOLE_HTML_FORMAT, CONSOLE_SVG_FORMAT};
24use crate::highlighter::Highlighter;
25use crate::screen_buffer::ScreenBuffer;
26use crate::segment::{ControlType, Segment, Segments};
27use crate::style::Style;
28use crate::table::{Column, Row, Table};
29use crate::terminal_theme::{DEFAULT_TERMINAL_THEME, SVG_EXPORT_THEME, TerminalTheme};
30use crate::text::Text;
31use crate::theme::{Theme, ThemeStack};
32use crate::traceback::Traceback;
33
34use std::time::SystemTime;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum WindowsRenderMode {
38    Segment,
39    Streaming,
40}
41
42fn parse_windows_render_mode(value: Option<&str>) -> WindowsRenderMode {
43    match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
44        Some("streaming") => WindowsRenderMode::Streaming,
45        Some("segment") => WindowsRenderMode::Segment,
46        _ => WindowsRenderMode::Streaming,
47    }
48}
49
50fn detect_windows_render_mode() -> WindowsRenderMode {
51    parse_windows_render_mode(env::var("RICH_RS_WINDOWS_RENDER_MODE").ok().as_deref())
52}
53
54fn parse_bool_env(value: &str) -> Option<bool> {
55    match value.trim().to_ascii_lowercase().as_str() {
56        "1" | "true" | "yes" | "on" => Some(true),
57        "0" | "false" | "no" | "off" => Some(false),
58        _ => None,
59    }
60}
61
62fn detect_legacy_windows_default() -> bool {
63    if let Ok(value) = env::var("RICH_RS_LEGACY_WINDOWS")
64        && let Some(parsed) = parse_bool_env(&value)
65    {
66        return parsed;
67    }
68    #[cfg(windows)]
69    {
70        // Align with Rich Python's capability-first gating:
71        // legacy mode is only required when VT is unavailable.
72        return !crossterm::ansi_support::supports_ansi();
73    }
74    #[cfg(not(windows))]
75    {
76        false
77    }
78}
79
80fn debug_segments_log(line: &str) {
81    static PATH: OnceLock<Option<String>> = OnceLock::new();
82    let path = PATH.get_or_init(|| env::var("RICH_RS_DEBUG_SEGMENTS_FILE").ok());
83    let Some(path) = path.as_ref() else {
84        return;
85    };
86    if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
87        let _ = writeln!(file, "{line}");
88    }
89}
90
91fn debug_ansi_log(line: &str) {
92    static PATH: OnceLock<Option<String>> = OnceLock::new();
93    let path = PATH.get_or_init(|| env::var("RICH_RS_DEBUG_ANSI_FILE").ok());
94    let Some(path) = path.as_ref() else {
95        return;
96    };
97    if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
98        let _ = writeln!(file, "{line}");
99    }
100}
101
102fn debug_segments_match_text(text: &str) -> bool {
103    static FILTERS: OnceLock<Vec<String>> = OnceLock::new();
104    let filters = FILTERS.get_or_init(|| {
105        env::var("RICH_RS_DEBUG_SEGMENTS_FILTER")
106            .ok()
107            .map(|value| {
108                value
109                    .split(',')
110                    .map(|part| part.trim().to_ascii_lowercase())
111                    .filter(|part| !part.is_empty())
112                    .collect::<Vec<_>>()
113            })
114            .unwrap_or_default()
115    });
116    if filters.is_empty() {
117        return true;
118    }
119    let lowered = text.to_ascii_lowercase();
120    filters.iter().any(|filter| lowered.contains(filter))
121}
122
123// ============================================================================
124// JustifyMethod
125// ============================================================================
126
127/// Text justification method.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum JustifyMethod {
130    /// Use default justification (typically left).
131    #[default]
132    Default,
133    /// Left-aligned text.
134    Left,
135    /// Center-aligned text.
136    Center,
137    /// Right-aligned text.
138    Right,
139    /// Full justification (stretch to fill width).
140    Full,
141}
142
143impl JustifyMethod {
144    /// Parse a justify method from a string.
145    pub fn parse(s: &str) -> Option<Self> {
146        match s.to_lowercase().as_str() {
147            "default" => Some(JustifyMethod::Default),
148            "left" => Some(JustifyMethod::Left),
149            "center" => Some(JustifyMethod::Center),
150            "right" => Some(JustifyMethod::Right),
151            "full" => Some(JustifyMethod::Full),
152            _ => None,
153        }
154    }
155}
156
157// ============================================================================
158// OverflowMethod
159// ============================================================================
160
161/// Text overflow handling method.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
163pub enum OverflowMethod {
164    /// Fold text at word boundaries.
165    #[default]
166    Fold,
167    /// Crop text at the edge.
168    Crop,
169    /// Add ellipsis when text is cropped.
170    Ellipsis,
171    /// Ignore overflow (let text extend beyond bounds).
172    Ignore,
173}
174
175impl OverflowMethod {
176    /// Parse an overflow method from a string.
177    pub fn parse(s: &str) -> Option<Self> {
178        match s.to_lowercase().as_str() {
179            "fold" => Some(OverflowMethod::Fold),
180            "crop" => Some(OverflowMethod::Crop),
181            "ellipsis" => Some(OverflowMethod::Ellipsis),
182            "ignore" => Some(OverflowMethod::Ignore),
183            _ => None,
184        }
185    }
186}
187
188// ============================================================================
189// ConsoleOptions
190// ============================================================================
191
192/// Options passed through the rendering pipeline.
193///
194/// This struct carries rendering context that flows through the entire
195/// render pipeline, allowing renderables to adapt to the output context.
196///
197/// # Console State
198///
199/// In addition to rendering options, this struct carries console configuration
200/// that renderables may need to access (theme styles, feature flags, etc.).
201/// This allows nested renderables to access console configuration without
202/// needing a direct reference to the Console.
203#[derive(Debug, Clone)]
204pub struct ConsoleOptions {
205    /// Terminal dimensions as (width, height).
206    pub size: (usize, usize),
207    /// Minimum width for rendering.
208    pub min_width: usize,
209    /// Maximum width for rendering.
210    pub max_width: usize,
211    /// Maximum height constraint.
212    pub max_height: usize,
213    /// Optional height constraint for specific renderables.
214    pub height: Option<usize>,
215    /// Whether output is to a terminal (vs file/pipe).
216    pub is_terminal: bool,
217    /// Character encoding.
218    pub encoding: String,
219    /// Whether to use legacy Windows console.
220    pub legacy_windows: bool,
221    /// Text justification override.
222    pub justify: Option<JustifyMethod>,
223    /// Text overflow handling override.
224    pub overflow: Option<OverflowMethod>,
225    /// Disable text wrapping.
226    pub no_wrap: bool,
227    /// Highlight override for render_str.
228    pub highlight: Option<bool>,
229    /// Markup parsing enabled.
230    pub markup: Option<bool>,
231
232    // =========================================================================
233    // Console state passed through to renderables
234    // =========================================================================
235    /// Theme stack for style lookups. Cloned from the console.
236    pub theme_stack: ThemeStack,
237    /// Current theme name (e.g., "default", "dracula").
238    /// Renderables can use this to match their theme to the console.
239    pub theme_name: String,
240    /// Whether markup parsing is enabled by default.
241    pub markup_enabled: bool,
242    /// Whether emoji replacement is enabled by default.
243    pub emoji_enabled: bool,
244    /// Whether highlighting is enabled by default.
245    pub highlight_enabled: bool,
246    /// Tab size for tab expansion.
247    pub tab_size: usize,
248    /// Disable terminal automatic line wrap while printing.
249    ///
250    /// This prevents "soft wrap" artifacts when output fills exactly the last
251    /// column of the terminal (common on Windows Terminal and xterm-like VTs).
252    ///
253    /// When enabled and writing to a real terminal, the Console will emit
254    /// `ESC[?7l` before printing and `ESC[?7h` after.
255    pub disable_line_wrap: bool,
256    /// Detected color system (None = no colors).
257    pub color_system: Option<ColorSystem>,
258}
259
260impl Default for ConsoleOptions {
261    fn default() -> Self {
262        ConsoleOptions {
263            size: (80, 24),
264            min_width: 1,
265            max_width: 80,
266            max_height: 24,
267            height: None,
268            is_terminal: true,
269            encoding: "utf-8".to_string(),
270            legacy_windows: false,
271            justify: None,
272            overflow: None,
273            no_wrap: false,
274            highlight: None,
275            markup: None,
276            // Console state defaults
277            theme_stack: ThemeStack::default(),
278            theme_name: "default".to_string(),
279            markup_enabled: true,
280            emoji_enabled: true,
281            highlight_enabled: true,
282            tab_size: 8,
283            disable_line_wrap: false,
284            color_system: Some(ColorSystem::EightBit),
285        }
286    }
287}
288
289impl ConsoleOptions {
290    /// Create options from the current terminal.
291    pub fn from_terminal() -> Self {
292        let (width, height) = terminal::size().unwrap_or((80, 24));
293        let width = width as usize;
294        let height = height as usize;
295        let is_terminal = atty::is(atty::Stream::Stdout);
296        let color_system = Console::<Stdout>::detect_color_system_static(is_terminal);
297        ConsoleOptions {
298            size: (width, height),
299            min_width: 1,
300            max_width: width.max(1),
301            max_height: height,
302            height: None,
303            is_terminal,
304            // Avoid soft-wrap artifacts by temporarily disabling automatic line wrap.
305            // This allows layouts to use the full terminal width while still preventing
306            // terminals from inserting an extra wrapped line when writing in the last column.
307            disable_line_wrap: true,
308            color_system,
309            ..Default::default()
310        }
311    }
312
313    /// Get a style from the theme stack by name.
314    pub fn get_style(&self, name: &str) -> Option<Style> {
315        self.theme_stack.get_style(name)
316    }
317
318    /// Check if renderables should use ASCII only.
319    pub fn ascii_only(&self) -> bool {
320        !self.encoding.to_lowercase().starts_with("utf")
321    }
322
323    /// Create a copy of the options.
324    pub fn copy(&self) -> Self {
325        self.clone()
326    }
327
328    /// Update values and return a new copy.
329    ///
330    /// Only non-None values in the update parameters will override
331    /// the existing values.
332    pub fn update(
333        &self,
334        width: Option<usize>,
335        min_width: Option<usize>,
336        max_width: Option<usize>,
337        justify: Option<Option<JustifyMethod>>,
338        overflow: Option<Option<OverflowMethod>>,
339        no_wrap: Option<bool>,
340        highlight: Option<Option<bool>>,
341        markup: Option<Option<bool>>,
342        height: Option<Option<usize>>,
343    ) -> Self {
344        let mut options = self.clone();
345
346        if let Some(w) = width {
347            options.min_width = w.max(0);
348            options.max_width = w.max(0);
349        }
350        if let Some(w) = min_width {
351            options.min_width = w;
352        }
353        if let Some(w) = max_width {
354            options.max_width = w;
355        }
356        if let Some(j) = justify {
357            options.justify = j;
358        }
359        if let Some(o) = overflow {
360            options.overflow = o;
361        }
362        if let Some(nw) = no_wrap {
363            options.no_wrap = nw;
364        }
365        if let Some(h) = highlight {
366            options.highlight = h;
367        }
368        if let Some(m) = markup {
369            options.markup = m;
370        }
371        if let Some(h) = height {
372            if let Some(h) = h {
373                options.max_height = h;
374            }
375            options.height = h;
376        }
377
378        options
379    }
380
381    /// Update just the width, return a copy.
382    pub fn update_width(&self, width: usize) -> Self {
383        let mut options = self.clone();
384        options.min_width = width.max(0);
385        options.max_width = width.max(0);
386        options
387    }
388
389    /// Update the height and return a copy.
390    pub fn update_height(&self, height: usize) -> Self {
391        let mut options = self.clone();
392        options.max_height = height;
393        options.height = Some(height);
394        options
395    }
396
397    /// Update both width and height, return a copy.
398    pub fn update_dimensions(&self, width: usize, height: usize) -> Self {
399        let mut options = self.clone();
400        options.min_width = width.max(0);
401        options.max_width = width.max(0);
402        options.max_height = height;
403        options.height = Some(height);
404        options
405    }
406
407    /// Reset height to None, return a copy.
408    pub fn reset_height(&self) -> Self {
409        let mut options = self.clone();
410        options.height = None;
411        options
412    }
413}
414
415// ============================================================================
416// Console (Generic over Writer)
417// ============================================================================
418
419/// The main console for rendering output.
420///
421/// Console is generic over the writer type, allowing it to write to any
422/// type that implements `Write`. The default is `Stdout`, but you can use
423/// `Vec<u8>` for testing or any other writer.
424///
425/// # Example
426///
427/// ```
428/// use rich_rs::Console;
429///
430/// let mut console = Console::new();
431/// console.print_text("Hello, World!").unwrap();
432/// ```
433///
434/// # Testing with capture
435///
436/// ```
437/// use rich_rs::Console;
438///
439/// let mut console = Console::capture();
440/// console.print_text("Hello").unwrap();
441/// assert!(console.get_captured().contains("Hello"));
442/// ```
443pub struct Console<W: Write = Stdout> {
444    /// Output writer.
445    writer: W,
446    /// Console options.
447    options: ConsoleOptions,
448    /// Detected color system.
449    color_system: Option<ColorSystem>,
450    /// Whether terminal mode is forced.
451    force_terminal: Option<bool>,
452    /// Whether to use legacy Windows console.
453    legacy_windows: bool,
454    /// Whether markup parsing is enabled by default.
455    markup_enabled: bool,
456    /// Whether emoji replacement is enabled by default.
457    emoji_enabled: bool,
458    /// Whether highlighting is enabled by default.
459    highlight_enabled: bool,
460    /// Theme stack for styled output.
461    theme_stack: ThemeStack,
462    /// Current theme name for renderables to use.
463    theme_name: String,
464    /// Whether the alt screen is currently active.
465    is_alt_screen: bool,
466    /// Whether to suppress all output (quiet mode).
467    quiet: bool,
468    /// Tab size for tab expansion.
469    tab_size: usize,
470    /// Live display manager (Live/Progress).
471    live: LiveManager,
472    /// Stable hyperlink id registry (per-console).
473    link_ids: HashMap<Arc<str>, Arc<str>>,
474    /// Next id counter for generated hyperlinks.
475    next_link_id: u64,
476    /// Whether recording is enabled.
477    record: bool,
478    /// Buffer for recorded segments (protected by mutex for thread safety).
479    record_buffer: Arc<Mutex<Vec<Segment>>>,
480    /// Render hooks stack.
481    render_hooks: Vec<Box<dyn Fn(&Segments) -> Segments + Send + Sync>>,
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
485enum LiveVerticalOverflow {
486    Crop,
487    Ellipsis,
488    Visible,
489}
490
491impl From<crate::live::VerticalOverflowMethod> for LiveVerticalOverflow {
492    fn from(v: crate::live::VerticalOverflowMethod) -> Self {
493        match v {
494            crate::live::VerticalOverflowMethod::Crop => Self::Crop,
495            crate::live::VerticalOverflowMethod::Ellipsis => Self::Ellipsis,
496            crate::live::VerticalOverflowMethod::Visible => Self::Visible,
497        }
498    }
499}
500
501struct LiveEntry {
502    renderable: Box<dyn crate::Renderable + Send + Sync>,
503    vertical_overflow: LiveVerticalOverflow,
504}
505
506#[derive(Default)]
507struct LiveManager {
508    next_id: usize,
509    stack: Vec<usize>,
510    entries: HashMap<usize, LiveEntry>,
511    shape: Option<(usize, usize)>,
512    buffer: Option<ScreenBuffer>,
513}
514
515impl Console<Stdout> {
516    /// Create a new console writing to stdout.
517    pub fn new() -> Self {
518        let options = ConsoleOptions::from_terminal();
519        let color_system = Self::detect_color_system_static(options.is_terminal);
520
521        Console {
522            writer: io::stdout(),
523            options,
524            color_system,
525            force_terminal: None,
526            legacy_windows: cfg!(windows) && detect_legacy_windows_default(),
527            markup_enabled: true,
528            emoji_enabled: true,
529            highlight_enabled: true,
530            theme_stack: ThemeStack::new(Theme::default()),
531            theme_name: "default".to_string(),
532            is_alt_screen: false,
533            quiet: false,
534            tab_size: 8,
535            live: LiveManager::default(),
536            link_ids: HashMap::new(),
537            next_link_id: 1,
538            record: false,
539            record_buffer: Arc::new(Mutex::new(Vec::new())),
540            render_hooks: Vec::new(),
541        }
542    }
543
544    /// Create a new console with recording enabled.
545    ///
546    /// When recording is enabled, all segments written via `print()` are
547    /// captured in an internal buffer that can be exported as SVG/HTML.
548    ///
549    /// # Example
550    ///
551    /// ```
552    /// use rich_rs::Console;
553    ///
554    /// let mut console = Console::new_with_record();
555    /// console.print_text("Hello, World!").unwrap();
556    /// let svg = console.export_svg("Example", None, true, None, 0.61, None);
557    /// ```
558    pub fn new_with_record() -> Self {
559        let mut console = Self::new();
560        console.record = true;
561        console
562    }
563
564    /// Set the console theme by name.
565    ///
566    /// This sets the base theme for all renderables. Renderables like `Pretty` and
567    /// `Syntax` will automatically use this theme unless they have an explicit theme set.
568    ///
569    /// Available themes: "default", "dracula", "gruvbox-dark", "nord"
570    ///
571    /// # Example
572    ///
573    /// ```
574    /// use rich_rs::Console;
575    ///
576    /// let console = Console::new().with_theme("dracula");
577    /// ```
578    pub fn with_theme(mut self, name: &str) -> Self {
579        if let Some(theme) = Theme::from_name(name) {
580            self.theme_stack = ThemeStack::new(theme.clone());
581            self.options.theme_stack = ThemeStack::new(theme);
582            self.theme_name = name.to_string();
583            self.options.theme_name = name.to_string();
584        }
585        self
586    }
587
588    /// Create a console with specific options.
589    ///
590    /// Console state fields (theme_stack, markup_enabled, etc.) are initialized
591    /// from the provided options, ensuring that nested renderables see the
592    /// correct state when a temp Console is created from options.
593    pub fn with_options(options: ConsoleOptions) -> Self {
594        Console {
595            writer: io::stdout(),
596            // Initialize Console fields from ConsoleOptions state
597            color_system: options.color_system,
598            markup_enabled: options.markup_enabled,
599            emoji_enabled: options.emoji_enabled,
600            highlight_enabled: options.highlight_enabled,
601            theme_stack: options.theme_stack.clone(),
602            theme_name: options.theme_name.clone(),
603            tab_size: options.tab_size,
604            legacy_windows: options.legacy_windows,
605            // Non-state fields
606            force_terminal: None,
607            is_alt_screen: false,
608            quiet: false,
609            // Store the options
610            options,
611            live: LiveManager::default(),
612            link_ids: HashMap::new(),
613            next_link_id: 1,
614            record: false,
615            record_buffer: Arc::new(Mutex::new(Vec::new())),
616            render_hooks: Vec::new(),
617        }
618    }
619
620    /// Detect color system from environment variables.
621    fn detect_color_system_static(is_terminal: bool) -> Option<ColorSystem> {
622        // Explicit override wins.
623        if let Ok(value) = env::var("RICH_RS_COLOR_SYSTEM") {
624            match value.to_ascii_lowercase().as_str() {
625                "none" | "off" | "0" => return None,
626                "16" | "standard" => return Some(ColorSystem::Standard),
627                "256" | "eightbit" | "8bit" => return Some(ColorSystem::EightBit),
628                "truecolor" | "24bit" | "rgb" => return Some(ColorSystem::TrueColor),
629                "auto" => {}
630                _ => {}
631            }
632        }
633
634        // NO_COLOR disables color unconditionally.
635        if env::var("NO_COLOR").is_ok() {
636            return None;
637        }
638
639        let force_color = env::var("FORCE_COLOR").is_ok();
640        if !is_terminal && !force_color {
641            return None;
642        }
643
644        #[cfg(windows)]
645        if is_terminal && !crossterm::ansi_support::supports_ansi() {
646            // Legacy Windows console path: keep colors conservative.
647            return Some(ColorSystem::Standard);
648        }
649
650        if let Ok(colorterm) = env::var("COLORTERM") {
651            let ct = colorterm.to_ascii_lowercase();
652            if ct == "truecolor" || ct == "24bit" || ct == "yes" || ct == "true" {
653                return Some(ColorSystem::TrueColor);
654            }
655        }
656
657        if let Ok(term) = env::var("TERM") {
658            let term_lower = term.to_ascii_lowercase();
659            if term_lower.contains("truecolor")
660                || term_lower.contains("24bit")
661                || term_lower.contains("direct")
662            {
663                return Some(ColorSystem::TrueColor);
664            }
665            if term_lower.contains("256color") {
666                return Some(ColorSystem::EightBit);
667            }
668            if term_lower == "dumb" || term_lower == "unknown" {
669                return None;
670            }
671        }
672
673        // Interactive default: modern assumption.
674        if is_terminal {
675            #[cfg(windows)]
676            {
677                return Some(ColorSystem::TrueColor);
678            }
679            #[cfg(not(windows))]
680            {
681                return Some(ColorSystem::TrueColor);
682            }
683        }
684        if force_color {
685            return Some(ColorSystem::EightBit);
686        }
687        None
688    }
689}
690
691impl Default for Console<Stdout> {
692    fn default() -> Self {
693        Console::new()
694    }
695}
696
697impl Console<Vec<u8>> {
698    /// Create a console that captures output to a buffer.
699    ///
700    /// Use this for testing to capture console output.
701    ///
702    /// # Example
703    ///
704    /// ```
705    /// use rich_rs::Console;
706    ///
707    /// let mut console = Console::capture();
708    /// console.print_text("Hello").unwrap();
709    /// let output = console.get_captured();
710    /// assert!(output.contains("Hello"));
711    /// ```
712    pub fn capture() -> Self {
713        Console::with_writer(
714            Vec::new(),
715            ConsoleOptions {
716                is_terminal: false,
717                ..Default::default()
718            },
719        )
720    }
721
722    /// Create a capture console with specific options.
723    pub fn capture_with_options(options: ConsoleOptions) -> Self {
724        Console::with_writer(Vec::new(), options)
725    }
726
727    /// Get captured output as a string.
728    pub fn get_captured(&self) -> String {
729        String::from_utf8_lossy(&self.writer).to_string()
730    }
731
732    /// Get captured output as bytes.
733    pub fn get_captured_bytes(&self) -> &[u8] {
734        &self.writer
735    }
736
737    /// Clear the capture buffer.
738    pub fn clear_captured(&mut self) {
739        self.writer.clear();
740    }
741}
742
743impl<W: Write> Console<W> {
744    fn link_id_for_url(&mut self, url: &Arc<str>) -> Arc<str> {
745        if let Some(existing) = self.link_ids.get(url) {
746            return existing.clone();
747        }
748        let id: Arc<str> = Arc::from(format!("richrs-{}", self.next_link_id));
749        self.next_link_id = self.next_link_id.saturating_add(1);
750        self.link_ids.insert(url.clone(), id.clone());
751        id
752    }
753
754    /// Create a console with a custom writer.
755    ///
756    /// Console state fields are initialized from the provided options,
757    /// ensuring that nested renderables see the correct state.
758    pub fn with_writer(writer: W, options: ConsoleOptions) -> Self {
759        Console {
760            writer,
761            // Initialize Console fields from ConsoleOptions state
762            color_system: options.color_system,
763            markup_enabled: options.markup_enabled,
764            emoji_enabled: options.emoji_enabled,
765            highlight_enabled: options.highlight_enabled,
766            theme_stack: options.theme_stack.clone(),
767            theme_name: options.theme_name.clone(),
768            tab_size: options.tab_size,
769            legacy_windows: options.legacy_windows,
770            // Non-state fields
771            force_terminal: None,
772            is_alt_screen: false,
773            quiet: false,
774            // Store the options
775            options,
776            live: LiveManager::default(),
777            link_ids: HashMap::new(),
778            next_link_id: 1,
779            record: false,
780            record_buffer: Arc::new(Mutex::new(Vec::new())),
781            render_hooks: Vec::new(),
782        }
783    }
784
785    // ========================================================================
786    // Configuration
787    // ========================================================================
788
789    /// Get the console options.
790    pub fn options(&self) -> &ConsoleOptions {
791        &self.options
792    }
793
794    /// Get mutable access to console options.
795    ///
796    /// # Warning
797    ///
798    /// Modifying state fields (`markup_enabled`, `emoji_enabled`, `highlight_enabled`,
799    /// `tab_size`, `color_system`, `theme_stack`) via `options_mut()` will NOT update
800    /// the corresponding `Console` fields. This can cause inconsistent behavior.
801    ///
802    /// Use the specific setters (`set_markup_enabled()`, `set_tab_size()`, etc.) to
803    /// modify these fields, which keep both in sync. Or call `sync_from_options()`
804    /// after modifying options directly.
805    ///
806    /// This method is safe for modifying non-state fields like `max_width`, `justify`, etc.
807    pub fn options_mut(&mut self) -> &mut ConsoleOptions {
808        &mut self.options
809    }
810
811    /// Sync Console fields from options.
812    ///
813    /// Call this after modifying state fields via `options_mut()` to ensure
814    /// Console fields stay in sync with options.
815    pub fn sync_from_options(&mut self) {
816        self.markup_enabled = self.options.markup_enabled;
817        self.emoji_enabled = self.options.emoji_enabled;
818        self.highlight_enabled = self.options.highlight_enabled;
819        self.tab_size = self.options.tab_size;
820        self.color_system = self.options.color_system;
821        self.theme_stack = self.options.theme_stack.clone();
822        self.theme_name = self.options.theme_name.clone();
823        self.legacy_windows = self.options.legacy_windows;
824    }
825
826    /// Get a copy of options with current console state.
827    ///
828    /// Since Console setters now keep `self.options` in sync, this just clones
829    /// the options. It ensures caller-provided options will have correct state
830    /// if they were derived from `console.options()`.
831    ///
832    /// # Note
833    ///
834    /// If a caller creates `ConsoleOptions` from scratch (not derived from
835    /// `console.options()`), they should ensure state fields (theme_stack,
836    /// markup_enabled, etc.) are set appropriately. The console state is
837    /// passed through `ConsoleOptions`, not through the `Console` reference.
838    pub fn options_with_state(&self) -> ConsoleOptions {
839        // Since setters keep self.options in sync, just return a clone
840        self.options.clone()
841    }
842
843    /// Get the terminal width.
844    pub fn width(&self) -> usize {
845        self.options.max_width
846    }
847
848    /// Get the terminal height.
849    pub fn height(&self) -> usize {
850        self.options.max_height
851    }
852
853    /// Get the terminal size as (width, height).
854    pub fn size(&self) -> (usize, usize) {
855        self.options.size
856    }
857
858    /// Set the terminal size.
859    pub fn set_size(&mut self, width: usize, height: usize) {
860        self.options.size = (width, height);
861        self.options.max_width = width;
862        self.options.max_height = height;
863    }
864
865    /// Check if the console is writing to a terminal.
866    pub fn is_terminal(&self) -> bool {
867        self.force_terminal.unwrap_or(self.options.is_terminal)
868    }
869
870    /// Check if the terminal is considered "dumb" (no cursor control).
871    pub fn is_dumb_terminal(&self) -> bool {
872        match env::var("TERM") {
873            Ok(term) => {
874                let t = term.to_lowercase();
875                t == "dumb" || t == "unknown"
876            }
877            Err(_) => false,
878        }
879    }
880
881    /// Force terminal mode on or off.
882    pub fn set_force_terminal(&mut self, force: Option<bool>) {
883        self.force_terminal = force;
884    }
885
886    /// Get the color system.
887    pub fn color_system(&self) -> Option<ColorSystem> {
888        self.color_system
889    }
890
891    /// Set the color system.
892    pub fn set_color_system(&mut self, system: Option<ColorSystem>) {
893        self.color_system = system;
894        self.options.color_system = system;
895    }
896
897    /// Check if markup is enabled by default.
898    pub fn is_markup_enabled(&self) -> bool {
899        self.markup_enabled
900    }
901
902    /// Enable or disable markup parsing by default.
903    pub fn set_markup_enabled(&mut self, enabled: bool) {
904        self.markup_enabled = enabled;
905        self.options.markup_enabled = enabled;
906    }
907
908    /// Check if emoji replacement is enabled by default.
909    pub fn is_emoji_enabled(&self) -> bool {
910        self.emoji_enabled
911    }
912
913    /// Enable or disable emoji replacement by default.
914    pub fn set_emoji_enabled(&mut self, enabled: bool) {
915        self.emoji_enabled = enabled;
916        self.options.emoji_enabled = enabled;
917    }
918
919    /// Check if highlighting is enabled by default.
920    pub fn is_highlight_enabled(&self) -> bool {
921        self.highlight_enabled
922    }
923
924    /// Enable or disable highlighting by default.
925    pub fn set_highlight_enabled(&mut self, enabled: bool) {
926        self.highlight_enabled = enabled;
927        self.options.highlight_enabled = enabled;
928    }
929
930    /// Get the tab size.
931    pub fn tab_size(&self) -> usize {
932        self.tab_size
933    }
934
935    /// Get the configured output encoding.
936    pub fn encoding(&self) -> &str {
937        &self.options.encoding
938    }
939
940    /// Set the output encoding.
941    pub fn set_encoding(&mut self, encoding: impl Into<String>) {
942        self.options.encoding = encoding.into();
943    }
944
945    /// Set the tab size.
946    pub fn set_tab_size(&mut self, size: usize) {
947        self.tab_size = size;
948        self.options.tab_size = size;
949    }
950
951    /// Check if quiet mode is enabled.
952    pub fn is_quiet(&self) -> bool {
953        self.quiet
954    }
955
956    /// Enable or disable quiet mode (suppress all output).
957    pub fn set_quiet(&mut self, quiet: bool) {
958        self.quiet = quiet;
959    }
960
961    /// Get the current theme name.
962    ///
963    /// Returns the name of the base theme (e.g., "default", "dracula").
964    pub fn theme_name(&self) -> &str {
965        &self.theme_name
966    }
967
968    /// Set the theme by name.
969    ///
970    /// This replaces the base theme. Any pushed themes remain on the stack.
971    ///
972    /// # Example
973    ///
974    /// ```
975    /// use rich_rs::Console;
976    ///
977    /// let mut console = Console::new();
978    /// console.set_theme("dracula");
979    /// assert_eq!(console.theme_name(), "dracula");
980    /// ```
981    pub fn set_theme(&mut self, name: &str) {
982        if let Some(theme) = Theme::from_name(name) {
983            // Create a new theme stack with the new base theme
984            self.theme_stack = ThemeStack::new(theme.clone());
985            self.options.theme_stack = ThemeStack::new(theme);
986            self.theme_name = name.to_string();
987            self.options.theme_name = name.to_string();
988        }
989    }
990
991    /// Get a reference to the theme stack.
992    pub fn theme_stack(&self) -> &ThemeStack {
993        &self.theme_stack
994    }
995
996    /// Get a mutable reference to the theme stack.
997    ///
998    /// # Warning
999    ///
1000    /// Modifying the theme stack directly will NOT update `self.options.theme_stack`.
1001    /// This can cause nested renderables (which read from options) to see stale theme data.
1002    ///
1003    /// Prefer using `push_theme()` and `pop_theme()` which keep both stacks in sync.
1004    /// If you need direct access, call `sync_theme_to_options()` after modifications.
1005    pub fn theme_stack_mut(&mut self) -> &mut ThemeStack {
1006        &mut self.theme_stack
1007    }
1008
1009    /// Sync the options theme stack from the Console theme stack.
1010    ///
1011    /// Call this after modifying the theme stack via `theme_stack_mut()` to ensure
1012    /// nested renderables see the updated theme.
1013    pub fn sync_theme_to_options(&mut self) {
1014        self.options.theme_stack = self.theme_stack.clone();
1015    }
1016
1017    /// Push a new theme onto the stack.
1018    ///
1019    /// If `inherit` is true, the new theme inherits styles from the current theme.
1020    pub fn push_theme(&mut self, theme: Theme) {
1021        self.theme_stack.push_theme(theme.clone());
1022        self.options.theme_stack.push_theme(theme);
1023    }
1024
1025    /// Pop the top theme from the stack.
1026    ///
1027    /// Returns an error if trying to pop the base theme.
1028    pub fn pop_theme(&mut self) -> Result<(), crate::theme::ThemeError> {
1029        self.theme_stack.pop_theme()?;
1030        self.options.theme_stack.pop_theme()
1031    }
1032
1033    // ========================================================================
1034    // Core Render Methods
1035    // ========================================================================
1036
1037    // Note: The Renderable trait takes &Console (defaults to Console<Stdout>) plus
1038    // &ConsoleOptions. Console state (theme_stack, markup_enabled, etc.) is now
1039    // passed through ConsoleOptions, so renderables can access it without needing
1040    // a direct reference to the caller's Console<W>.
1041
1042    /// Render to a grid of lines (for layout).
1043    ///
1044    /// # Arguments
1045    ///
1046    /// * `renderable` - The object to render.
1047    /// * `options` - Optional custom options, or None to use console defaults.
1048    ///   If provided, ensure these options include the console state fields
1049    ///   (theme_stack, markup_enabled, etc.) by deriving from `console.options()`.
1050    /// * `style` - Optional style to apply to all segments.
1051    /// * `pad` - Whether to pad lines to the full width.
1052    /// * `new_lines` - Whether to include newline segments at the end of lines.
1053    pub fn render_lines<R: Renderable + ?Sized>(
1054        &self,
1055        renderable: &R,
1056        options: Option<&ConsoleOptions>,
1057        style: Option<Style>,
1058        pad: bool,
1059        new_lines: bool,
1060    ) -> Vec<Vec<Segment>> {
1061        // Use provided options or console's options (which include state)
1062        let render_options = options.cloned().unwrap_or_else(|| self.options.clone());
1063
1064        // Create a temp Console<Stdout> for the rendering call.
1065        // Console::with_options() initializes console fields from options,
1066        // so nested renderables will see the correct state.
1067        let temp_console = Console::<Stdout>::with_options(render_options.clone());
1068        let segments = renderable.render(&temp_console, &render_options);
1069
1070        // Apply style if provided
1071        let segments = if let Some(s) = style {
1072            Segment::apply_style_to_segments(segments, Some(s), None)
1073        } else {
1074            segments
1075        };
1076        let segments = self.apply_render_hooks(segments);
1077
1078        // Split and crop lines
1079        let width = render_options.max_width;
1080        Segment::split_and_crop_lines(segments, width, style, pad, new_lines)
1081    }
1082
1083    fn apply_render_hooks(&self, mut segments: Segments) -> Segments {
1084        for hook in &self.render_hooks {
1085            segments = hook(&segments);
1086        }
1087        segments
1088    }
1089
1090    /// Render a string to Text with optional markup/emoji/highlight.
1091    ///
1092    /// This method converts a string to a Text object, applying:
1093    /// - Markup parsing (if enabled)
1094    /// - Emoji replacement (if enabled)
1095    /// - Syntax highlighting (if highlighter provided)
1096    ///
1097    /// # Arguments
1098    ///
1099    /// * `text` - The text to render.
1100    /// * `markup` - Whether to parse markup, or None to use console default.
1101    /// * `emoji` - Whether to replace emoji codes, or None to use console default.
1102    /// * `highlight` - Whether to apply highlighting, or None to use console default.
1103    /// * `highlighter` - Optional highlighter to apply.
1104    pub fn render_str(
1105        &self,
1106        text: &str,
1107        markup: Option<bool>,
1108        emoji: Option<bool>,
1109        highlight: Option<bool>,
1110        highlighter: Option<&dyn Highlighter>,
1111    ) -> Text {
1112        let markup_enabled = markup.unwrap_or(self.markup_enabled);
1113        let emoji_enabled = emoji.unwrap_or(self.emoji_enabled);
1114        let highlight_enabled = highlight.unwrap_or(self.highlight_enabled);
1115
1116        // Start with the input text, possibly with emoji replaced
1117        let processed_text = if emoji_enabled {
1118            Emoji::replace(text)
1119        } else {
1120            text.to_string()
1121        };
1122
1123        // Parse markup if enabled
1124        let mut result = if markup_enabled {
1125            Text::from_markup(&processed_text, false)
1126                .unwrap_or_else(|_| Text::plain(&processed_text))
1127        } else {
1128            Text::plain(&processed_text)
1129        };
1130
1131        // Apply highlighter if provided and highlighting is enabled
1132        if let (true, Some(hl)) = (highlight_enabled, highlighter) {
1133            hl.highlight(&mut result);
1134        }
1135
1136        result
1137    }
1138
1139    // ========================================================================
1140    // Output Methods
1141    // ========================================================================
1142
1143    /// Write raw bytes to the output.
1144    pub fn write_raw(&mut self, data: &[u8]) -> io::Result<()> {
1145        if self.quiet {
1146            return Ok(());
1147        }
1148        self.writer.write_all(data)?;
1149        self.writer.flush()
1150    }
1151
1152    /// Write a string directly to the output.
1153    pub fn write_str(&mut self, s: &str) -> io::Result<()> {
1154        self.write_raw(s.as_bytes())
1155    }
1156
1157    /// Print plain text with a newline.
1158    pub fn print_text(&mut self, text: &str) -> io::Result<()> {
1159        if self.quiet {
1160            return Ok(());
1161        }
1162        // Use the full print() path if recording or live mode is active
1163        if self.record || (self.is_terminal() && !self.is_dumb_terminal() && self.has_live()) {
1164            return self.print(&Text::plain(text), None, None, None, false, "\n");
1165        }
1166        writeln!(self.writer, "{}", text)?;
1167        self.writer.flush()
1168    }
1169
1170    /// Print styled text with a newline.
1171    pub fn print_styled(&mut self, text: &str, style: Style) -> io::Result<()> {
1172        if self.quiet {
1173            return Ok(());
1174        }
1175        // Use the full print() path if recording or live mode is active
1176        if self.record || (self.is_terminal() && !self.is_dumb_terminal() && self.has_live()) {
1177            return self.print(&Text::styled(text, style), None, None, None, false, "\n");
1178        }
1179        // Only apply ANSI styling if color system is available
1180        if let Some(color_system) = self.color_system {
1181            let styled = style.render(text, color_system);
1182            writeln!(self.writer, "{}", styled)?;
1183        } else {
1184            writeln!(self.writer, "{}", text)?;
1185        }
1186        self.writer.flush()
1187    }
1188
1189    /// Print a traceback.
1190    ///
1191    /// This renders the given `Traceback` to the console with appropriate
1192    /// styling. It's the Rust equivalent of Python Rich's `console.print_exception()`.
1193    ///
1194    /// # Example
1195    ///
1196    /// ```rust,no_run
1197    /// use rich_rs::{Console, traceback::{Traceback, Trace, Stack, Frame}};
1198    ///
1199    /// let frame = Frame::new("main.rs", 42, "main");
1200    /// let stack = Stack::new("Error", "Something went wrong").with_frame(frame);
1201    /// let trace = Trace::new(vec![stack]);
1202    /// let tb = Traceback::new(trace);
1203    ///
1204    /// let mut console = Console::new();
1205    /// console.print_traceback(&tb).unwrap();
1206    /// ```
1207    pub fn print_traceback(&mut self, traceback: &Traceback) -> io::Result<()> {
1208        self.print(traceback, None, None, None, false, "\n")
1209    }
1210
1211    /// Print a segment.
1212    pub fn print_segment(&mut self, segment: &Segment) -> io::Result<()> {
1213        if self.quiet {
1214            return Ok(());
1215        }
1216
1217        if let Some(style) = segment.style {
1218            if let Some(color_system) = self.color_system {
1219                let styled = style.render(&segment.text, color_system);
1220                write!(self.writer, "{}", styled)?;
1221            } else {
1222                write!(self.writer, "{}", segment.text)?;
1223            }
1224        } else {
1225            write!(self.writer, "{}", segment.text)?;
1226        }
1227        self.writer.flush()
1228    }
1229
1230    /// Print multiple segments.
1231    ///
1232    /// Uses streaming output that avoids resetting styles between segments,
1233    /// which prevents visual artifacts like black hairlines between colored lines.
1234    pub fn print_segments(&mut self, segments: &Segments) -> io::Result<()> {
1235        if self.quiet {
1236            return Ok(());
1237        }
1238        if cfg!(windows) && detect_windows_render_mode() == WindowsRenderMode::Segment {
1239            return self.print_segments_segment_mode(segments);
1240        }
1241
1242        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1243        struct StyleState {
1244            fg: crate::color::SimpleColor,
1245            bg: crate::color::SimpleColor,
1246            bold: bool,
1247            dim: bool,
1248            italic: bool,
1249            underline: bool,
1250            blink: bool,
1251            reverse: bool,
1252            strike: bool,
1253        }
1254
1255        impl StyleState {
1256            const DEFAULT: Self = Self {
1257                fg: crate::color::SimpleColor::Default,
1258                bg: crate::color::SimpleColor::Default,
1259                bold: false,
1260                dim: false,
1261                italic: false,
1262                underline: false,
1263                blink: false,
1264                reverse: false,
1265                strike: false,
1266            };
1267
1268            fn from_style(style: Option<Style>) -> Self {
1269                let style = style.unwrap_or_default();
1270                Self {
1271                    fg: style.color.unwrap_or(crate::color::SimpleColor::Default),
1272                    bg: style.bgcolor.unwrap_or(crate::color::SimpleColor::Default),
1273                    bold: style.bold.unwrap_or(false),
1274                    dim: style.dim.unwrap_or(false),
1275                    italic: style.italic.unwrap_or(false),
1276                    underline: style.underline.unwrap_or(false),
1277                    blink: style.blink.unwrap_or(false),
1278                    reverse: style.reverse.unwrap_or(false),
1279                    strike: style.strike.unwrap_or(false),
1280                }
1281            }
1282
1283            fn sgr_diff(self, target: Self, color_system: ColorSystem) -> String {
1284                if self == target {
1285                    return String::new();
1286                }
1287
1288                let mut sgr: Vec<String> = Vec::new();
1289
1290                // Reset codes first (explicitly turning attributes off).
1291                // Note: SGR 22 resets both bold AND dim.
1292                let needs_22 = (self.bold && !target.bold) || (self.dim && !target.dim);
1293                if needs_22 {
1294                    sgr.push("22".to_string());
1295                }
1296                if self.italic && !target.italic {
1297                    sgr.push("23".to_string());
1298                }
1299                if self.underline && !target.underline {
1300                    sgr.push("24".to_string());
1301                }
1302                if self.blink && !target.blink {
1303                    sgr.push("25".to_string());
1304                }
1305                if self.reverse && !target.reverse {
1306                    sgr.push("27".to_string());
1307                }
1308                if self.strike && !target.strike {
1309                    sgr.push("29".to_string());
1310                }
1311
1312                // Colors: treat unspecified colors as Default (39/49) to avoid "bleed"
1313                // between segments when using streaming output.
1314                if self.fg != target.fg {
1315                    let fg = target.fg.downgrade(color_system);
1316                    sgr.extend(fg.get_ansi_codes(true));
1317                }
1318                if self.bg != target.bg {
1319                    let bg = target.bg.downgrade(color_system);
1320                    sgr.extend(bg.get_ansi_codes(false));
1321                }
1322
1323                // Enable codes last.
1324                if target.bold && (!self.bold || needs_22) {
1325                    sgr.push("1".to_string());
1326                }
1327                if target.dim && (!self.dim || needs_22) {
1328                    sgr.push("2".to_string());
1329                }
1330                if target.italic && !self.italic {
1331                    sgr.push("3".to_string());
1332                }
1333                if target.underline && !self.underline {
1334                    sgr.push("4".to_string());
1335                }
1336                if target.blink && !self.blink {
1337                    sgr.push("5".to_string());
1338                }
1339                if target.reverse && !self.reverse {
1340                    sgr.push("7".to_string());
1341                }
1342                if target.strike && !self.strike {
1343                    sgr.push("9".to_string());
1344                }
1345
1346                sgr.join(";")
1347            }
1348        }
1349
1350        let mut current = StyleState::DEFAULT;
1351        let mut used_sgr = false;
1352        let hyperlinks_enabled = self.is_terminal() && !self.is_dumb_terminal();
1353        let mut current_link: Option<(Arc<str>, Option<Arc<str>>)> = None;
1354        let mut hyperlink_manual = false;
1355
1356        for segment in segments.iter() {
1357            if let Some(control) = &segment.control {
1358                debug_segments_log(&format!("[control][streaming] {:?}", control));
1359                // Emit terminal controls regardless of style state.
1360                // Control sequences generally do not alter SGR state.
1361                match control {
1362                    ControlType::Bell => write!(self.writer, "\x07")?,
1363                    ControlType::CarriageReturn => write!(self.writer, "\r")?,
1364                    ControlType::Home => write!(self.writer, "\x1b[H")?,
1365                    ControlType::Clear => write!(self.writer, "\x1b[2J\x1b[H")?,
1366                    ControlType::ShowCursor => write!(self.writer, "\x1b[?25h")?,
1367                    ControlType::HideCursor => write!(self.writer, "\x1b[?25l")?,
1368                    ControlType::EnableAltScreen => write!(self.writer, "\x1b[?1049h")?,
1369                    ControlType::DisableAltScreen => write!(self.writer, "\x1b[?1049l")?,
1370                    ControlType::SetTitle => {
1371                        // Not representable without a payload; ignore.
1372                    }
1373                    ControlType::CursorUp(n) => write!(self.writer, "\x1b[{}A", n)?,
1374                    ControlType::CursorDown(n) => write!(self.writer, "\x1b[{}B", n)?,
1375                    ControlType::CursorForward(n) => write!(self.writer, "\x1b[{}C", n)?,
1376                    ControlType::CursorBackward(n) => write!(self.writer, "\x1b[{}D", n)?,
1377                    ControlType::EraseInLine(mode) => write!(self.writer, "\x1b[{}K", mode)?,
1378                    ControlType::HyperlinkStart { url, id } => {
1379                        if hyperlinks_enabled {
1380                            if let Some(id) = id.as_deref() {
1381                                write!(self.writer, "\x1b]8;id={};{}\x1b\\", id, url)?;
1382                            } else {
1383                                write!(self.writer, "\x1b]8;;{}\x1b\\", url)?;
1384                            }
1385                            current_link = Some((url.clone(), id.clone()));
1386                            hyperlink_manual = true;
1387                        }
1388                    }
1389                    ControlType::HyperlinkEnd => {
1390                        if hyperlinks_enabled {
1391                            write!(self.writer, "\x1b]8;;\x1b\\")?;
1392                            current_link = None;
1393                            hyperlink_manual = false;
1394                        }
1395                    }
1396                    ControlType::MoveTo { x, y } => {
1397                        // CSI row;col H (1-based)
1398                        write!(
1399                            self.writer,
1400                            "\x1b[{};{}H",
1401                            (*y as usize) + 1,
1402                            (*x as usize) + 1
1403                        )?
1404                    }
1405                }
1406                continue;
1407            }
1408
1409            if hyperlinks_enabled && !hyperlink_manual {
1410                let mut desired_link: Option<(Arc<str>, Option<Arc<str>>)> = None;
1411                if let Some(meta) = segment.meta.as_ref() {
1412                    if let Some(url) = meta.link.as_ref() {
1413                        let url = url.clone();
1414                        let id = meta
1415                            .link_id
1416                            .clone()
1417                            .or_else(|| Some(self.link_id_for_url(&url)));
1418                        desired_link = Some((url, id));
1419                    }
1420                }
1421
1422                if desired_link != current_link {
1423                    // Close any previous link.
1424                    if current_link.is_some() {
1425                        write!(self.writer, "\x1b]8;;\x1b\\")?;
1426                    }
1427                    // Open the new link.
1428                    if let Some((url, id)) = &desired_link {
1429                        if let Some(id) = id.as_deref() {
1430                            write!(self.writer, "\x1b]8;id={};{}\x1b\\", id, url)?;
1431                        } else {
1432                            write!(self.writer, "\x1b]8;;{}\x1b\\", url)?;
1433                        }
1434                    }
1435                    current_link = desired_link;
1436                }
1437            }
1438
1439            if let Some(color_system) = self.color_system {
1440                if debug_segments_match_text(&segment.text) {
1441                    debug_segments_log(&format!(
1442                        "[segment][streaming] text={:?} style={:?} color_system={:?}",
1443                        segment.text, segment.style, self.color_system
1444                    ));
1445                }
1446                let target = StyleState::from_style(segment.style);
1447                let diff = current.sgr_diff(target, color_system);
1448                if !diff.is_empty() {
1449                    write!(self.writer, "\x1b[{}m", diff)?;
1450                    if debug_segments_match_text(&segment.text) {
1451                        debug_ansi_log(&format!(
1452                            "[ansi][streaming] text={:?} sgr=\\x1b[{}m target={:?}",
1453                            segment.text, diff, target
1454                        ));
1455                    }
1456                    used_sgr = true;
1457                }
1458                write!(self.writer, "{}", segment.text)?;
1459                current = target;
1460            } else {
1461                if debug_segments_match_text(&segment.text) {
1462                    debug_segments_log(&format!(
1463                        "[segment][streaming] text={:?} style={:?} color_system=None",
1464                        segment.text, segment.style
1465                    ));
1466                }
1467                write!(self.writer, "{}", segment.text)?;
1468            }
1469        }
1470
1471        // Close any active hyperlink so it doesn't leak past the renderable.
1472        if hyperlinks_enabled && current_link.is_some() {
1473            write!(self.writer, "\x1b]8;;\x1b\\")?;
1474        }
1475
1476        // Reset at the end so terminal state doesn't leak past the renderable.
1477        if self.color_system.is_some() && used_sgr && current != StyleState::DEFAULT {
1478            write!(self.writer, "\x1b[0m")?;
1479            debug_ansi_log("[ansi][streaming] tail-reset=\\x1b[0m");
1480        }
1481
1482        self.writer.flush()
1483    }
1484
1485    fn print_segments_segment_mode(&mut self, segments: &Segments) -> io::Result<()> {
1486        let hyperlinks_enabled = self.is_terminal() && !self.is_dumb_terminal();
1487        let mut current_link: Option<(Arc<str>, Option<Arc<str>>)> = None;
1488        let mut hyperlink_manual = false;
1489
1490        for segment in segments.iter() {
1491            if let Some(control) = &segment.control {
1492                debug_segments_log(&format!("[control][segment] {:?}", control));
1493                match control {
1494                    ControlType::Bell => write!(self.writer, "\x07")?,
1495                    ControlType::CarriageReturn => write!(self.writer, "\r")?,
1496                    ControlType::Home => write!(self.writer, "\x1b[H")?,
1497                    ControlType::Clear => write!(self.writer, "\x1b[2J\x1b[H")?,
1498                    ControlType::ShowCursor => write!(self.writer, "\x1b[?25h")?,
1499                    ControlType::HideCursor => write!(self.writer, "\x1b[?25l")?,
1500                    ControlType::EnableAltScreen => write!(self.writer, "\x1b[?1049h")?,
1501                    ControlType::DisableAltScreen => write!(self.writer, "\x1b[?1049l")?,
1502                    ControlType::SetTitle => {}
1503                    ControlType::CursorUp(n) => write!(self.writer, "\x1b[{}A", n)?,
1504                    ControlType::CursorDown(n) => write!(self.writer, "\x1b[{}B", n)?,
1505                    ControlType::CursorForward(n) => write!(self.writer, "\x1b[{}C", n)?,
1506                    ControlType::CursorBackward(n) => write!(self.writer, "\x1b[{}D", n)?,
1507                    ControlType::EraseInLine(mode) => write!(self.writer, "\x1b[{}K", mode)?,
1508                    ControlType::HyperlinkStart { url, id } => {
1509                        if hyperlinks_enabled {
1510                            if let Some(id) = id.as_deref() {
1511                                write!(self.writer, "\x1b]8;id={};{}\x1b\\", id, url)?;
1512                            } else {
1513                                write!(self.writer, "\x1b]8;;{}\x1b\\", url)?;
1514                            }
1515                            current_link = Some((url.clone(), id.clone()));
1516                            hyperlink_manual = true;
1517                        }
1518                    }
1519                    ControlType::HyperlinkEnd => {
1520                        if hyperlinks_enabled {
1521                            write!(self.writer, "\x1b]8;;\x1b\\")?;
1522                            current_link = None;
1523                            hyperlink_manual = false;
1524                        }
1525                    }
1526                    ControlType::MoveTo { x, y } => write!(
1527                        self.writer,
1528                        "\x1b[{};{}H",
1529                        (*y as usize) + 1,
1530                        (*x as usize) + 1
1531                    )?,
1532                }
1533                continue;
1534            }
1535
1536            if hyperlinks_enabled && !hyperlink_manual {
1537                let mut desired_link: Option<(Arc<str>, Option<Arc<str>>)> = None;
1538                if let Some(meta) = segment.meta.as_ref() {
1539                    if let Some(url) = meta.link.as_ref() {
1540                        let url = url.clone();
1541                        let id = meta
1542                            .link_id
1543                            .clone()
1544                            .or_else(|| Some(self.link_id_for_url(&url)));
1545                        desired_link = Some((url, id));
1546                    }
1547                }
1548
1549                if desired_link != current_link {
1550                    if current_link.is_some() {
1551                        write!(self.writer, "\x1b]8;;\x1b\\")?;
1552                    }
1553                    if let Some((url, id)) = &desired_link {
1554                        if let Some(id) = id.as_deref() {
1555                            write!(self.writer, "\x1b]8;id={};{}\x1b\\", id, url)?;
1556                        } else {
1557                            write!(self.writer, "\x1b]8;;{}\x1b\\", url)?;
1558                        }
1559                    }
1560                    current_link = desired_link;
1561                }
1562            }
1563
1564            if let Some(style) = segment.style {
1565                if debug_segments_match_text(&segment.text) {
1566                    debug_segments_log(&format!(
1567                        "[segment][segment] text={:?} style={:?} color_system={:?}",
1568                        segment.text, style, self.color_system
1569                    ));
1570                }
1571                if let Some(color_system) = self.color_system {
1572                    let styled = style.render(&segment.text, color_system);
1573                    if debug_segments_match_text(&segment.text) {
1574                        let sgr = styled
1575                            .strip_prefix("\x1b[")
1576                            .and_then(|rest| rest.split_once('m').map(|(a, _)| a))
1577                            .unwrap_or("<none>");
1578                        debug_ansi_log(&format!(
1579                            "[ansi][segment] text={:?} sgr=\\x1b[{}m style={:?} color_system={:?}",
1580                            segment.text, sgr, style, self.color_system
1581                        ));
1582                    }
1583                    write!(self.writer, "{}", styled)?;
1584                } else {
1585                    write!(self.writer, "{}", segment.text)?;
1586                }
1587            } else {
1588                if debug_segments_match_text(&segment.text) {
1589                    debug_segments_log(&format!(
1590                        "[segment][segment] text={:?} style=None color_system={:?}",
1591                        segment.text, self.color_system
1592                    ));
1593                }
1594                write!(self.writer, "{}", segment.text)?;
1595            }
1596        }
1597
1598        if hyperlinks_enabled && current_link.is_some() {
1599            write!(self.writer, "\x1b]8;;\x1b\\")?;
1600        }
1601
1602        self.writer.flush()
1603    }
1604
1605    /// Print a renderable object.
1606    ///
1607    /// This is the main method for printing content to the console.
1608    /// It renders the object to segments and writes them to the output.
1609    ///
1610    /// # Arguments
1611    ///
1612    /// * `renderable` - The object to render and print.
1613    /// * `style` - Optional style to apply to all output.
1614    /// * `justify` - Optional justify override.
1615    /// * `overflow` - Optional overflow override.
1616    /// * `no_wrap` - Whether to disable word wrapping.
1617    /// * `end` - String to print at the end (default "\n").
1618    pub fn print<R: Renderable + ?Sized>(
1619        &mut self,
1620        renderable: &R,
1621        style: Option<Style>,
1622        justify: Option<JustifyMethod>,
1623        overflow: Option<OverflowMethod>,
1624        no_wrap: bool,
1625        end: &str,
1626    ) -> io::Result<()> {
1627        if self.quiet {
1628            return Ok(());
1629        }
1630
1631        // Create options with overrides - self.options already contains console state
1632        let options = self.options.update(
1633            None,
1634            None,
1635            None,
1636            Some(justify),
1637            Some(overflow),
1638            Some(no_wrap),
1639            None,
1640            None,
1641            None,
1642        );
1643
1644        // Create a temp Console<Stdout> for the rendering call.
1645        // Console::with_options() initializes console fields from options.
1646        let temp_console = Console::<Stdout>::with_options(options.clone());
1647
1648        // Render to segments
1649        let segments = renderable.render(&temp_console, &options);
1650
1651        // Apply style if provided
1652        let mut segments = if let Some(s) = style {
1653            Segment::apply_style_to_segments(segments, Some(s), None)
1654        } else {
1655            segments
1656        };
1657        segments = self.apply_render_hooks(segments);
1658
1659        let live_active = self.is_terminal() && !self.is_dumb_terminal() && self.has_live();
1660        let mut end_to_write = end;
1661        if live_active {
1662            // Cursor repositioning must be based on the *previous* live shape
1663            // (the currently visible frame), not the newly rendered shape.
1664            let previous_live_shape = self.live.shape;
1665            // When Live is active, the trailing newline belongs to the *printed* content,
1666            // and the live render must be re-drawn after it (Rich behavior).
1667            if !end.is_empty() {
1668                segments.push(Segment::new(end.to_string()));
1669            }
1670            end_to_write = "";
1671
1672            let (live_segments, full_redraw) = self.render_live_segments(&options);
1673            let mut wrapped = Segments::new();
1674            let cursor_controls = if full_redraw {
1675                self.live_position_cursor_for_shape(previous_live_shape, true)
1676            } else {
1677                self.live_position_cursor_for_shape(previous_live_shape, false)
1678            };
1679            for seg in cursor_controls.iter() {
1680                wrapped.push(seg.clone());
1681            }
1682            for seg in segments.into_iter() {
1683                wrapped.push(seg);
1684            }
1685            for seg in live_segments.into_iter() {
1686                wrapped.push(seg);
1687            }
1688            segments = wrapped;
1689        }
1690
1691        let should_disable_wrap = self.options.disable_line_wrap && atty::is(atty::Stream::Stdout);
1692        if should_disable_wrap {
1693            // Disable automatic line wrap (DECAWM) so output can use full width
1694            // without terminals inserting an extra wrapped line.
1695            write!(self.writer, "\x1b[?7l")?;
1696        }
1697
1698        // Record segments if recording is enabled
1699        if self.record {
1700            if let Ok(mut buffer) = self.record_buffer.lock() {
1701                for seg in segments.iter() {
1702                    buffer.push(seg.clone());
1703                }
1704                if !end_to_write.is_empty() {
1705                    buffer.push(Segment::new(end_to_write.to_string()));
1706                }
1707            }
1708        }
1709
1710        let result = (|| {
1711            // Print segments
1712            self.print_segments(&segments)?;
1713
1714            // Print end string
1715            if !end_to_write.is_empty() {
1716                write!(self.writer, "{}", end_to_write)?;
1717            }
1718
1719            self.writer.flush()
1720        })();
1721
1722        if should_disable_wrap {
1723            // Always attempt to restore wrap mode.
1724            let _ = write!(self.writer, "\x1b[?7h");
1725        }
1726
1727        result
1728    }
1729
1730    /// Log a renderable with timestamp prefix.
1731    ///
1732    /// Similar to `print()`, but adds a timestamp prefix in `[HH:MM:SS]` format.
1733    /// Optionally displays the source file and line number.
1734    ///
1735    /// # Arguments
1736    ///
1737    /// * `renderable` - The object to render and log.
1738    /// * `file` - Optional source file name (use `file!()` macro at call site).
1739    /// * `line` - Optional line number (use `line!()` macro at call site).
1740    ///
1741    /// # Example
1742    ///
1743    /// ```ignore
1744    /// use rich_rs::{Console, Text};
1745    ///
1746    /// let mut console = Console::new();
1747    /// // Using the log! macro (recommended)
1748    /// rich_rs::log!(console, &Text::plain("Server starting..."));
1749    ///
1750    /// // Or directly with file/line
1751    /// console.log(&Text::plain("Message"), Some(file!()), Some(line!())).unwrap();
1752    /// ```
1753    pub fn log<R: Renderable + ?Sized>(
1754        &mut self,
1755        renderable: &R,
1756        file: Option<&str>,
1757        line: Option<u32>,
1758    ) -> io::Result<()> {
1759        if self.quiet {
1760            return Ok(());
1761        }
1762
1763        // Get current time
1764        let now = SystemTime::now();
1765        let duration = now
1766            .duration_since(SystemTime::UNIX_EPOCH)
1767            .unwrap_or_default();
1768        let secs = duration.as_secs();
1769        let hours = (secs / 3600) % 24;
1770        let minutes = (secs / 60) % 60;
1771        let seconds = secs % 60;
1772
1773        // Create timestamp text with dim style
1774        let timestamp = format!("[{:02}:{:02}:{:02}]", hours, minutes, seconds);
1775        let time_style = self
1776            .theme_stack
1777            .get_style("log.time")
1778            .unwrap_or_else(|| Style::new().with_dim(true));
1779        let time_text = Text::styled(&timestamp, time_style);
1780
1781        // Create a grid table for layout: [time] [message] [path:line]
1782        let mut grid = Table::grid().with_padding(0, 1).with_expand(true);
1783
1784        // Time column
1785        grid.add_column(Column::new().style(time_style).no_wrap(true));
1786
1787        // Message column (ratio=1, expands to fill)
1788        let message_style = self
1789            .theme_stack
1790            .get_style("log.message")
1791            .unwrap_or_default();
1792        grid.add_column(Column::new().style(message_style).ratio(1));
1793
1794        // Path column (optional)
1795        let has_path = file.is_some();
1796        if has_path {
1797            let path_style = self
1798                .theme_stack
1799                .get_style("log.path")
1800                .unwrap_or_else(|| Style::new().with_dim(true));
1801            grid.add_column(Column::new().style(path_style).no_wrap(true));
1802        }
1803
1804        // Build the row
1805        let mut cells: Vec<Box<dyn Renderable + Send + Sync>> = vec![Box::new(time_text)];
1806
1807        // Wrap the renderable in a capturing approach
1808        // We need to render the user's content and wrap it
1809        let options = self.options.clone();
1810        let temp_console = Console::<Stdout>::with_options(options.clone());
1811        let segments = renderable.render(&temp_console, &options);
1812
1813        // Convert segments to Text for the cell
1814        let mut message_text = Text::plain("");
1815        for seg in segments.iter() {
1816            if seg.control.is_none() {
1817                message_text.append(&*seg.text, seg.style);
1818            }
1819        }
1820        cells.push(Box::new(message_text));
1821
1822        // Add path:line if provided
1823        if let Some(f) = file {
1824            // Extract just the filename from the path
1825            let filename = f.rsplit(['/', '\\']).next().unwrap_or(f);
1826            let path_text = if let Some(l) = line {
1827                Text::plain(format!("{}:{}", filename, l))
1828            } else {
1829                Text::plain(filename)
1830            };
1831            cells.push(Box::new(path_text));
1832        }
1833
1834        grid.add_row(Row::new(cells));
1835
1836        // Print the grid
1837        self.print(&grid, None, None, None, false, "\n")
1838    }
1839
1840    /// Render a line (horizontal rule).
1841    pub fn rule(&mut self, title: Option<&str>) -> io::Result<()> {
1842        if self.quiet {
1843            return Ok(());
1844        }
1845
1846        let width = self.width();
1847        match title {
1848            Some(t) => {
1849                // Use cell_len for correct width calculation with wide characters
1850                let title_width = crate::cells::cell_len(t);
1851                let padding = (width.saturating_sub(title_width + 2)) / 2;
1852                let line: String = "─".repeat(padding);
1853                writeln!(self.writer, "{} {} {}", line, t, line)?;
1854            }
1855            None => {
1856                let line: String = "─".repeat(width);
1857                writeln!(self.writer, "{}", line)?;
1858            }
1859        }
1860        self.writer.flush()
1861    }
1862
1863    /// Print new line(s).
1864    pub fn line(&mut self, count: usize) -> io::Result<()> {
1865        if self.quiet {
1866            return Ok(());
1867        }
1868
1869        if self.is_terminal() && !self.is_dumb_terminal() && self.has_live() {
1870            for _ in 0..count {
1871                self.print(&Text::plain(""), None, None, None, false, "\n")?;
1872            }
1873            return Ok(());
1874        }
1875
1876        for _ in 0..count {
1877            writeln!(self.writer)?;
1878        }
1879        self.writer.flush()
1880    }
1881
1882    // ========================================================================
1883    // Terminal Control
1884    // ========================================================================
1885
1886    /// Clear the screen.
1887    pub fn clear(&mut self) -> io::Result<()> {
1888        if !self.is_terminal() {
1889            return Ok(());
1890        }
1891        execute!(self.writer, ct::Clear(ClearType::All))?;
1892        execute!(self.writer, cursor::MoveTo(0, 0))?;
1893        self.writer.flush()
1894    }
1895
1896    /// Clear the current line.
1897    pub fn clear_line(&mut self) -> io::Result<()> {
1898        if !self.is_terminal() {
1899            return Ok(());
1900        }
1901        execute!(self.writer, ct::Clear(ClearType::CurrentLine))?;
1902        self.writer.flush()
1903    }
1904
1905    /// Move the cursor to a specific position.
1906    pub fn move_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
1907        if !self.is_terminal() {
1908            return Ok(());
1909        }
1910        execute!(self.writer, cursor::MoveTo(x, y))?;
1911        self.writer.flush()
1912    }
1913
1914    /// Show or hide the cursor.
1915    pub fn show_cursor(&mut self, show: bool) -> io::Result<bool> {
1916        if !self.is_terminal() {
1917            return Ok(false);
1918        }
1919        if show {
1920            execute!(self.writer, cursor::Show)?;
1921        } else {
1922            execute!(self.writer, cursor::Hide)?;
1923        }
1924        self.writer.flush()?;
1925        Ok(true)
1926    }
1927
1928    /// Enter alternate screen mode.
1929    ///
1930    /// The alternate screen is a separate screen buffer that can be used
1931    /// for full-screen applications. Call `leave_alt_screen` when done.
1932    pub fn enter_alt_screen(&mut self) -> io::Result<bool> {
1933        if !self.is_terminal() || self.legacy_windows {
1934            return Ok(false);
1935        }
1936        self.set_alt_screen(true)
1937    }
1938
1939    /// Leave alternate screen mode.
1940    pub fn leave_alt_screen(&mut self) -> io::Result<bool> {
1941        if !self.is_terminal() || !self.is_alt_screen {
1942            return Ok(false);
1943        }
1944        self.set_alt_screen(false)
1945    }
1946
1947    /// Check if alternate screen mode is active.
1948    pub fn is_alt_screen(&self) -> bool {
1949        self.is_alt_screen
1950    }
1951
1952    /// Enable or disable alternate screen mode (Rich parity).
1953    ///
1954    /// When enabling, Rich emits `ENABLE_ALT_SCREEN` followed by `HOME`.
1955    /// When disabling, Rich emits `DISABLE_ALT_SCREEN`.
1956    pub fn set_alt_screen(&mut self, enable: bool) -> io::Result<bool> {
1957        if !self.is_terminal() || self.legacy_windows {
1958            return Ok(false);
1959        }
1960        if enable == self.is_alt_screen {
1961            return Ok(false);
1962        }
1963
1964        let mut segs = Segments::new();
1965        if enable {
1966            segs.push(Segment::control(ControlType::EnableAltScreen));
1967            segs.push(Segment::control(ControlType::Home));
1968            self.is_alt_screen = true;
1969        } else {
1970            segs.push(Segment::control(ControlType::DisableAltScreen));
1971            self.is_alt_screen = false;
1972        }
1973        self.print_segments(&segs)?;
1974        Ok(true)
1975    }
1976
1977    /// Enter alternate screen mode with a context guard.
1978    ///
1979    /// This returns a [`crate::ScreenContext`] that automatically leaves alternate screen
1980    /// mode when dropped, providing RAII semantics for full-screen applications.
1981    ///
1982    /// # Arguments
1983    ///
1984    /// * `hide_cursor` - Whether to hide the cursor while in alternate screen mode.
1985    /// * `style` - Optional background style for the screen.
1986    ///
1987    /// # Example
1988    ///
1989    /// ```ignore
1990    /// use rich_rs::{Console, Text};
1991    ///
1992    /// let mut console = Console::new();
1993    /// let mut screen = console.screen(true, None)?;
1994    /// screen.update(Text::plain("Hello!"))?;
1995    /// // Screen is automatically exited when `screen` is dropped
1996    /// ```
1997    pub fn screen(
1998        &mut self,
1999        hide_cursor: bool,
2000        style: Option<Style>,
2001    ) -> io::Result<crate::screen_context::ScreenContext<'_, W>> {
2002        crate::screen_context::ScreenContext::new(self, hide_cursor, style)
2003    }
2004
2005    /// Set the window title.
2006    pub fn set_window_title(&mut self, title: &str) -> io::Result<bool> {
2007        if !self.is_terminal() {
2008            return Ok(false);
2009        }
2010        execute!(self.writer, ct::SetTitle(title))?;
2011        self.writer.flush()?;
2012        Ok(true)
2013    }
2014
2015    /// Ring the terminal bell.
2016    pub fn bell(&mut self) -> io::Result<()> {
2017        write!(self.writer, "\x07")?;
2018        self.writer.flush()
2019    }
2020
2021    // ========================================================================
2022    // Live display integration (used by Live / Progress)
2023    // ========================================================================
2024
2025    pub fn live_start(
2026        &mut self,
2027        renderable: Box<dyn crate::Renderable + Send + Sync>,
2028        vertical_overflow: crate::live::VerticalOverflowMethod,
2029    ) -> (usize, bool) {
2030        let is_root = self.live.stack.is_empty();
2031        let id = self.live.next_id;
2032        self.live.next_id += 1;
2033
2034        self.live.entries.insert(
2035            id,
2036            LiveEntry {
2037                renderable,
2038                vertical_overflow: vertical_overflow.into(),
2039            },
2040        );
2041        self.live.stack.push(id);
2042        (id, is_root)
2043    }
2044
2045    pub fn live_update(&mut self, id: usize, renderable: Box<dyn crate::Renderable + Send + Sync>) {
2046        if let Some(entry) = self.live.entries.get_mut(&id) {
2047            entry.renderable = renderable;
2048        }
2049    }
2050
2051    pub fn live_set_vertical_overflow(
2052        &mut self,
2053        id: usize,
2054        vertical_overflow: crate::live::VerticalOverflowMethod,
2055    ) {
2056        if let Some(entry) = self.live.entries.get_mut(&id) {
2057            entry.vertical_overflow = vertical_overflow.into();
2058        }
2059    }
2060
2061    pub fn live_stop(&mut self, id: usize) -> Option<Box<dyn crate::Renderable + Send + Sync>> {
2062        self.live.stack.retain(|&x| x != id);
2063        let entry = self.live.entries.remove(&id);
2064        if self.live.stack.is_empty() {
2065            self.live.shape = None;
2066            self.live.buffer = None;
2067        }
2068        entry.map(|e| e.renderable)
2069    }
2070
2071    pub fn live_clear(&mut self) {
2072        self.live.stack.clear();
2073        self.live.entries.clear();
2074        self.live.shape = None;
2075        self.live.buffer = None;
2076    }
2077
2078    fn has_live(&self) -> bool {
2079        !self.live.stack.is_empty()
2080    }
2081
2082    fn live_root(&self) -> Option<&LiveEntry> {
2083        let id = *self.live.stack.first()?;
2084        self.live.entries.get(&id)
2085    }
2086
2087    fn live_position_cursor_for_shape(
2088        &self,
2089        shape: Option<(usize, usize)>,
2090        erase: bool,
2091    ) -> Segments {
2092        let Some((_, height)) = shape else {
2093            return Segments::new();
2094        };
2095        if height == 0 {
2096            return Segments::new();
2097        }
2098        let mut controls = Vec::new();
2099        controls.push(Segment::control(ControlType::CarriageReturn));
2100        if erase {
2101            controls.push(Segment::control(ControlType::EraseInLine(2)));
2102        }
2103        for _ in 0..height.saturating_sub(1) {
2104            controls.push(Segment::control(ControlType::CursorUp(1)));
2105            if erase {
2106                controls.push(Segment::control(ControlType::CarriageReturn));
2107                controls.push(Segment::control(ControlType::EraseInLine(2)));
2108            }
2109        }
2110        Segments::from_iter(controls)
2111    }
2112
2113    pub(crate) fn live_restore_cursor(&self) -> Segments {
2114        let Some((_, height)) = self.live.shape else {
2115            return Segments::new();
2116        };
2117        if height == 0 {
2118            return Segments::new();
2119        }
2120        let mut controls = Vec::new();
2121        controls.push(Segment::control(ControlType::CarriageReturn));
2122        for _ in 0..height {
2123            controls.push(Segment::control(ControlType::CursorUp(1)));
2124            controls.push(Segment::control(ControlType::CarriageReturn));
2125            controls.push(Segment::control(ControlType::EraseInLine(2)));
2126        }
2127        Segments::from_iter(controls)
2128    }
2129
2130    fn render_live_segments(&mut self, options: &ConsoleOptions) -> (Segments, bool) {
2131        let root = match self.live_root() {
2132            Some(root) => root,
2133            None => return (Segments::new(), false),
2134        };
2135
2136        let mut lines: Vec<Vec<Segment>> = Vec::new();
2137        for id in self.live.stack.iter() {
2138            if let Some(entry) = self.live.entries.get(id) {
2139                let mut rendered =
2140                    self.render_lines(entry.renderable.as_ref(), Some(options), None, false, false);
2141                lines.append(&mut rendered);
2142            }
2143        }
2144
2145        let max_height = options.size.1;
2146        if max_height > 0 && lines.len() > max_height {
2147            match root.vertical_overflow {
2148                LiveVerticalOverflow::Visible => {}
2149                LiveVerticalOverflow::Crop => {
2150                    lines.truncate(max_height);
2151                }
2152                LiveVerticalOverflow::Ellipsis => {
2153                    lines.truncate(max_height.saturating_sub(1));
2154                    let style = options.get_style("live.ellipsis").unwrap_or_default();
2155                    let ellipsis = Text::styled("...", style).center(options.max_width);
2156                    let ellipsis_lines =
2157                        self.render_lines(&ellipsis, Some(options), None, false, false);
2158                    if let Some(first) = ellipsis_lines.into_iter().next() {
2159                        lines.push(first);
2160                    }
2161                }
2162            }
2163        }
2164
2165        let shape = Segment::get_shape(&lines);
2166        self.live.shape = Some(shape);
2167
2168        let width = options.max_width.max(1);
2169        let height = shape.1.max(1);
2170        let current_buffer = ScreenBuffer::from_lines(&lines, width, height, None);
2171
2172        let use_diff = self.live.buffer.as_ref().is_some_and(|previous| {
2173            previous.width == current_buffer.width && previous.height == current_buffer.height
2174        });
2175
2176        if use_diff {
2177            let previous = self.live.buffer.as_ref().expect("checked above");
2178            let diff = current_buffer.diff_to_segments_from_origin(previous);
2179            self.live.buffer = Some(current_buffer);
2180            return (diff, false);
2181        }
2182
2183        self.live.buffer = Some(current_buffer);
2184
2185        let mut out = Segments::new();
2186        let new_line = Segment::line();
2187        for (i, line) in lines.into_iter().enumerate() {
2188            for seg in line {
2189                out.push(seg);
2190            }
2191            if i + 1 < shape.1 {
2192                out.push(new_line.clone());
2193            }
2194        }
2195        (out, true)
2196    }
2197
2198    // ========================================================================
2199    // Input Methods
2200    // ========================================================================
2201
2202    /// Read a line of input from the user.
2203    ///
2204    /// This method displays a prompt and reads a line of input from stdin.
2205    /// If `password` is true, input is masked (not echoed to the terminal).
2206    ///
2207    /// # Arguments
2208    ///
2209    /// * `prompt` - The text to display as a prompt.
2210    /// * `password` - If true, input will be masked for password entry.
2211    ///
2212    /// # Returns
2213    ///
2214    /// The user's input as a string (without trailing newline).
2215    ///
2216    /// # Errors
2217    ///
2218    /// Returns an error if reading from stdin fails or if the input stream
2219    /// reaches EOF unexpectedly.
2220    ///
2221    /// # Example
2222    ///
2223    /// ```ignore
2224    /// use rich_rs::{Console, Text};
2225    ///
2226    /// let mut console = Console::new();
2227    /// let prompt = Text::plain("Enter your name: ");
2228    /// let name = console.input(&prompt, false)?;
2229    /// println!("Hello, {}!", name);
2230    /// ```
2231    pub fn input(&mut self, prompt: &Text, password: bool) -> io::Result<String> {
2232        use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
2233        use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
2234
2235        // Print the prompt
2236        self.print(prompt, None, None, None, false, "")?;
2237        self.writer.flush()?;
2238
2239        // For password input, use raw mode to capture without echo
2240        if password && self.is_terminal() && !self.is_dumb_terminal() {
2241            enable_raw_mode()?;
2242
2243            let result = (|| -> io::Result<String> {
2244                let mut input = String::new();
2245
2246                loop {
2247                    if let Event::Key(KeyEvent {
2248                        code, modifiers, ..
2249                    }) = event::read()?
2250                    {
2251                        match code {
2252                            KeyCode::Enter => {
2253                                // Print newline after password entry
2254                                write!(self.writer, "\r\n")?;
2255                                self.writer.flush()?;
2256                                return Ok(input);
2257                            }
2258                            KeyCode::Backspace => {
2259                                input.pop();
2260                            }
2261                            KeyCode::Char(c) => {
2262                                // Check for Ctrl+C
2263                                if c == 'c' && modifiers.contains(KeyModifiers::CONTROL) {
2264                                    write!(self.writer, "\r\n")?;
2265                                    self.writer.flush()?;
2266                                    return Err(io::Error::new(
2267                                        io::ErrorKind::Interrupted,
2268                                        "Input cancelled",
2269                                    ));
2270                                }
2271                                // Check for Ctrl+D (EOF)
2272                                if c == 'd' && modifiers.contains(KeyModifiers::CONTROL) {
2273                                    write!(self.writer, "\r\n")?;
2274                                    self.writer.flush()?;
2275                                    return Err(io::Error::new(
2276                                        io::ErrorKind::UnexpectedEof,
2277                                        "EOF",
2278                                    ));
2279                                }
2280                                input.push(c);
2281                            }
2282                            KeyCode::Esc => {
2283                                // ESC cancels input
2284                                write!(self.writer, "\r\n")?;
2285                                self.writer.flush()?;
2286                                return Err(io::Error::new(
2287                                    io::ErrorKind::Interrupted,
2288                                    "Input cancelled",
2289                                ));
2290                            }
2291                            _ => {}
2292                        }
2293                    }
2294                }
2295            })();
2296
2297            // Always restore terminal mode
2298            let _ = disable_raw_mode();
2299            result
2300        } else {
2301            // Normal input: read from stdin
2302            let mut input = String::new();
2303            io::stdin().read_line(&mut input)?;
2304            // Remove trailing newline
2305            if input.ends_with('\n') {
2306                input.pop();
2307                if input.ends_with('\r') {
2308                    input.pop();
2309                }
2310            }
2311            Ok(input)
2312        }
2313    }
2314
2315    // ========================================================================
2316    // Measurement
2317    // ========================================================================
2318
2319    /// Measure a renderable object.
2320    ///
2321    /// Returns the minimum and maximum width required to render the object.
2322    ///
2323    /// # Arguments
2324    ///
2325    /// * `renderable` - The object to measure.
2326    /// * `options` - Optional custom options, or None to use console defaults.
2327    ///   If provided, ensure these options include the console state fields
2328    ///   by deriving from `console.options()`.
2329    pub fn measure<R: Renderable + ?Sized>(
2330        &self,
2331        renderable: &R,
2332        options: Option<&ConsoleOptions>,
2333    ) -> crate::measure::Measurement {
2334        // Use provided options or console's options (which include state)
2335        let measure_opts = options.cloned().unwrap_or_else(|| self.options.clone());
2336
2337        // Create a temp Console<Stdout> for the measure call.
2338        // Console::with_options() initializes console fields from options.
2339        let temp_console = Console::<Stdout>::with_options(measure_opts.clone());
2340        renderable.measure(&temp_console, &measure_opts)
2341    }
2342
2343    // ========================================================================
2344    // New parity methods
2345    // ========================================================================
2346
2347    /// Low-level output that bypasses the full rendering pipeline.
2348    ///
2349    /// Unlike `print()`, this won't pretty print, wrap text, or apply markup,
2350    /// but will optionally apply a basic style and highlighting.
2351    pub fn out(
2352        &mut self,
2353        text: &str,
2354        style: Option<Style>,
2355        _highlight: Option<bool>,
2356    ) -> io::Result<()> {
2357        if self.quiet {
2358            return Ok(());
2359        }
2360        self.print(
2361            &Text::plain(text),
2362            style,
2363            None,
2364            Some(OverflowMethod::Ignore),
2365            true, // no_wrap
2366            "\n",
2367        )
2368    }
2369
2370    /// Export recorded output as plain text.
2371    ///
2372    /// Requires `record=true` to be set. If `styles` is false, ANSI codes are
2373    /// stripped from the output (only plain text is returned).
2374    pub fn export_text(&self, clear: bool, styles: bool) -> String {
2375        let mut buffer = self.record_buffer.lock().unwrap();
2376        let text = if styles {
2377            buffer
2378                .iter()
2379                .filter(|s| s.control.is_none())
2380                .map(|s| {
2381                    if let Some(style) = s.style {
2382                        if let Some(color_system) = self.color_system {
2383                            style.render(&s.text, color_system)
2384                        } else {
2385                            s.text.to_string()
2386                        }
2387                    } else {
2388                        s.text.to_string()
2389                    }
2390                })
2391                .collect::<String>()
2392        } else {
2393            buffer
2394                .iter()
2395                .filter(|s| s.control.is_none())
2396                .map(|s| s.text.to_string())
2397                .collect::<String>()
2398        };
2399        if clear {
2400            buffer.clear();
2401        }
2402        text
2403    }
2404
2405    /// Save export_text output to a file.
2406    pub fn save_text(&self, path: &str, clear: bool, styles: bool) -> io::Result<()> {
2407        let text = self.export_text(clear, styles);
2408        std::fs::write(path, text)
2409    }
2410
2411    /// Push a render hook that intercepts/transforms rendered segments before output.
2412    pub fn push_render_hook(&mut self, hook: Box<dyn Fn(&Segments) -> Segments + Send + Sync>) {
2413        self.render_hooks.push(hook);
2414    }
2415
2416    /// Remove the last render hook.
2417    pub fn pop_render_hook(&mut self) {
2418        self.render_hooks.pop();
2419    }
2420
2421    /// Create and return a Status spinner.
2422    ///
2423    /// This is a convenience method that creates a `Status` with the console's
2424    /// default settings.
2425    pub fn status(
2426        &self,
2427        status: &str,
2428        spinner: Option<&str>,
2429        spinner_style: Option<Style>,
2430        speed: Option<f64>,
2431        refresh_per_second: Option<f64>,
2432    ) -> crate::status::Status {
2433        crate::status::Status::with_options(
2434            status,
2435            spinner.unwrap_or("dots"),
2436            spinner_style,
2437            speed.unwrap_or(1.0),
2438            refresh_per_second.unwrap_or(12.5),
2439        )
2440    }
2441
2442    /// Pretty-print JSON.
2443    ///
2444    /// Parse, format, highlight, and print JSON content.
2445    pub fn print_json(
2446        &mut self,
2447        json: &str,
2448        indent: usize,
2449        highlight: bool,
2450        sort_keys: bool,
2451    ) -> io::Result<()> {
2452        let json_renderable = crate::json::Json::new(json, indent, highlight, sort_keys);
2453        self.print(&json_renderable, None, None, None, true, "\n")
2454    }
2455}
2456
2457// Implement render and render_with_options specifically for Console<Stdout>
2458// since the Renderable trait requires &Console<Stdout>
2459impl Console<Stdout> {
2460    /// Render a Renderable to Segments.
2461    pub fn render<R: Renderable + ?Sized>(&self, renderable: &R) -> Segments {
2462        self.apply_render_hooks(renderable.render(self, &self.options))
2463    }
2464
2465    /// Render a Renderable with custom options.
2466    pub fn render_with_options<R: Renderable + ?Sized>(
2467        &self,
2468        renderable: &R,
2469        options: &ConsoleOptions,
2470    ) -> Segments {
2471        self.apply_render_hooks(renderable.render(self, options))
2472    }
2473
2474    /// Update rendered lines at an offset on the alternate screen.
2475    ///
2476    /// This is the Rust equivalent of Rich's `Console.update_screen_lines`.
2477    pub fn update_screen_lines(
2478        &mut self,
2479        lines: &[Vec<Segment>],
2480        x: u16,
2481        y: u16,
2482    ) -> io::Result<()> {
2483        if !self.is_alt_screen() {
2484            return Err(io::Error::new(
2485                io::ErrorKind::Other,
2486                "Alt screen must be enabled to call update_screen_lines",
2487            ));
2488        }
2489
2490        let mut segments = Segments::new();
2491        for (offset, line) in lines.iter().enumerate() {
2492            segments.push(Segment::control(ControlType::MoveTo {
2493                x,
2494                y: y.saturating_add(offset as u16),
2495            }));
2496            segments.extend(line.iter().cloned());
2497        }
2498        self.print_segments(&segments)?;
2499        Ok(())
2500    }
2501}
2502
2503// ============================================================================
2504// Pager Context
2505// ============================================================================
2506
2507/// Options for the pager context.
2508#[derive(Debug, Clone, Default)]
2509pub struct PagerOptions {
2510    /// Whether to preserve ANSI styles in pager output.
2511    pub styles: bool,
2512}
2513
2514impl PagerOptions {
2515    /// Create new pager options.
2516    pub fn new() -> Self {
2517        Self::default()
2518    }
2519
2520    /// Enable or disable styles in pager output.
2521    pub fn with_styles(mut self, styles: bool) -> Self {
2522        self.styles = styles;
2523        self
2524    }
2525}
2526
2527/// A guard that captures console output and sends it to a pager on drop.
2528///
2529/// This struct is returned by `Console::pager()` and implements a context-manager
2530/// pattern similar to Python's `with console.pager():`.
2531///
2532/// # Example
2533///
2534/// ```no_run
2535/// use rich_rs::Console;
2536///
2537/// let mut console = Console::new();
2538/// {
2539///     let mut pager = console.pager(None);
2540///     pager.print_text("Long content...").unwrap();
2541///     // Content is sent to pager when `pager` is dropped
2542/// }
2543/// ```
2544pub struct PagerContext {
2545    /// Captured output buffer.
2546    buffer: Vec<u8>,
2547    /// Pager options.
2548    options: PagerOptions,
2549    /// Console options for rendering.
2550    console_options: ConsoleOptions,
2551}
2552
2553impl PagerContext {
2554    /// Create a new pager context.
2555    fn new(console_options: ConsoleOptions, options: Option<PagerOptions>) -> Self {
2556        let options = options.unwrap_or_default();
2557        // If styles are disabled, turn off color system so no ANSI escapes are emitted
2558        let mut console_options = console_options;
2559        if !options.styles {
2560            console_options.is_terminal = false;
2561            console_options.color_system = None;
2562        }
2563        Self {
2564            buffer: Vec::new(),
2565            options,
2566            console_options,
2567        }
2568    }
2569
2570    /// Print plain text.
2571    pub fn print_text(&mut self, text: &str) -> io::Result<()> {
2572        writeln!(self.buffer, "{}", text)
2573    }
2574
2575    /// Print a renderable.
2576    pub fn print<R: crate::Renderable + ?Sized>(
2577        &mut self,
2578        renderable: &R,
2579        style: Option<Style>,
2580        justify: Option<JustifyMethod>,
2581        overflow: Option<OverflowMethod>,
2582        no_wrap: bool,
2583        end: &str,
2584    ) -> io::Result<()> {
2585        // Create a capture console to render
2586        let mut console = Console::with_writer(Vec::new(), self.console_options.clone());
2587
2588        // Render to the buffer
2589        console.print(renderable, style, justify, overflow, no_wrap, end)?;
2590
2591        // Append to our buffer
2592        self.buffer.extend_from_slice(console.get_captured_bytes());
2593        Ok(())
2594    }
2595
2596    /// Get the current buffer contents.
2597    pub fn get_buffer(&self) -> &[u8] {
2598        &self.buffer
2599    }
2600
2601    /// Get the buffer as a string.
2602    pub fn get_buffer_string(&self) -> String {
2603        String::from_utf8_lossy(&self.buffer).to_string()
2604    }
2605
2606    /// Manually send content to the pager.
2607    pub fn show(&self) -> io::Result<()> {
2608        use crate::pager::{Pager, SystemPager};
2609        let pager = SystemPager::with_styles(self.options.styles);
2610        let content = self.get_buffer_string();
2611        pager.show(&content)
2612    }
2613}
2614
2615impl Drop for PagerContext {
2616    fn drop(&mut self) {
2617        if !self.buffer.is_empty() {
2618            let _ = self.show();
2619        }
2620    }
2621}
2622
2623impl Console<Stdout> {
2624    /// Create a pager context that captures output and sends it to a pager.
2625    ///
2626    /// Similar to Python Rich's `with console.pager():` context manager.
2627    ///
2628    /// # Arguments
2629    ///
2630    /// * `options` - Optional pager options. Use `PagerOptions::new().with_styles(true)`
2631    ///   to preserve ANSI escape sequences in the pager.
2632    ///
2633    /// # Example
2634    ///
2635    /// ```no_run
2636    /// use rich_rs::{Console, PagerOptions};
2637    ///
2638    /// let console = Console::new();
2639    /// {
2640    ///     let mut pager = console.pager(Some(PagerOptions::new().with_styles(true)));
2641    ///     pager.print_text("Long content that should be paged...").unwrap();
2642    ///     // Content is sent to pager when `pager` is dropped
2643    /// }
2644    /// ```
2645    pub fn pager(&self, options: Option<PagerOptions>) -> PagerContext {
2646        PagerContext::new(self.options.clone(), options)
2647    }
2648
2649    // ========================================================================
2650    // Recording and Export Methods
2651    // ========================================================================
2652
2653    /// Check if recording is enabled.
2654    pub fn is_recording(&self) -> bool {
2655        self.record
2656    }
2657
2658    /// Enable or disable recording.
2659    ///
2660    /// When recording is enabled, all segments written via `print()` are
2661    /// captured in an internal buffer that can be exported as SVG/HTML.
2662    pub fn set_record(&mut self, record: bool) {
2663        self.record = record;
2664    }
2665
2666    /// Clear the record buffer.
2667    pub fn clear_record_buffer(&mut self) {
2668        if let Ok(mut buffer) = self.record_buffer.lock() {
2669            buffer.clear();
2670        }
2671    }
2672
2673    /// Get the current record buffer contents.
2674    ///
2675    /// Returns a clone of the recorded segments.
2676    pub fn get_record_buffer(&self) -> Vec<Segment> {
2677        self.record_buffer
2678            .lock()
2679            .map(|buf| buf.clone())
2680            .unwrap_or_default()
2681    }
2682
2683    /// Export console contents as SVG.
2684    ///
2685    /// Generates an SVG image from the recorded console output. Requires
2686    /// `record=true` to have been set (via `new_with_record()` or `set_record(true)`).
2687    ///
2688    /// # Arguments
2689    ///
2690    /// * `title` - The title shown in the terminal window chrome.
2691    /// * `theme` - Optional terminal theme for colors. Defaults to `SVG_EXPORT_THEME`.
2692    /// * `clear` - Whether to clear the record buffer after exporting.
2693    /// * `code_format` - Optional custom SVG template. Defaults to `CONSOLE_SVG_FORMAT`.
2694    /// * `font_aspect_ratio` - Width/height ratio of the font. Defaults to 0.61 (Fira Code).
2695    /// * `unique_id` - Optional unique ID for CSS classes. Auto-generated if not provided.
2696    ///
2697    /// # Example
2698    ///
2699    /// ```
2700    /// use rich_rs::Console;
2701    ///
2702    /// let mut console = Console::new_with_record();
2703    /// console.print_text("Hello, World!").unwrap();
2704    /// let svg = console.export_svg("Example", None, true, None, 0.61, None);
2705    /// assert!(svg.contains("Hello"));
2706    /// ```
2707    pub fn export_svg(
2708        &mut self,
2709        title: &str,
2710        theme: Option<&TerminalTheme>,
2711        clear: bool,
2712        code_format: Option<&str>,
2713        font_aspect_ratio: f64,
2714        unique_id: Option<&str>,
2715    ) -> String {
2716        let theme = theme.unwrap_or(&*SVG_EXPORT_THEME);
2717        let code_format = code_format.unwrap_or(CONSOLE_SVG_FORMAT);
2718
2719        // CSS rules cache - uses string key instead of Style (which doesn't implement Hash)
2720        let mut classes: HashMap<String, usize> = HashMap::new();
2721        let mut style_no = 1usize;
2722
2723        let width = self.width();
2724        let char_height = 20.0;
2725        let char_width = char_height * font_aspect_ratio;
2726        let line_height = char_height * 1.22;
2727
2728        let margin_top = 1.0;
2729        let margin_right = 1.0;
2730        let margin_bottom = 1.0;
2731        let margin_left = 1.0;
2732
2733        let padding_top = 40.0;
2734        let padding_right = 8.0;
2735        let padding_bottom = 8.0;
2736        let padding_left = 8.0;
2737
2738        let padding_width = padding_left + padding_right;
2739        let padding_height = padding_top + padding_bottom;
2740        let margin_width = margin_left + margin_right;
2741        let margin_height = margin_top + margin_bottom;
2742
2743        let mut text_backgrounds: Vec<String> = Vec::new();
2744        let mut text_group: Vec<String> = Vec::new();
2745
2746        // Get segments from record buffer
2747        let segments: Vec<Segment> = {
2748            let mut buffer = self.record_buffer.lock().unwrap();
2749            let segments: Vec<Segment> = buffer
2750                .iter()
2751                .filter(|s| s.control.is_none())
2752                .cloned()
2753                .collect();
2754            if clear {
2755                buffer.clear();
2756            }
2757            segments
2758        };
2759
2760        // Generate unique ID if not provided
2761        let unique_id = unique_id.map(|s| s.to_string()).unwrap_or_else(|| {
2762            let content: String = segments
2763                .iter()
2764                .map(|s| format!("{:?}", s))
2765                .collect::<Vec<_>>()
2766                .join("");
2767            let hash = adler32(&format!("{}{}", content, title));
2768            format!("terminal-{}", hash)
2769        });
2770
2771        // Split segments into lines
2772        let lines =
2773            Segment::split_and_crop_lines(Segments::from_iter(segments), width, None, false, false);
2774
2775        let mut y = 0usize;
2776        for line in &lines {
2777            let mut x = 0usize;
2778
2779            for segment in line {
2780                let style = segment.style.unwrap_or_default();
2781                let rules = get_svg_style_for_segment(&style, theme);
2782
2783                if !classes.contains_key(&rules) {
2784                    classes.insert(rules.clone(), style_no);
2785                    style_no += 1;
2786                }
2787                let class_name = format!("r{}", classes[&rules]);
2788
2789                // Check for background
2790                let has_background = if style.reverse.unwrap_or(false) {
2791                    true
2792                } else {
2793                    style.bgcolor.is_some() && !is_default_color(style.bgcolor)
2794                };
2795
2796                let background = if style.reverse.unwrap_or(false) {
2797                    style
2798                        .color
2799                        .map(|c| resolve_color_for_svg(c, theme, true))
2800                        .unwrap_or(theme.foreground_color)
2801                } else {
2802                    style
2803                        .bgcolor
2804                        .map(|c| resolve_color_for_svg(c, theme, false))
2805                        .unwrap_or(theme.background_color)
2806                };
2807
2808                let text_length = cell_len(&segment.text);
2809
2810                if has_background {
2811                    text_backgrounds.push(make_tag(
2812                        "rect",
2813                        None,
2814                        &[
2815                            ("fill", &background.hex()),
2816                            ("x", &format_number(x as f64 * char_width)),
2817                            ("y", &format_number(y as f64 * line_height + 1.5)),
2818                            ("width", &format_number(char_width * text_length as f64)),
2819                            ("height", &format_number(line_height + 0.25)),
2820                            ("shape-rendering", "crispEdges"),
2821                        ],
2822                    ));
2823                }
2824
2825                // Only add text if it's not all spaces
2826                if !segment.text.chars().all(|c| c == ' ') {
2827                    text_group.push(make_tag(
2828                        "text",
2829                        Some(&escape_text(&segment.text)),
2830                        &[
2831                            ("class", &format!("{}-{}", unique_id, class_name)),
2832                            ("x", &format_number(x as f64 * char_width)),
2833                            ("y", &format_number(y as f64 * line_height + char_height)),
2834                            (
2835                                "textLength",
2836                                &format_number(char_width * text_length as f64),
2837                            ),
2838                            ("clip-path", &format!("url(#{}-line-{})", unique_id, y)),
2839                        ],
2840                    ));
2841                }
2842
2843                x += text_length;
2844            }
2845            y += 1;
2846        }
2847
2848        // Generate clip paths for lines
2849        let line_offsets: Vec<f64> = (0..y)
2850            .map(|line_no| line_no as f64 * line_height + 1.5)
2851            .collect();
2852
2853        let lines_svg: String = line_offsets
2854            .iter()
2855            .enumerate()
2856            .map(|(line_no, offset)| {
2857                format!(
2858                    r#"<clipPath id="{}-line-{}">
2859    {}
2860            </clipPath>"#,
2861                    unique_id,
2862                    line_no,
2863                    make_tag(
2864                        "rect",
2865                        None,
2866                        &[
2867                            ("x", "0"),
2868                            ("y", &format_number(*offset)),
2869                            ("width", &format_number(char_width * width as f64)),
2870                            ("height", &format_number(line_height + 0.25)),
2871                        ],
2872                    )
2873                )
2874            })
2875            .collect::<Vec<_>>()
2876            .join("\n");
2877
2878        // Generate CSS styles
2879        let styles: String = classes
2880            .iter()
2881            .map(|(css, rule_no)| format!(".{}-r{} {{ {} }}", unique_id, rule_no, css))
2882            .collect::<Vec<_>>()
2883            .join("\n");
2884
2885        let backgrounds = text_backgrounds.join("");
2886        let matrix = text_group.join("\n");
2887
2888        let terminal_width = (width as f64 * char_width + padding_width).ceil();
2889        let terminal_height = (y as f64 + 1.0) * line_height + padding_height;
2890
2891        // Generate terminal chrome
2892        let mut chrome = make_tag(
2893            "rect",
2894            None,
2895            &[
2896                ("fill", &theme.background_color.hex()),
2897                ("stroke", "rgba(255,255,255,0.35)"),
2898                ("stroke-width", "1"),
2899                ("x", &format_number(margin_left)),
2900                ("y", &format_number(margin_top)),
2901                ("width", &format_number(terminal_width)),
2902                ("height", &format_number(terminal_height)),
2903                ("rx", "8"),
2904            ],
2905        );
2906
2907        // Add title if provided
2908        if !title.is_empty() {
2909            chrome.push_str(&make_tag(
2910                "text",
2911                Some(&escape_text(title)),
2912                &[
2913                    ("class", &format!("{}-title", unique_id)),
2914                    ("fill", &theme.foreground_color.hex()),
2915                    ("text-anchor", "middle"),
2916                    ("x", &format_number(terminal_width / 2.0)),
2917                    ("y", &format_number(margin_top + char_height + 6.0)),
2918                ],
2919            ));
2920        }
2921
2922        // Add window buttons
2923        chrome.push_str(
2924            r##"
2925            <g transform="translate(26,22)">
2926            <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
2927            <circle cx="22" cy="0" r="7" fill="#febc2e"/>
2928            <circle cx="44" cy="0" r="7" fill="#28c840"/>
2929            </g>
2930        "##,
2931        );
2932
2933        // Generate final SVG
2934        code_format
2935            .replace("{unique_id}", &unique_id)
2936            .replace("{char_width}", &format_number(char_width))
2937            .replace("{char_height}", &format_number(char_height))
2938            .replace("{line_height}", &format_number(line_height))
2939            .replace(
2940                "{terminal_width}",
2941                &format_number(char_width * width as f64 - 1.0),
2942            )
2943            .replace(
2944                "{terminal_height}",
2945                &format_number((y as f64 + 1.0) * line_height - 1.0),
2946            )
2947            .replace("{width}", &format_number(terminal_width + margin_width))
2948            .replace("{height}", &format_number(terminal_height + margin_height))
2949            .replace("{terminal_x}", &format_number(margin_left + padding_left))
2950            .replace("{terminal_y}", &format_number(margin_top + padding_top))
2951            .replace("{styles}", &styles)
2952            .replace("{chrome}", &chrome)
2953            .replace("{backgrounds}", &backgrounds)
2954            .replace("{matrix}", &matrix)
2955            .replace("{lines}", &lines_svg)
2956    }
2957
2958    /// Export console contents as HTML.
2959    ///
2960    /// Generates an HTML document from the recorded console output. Requires
2961    /// `record=true` to have been set (via `new_with_record()` or `set_record(true)`).
2962    ///
2963    /// This is modeled after Python Rich's `Console.export_html()`. Hyperlinks are
2964    /// emitted as `<a href="...">` when `StyleMeta.link` is present on segments.
2965    ///
2966    /// # Arguments
2967    ///
2968    /// * `theme` - Optional terminal theme for colors. Defaults to `DEFAULT_TERMINAL_THEME`.
2969    /// * `clear` - Whether to clear the record buffer after exporting.
2970    /// * `code_format` - Optional custom HTML template. Defaults to `CONSOLE_HTML_FORMAT`.
2971    ///
2972    /// # Example
2973    ///
2974    /// ```
2975    /// use rich_rs::Console;
2976    ///
2977    /// let mut console = Console::new_with_record();
2978    /// console.print_text("Hello, World!").unwrap();
2979    /// let html = console.export_html(None, true, None);
2980    /// assert!(html.contains("<!DOCTYPE html>"));
2981    /// assert!(html.contains("Hello, World!"));
2982    /// ```
2983    pub fn export_html(
2984        &mut self,
2985        theme: Option<&TerminalTheme>,
2986        clear: bool,
2987        code_format: Option<&str>,
2988    ) -> String {
2989        let theme = theme.unwrap_or(&*DEFAULT_TERMINAL_THEME);
2990        let code_format = code_format.unwrap_or(CONSOLE_HTML_FORMAT);
2991
2992        // CSS rules cache - uses string key instead of Style (which doesn't implement Hash).
2993        let mut classes: HashMap<String, usize> = HashMap::new();
2994        let mut style_no = 1usize;
2995
2996        // Get segments from record buffer.
2997        let segments: Vec<Segment> = {
2998            let mut buffer = self.record_buffer.lock().unwrap();
2999            let segments: Vec<Segment> = buffer
3000                .iter()
3001                .filter(|s| s.control.is_none())
3002                .cloned()
3003                .collect();
3004            if clear {
3005                buffer.clear();
3006            }
3007            segments
3008        };
3009
3010        // First pass: collect CSS classes needed.
3011        for segment in &segments {
3012            let style = segment.style.unwrap_or_default();
3013            let rules = get_html_style_for_segment(&style, theme);
3014            if !rules.is_empty() && !classes.contains_key(&rules) {
3015                classes.insert(rules, style_no);
3016                style_no += 1;
3017            }
3018        }
3019
3020        // Generate CSS styles.
3021        let stylesheet: String = classes
3022            .iter()
3023            .map(|(css, rule_no)| format!(".r{} {{ {} }}", rule_no, css))
3024            .collect::<Vec<_>>()
3025            .join("\n");
3026
3027        // Render HTML-encoded console content.
3028        #[derive(Debug, Clone, PartialEq, Eq)]
3029        enum OpenTag {
3030            Span {
3031                class_no: usize,
3032            },
3033            Link {
3034                class_no: Option<usize>,
3035                href: Arc<str>,
3036            },
3037        }
3038
3039        let mut code = String::new();
3040        let mut open: Option<OpenTag> = None;
3041
3042        let close_open = |code: &mut String, open: &mut Option<OpenTag>| {
3043            if let Some(tag) = open.take() {
3044                match tag {
3045                    OpenTag::Span { .. } => code.push_str("</span>"),
3046                    OpenTag::Link { .. } => code.push_str("</a>"),
3047                }
3048            }
3049        };
3050
3051        let open_tag = |code: &mut String, tag: &OpenTag| match tag {
3052            OpenTag::Span { class_no } => {
3053                code.push_str(&format!("<span class=\"r{}\">", class_no));
3054            }
3055            OpenTag::Link { class_no, href } => {
3056                let href_escaped = escape_html_attr(href);
3057                if let Some(class_no) = class_no {
3058                    code.push_str(&format!(
3059                        "<a class=\"r{}\" href=\"{}\">",
3060                        class_no, href_escaped
3061                    ));
3062                } else {
3063                    code.push_str(&format!("<a href=\"{}\">", href_escaped));
3064                }
3065            }
3066        };
3067
3068        for segment in &segments {
3069            let text = segment.text.as_ref();
3070            if text.is_empty() {
3071                continue;
3072            }
3073
3074            let style = segment.style.unwrap_or_default();
3075            let rules = get_html_style_for_segment(&style, theme);
3076            let class_no = if rules.is_empty() {
3077                None
3078            } else {
3079                Some(classes[&rules])
3080            };
3081
3082            let href = segment.meta.as_ref().and_then(|m| m.link.as_ref()).cloned();
3083
3084            let desired: Option<OpenTag> = if let Some(href) = href {
3085                Some(OpenTag::Link { class_no, href })
3086            } else if let Some(class_no) = class_no {
3087                Some(OpenTag::Span { class_no })
3088            } else {
3089                None
3090            };
3091
3092            if desired != open {
3093                close_open(&mut code, &mut open);
3094                if let Some(tag) = &desired {
3095                    open_tag(&mut code, tag);
3096                }
3097                open = desired;
3098            }
3099
3100            // HTML-escape text (do not replace spaces; <pre> preserves them).
3101            code.push_str(&escape_html_text(text));
3102        }
3103
3104        close_open(&mut code, &mut open);
3105
3106        // Generate final HTML.
3107        code_format
3108            .replace("{stylesheet}", &stylesheet)
3109            .replace("{foreground}", &theme.foreground_color.hex())
3110            .replace("{background}", &theme.background_color.hex())
3111            .replace("{code}", &code)
3112    }
3113
3114    /// Save console contents to an HTML file.
3115    ///
3116    /// This is a convenience method that calls `export_html()` and writes the result to a file.
3117    pub fn save_html(
3118        &mut self,
3119        path: &str,
3120        theme: Option<&TerminalTheme>,
3121        clear: bool,
3122        code_format: Option<&str>,
3123    ) -> io::Result<()> {
3124        let html = self.export_html(theme, clear, code_format);
3125        std::fs::write(path, html)
3126    }
3127
3128    /// Save console contents to an SVG file.
3129    ///
3130    /// This is a convenience method that calls `export_svg()` and writes
3131    /// the result to a file.
3132    ///
3133    /// # Arguments
3134    ///
3135    /// * `path` - The file path to write to.
3136    /// * `title` - The title shown in the terminal window chrome.
3137    /// * `theme` - Optional terminal theme for colors.
3138    /// * `clear` - Whether to clear the record buffer after exporting.
3139    /// * `font_aspect_ratio` - Width/height ratio of the font. Defaults to 0.61.
3140    /// * `unique_id` - Optional unique ID for CSS classes.
3141    ///
3142    /// # Example
3143    ///
3144    /// ```no_run
3145    /// use rich_rs::Console;
3146    ///
3147    /// let mut console = Console::new_with_record();
3148    /// console.print_text("Hello, World!").unwrap();
3149    /// console.save_svg("output.svg", "Example", None, true, 0.61, None).unwrap();
3150    /// ```
3151    pub fn save_svg(
3152        &mut self,
3153        path: &str,
3154        title: &str,
3155        theme: Option<&TerminalTheme>,
3156        clear: bool,
3157        font_aspect_ratio: f64,
3158        unique_id: Option<&str>,
3159    ) -> io::Result<()> {
3160        let svg = self.export_svg(title, theme, clear, None, font_aspect_ratio, unique_id);
3161        std::fs::write(path, svg)
3162    }
3163}
3164
3165// ============================================================================
3166// SVG Export Helper Functions
3167// ============================================================================
3168
3169/// Get CSS style rules for a segment style.
3170pub(crate) fn get_svg_style_for_segment(style: &Style, theme: &TerminalTheme) -> String {
3171    let mut css_rules = Vec::new();
3172
3173    // Get foreground color
3174    let fg_color = style
3175        .color
3176        .map(|c| resolve_color_for_svg(c, theme, true))
3177        .unwrap_or(theme.foreground_color);
3178
3179    // Get background color
3180    let bg_color = style
3181        .bgcolor
3182        .map(|c| resolve_color_for_svg(c, theme, false))
3183        .unwrap_or(theme.background_color);
3184
3185    // Handle reverse
3186    let (fg_color, bg_color) = if style.reverse.unwrap_or(false) {
3187        (bg_color, fg_color)
3188    } else {
3189        (fg_color, bg_color)
3190    };
3191
3192    // Handle dim
3193    let fg_color = if style.dim.unwrap_or(false) {
3194        blend_rgb_for_svg(fg_color, bg_color, 0.4)
3195    } else {
3196        fg_color
3197    };
3198
3199    css_rules.push(format!("fill: {}", fg_color.hex()));
3200
3201    if style.bold.unwrap_or(false) {
3202        css_rules.push("font-weight: bold".to_string());
3203    }
3204    if style.italic.unwrap_or(false) {
3205        css_rules.push("font-style: italic".to_string());
3206    }
3207    if style.underline.unwrap_or(false) {
3208        css_rules.push("text-decoration: underline".to_string());
3209    }
3210    if style.strike.unwrap_or(false) {
3211        css_rules.push("text-decoration: line-through".to_string());
3212    }
3213
3214    css_rules.join(";")
3215}
3216
3217/// Get CSS style rules for a segment style in HTML export.
3218fn get_html_style_for_segment(style: &Style, theme: &TerminalTheme) -> String {
3219    let mut css_rules: Vec<String> = Vec::new();
3220
3221    // Get foreground color
3222    let fg_color = style
3223        .color
3224        .map(|c| resolve_color_for_svg(c, theme, true))
3225        .unwrap_or(theme.foreground_color);
3226
3227    // Get background color
3228    let bg_color = style
3229        .bgcolor
3230        .map(|c| resolve_color_for_svg(c, theme, false))
3231        .unwrap_or(theme.background_color);
3232
3233    // Handle reverse
3234    let (fg_color, bg_color) = if style.reverse.unwrap_or(false) {
3235        (bg_color, fg_color)
3236    } else {
3237        (fg_color, bg_color)
3238    };
3239
3240    // Handle dim (match Python Rich export_html: blend 50% towards background)
3241    let fg_color = if style.dim.unwrap_or(false) {
3242        blend_rgb_for_svg(fg_color, bg_color, 0.5)
3243    } else {
3244        fg_color
3245    };
3246
3247    // Foreground color
3248    if style.color.is_some() || style.reverse.unwrap_or(false) || style.dim.unwrap_or(false) {
3249        css_rules.push(format!("color: {}", fg_color.hex()));
3250        css_rules.push(format!("text-decoration-color: {}", fg_color.hex()));
3251    }
3252
3253    // Background color only when explicitly set (or reverse forces it)
3254    let has_background = if style.reverse.unwrap_or(false) {
3255        true
3256    } else {
3257        style.bgcolor.is_some() && !is_default_color(style.bgcolor)
3258    };
3259    if has_background {
3260        css_rules.push(format!("background-color: {}", bg_color.hex()));
3261    }
3262
3263    // Attributes
3264    if style.bold.unwrap_or(false) {
3265        css_rules.push("font-weight: bold".to_string());
3266    }
3267    if style.italic.unwrap_or(false) {
3268        css_rules.push("font-style: italic".to_string());
3269    }
3270
3271    let mut decorations = Vec::new();
3272    if style.underline.unwrap_or(false) {
3273        decorations.push("underline");
3274    }
3275    if style.strike.unwrap_or(false) {
3276        decorations.push("line-through");
3277    }
3278    if !decorations.is_empty() {
3279        css_rules.push(format!("text-decoration: {}", decorations.join(" ")));
3280    }
3281
3282    css_rules.join("; ")
3283}
3284
3285/// Resolve a SimpleColor to a ColorTriplet using the terminal theme.
3286pub(crate) fn resolve_color_for_svg(
3287    color: SimpleColor,
3288    theme: &TerminalTheme,
3289    is_foreground: bool,
3290) -> ColorTriplet {
3291    match color {
3292        SimpleColor::Default => {
3293            if is_foreground {
3294                theme.foreground_color
3295            } else {
3296                theme.background_color
3297            }
3298        }
3299        SimpleColor::Standard(index) => theme.get_ansi_color(index as usize),
3300        SimpleColor::EightBit(index) => {
3301            // For 8-bit colors, use the 256-color palette lookup
3302            if let Some(triplet) = crate::color::EIGHT_BIT_PALETTE.get(index as usize) {
3303                triplet
3304            } else {
3305                theme.foreground_color
3306            }
3307        }
3308        SimpleColor::Rgb { r, g, b } => ColorTriplet::new(r, g, b),
3309    }
3310}
3311
3312/// Check if a color is the default color.
3313pub(crate) fn is_default_color(color: Option<SimpleColor>) -> bool {
3314    matches!(color, None | Some(SimpleColor::Default))
3315}
3316
3317/// Blend two colors for dim effect.
3318pub(crate) fn blend_rgb_for_svg(
3319    color: ColorTriplet,
3320    background: ColorTriplet,
3321    factor: f64,
3322) -> ColorTriplet {
3323    let r = (color.red as f64 + (background.red as f64 - color.red as f64) * factor) as u8;
3324    let g = (color.green as f64 + (background.green as f64 - color.green as f64) * factor) as u8;
3325    let b = (color.blue as f64 + (background.blue as f64 - color.blue as f64) * factor) as u8;
3326    ColorTriplet::new(r, g, b)
3327}
3328
3329/// Simple Adler-32 checksum for generating unique IDs.
3330pub(crate) fn adler32(data: &str) -> u32 {
3331    let mut a: u32 = 1;
3332    let mut b: u32 = 0;
3333    const MOD_ADLER: u32 = 65521;
3334
3335    for byte in data.bytes() {
3336        a = (a + byte as u32) % MOD_ADLER;
3337        b = (b + a) % MOD_ADLER;
3338    }
3339
3340    (b << 16) | a
3341}
3342
3343/// Escape text for SVG/HTML.
3344pub(crate) fn escape_text(text: &str) -> String {
3345    text.replace('&', "&amp;")
3346        .replace('<', "&lt;")
3347        .replace('>', "&gt;")
3348        .replace(' ', "&#160;")
3349}
3350
3351/// Escape text content for HTML (does not replace spaces; `<pre>` preserves them).
3352fn escape_html_text(text: &str) -> String {
3353    text.replace('&', "&amp;")
3354        .replace('<', "&lt;")
3355        .replace('>', "&gt;")
3356}
3357
3358/// Escape a string for use in an HTML attribute value (double-quoted).
3359fn escape_html_attr(text: &str) -> String {
3360    text.replace('&', "&amp;")
3361        .replace('<', "&lt;")
3362        .replace('>', "&gt;")
3363        .replace('"', "&quot;")
3364}
3365
3366/// Format a number for SVG attributes (removes trailing zeros).
3367pub(crate) fn format_number(value: f64) -> String {
3368    if value.fract() == 0.0 {
3369        format!("{}", value as i64)
3370    } else {
3371        format!("{:.2}", value)
3372            .trim_end_matches('0')
3373            .trim_end_matches('.')
3374            .to_string()
3375    }
3376}
3377
3378/// Make an SVG tag with attributes.
3379pub(crate) fn make_tag(name: &str, content: Option<&str>, attribs: &[(&str, &str)]) -> String {
3380    let attribs_str: String = attribs
3381        .iter()
3382        .map(|(k, v)| format!("{}=\"{}\"", k, v))
3383        .collect::<Vec<_>>()
3384        .join(" ");
3385
3386    if let Some(content) = content {
3387        format!("<{} {}>{}</{}>", name, attribs_str, content, name)
3388    } else {
3389        format!("<{} {}/>", name, attribs_str)
3390    }
3391}
3392
3393// ============================================================================
3394// Tests
3395// ============================================================================
3396
3397#[cfg(test)]
3398mod tests {
3399    use super::*;
3400    use crate::Control;
3401    use crate::StyleMeta;
3402
3403    // ==================== JustifyMethod tests ====================
3404
3405    #[test]
3406    fn test_justify_method_parse() {
3407        assert_eq!(JustifyMethod::parse("left"), Some(JustifyMethod::Left));
3408        assert_eq!(JustifyMethod::parse("CENTER"), Some(JustifyMethod::Center));
3409        assert_eq!(JustifyMethod::parse("Right"), Some(JustifyMethod::Right));
3410        assert_eq!(JustifyMethod::parse("full"), Some(JustifyMethod::Full));
3411        assert_eq!(
3412            JustifyMethod::parse("default"),
3413            Some(JustifyMethod::Default)
3414        );
3415        assert_eq!(JustifyMethod::parse("invalid"), None);
3416    }
3417
3418    // ==================== OverflowMethod tests ====================
3419
3420    #[test]
3421    fn test_overflow_method_parse() {
3422        assert_eq!(OverflowMethod::parse("fold"), Some(OverflowMethod::Fold));
3423        assert_eq!(OverflowMethod::parse("CROP"), Some(OverflowMethod::Crop));
3424        assert_eq!(
3425            OverflowMethod::parse("Ellipsis"),
3426            Some(OverflowMethod::Ellipsis)
3427        );
3428        assert_eq!(
3429            OverflowMethod::parse("ignore"),
3430            Some(OverflowMethod::Ignore)
3431        );
3432        assert_eq!(OverflowMethod::parse("invalid"), None);
3433    }
3434
3435    // ==================== ConsoleOptions tests ====================
3436
3437    #[test]
3438    fn test_console_options_default() {
3439        let options = ConsoleOptions::default();
3440        assert_eq!(options.size, (80, 24));
3441        assert_eq!(options.min_width, 1);
3442        assert_eq!(options.max_width, 80);
3443        assert_eq!(options.max_height, 24);
3444        assert!(options.is_terminal);
3445        assert_eq!(options.encoding, "utf-8");
3446    }
3447
3448    #[test]
3449    fn test_console_options_ascii_only() {
3450        let options = ConsoleOptions {
3451            encoding: "utf-8".to_string(),
3452            ..Default::default()
3453        };
3454        assert!(!options.ascii_only());
3455
3456        let options = ConsoleOptions {
3457            encoding: "ascii".to_string(),
3458            ..Default::default()
3459        };
3460        assert!(options.ascii_only());
3461
3462        let options = ConsoleOptions {
3463            encoding: "latin-1".to_string(),
3464            ..Default::default()
3465        };
3466        assert!(options.ascii_only());
3467    }
3468
3469    #[test]
3470    fn test_console_options_update_width() {
3471        let options = ConsoleOptions::default();
3472        let updated = options.update_width(120);
3473        assert_eq!(updated.min_width, 120);
3474        assert_eq!(updated.max_width, 120);
3475    }
3476
3477    #[test]
3478    fn test_console_options_update_height() {
3479        let options = ConsoleOptions::default();
3480        let updated = options.update_height(40);
3481        assert_eq!(updated.max_height, 40);
3482        assert_eq!(updated.height, Some(40));
3483    }
3484
3485    #[test]
3486    fn test_console_options_update_dimensions() {
3487        let options = ConsoleOptions::default();
3488        let updated = options.update_dimensions(100, 50);
3489        assert_eq!(updated.min_width, 100);
3490        assert_eq!(updated.max_width, 100);
3491        assert_eq!(updated.max_height, 50);
3492        assert_eq!(updated.height, Some(50));
3493    }
3494
3495    #[test]
3496    fn test_console_options_reset_height() {
3497        let options = ConsoleOptions {
3498            height: Some(40),
3499            ..Default::default()
3500        };
3501        let reset = options.reset_height();
3502        assert_eq!(reset.height, None);
3503    }
3504
3505    // ==================== Console capture tests ====================
3506
3507    #[test]
3508    fn test_console_capture() {
3509        let mut console = Console::capture();
3510        console.print_text("Hello, World!").unwrap();
3511        let output = console.get_captured();
3512        assert!(output.contains("Hello, World!"));
3513    }
3514
3515    #[test]
3516    fn test_console_capture_styled() {
3517        let mut console = Console::capture();
3518        let style = Style::new().with_bold(true);
3519        console.print_styled("Bold text", style).unwrap();
3520        let output = console.get_captured();
3521        assert!(output.contains("Bold text"));
3522    }
3523
3524    #[test]
3525    fn test_console_capture_clear() {
3526        let mut console = Console::capture();
3527        console.print_text("First").unwrap();
3528        console.clear_captured();
3529        console.print_text("Second").unwrap();
3530        let output = console.get_captured();
3531        assert!(!output.contains("First"));
3532        assert!(output.contains("Second"));
3533    }
3534
3535    #[test]
3536    fn test_console_capture_bytes() {
3537        let mut console = Console::capture();
3538        console.print_text("Test").unwrap();
3539        let bytes = console.get_captured_bytes();
3540        assert!(!bytes.is_empty());
3541    }
3542
3543    #[test]
3544    fn test_render_hook_runs_in_print_pipeline() {
3545        let mut console = Console::capture();
3546        console.push_render_hook(Box::new(|segments: &Segments| {
3547            Segments::from_iter(segments.iter().map(|seg| {
3548                if seg.control.is_some() {
3549                    seg.clone()
3550                } else {
3551                    Segment::new(seg.text.to_string().to_uppercase())
3552                }
3553            }))
3554        }));
3555
3556        console
3557            .print(&Text::plain("hooked"), None, None, None, false, "\n")
3558            .unwrap();
3559        let output = console.get_captured();
3560        assert!(output.contains("HOOKED"));
3561    }
3562
3563    #[test]
3564    fn test_render_hook_runs_for_live_renderables() {
3565        let mut console = Console::with_writer(
3566            Vec::new(),
3567            ConsoleOptions {
3568                is_terminal: true,
3569                ..Default::default()
3570            },
3571        );
3572        console.set_force_terminal(Some(true));
3573        if console.is_dumb_terminal() {
3574            return;
3575        }
3576        console.push_render_hook(Box::new(|segments: &Segments| {
3577            let mut out = Segments::new();
3578            for seg in segments.iter() {
3579                out.push(seg.clone());
3580            }
3581            out.push(Segment::new("!"));
3582            out
3583        }));
3584
3585        let (_id, _is_root) = console.live_start(
3586            Box::new(Text::plain("LIVE")),
3587            crate::live::VerticalOverflowMethod::Ellipsis,
3588        );
3589        console
3590            .print(&Control::new(), None, None, None, false, "")
3591            .unwrap();
3592
3593        let output = console.get_captured();
3594        assert!(
3595            output.contains("LIVE!"),
3596            "expected hooked live output in captured text, got: {:?}",
3597            output
3598        );
3599    }
3600
3601    #[test]
3602    fn test_console_status_honors_refresh_per_second() {
3603        let console = Console::capture();
3604        let status = console.status("Working...", None, None, None, Some(9.0));
3605        assert_eq!(status.refresh_per_second(), 9.0);
3606    }
3607
3608    #[test]
3609    fn test_live_wrap_emits_cursor_controls_after_first_render() {
3610        let mut console = Console::with_writer(
3611            Vec::new(),
3612            ConsoleOptions {
3613                is_terminal: true,
3614                ..Default::default()
3615            },
3616        );
3617        console.set_force_terminal(Some(true));
3618        if console.is_dumb_terminal() {
3619            // Live wrapping is disabled in dumb terminals.
3620            return;
3621        }
3622
3623        let (_id, _is_root) = console.live_start(
3624            Box::new(Text::plain("LIVE")),
3625            crate::live::VerticalOverflowMethod::Ellipsis,
3626        );
3627
3628        // First render establishes the live shape but does not emit cursor positioning.
3629        console
3630            .print(&Text::plain("A"), None, None, None, false, "\n")
3631            .unwrap();
3632        console.clear_captured();
3633
3634        // Second render takes the screen-buffer diff path (same shape), so it
3635        // repositions the cursor without a full erase.  Verify that cursor
3636        // repositioning is emitted (\r for carriage return).
3637        console
3638            .print(&Text::plain("B"), None, None, None, false, "\n")
3639            .unwrap();
3640        let out = console.get_captured();
3641        assert!(
3642            out.contains("\r"),
3643            "expected cursor repositioning (\\r) in second live render, got: {:?}",
3644            out,
3645        );
3646    }
3647
3648    #[test]
3649    fn test_live_full_redraw_repositions_from_previous_shape() {
3650        let mut console = Console::with_writer(
3651            Vec::new(),
3652            ConsoleOptions {
3653                is_terminal: true,
3654                ..Default::default()
3655            },
3656        );
3657        console.set_force_terminal(Some(true));
3658        if console.is_dumb_terminal() {
3659            return;
3660        }
3661
3662        let (id, _is_root) = console.live_start(
3663            Box::new(Text::plain("one line")),
3664            crate::live::VerticalOverflowMethod::Ellipsis,
3665        );
3666
3667        // Establish an initial 1-line live frame.
3668        console
3669            .print(&Control::new(), None, None, None, false, "")
3670            .unwrap();
3671        console.clear_captured();
3672
3673        // Grow the live render to 2 lines. Full redraw should position cursor
3674        // using the previous (1-line) frame, so no cursor-up should be emitted.
3675        console.live_update(id, Box::new(Text::plain("line 1\nline 2")));
3676        console
3677            .print(&Control::new(), None, None, None, false, "")
3678            .unwrap();
3679
3680        let out = console.get_captured();
3681        assert!(
3682            !out.contains("\x1b[1A"),
3683            "did not expect cursor-up for previous 1-line frame, got: {:?}",
3684            out,
3685        );
3686    }
3687
3688    #[test]
3689    fn test_set_alt_screen_emits_enable_and_home() {
3690        let mut console = Console::with_writer(
3691            Vec::new(),
3692            ConsoleOptions {
3693                is_terminal: true,
3694                ..Default::default()
3695            },
3696        );
3697        console.set_force_terminal(Some(true));
3698        if console.is_dumb_terminal() {
3699            // Alt screen isn't meaningful on dumb terminals.
3700            return;
3701        }
3702
3703        console.set_alt_screen(true).unwrap();
3704        let out = console.get_captured();
3705        assert!(out.contains("\x1b[?1049h"));
3706        assert!(out.contains("\x1b[H"));
3707    }
3708
3709    #[test]
3710    fn test_print_segments_does_not_emit_osc8_when_not_terminal() {
3711        let mut console = Console::capture();
3712        let mut segments = Segments::new();
3713        segments.push(Segment::new_with_meta(
3714            "X",
3715            StyleMeta::with_link("https://example.com"),
3716        ));
3717        console.print_segments(&segments).unwrap();
3718        let out = console.get_captured();
3719        assert!(out.contains("X"));
3720        assert!(!out.contains("\x1b]8;"));
3721    }
3722
3723    #[test]
3724    fn test_print_segments_emits_osc8_for_segment_meta_link() {
3725        let mut console = Console::with_writer(
3726            Vec::new(),
3727            ConsoleOptions {
3728                is_terminal: true,
3729                ..Default::default()
3730            },
3731        );
3732        console.set_force_terminal(Some(true));
3733        if console.is_dumb_terminal() {
3734            // OSC8 is not meaningful on dumb terminals.
3735            return;
3736        }
3737
3738        let mut segments = Segments::new();
3739        segments.push(Segment::new_with_meta(
3740            "X",
3741            StyleMeta::with_link("https://example.com"),
3742        ));
3743        console.print_segments(&segments).unwrap();
3744        let out = console.get_captured();
3745        assert!(out.contains("\x1b]8;"));
3746        assert!(out.contains("https://example.com"));
3747        assert!(out.contains("\x1b]8;;\x1b\\"));
3748    }
3749
3750    #[test]
3751    fn test_print_segments_osc8_link_id_stable_per_console() {
3752        let mut console = Console::with_writer(
3753            Vec::new(),
3754            ConsoleOptions {
3755                is_terminal: true,
3756                ..Default::default()
3757            },
3758        );
3759        console.set_force_terminal(Some(true));
3760        if console.is_dumb_terminal() {
3761            return;
3762        }
3763
3764        let mut segments = Segments::new();
3765        segments.push(Segment::new_with_meta(
3766            "X",
3767            StyleMeta::with_link("https://example.com"),
3768        ));
3769
3770        console.print_segments(&segments).unwrap();
3771        let out1 = console.get_captured();
3772        assert!(out1.contains("id=richrs-1;https://example.com"));
3773
3774        console.clear_captured();
3775        console.print_segments(&segments).unwrap();
3776        let out2 = console.get_captured();
3777        assert!(out2.contains("id=richrs-1;https://example.com"));
3778    }
3779
3780    #[test]
3781    fn test_print_text_from_ansi_emits_osc8_lifecycle_from_style_meta() {
3782        let mut console = Console::with_writer(
3783            Vec::new(),
3784            ConsoleOptions {
3785                is_terminal: true,
3786                ..Default::default()
3787            },
3788        );
3789        console.set_force_terminal(Some(true));
3790        if console.is_dumb_terminal() {
3791            return;
3792        }
3793
3794        let text = Text::from_ansi("\x1b]8;id=src42;https://example.com\x07Link\x1b]8;;\x07 done");
3795        console.print(&text, None, None, None, false, "").unwrap();
3796        let out = console.get_captured();
3797
3798        let open = "\x1b]8;id=src42;https://example.com\x1b\\";
3799        let close = "\x1b]8;;\x1b\\";
3800        assert!(out.contains(open));
3801        assert!(out.contains(close));
3802
3803        let open_pos = out.find(open).unwrap();
3804        let link_text_pos = out.find("Link").unwrap();
3805        let close_pos = out.find(close).unwrap();
3806        let plain_pos = out.rfind(" done").unwrap();
3807        assert!(open_pos < link_text_pos);
3808        assert!(link_text_pos < close_pos);
3809        assert!(close_pos < plain_pos);
3810    }
3811
3812    #[test]
3813    fn test_print_segments_closes_hyperlink_before_tail_reset() {
3814        let mut console = Console::with_writer(
3815            Vec::new(),
3816            ConsoleOptions {
3817                is_terminal: true,
3818                ..Default::default()
3819            },
3820        );
3821        console.set_force_terminal(Some(true));
3822        if console.is_dumb_terminal() {
3823            return;
3824        }
3825
3826        let mut segments = Segments::new();
3827        segments.push(Segment::styled_with_meta(
3828            "X",
3829            Style::new().with_bold(true),
3830            StyleMeta::with_link("https://example.com"),
3831        ));
3832
3833        console.print_segments(&segments).unwrap();
3834        let out = console.get_captured();
3835        let close_pos = out.find("\x1b]8;;\x1b\\").unwrap();
3836        let reset_pos = out.rfind("\x1b[0m").unwrap();
3837        assert!(close_pos < reset_pos);
3838    }
3839
3840    #[test]
3841    fn test_parse_windows_render_mode_defaults_to_streaming() {
3842        assert_eq!(
3843            parse_windows_render_mode(None),
3844            WindowsRenderMode::Streaming
3845        );
3846        assert_eq!(
3847            parse_windows_render_mode(Some("invalid")),
3848            WindowsRenderMode::Streaming
3849        );
3850    }
3851
3852    #[test]
3853    fn test_parse_windows_render_mode_values() {
3854        assert_eq!(
3855            parse_windows_render_mode(Some("segment")),
3856            WindowsRenderMode::Segment
3857        );
3858        assert_eq!(
3859            parse_windows_render_mode(Some("streaming")),
3860            WindowsRenderMode::Streaming
3861        );
3862        assert_eq!(
3863            parse_windows_render_mode(Some("  StReAmInG  ")),
3864            WindowsRenderMode::Streaming
3865        );
3866    }
3867
3868    // ==================== Console configuration tests ====================
3869
3870    #[test]
3871    fn test_console_width() {
3872        let console = Console::capture_with_options(ConsoleOptions {
3873            max_width: 120,
3874            ..Default::default()
3875        });
3876        assert_eq!(console.width(), 120);
3877    }
3878
3879    #[test]
3880    fn test_console_set_size() {
3881        let mut console = Console::capture();
3882        console.set_size(100, 50);
3883        assert_eq!(console.width(), 100);
3884        assert_eq!(console.height(), 50);
3885        assert_eq!(console.size(), (100, 50));
3886    }
3887
3888    #[test]
3889    fn test_console_force_terminal() {
3890        let mut console = Console::capture();
3891        assert!(!console.is_terminal()); // Capture is not terminal by default
3892
3893        console.set_force_terminal(Some(true));
3894        assert!(console.is_terminal());
3895
3896        console.set_force_terminal(Some(false));
3897        assert!(!console.is_terminal());
3898    }
3899
3900    #[test]
3901    fn test_console_quiet_mode() {
3902        let mut console = Console::capture();
3903        console.set_quiet(true);
3904        console.print_text("This should not appear").unwrap();
3905        assert!(console.get_captured().is_empty());
3906    }
3907
3908    #[test]
3909    fn test_console_markup_emoji_highlight() {
3910        let mut console = Console::capture();
3911
3912        assert!(console.is_markup_enabled());
3913        console.set_markup_enabled(false);
3914        assert!(!console.is_markup_enabled());
3915
3916        assert!(console.is_emoji_enabled());
3917        console.set_emoji_enabled(false);
3918        assert!(!console.is_emoji_enabled());
3919
3920        assert!(console.is_highlight_enabled());
3921        console.set_highlight_enabled(false);
3922        assert!(!console.is_highlight_enabled());
3923    }
3924
3925    #[test]
3926    fn test_console_tab_size() {
3927        let mut console = Console::capture();
3928        assert_eq!(console.tab_size(), 8);
3929        console.set_tab_size(4);
3930        assert_eq!(console.tab_size(), 4);
3931    }
3932
3933    #[test]
3934    fn test_console_encoding() {
3935        let mut console = Console::capture();
3936        assert_eq!(console.encoding(), "utf-8");
3937
3938        console.set_encoding("latin-1");
3939        assert_eq!(console.encoding(), "latin-1");
3940        assert_eq!(console.options().encoding, "latin-1");
3941    }
3942
3943    // ==================== Console render tests ====================
3944
3945    #[test]
3946    fn test_console_render_text() {
3947        // Use Console<Stdout> directly for render methods
3948        let console = Console::with_options(ConsoleOptions::default());
3949        let text = Text::plain("Hello, World!");
3950        let segments = console.render(&text);
3951        assert!(!segments.is_empty());
3952
3953        let combined: String = segments.iter().map(|s| s.text.to_string()).collect();
3954        assert_eq!(combined, "Hello, World!");
3955    }
3956
3957    #[test]
3958    fn test_console_render_str() {
3959        let console = Console::capture();
3960        let text = console.render_str("Hello", None, None, None, None);
3961        assert_eq!(text.plain_text(), "Hello");
3962    }
3963
3964    #[test]
3965    fn test_console_render_str_with_emoji() {
3966        let console = Console::capture();
3967        let text = console.render_str(":smile:", None, Some(true), None, None);
3968        // Should contain the emoji or the original text if emoji not found
3969        assert!(!text.plain_text().is_empty());
3970    }
3971
3972    // ==================== Console print tests ====================
3973
3974    #[test]
3975    fn test_console_print_renderable() {
3976        let mut console = Console::capture();
3977        let text = Text::plain("Hello");
3978        console.print(&text, None, None, None, false, "\n").unwrap();
3979        let output = console.get_captured();
3980        assert!(output.contains("Hello"));
3981    }
3982
3983    #[test]
3984    fn test_console_print_with_style() {
3985        let mut console = Console::capture();
3986        let text = Text::plain("Styled");
3987        let style = Style::new().with_bold(true);
3988        console
3989            .print(&text, Some(style), None, None, false, "\n")
3990            .unwrap();
3991        let output = console.get_captured();
3992        assert!(output.contains("Styled"));
3993    }
3994
3995    #[test]
3996    fn test_console_rule() {
3997        let mut console = Console::capture();
3998        console.rule(None).unwrap();
3999        let output = console.get_captured();
4000        assert!(output.contains("─"));
4001    }
4002
4003    #[test]
4004    fn test_console_rule_with_title() {
4005        let mut console = Console::capture();
4006        console.rule(Some("Title")).unwrap();
4007        let output = console.get_captured();
4008        assert!(output.contains("Title"));
4009        assert!(output.contains("─"));
4010    }
4011
4012    #[test]
4013    fn test_console_line() {
4014        let mut console = Console::capture();
4015        console.line(3).unwrap();
4016        let output = console.get_captured();
4017        assert_eq!(output.matches('\n').count(), 3);
4018    }
4019
4020    // ==================== Console measure tests ====================
4021
4022    #[test]
4023    fn test_console_measure() {
4024        let console = Console::capture();
4025        let text = Text::plain("Hello World");
4026        let measurement = console.measure(&text, None);
4027        assert!(measurement.minimum > 0);
4028        assert!(measurement.maximum >= measurement.minimum);
4029    }
4030
4031    // ==================== Console alt screen tests (unit only) ====================
4032
4033    #[test]
4034    fn test_console_alt_screen_tracking() {
4035        let console = Console::capture();
4036        assert!(!console.is_alt_screen());
4037        // Can't actually test enter/leave without a real terminal
4038    }
4039
4040    // ==================== Theme tests ====================
4041
4042    #[test]
4043    fn test_console_theme_stack() {
4044        let mut console = Console::capture();
4045        let theme = Theme::default();
4046        console.push_theme(theme.clone());
4047        assert!(console.pop_theme().is_ok());
4048    }
4049
4050    // ==================== Color system detection tests ====================
4051
4052    #[test]
4053    fn test_color_system_detection_no_terminal() {
4054        let result = Console::<Stdout>::detect_color_system_static(false);
4055        assert!(result.is_none());
4056    }
4057
4058    // ==================== State propagation tests ====================
4059
4060    #[test]
4061    fn test_console_setters_sync_to_options() {
4062        let mut console = Console::capture();
4063
4064        // Test set_markup_enabled
4065        console.set_markup_enabled(false);
4066        assert!(!console.is_markup_enabled());
4067        assert!(!console.options().markup_enabled);
4068
4069        // Test set_emoji_enabled
4070        console.set_emoji_enabled(false);
4071        assert!(!console.is_emoji_enabled());
4072        assert!(!console.options().emoji_enabled);
4073
4074        // Test set_highlight_enabled
4075        console.set_highlight_enabled(false);
4076        assert!(!console.is_highlight_enabled());
4077        assert!(!console.options().highlight_enabled);
4078
4079        // Test set_tab_size
4080        console.set_tab_size(4);
4081        assert_eq!(console.tab_size(), 4);
4082        assert_eq!(console.options().tab_size, 4);
4083
4084        // Test set_encoding
4085        console.set_encoding("cp1252");
4086        assert_eq!(console.encoding(), "cp1252");
4087        assert_eq!(console.options().encoding, "cp1252");
4088
4089        // Test set_color_system
4090        console.set_color_system(Some(ColorSystem::TrueColor));
4091        assert_eq!(console.color_system(), Some(ColorSystem::TrueColor));
4092        assert_eq!(console.options().color_system, Some(ColorSystem::TrueColor));
4093    }
4094
4095    #[test]
4096    fn test_console_options_with_state() {
4097        let mut console = Console::capture();
4098
4099        // Modify console state
4100        console.set_markup_enabled(false);
4101        console.set_tab_size(2);
4102
4103        // Get options with state
4104        let opts = console.options_with_state();
4105        assert!(!opts.markup_enabled);
4106        assert_eq!(opts.tab_size, 2);
4107    }
4108
4109    #[test]
4110    fn test_with_options_initializes_from_options() {
4111        // Create options with custom state
4112        let mut options = ConsoleOptions::default();
4113        options.markup_enabled = false;
4114        options.emoji_enabled = false;
4115        options.tab_size = 4;
4116        options.encoding = "ascii".to_string();
4117        options.color_system = Some(ColorSystem::Standard);
4118
4119        // Create console from options
4120        let console = Console::with_options(options);
4121
4122        // Console fields should match options
4123        assert!(!console.is_markup_enabled());
4124        assert!(!console.is_emoji_enabled());
4125        assert_eq!(console.tab_size(), 4);
4126        assert_eq!(console.encoding(), "ascii");
4127        assert_eq!(console.color_system(), Some(ColorSystem::Standard));
4128    }
4129
4130    #[test]
4131    fn test_sync_from_options() {
4132        let mut console = Console::capture();
4133
4134        // Modify options directly (not recommended, but supported)
4135        console.options_mut().markup_enabled = false;
4136        console.options_mut().tab_size = 2;
4137
4138        // Console fields are now out of sync
4139        assert!(console.is_markup_enabled()); // Still true!
4140        assert_eq!(console.tab_size(), 8); // Still 8!
4141
4142        // Sync from options
4143        console.sync_from_options();
4144
4145        // Now console fields match options
4146        assert!(!console.is_markup_enabled());
4147        assert_eq!(console.tab_size(), 2);
4148    }
4149
4150    #[test]
4151    fn test_sync_theme_to_options() {
4152        let mut console = Console::capture();
4153
4154        // Modify theme stack directly (not recommended, but supported)
4155        let mut custom_theme = Theme::empty();
4156        custom_theme.add_style("direct.style", Style::new().with_italic(true));
4157        console.theme_stack_mut().push_theme(custom_theme);
4158
4159        // Options theme stack is now out of sync
4160        assert!(console.theme_stack().get_style("direct.style").is_some());
4161        assert!(
4162            console
4163                .options()
4164                .theme_stack
4165                .get_style("direct.style")
4166                .is_none()
4167        ); // Out of sync!
4168
4169        // Sync theme to options
4170        console.sync_theme_to_options();
4171
4172        // Now options theme stack matches
4173        assert!(
4174            console
4175                .options()
4176                .theme_stack
4177                .get_style("direct.style")
4178                .is_some()
4179        );
4180    }
4181
4182    #[test]
4183    fn test_nested_renderable_gets_state() {
4184        // This test verifies that when Padding (which calls render_lines internally)
4185        // renders, the inner renderable gets the correct console state.
4186
4187        use crate::padding::Padding;
4188
4189        let mut console = Console::capture();
4190        console.set_markup_enabled(false);
4191
4192        // Create a simple padding around text
4193        let text = Text::plain("Hello");
4194        let padded = Padding::new(Box::new(text), 1);
4195
4196        // Render - if state doesn't propagate, this would crash or produce wrong output
4197        let options = console.options().clone();
4198        let segments = padded.render(&Console::with_options(options.clone()), &options);
4199
4200        // Verify rendering worked (basic check)
4201        assert!(!segments.is_empty());
4202    }
4203
4204    #[test]
4205    fn test_theme_push_syncs_to_options() {
4206        let mut console = Console::capture();
4207
4208        // Get initial depth
4209        let initial_depth = console.theme_stack().depth();
4210        let initial_opts_depth = console.options().theme_stack.depth();
4211        assert_eq!(initial_depth, initial_opts_depth);
4212
4213        // Push a theme
4214        let mut custom_theme = Theme::empty();
4215        custom_theme.add_style("test.style", Style::new().with_bold(true));
4216        console.push_theme(custom_theme);
4217
4218        // Both should have increased depth
4219        assert_eq!(console.theme_stack().depth(), initial_depth + 1);
4220        assert_eq!(console.options().theme_stack.depth(), initial_depth + 1);
4221
4222        // Both should see the new style
4223        assert!(console.theme_stack().get_style("test.style").is_some());
4224        assert!(
4225            console
4226                .options()
4227                .theme_stack
4228                .get_style("test.style")
4229                .is_some()
4230        );
4231
4232        // Pop the theme
4233        console.pop_theme().unwrap();
4234
4235        // Both should be back to original depth
4236        assert_eq!(console.theme_stack().depth(), initial_depth);
4237        assert_eq!(console.options().theme_stack.depth(), initial_depth);
4238    }
4239
4240    // ==================== Send + Sync assertions ====================
4241
4242    #[test]
4243    fn test_console_options_is_send_sync() {
4244        fn assert_send<T: Send>() {}
4245        fn assert_sync<T: Sync>() {}
4246        assert_send::<ConsoleOptions>();
4247        assert_sync::<ConsoleOptions>();
4248    }
4249
4250    // ==================== Pager context tests ====================
4251
4252    #[test]
4253    fn test_pager_options_default() {
4254        let opts = PagerOptions::default();
4255        assert!(!opts.styles);
4256    }
4257
4258    #[test]
4259    fn test_pager_options_with_styles() {
4260        let opts = PagerOptions::new().with_styles(true);
4261        assert!(opts.styles);
4262    }
4263
4264    #[test]
4265    fn test_pager_context_captures_text() {
4266        let console = Console::new();
4267        let mut pager = console.pager(None);
4268
4269        pager.print_text("Hello").unwrap();
4270        pager.print_text("World").unwrap();
4271
4272        let buffer = pager.get_buffer_string();
4273        assert!(buffer.contains("Hello"));
4274        assert!(buffer.contains("World"));
4275
4276        // Prevent the pager from actually running during the test
4277        pager.buffer.clear();
4278    }
4279
4280    #[test]
4281    fn test_pager_context_captures_renderable() {
4282        let console = Console::new();
4283        let mut pager = console.pager(None);
4284
4285        let text = Text::plain("Rendered text");
4286        pager.print(&text, None, None, None, false, "\n").unwrap();
4287
4288        let buffer = pager.get_buffer_string();
4289        assert!(buffer.contains("Rendered text"));
4290
4291        // Prevent the pager from actually running during the test
4292        pager.buffer.clear();
4293    }
4294
4295    // ==================== Recording and SVG export tests ====================
4296
4297    #[test]
4298    fn test_console_new_with_record() {
4299        let console = Console::new_with_record();
4300        assert!(console.is_recording());
4301    }
4302
4303    #[test]
4304    fn test_console_set_record() {
4305        let mut console = Console::new();
4306        assert!(!console.is_recording());
4307
4308        console.set_record(true);
4309        assert!(console.is_recording());
4310
4311        console.set_record(false);
4312        assert!(!console.is_recording());
4313    }
4314
4315    #[test]
4316    fn test_console_record_buffer() {
4317        let mut console = Console::new_with_record();
4318        console.options_mut().is_terminal = false;
4319        console.options_mut().max_width = 80;
4320
4321        // Print something
4322        console.print_text("Hello").unwrap();
4323
4324        // Check that something was recorded
4325        let buffer = console.get_record_buffer();
4326        assert!(!buffer.is_empty());
4327
4328        // Find the segment containing "Hello"
4329        let has_hello = buffer.iter().any(|s| s.text.contains("Hello"));
4330        assert!(has_hello, "Record buffer should contain 'Hello'");
4331
4332        // Clear the buffer
4333        console.clear_record_buffer();
4334        let buffer = console.get_record_buffer();
4335        assert!(buffer.is_empty());
4336    }
4337
4338    #[test]
4339    fn test_export_svg_basic() {
4340        let mut console = Console::new_with_record();
4341        console.options_mut().is_terminal = false;
4342        console.options_mut().max_width = 40;
4343
4344        console.print_text("Hello, World!").unwrap();
4345
4346        let svg = console.export_svg("Test", None, true, None, 0.61, None);
4347
4348        // Check SVG structure
4349        assert!(svg.contains("<svg"));
4350        assert!(svg.contains("</svg>"));
4351        assert!(svg.contains("Hello"));
4352        assert!(svg.contains("rich-terminal"));
4353
4354        // Buffer should be cleared
4355        let buffer = console.get_record_buffer();
4356        assert!(buffer.is_empty());
4357    }
4358
4359    #[test]
4360    fn test_export_svg_with_custom_title() {
4361        let mut console = Console::new_with_record();
4362        console.options_mut().is_terminal = false;
4363        console.options_mut().max_width = 40;
4364
4365        console.print_text("Test").unwrap();
4366
4367        let svg = console.export_svg("My Custom Title", None, true, None, 0.61, None);
4368
4369        assert!(svg.contains("My&#160;Custom&#160;Title"));
4370    }
4371
4372    #[test]
4373    fn test_export_svg_with_unique_id() {
4374        let mut console = Console::new_with_record();
4375        console.options_mut().is_terminal = false;
4376        console.options_mut().max_width = 40;
4377
4378        console.print_text("Test").unwrap();
4379
4380        let svg = console.export_svg("Test", None, true, None, 0.61, Some("my-unique-id"));
4381
4382        assert!(svg.contains("my-unique-id"));
4383    }
4384
4385    #[test]
4386    fn test_export_svg_escape_text() {
4387        let mut console = Console::new_with_record();
4388        console.options_mut().is_terminal = false;
4389        console.options_mut().max_width = 80;
4390
4391        console.print_text("<script>alert('XSS')</script>").unwrap();
4392
4393        let svg = console.export_svg("Test", None, true, None, 0.61, None);
4394
4395        // Should be escaped
4396        assert!(svg.contains("&lt;"));
4397        assert!(svg.contains("&gt;"));
4398        assert!(!svg.contains("<script>"));
4399    }
4400
4401    #[test]
4402    fn test_export_svg_no_clear() {
4403        let mut console = Console::new_with_record();
4404        console.options_mut().is_terminal = false;
4405        console.options_mut().max_width = 40;
4406
4407        console.print_text("Hello").unwrap();
4408
4409        // Export without clearing
4410        let _svg = console.export_svg("Test", None, false, None, 0.61, None);
4411
4412        // Buffer should still contain data
4413        let buffer = console.get_record_buffer();
4414        assert!(!buffer.is_empty());
4415    }
4416
4417    #[test]
4418    fn test_export_html_basic() {
4419        let mut console = Console::new_with_record();
4420        console.options_mut().is_terminal = false;
4421        console.options_mut().max_width = 40;
4422
4423        console.print_text("Hello, World!").unwrap();
4424
4425        let html = console.export_html(None, true, None);
4426        assert!(html.contains("<!DOCTYPE html>"));
4427        assert!(html.contains("Hello, World!"));
4428
4429        // Buffer should be cleared
4430        let buffer = console.get_record_buffer();
4431        assert!(buffer.is_empty());
4432    }
4433
4434    #[test]
4435    fn test_export_html_link_emits_anchor_tag() {
4436        let mut console = Console::new_with_record();
4437        console.options_mut().is_terminal = false;
4438
4439        let text =
4440            Text::from_markup("[link=https://textualize.io]Textualize.io[/link]", false).unwrap();
4441        console.print(&text, None, None, None, false, "\n").unwrap();
4442
4443        let html = console.export_html(None, true, None);
4444        assert!(html.contains("<a"));
4445        assert!(html.contains("href=\"https://textualize.io\""));
4446        assert!(html.contains("Textualize.io"));
4447    }
4448
4449    #[test]
4450    fn test_export_html_escapes_text() {
4451        let mut console = Console::new_with_record();
4452        console.options_mut().is_terminal = false;
4453
4454        console.print_text("<script>alert('XSS')</script>").unwrap();
4455        let html = console.export_html(None, true, None);
4456        assert!(html.contains("&lt;script&gt;"));
4457        assert!(!html.contains("<script>"));
4458    }
4459}