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            // When Live is active, the trailing newline belongs to the *printed* content,
1663            // and the live render must be re-drawn after it (Rich behavior).
1664            if !end.is_empty() {
1665                segments.push(Segment::new(end.to_string()));
1666            }
1667            end_to_write = "";
1668
1669            let (live_segments, full_redraw) = self.render_live_segments(&options);
1670            let mut wrapped = Segments::new();
1671            let cursor_controls = if full_redraw {
1672                self.live_position_cursor()
1673            } else {
1674                self.live_position_cursor_no_erase()
1675            };
1676            for seg in cursor_controls.iter() {
1677                wrapped.push(seg.clone());
1678            }
1679            for seg in segments.into_iter() {
1680                wrapped.push(seg);
1681            }
1682            for seg in live_segments.into_iter() {
1683                wrapped.push(seg);
1684            }
1685            segments = wrapped;
1686        }
1687
1688        let should_disable_wrap = self.options.disable_line_wrap && atty::is(atty::Stream::Stdout);
1689        if should_disable_wrap {
1690            // Disable automatic line wrap (DECAWM) so output can use full width
1691            // without terminals inserting an extra wrapped line.
1692            write!(self.writer, "\x1b[?7l")?;
1693        }
1694
1695        // Record segments if recording is enabled
1696        if self.record {
1697            if let Ok(mut buffer) = self.record_buffer.lock() {
1698                for seg in segments.iter() {
1699                    buffer.push(seg.clone());
1700                }
1701                if !end_to_write.is_empty() {
1702                    buffer.push(Segment::new(end_to_write.to_string()));
1703                }
1704            }
1705        }
1706
1707        let result = (|| {
1708            // Print segments
1709            self.print_segments(&segments)?;
1710
1711            // Print end string
1712            if !end_to_write.is_empty() {
1713                write!(self.writer, "{}", end_to_write)?;
1714            }
1715
1716            self.writer.flush()
1717        })();
1718
1719        if should_disable_wrap {
1720            // Always attempt to restore wrap mode.
1721            let _ = write!(self.writer, "\x1b[?7h");
1722        }
1723
1724        result
1725    }
1726
1727    /// Log a renderable with timestamp prefix.
1728    ///
1729    /// Similar to `print()`, but adds a timestamp prefix in `[HH:MM:SS]` format.
1730    /// Optionally displays the source file and line number.
1731    ///
1732    /// # Arguments
1733    ///
1734    /// * `renderable` - The object to render and log.
1735    /// * `file` - Optional source file name (use `file!()` macro at call site).
1736    /// * `line` - Optional line number (use `line!()` macro at call site).
1737    ///
1738    /// # Example
1739    ///
1740    /// ```ignore
1741    /// use rich_rs::{Console, Text};
1742    ///
1743    /// let mut console = Console::new();
1744    /// // Using the log! macro (recommended)
1745    /// rich_rs::log!(console, &Text::plain("Server starting..."));
1746    ///
1747    /// // Or directly with file/line
1748    /// console.log(&Text::plain("Message"), Some(file!()), Some(line!())).unwrap();
1749    /// ```
1750    pub fn log<R: Renderable + ?Sized>(
1751        &mut self,
1752        renderable: &R,
1753        file: Option<&str>,
1754        line: Option<u32>,
1755    ) -> io::Result<()> {
1756        if self.quiet {
1757            return Ok(());
1758        }
1759
1760        // Get current time
1761        let now = SystemTime::now();
1762        let duration = now
1763            .duration_since(SystemTime::UNIX_EPOCH)
1764            .unwrap_or_default();
1765        let secs = duration.as_secs();
1766        let hours = (secs / 3600) % 24;
1767        let minutes = (secs / 60) % 60;
1768        let seconds = secs % 60;
1769
1770        // Create timestamp text with dim style
1771        let timestamp = format!("[{:02}:{:02}:{:02}]", hours, minutes, seconds);
1772        let time_style = self
1773            .theme_stack
1774            .get_style("log.time")
1775            .unwrap_or_else(|| Style::new().with_dim(true));
1776        let time_text = Text::styled(&timestamp, time_style);
1777
1778        // Create a grid table for layout: [time] [message] [path:line]
1779        let mut grid = Table::grid().with_padding(0, 1).with_expand(true);
1780
1781        // Time column
1782        grid.add_column(Column::new().style(time_style).no_wrap(true));
1783
1784        // Message column (ratio=1, expands to fill)
1785        let message_style = self
1786            .theme_stack
1787            .get_style("log.message")
1788            .unwrap_or_default();
1789        grid.add_column(Column::new().style(message_style).ratio(1));
1790
1791        // Path column (optional)
1792        let has_path = file.is_some();
1793        if has_path {
1794            let path_style = self
1795                .theme_stack
1796                .get_style("log.path")
1797                .unwrap_or_else(|| Style::new().with_dim(true));
1798            grid.add_column(Column::new().style(path_style).no_wrap(true));
1799        }
1800
1801        // Build the row
1802        let mut cells: Vec<Box<dyn Renderable + Send + Sync>> = vec![Box::new(time_text)];
1803
1804        // Wrap the renderable in a capturing approach
1805        // We need to render the user's content and wrap it
1806        let options = self.options.clone();
1807        let temp_console = Console::<Stdout>::with_options(options.clone());
1808        let segments = renderable.render(&temp_console, &options);
1809
1810        // Convert segments to Text for the cell
1811        let mut message_text = Text::plain("");
1812        for seg in segments.iter() {
1813            if seg.control.is_none() {
1814                message_text.append(&*seg.text, seg.style);
1815            }
1816        }
1817        cells.push(Box::new(message_text));
1818
1819        // Add path:line if provided
1820        if let Some(f) = file {
1821            // Extract just the filename from the path
1822            let filename = f.rsplit(['/', '\\']).next().unwrap_or(f);
1823            let path_text = if let Some(l) = line {
1824                Text::plain(format!("{}:{}", filename, l))
1825            } else {
1826                Text::plain(filename)
1827            };
1828            cells.push(Box::new(path_text));
1829        }
1830
1831        grid.add_row(Row::new(cells));
1832
1833        // Print the grid
1834        self.print(&grid, None, None, None, false, "\n")
1835    }
1836
1837    /// Render a line (horizontal rule).
1838    pub fn rule(&mut self, title: Option<&str>) -> io::Result<()> {
1839        if self.quiet {
1840            return Ok(());
1841        }
1842
1843        let width = self.width();
1844        match title {
1845            Some(t) => {
1846                // Use cell_len for correct width calculation with wide characters
1847                let title_width = crate::cells::cell_len(t);
1848                let padding = (width.saturating_sub(title_width + 2)) / 2;
1849                let line: String = "─".repeat(padding);
1850                writeln!(self.writer, "{} {} {}", line, t, line)?;
1851            }
1852            None => {
1853                let line: String = "─".repeat(width);
1854                writeln!(self.writer, "{}", line)?;
1855            }
1856        }
1857        self.writer.flush()
1858    }
1859
1860    /// Print new line(s).
1861    pub fn line(&mut self, count: usize) -> io::Result<()> {
1862        if self.quiet {
1863            return Ok(());
1864        }
1865
1866        if self.is_terminal() && !self.is_dumb_terminal() && self.has_live() {
1867            for _ in 0..count {
1868                self.print(&Text::plain(""), None, None, None, false, "\n")?;
1869            }
1870            return Ok(());
1871        }
1872
1873        for _ in 0..count {
1874            writeln!(self.writer)?;
1875        }
1876        self.writer.flush()
1877    }
1878
1879    // ========================================================================
1880    // Terminal Control
1881    // ========================================================================
1882
1883    /// Clear the screen.
1884    pub fn clear(&mut self) -> io::Result<()> {
1885        if !self.is_terminal() {
1886            return Ok(());
1887        }
1888        execute!(self.writer, ct::Clear(ClearType::All))?;
1889        execute!(self.writer, cursor::MoveTo(0, 0))?;
1890        self.writer.flush()
1891    }
1892
1893    /// Clear the current line.
1894    pub fn clear_line(&mut self) -> io::Result<()> {
1895        if !self.is_terminal() {
1896            return Ok(());
1897        }
1898        execute!(self.writer, ct::Clear(ClearType::CurrentLine))?;
1899        self.writer.flush()
1900    }
1901
1902    /// Move the cursor to a specific position.
1903    pub fn move_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
1904        if !self.is_terminal() {
1905            return Ok(());
1906        }
1907        execute!(self.writer, cursor::MoveTo(x, y))?;
1908        self.writer.flush()
1909    }
1910
1911    /// Show or hide the cursor.
1912    pub fn show_cursor(&mut self, show: bool) -> io::Result<bool> {
1913        if !self.is_terminal() {
1914            return Ok(false);
1915        }
1916        if show {
1917            execute!(self.writer, cursor::Show)?;
1918        } else {
1919            execute!(self.writer, cursor::Hide)?;
1920        }
1921        self.writer.flush()?;
1922        Ok(true)
1923    }
1924
1925    /// Enter alternate screen mode.
1926    ///
1927    /// The alternate screen is a separate screen buffer that can be used
1928    /// for full-screen applications. Call `leave_alt_screen` when done.
1929    pub fn enter_alt_screen(&mut self) -> io::Result<bool> {
1930        if !self.is_terminal() || self.legacy_windows {
1931            return Ok(false);
1932        }
1933        self.set_alt_screen(true)
1934    }
1935
1936    /// Leave alternate screen mode.
1937    pub fn leave_alt_screen(&mut self) -> io::Result<bool> {
1938        if !self.is_terminal() || !self.is_alt_screen {
1939            return Ok(false);
1940        }
1941        self.set_alt_screen(false)
1942    }
1943
1944    /// Check if alternate screen mode is active.
1945    pub fn is_alt_screen(&self) -> bool {
1946        self.is_alt_screen
1947    }
1948
1949    /// Enable or disable alternate screen mode (Rich parity).
1950    ///
1951    /// When enabling, Rich emits `ENABLE_ALT_SCREEN` followed by `HOME`.
1952    /// When disabling, Rich emits `DISABLE_ALT_SCREEN`.
1953    pub fn set_alt_screen(&mut self, enable: bool) -> io::Result<bool> {
1954        if !self.is_terminal() || self.legacy_windows {
1955            return Ok(false);
1956        }
1957        if enable == self.is_alt_screen {
1958            return Ok(false);
1959        }
1960
1961        let mut segs = Segments::new();
1962        if enable {
1963            segs.push(Segment::control(ControlType::EnableAltScreen));
1964            segs.push(Segment::control(ControlType::Home));
1965            self.is_alt_screen = true;
1966        } else {
1967            segs.push(Segment::control(ControlType::DisableAltScreen));
1968            self.is_alt_screen = false;
1969        }
1970        self.print_segments(&segs)?;
1971        Ok(true)
1972    }
1973
1974    /// Enter alternate screen mode with a context guard.
1975    ///
1976    /// This returns a [`crate::ScreenContext`] that automatically leaves alternate screen
1977    /// mode when dropped, providing RAII semantics for full-screen applications.
1978    ///
1979    /// # Arguments
1980    ///
1981    /// * `hide_cursor` - Whether to hide the cursor while in alternate screen mode.
1982    /// * `style` - Optional background style for the screen.
1983    ///
1984    /// # Example
1985    ///
1986    /// ```ignore
1987    /// use rich_rs::{Console, Text};
1988    ///
1989    /// let mut console = Console::new();
1990    /// let mut screen = console.screen(true, None)?;
1991    /// screen.update(Text::plain("Hello!"))?;
1992    /// // Screen is automatically exited when `screen` is dropped
1993    /// ```
1994    pub fn screen(
1995        &mut self,
1996        hide_cursor: bool,
1997        style: Option<Style>,
1998    ) -> io::Result<crate::screen_context::ScreenContext<'_, W>> {
1999        crate::screen_context::ScreenContext::new(self, hide_cursor, style)
2000    }
2001
2002    /// Set the window title.
2003    pub fn set_window_title(&mut self, title: &str) -> io::Result<bool> {
2004        if !self.is_terminal() {
2005            return Ok(false);
2006        }
2007        execute!(self.writer, ct::SetTitle(title))?;
2008        self.writer.flush()?;
2009        Ok(true)
2010    }
2011
2012    /// Ring the terminal bell.
2013    pub fn bell(&mut self) -> io::Result<()> {
2014        write!(self.writer, "\x07")?;
2015        self.writer.flush()
2016    }
2017
2018    // ========================================================================
2019    // Live display integration (used by Live / Progress)
2020    // ========================================================================
2021
2022    pub fn live_start(
2023        &mut self,
2024        renderable: Box<dyn crate::Renderable + Send + Sync>,
2025        vertical_overflow: crate::live::VerticalOverflowMethod,
2026    ) -> (usize, bool) {
2027        let is_root = self.live.stack.is_empty();
2028        let id = self.live.next_id;
2029        self.live.next_id += 1;
2030
2031        self.live.entries.insert(
2032            id,
2033            LiveEntry {
2034                renderable,
2035                vertical_overflow: vertical_overflow.into(),
2036            },
2037        );
2038        self.live.stack.push(id);
2039        (id, is_root)
2040    }
2041
2042    pub fn live_update(&mut self, id: usize, renderable: Box<dyn crate::Renderable + Send + Sync>) {
2043        if let Some(entry) = self.live.entries.get_mut(&id) {
2044            entry.renderable = renderable;
2045        }
2046    }
2047
2048    pub fn live_set_vertical_overflow(
2049        &mut self,
2050        id: usize,
2051        vertical_overflow: crate::live::VerticalOverflowMethod,
2052    ) {
2053        if let Some(entry) = self.live.entries.get_mut(&id) {
2054            entry.vertical_overflow = vertical_overflow.into();
2055        }
2056    }
2057
2058    pub fn live_stop(&mut self, id: usize) -> Option<Box<dyn crate::Renderable + Send + Sync>> {
2059        self.live.stack.retain(|&x| x != id);
2060        let entry = self.live.entries.remove(&id);
2061        if self.live.stack.is_empty() {
2062            self.live.shape = None;
2063            self.live.buffer = None;
2064        }
2065        entry.map(|e| e.renderable)
2066    }
2067
2068    pub fn live_clear(&mut self) {
2069        self.live.stack.clear();
2070        self.live.entries.clear();
2071        self.live.shape = None;
2072        self.live.buffer = None;
2073    }
2074
2075    fn has_live(&self) -> bool {
2076        !self.live.stack.is_empty()
2077    }
2078
2079    fn live_root(&self) -> Option<&LiveEntry> {
2080        let id = *self.live.stack.first()?;
2081        self.live.entries.get(&id)
2082    }
2083
2084    pub(crate) fn live_position_cursor(&self) -> Segments {
2085        let Some((_, height)) = self.live.shape else {
2086            return Segments::new();
2087        };
2088        if height == 0 {
2089            return Segments::new();
2090        }
2091        let mut controls = Vec::new();
2092        controls.push(Segment::control(ControlType::CarriageReturn));
2093        controls.push(Segment::control(ControlType::EraseInLine(2)));
2094        for _ in 0..height.saturating_sub(1) {
2095            controls.push(Segment::control(ControlType::CursorUp(1)));
2096            controls.push(Segment::control(ControlType::CarriageReturn));
2097            controls.push(Segment::control(ControlType::EraseInLine(2)));
2098        }
2099        Segments::from_iter(controls)
2100    }
2101
2102    pub(crate) fn live_position_cursor_no_erase(&self) -> Segments {
2103        let Some((_, height)) = self.live.shape else {
2104            return Segments::new();
2105        };
2106        if height == 0 {
2107            return Segments::new();
2108        }
2109        let mut controls = Vec::new();
2110        controls.push(Segment::control(ControlType::CarriageReturn));
2111        for _ in 0..height.saturating_sub(1) {
2112            controls.push(Segment::control(ControlType::CursorUp(1)));
2113        }
2114        Segments::from_iter(controls)
2115    }
2116
2117    pub(crate) fn live_restore_cursor(&self) -> Segments {
2118        let Some((_, height)) = self.live.shape else {
2119            return Segments::new();
2120        };
2121        if height == 0 {
2122            return Segments::new();
2123        }
2124        let mut controls = Vec::new();
2125        controls.push(Segment::control(ControlType::CarriageReturn));
2126        for _ in 0..height {
2127            controls.push(Segment::control(ControlType::CursorUp(1)));
2128            controls.push(Segment::control(ControlType::CarriageReturn));
2129            controls.push(Segment::control(ControlType::EraseInLine(2)));
2130        }
2131        Segments::from_iter(controls)
2132    }
2133
2134    fn render_live_segments(&mut self, options: &ConsoleOptions) -> (Segments, bool) {
2135        let root = match self.live_root() {
2136            Some(root) => root,
2137            None => return (Segments::new(), false),
2138        };
2139
2140        let mut lines: Vec<Vec<Segment>> = Vec::new();
2141        for id in self.live.stack.iter() {
2142            if let Some(entry) = self.live.entries.get(id) {
2143                let mut rendered =
2144                    self.render_lines(entry.renderable.as_ref(), Some(options), None, false, false);
2145                lines.append(&mut rendered);
2146            }
2147        }
2148
2149        let max_height = options.size.1;
2150        if max_height > 0 && lines.len() > max_height {
2151            match root.vertical_overflow {
2152                LiveVerticalOverflow::Visible => {}
2153                LiveVerticalOverflow::Crop => {
2154                    lines.truncate(max_height);
2155                }
2156                LiveVerticalOverflow::Ellipsis => {
2157                    lines.truncate(max_height.saturating_sub(1));
2158                    let style = options.get_style("live.ellipsis").unwrap_or_default();
2159                    let ellipsis = Text::styled("...", style).center(options.max_width);
2160                    let ellipsis_lines =
2161                        self.render_lines(&ellipsis, Some(options), None, false, false);
2162                    if let Some(first) = ellipsis_lines.into_iter().next() {
2163                        lines.push(first);
2164                    }
2165                }
2166            }
2167        }
2168
2169        let shape = Segment::get_shape(&lines);
2170        self.live.shape = Some(shape);
2171
2172        let width = options.max_width.max(1);
2173        let height = shape.1.max(1);
2174        let current_buffer = ScreenBuffer::from_lines(&lines, width, height, None);
2175
2176        let use_diff = self.live.buffer.as_ref().is_some_and(|previous| {
2177            previous.width == current_buffer.width && previous.height == current_buffer.height
2178        });
2179
2180        if use_diff {
2181            let previous = self.live.buffer.as_ref().expect("checked above");
2182            let diff = current_buffer.diff_to_segments_from_origin(previous);
2183            self.live.buffer = Some(current_buffer);
2184            return (diff, false);
2185        }
2186
2187        self.live.buffer = Some(current_buffer);
2188
2189        let mut out = Segments::new();
2190        let new_line = Segment::line();
2191        for (i, line) in lines.into_iter().enumerate() {
2192            for seg in line {
2193                out.push(seg);
2194            }
2195            if i + 1 < shape.1 {
2196                out.push(new_line.clone());
2197            }
2198        }
2199        (out, true)
2200    }
2201
2202    // ========================================================================
2203    // Input Methods
2204    // ========================================================================
2205
2206    /// Read a line of input from the user.
2207    ///
2208    /// This method displays a prompt and reads a line of input from stdin.
2209    /// If `password` is true, input is masked (not echoed to the terminal).
2210    ///
2211    /// # Arguments
2212    ///
2213    /// * `prompt` - The text to display as a prompt.
2214    /// * `password` - If true, input will be masked for password entry.
2215    ///
2216    /// # Returns
2217    ///
2218    /// The user's input as a string (without trailing newline).
2219    ///
2220    /// # Errors
2221    ///
2222    /// Returns an error if reading from stdin fails or if the input stream
2223    /// reaches EOF unexpectedly.
2224    ///
2225    /// # Example
2226    ///
2227    /// ```ignore
2228    /// use rich_rs::{Console, Text};
2229    ///
2230    /// let mut console = Console::new();
2231    /// let prompt = Text::plain("Enter your name: ");
2232    /// let name = console.input(&prompt, false)?;
2233    /// println!("Hello, {}!", name);
2234    /// ```
2235    pub fn input(&mut self, prompt: &Text, password: bool) -> io::Result<String> {
2236        use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
2237        use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
2238
2239        // Print the prompt
2240        self.print(prompt, None, None, None, false, "")?;
2241        self.writer.flush()?;
2242
2243        // For password input, use raw mode to capture without echo
2244        if password && self.is_terminal() && !self.is_dumb_terminal() {
2245            enable_raw_mode()?;
2246
2247            let result = (|| -> io::Result<String> {
2248                let mut input = String::new();
2249
2250                loop {
2251                    if let Event::Key(KeyEvent {
2252                        code, modifiers, ..
2253                    }) = event::read()?
2254                    {
2255                        match code {
2256                            KeyCode::Enter => {
2257                                // Print newline after password entry
2258                                write!(self.writer, "\r\n")?;
2259                                self.writer.flush()?;
2260                                return Ok(input);
2261                            }
2262                            KeyCode::Backspace => {
2263                                input.pop();
2264                            }
2265                            KeyCode::Char(c) => {
2266                                // Check for Ctrl+C
2267                                if c == 'c' && modifiers.contains(KeyModifiers::CONTROL) {
2268                                    write!(self.writer, "\r\n")?;
2269                                    self.writer.flush()?;
2270                                    return Err(io::Error::new(
2271                                        io::ErrorKind::Interrupted,
2272                                        "Input cancelled",
2273                                    ));
2274                                }
2275                                // Check for Ctrl+D (EOF)
2276                                if c == 'd' && modifiers.contains(KeyModifiers::CONTROL) {
2277                                    write!(self.writer, "\r\n")?;
2278                                    self.writer.flush()?;
2279                                    return Err(io::Error::new(
2280                                        io::ErrorKind::UnexpectedEof,
2281                                        "EOF",
2282                                    ));
2283                                }
2284                                input.push(c);
2285                            }
2286                            KeyCode::Esc => {
2287                                // ESC cancels input
2288                                write!(self.writer, "\r\n")?;
2289                                self.writer.flush()?;
2290                                return Err(io::Error::new(
2291                                    io::ErrorKind::Interrupted,
2292                                    "Input cancelled",
2293                                ));
2294                            }
2295                            _ => {}
2296                        }
2297                    }
2298                }
2299            })();
2300
2301            // Always restore terminal mode
2302            let _ = disable_raw_mode();
2303            result
2304        } else {
2305            // Normal input: read from stdin
2306            let mut input = String::new();
2307            io::stdin().read_line(&mut input)?;
2308            // Remove trailing newline
2309            if input.ends_with('\n') {
2310                input.pop();
2311                if input.ends_with('\r') {
2312                    input.pop();
2313                }
2314            }
2315            Ok(input)
2316        }
2317    }
2318
2319    // ========================================================================
2320    // Measurement
2321    // ========================================================================
2322
2323    /// Measure a renderable object.
2324    ///
2325    /// Returns the minimum and maximum width required to render the object.
2326    ///
2327    /// # Arguments
2328    ///
2329    /// * `renderable` - The object to measure.
2330    /// * `options` - Optional custom options, or None to use console defaults.
2331    ///   If provided, ensure these options include the console state fields
2332    ///   by deriving from `console.options()`.
2333    pub fn measure<R: Renderable + ?Sized>(
2334        &self,
2335        renderable: &R,
2336        options: Option<&ConsoleOptions>,
2337    ) -> crate::measure::Measurement {
2338        // Use provided options or console's options (which include state)
2339        let measure_opts = options.cloned().unwrap_or_else(|| self.options.clone());
2340
2341        // Create a temp Console<Stdout> for the measure call.
2342        // Console::with_options() initializes console fields from options.
2343        let temp_console = Console::<Stdout>::with_options(measure_opts.clone());
2344        renderable.measure(&temp_console, &measure_opts)
2345    }
2346
2347    // ========================================================================
2348    // New parity methods
2349    // ========================================================================
2350
2351    /// Low-level output that bypasses the full rendering pipeline.
2352    ///
2353    /// Unlike `print()`, this won't pretty print, wrap text, or apply markup,
2354    /// but will optionally apply a basic style and highlighting.
2355    pub fn out(
2356        &mut self,
2357        text: &str,
2358        style: Option<Style>,
2359        _highlight: Option<bool>,
2360    ) -> io::Result<()> {
2361        if self.quiet {
2362            return Ok(());
2363        }
2364        self.print(
2365            &Text::plain(text),
2366            style,
2367            None,
2368            Some(OverflowMethod::Ignore),
2369            true, // no_wrap
2370            "\n",
2371        )
2372    }
2373
2374    /// Export recorded output as plain text.
2375    ///
2376    /// Requires `record=true` to be set. If `styles` is false, ANSI codes are
2377    /// stripped from the output (only plain text is returned).
2378    pub fn export_text(&self, clear: bool, styles: bool) -> String {
2379        let mut buffer = self.record_buffer.lock().unwrap();
2380        let text = if styles {
2381            buffer
2382                .iter()
2383                .filter(|s| s.control.is_none())
2384                .map(|s| {
2385                    if let Some(style) = s.style {
2386                        if let Some(color_system) = self.color_system {
2387                            style.render(&s.text, color_system)
2388                        } else {
2389                            s.text.to_string()
2390                        }
2391                    } else {
2392                        s.text.to_string()
2393                    }
2394                })
2395                .collect::<String>()
2396        } else {
2397            buffer
2398                .iter()
2399                .filter(|s| s.control.is_none())
2400                .map(|s| s.text.to_string())
2401                .collect::<String>()
2402        };
2403        if clear {
2404            buffer.clear();
2405        }
2406        text
2407    }
2408
2409    /// Save export_text output to a file.
2410    pub fn save_text(&self, path: &str, clear: bool, styles: bool) -> io::Result<()> {
2411        let text = self.export_text(clear, styles);
2412        std::fs::write(path, text)
2413    }
2414
2415    /// Push a render hook that intercepts/transforms rendered segments before output.
2416    pub fn push_render_hook(&mut self, hook: Box<dyn Fn(&Segments) -> Segments + Send + Sync>) {
2417        self.render_hooks.push(hook);
2418    }
2419
2420    /// Remove the last render hook.
2421    pub fn pop_render_hook(&mut self) {
2422        self.render_hooks.pop();
2423    }
2424
2425    /// Create and return a Status spinner.
2426    ///
2427    /// This is a convenience method that creates a `Status` with the console's
2428    /// default settings.
2429    pub fn status(
2430        &self,
2431        status: &str,
2432        spinner: Option<&str>,
2433        spinner_style: Option<Style>,
2434        speed: Option<f64>,
2435        refresh_per_second: Option<f64>,
2436    ) -> crate::status::Status {
2437        crate::status::Status::with_options(
2438            status,
2439            spinner.unwrap_or("dots"),
2440            spinner_style,
2441            speed.unwrap_or(1.0),
2442            refresh_per_second.unwrap_or(12.5),
2443        )
2444    }
2445
2446    /// Pretty-print JSON.
2447    ///
2448    /// Parse, format, highlight, and print JSON content.
2449    pub fn print_json(
2450        &mut self,
2451        json: &str,
2452        indent: usize,
2453        highlight: bool,
2454        sort_keys: bool,
2455    ) -> io::Result<()> {
2456        let json_renderable = crate::json::Json::new(json, indent, highlight, sort_keys);
2457        self.print(&json_renderable, None, None, None, true, "\n")
2458    }
2459}
2460
2461// Implement render and render_with_options specifically for Console<Stdout>
2462// since the Renderable trait requires &Console<Stdout>
2463impl Console<Stdout> {
2464    /// Render a Renderable to Segments.
2465    pub fn render<R: Renderable + ?Sized>(&self, renderable: &R) -> Segments {
2466        self.apply_render_hooks(renderable.render(self, &self.options))
2467    }
2468
2469    /// Render a Renderable with custom options.
2470    pub fn render_with_options<R: Renderable + ?Sized>(
2471        &self,
2472        renderable: &R,
2473        options: &ConsoleOptions,
2474    ) -> Segments {
2475        self.apply_render_hooks(renderable.render(self, options))
2476    }
2477
2478    /// Update rendered lines at an offset on the alternate screen.
2479    ///
2480    /// This is the Rust equivalent of Rich's `Console.update_screen_lines`.
2481    pub fn update_screen_lines(
2482        &mut self,
2483        lines: &[Vec<Segment>],
2484        x: u16,
2485        y: u16,
2486    ) -> io::Result<()> {
2487        if !self.is_alt_screen() {
2488            return Err(io::Error::new(
2489                io::ErrorKind::Other,
2490                "Alt screen must be enabled to call update_screen_lines",
2491            ));
2492        }
2493
2494        let mut segments = Segments::new();
2495        for (offset, line) in lines.iter().enumerate() {
2496            segments.push(Segment::control(ControlType::MoveTo {
2497                x,
2498                y: y.saturating_add(offset as u16),
2499            }));
2500            segments.extend(line.iter().cloned());
2501        }
2502        self.print_segments(&segments)?;
2503        Ok(())
2504    }
2505}
2506
2507// ============================================================================
2508// Pager Context
2509// ============================================================================
2510
2511/// Options for the pager context.
2512#[derive(Debug, Clone, Default)]
2513pub struct PagerOptions {
2514    /// Whether to preserve ANSI styles in pager output.
2515    pub styles: bool,
2516}
2517
2518impl PagerOptions {
2519    /// Create new pager options.
2520    pub fn new() -> Self {
2521        Self::default()
2522    }
2523
2524    /// Enable or disable styles in pager output.
2525    pub fn with_styles(mut self, styles: bool) -> Self {
2526        self.styles = styles;
2527        self
2528    }
2529}
2530
2531/// A guard that captures console output and sends it to a pager on drop.
2532///
2533/// This struct is returned by `Console::pager()` and implements a context-manager
2534/// pattern similar to Python's `with console.pager():`.
2535///
2536/// # Example
2537///
2538/// ```no_run
2539/// use rich_rs::Console;
2540///
2541/// let mut console = Console::new();
2542/// {
2543///     let mut pager = console.pager(None);
2544///     pager.print_text("Long content...").unwrap();
2545///     // Content is sent to pager when `pager` is dropped
2546/// }
2547/// ```
2548pub struct PagerContext {
2549    /// Captured output buffer.
2550    buffer: Vec<u8>,
2551    /// Pager options.
2552    options: PagerOptions,
2553    /// Console options for rendering.
2554    console_options: ConsoleOptions,
2555}
2556
2557impl PagerContext {
2558    /// Create a new pager context.
2559    fn new(console_options: ConsoleOptions, options: Option<PagerOptions>) -> Self {
2560        let options = options.unwrap_or_default();
2561        // If styles are disabled, turn off color system so no ANSI escapes are emitted
2562        let mut console_options = console_options;
2563        if !options.styles {
2564            console_options.is_terminal = false;
2565            console_options.color_system = None;
2566        }
2567        Self {
2568            buffer: Vec::new(),
2569            options,
2570            console_options,
2571        }
2572    }
2573
2574    /// Print plain text.
2575    pub fn print_text(&mut self, text: &str) -> io::Result<()> {
2576        writeln!(self.buffer, "{}", text)
2577    }
2578
2579    /// Print a renderable.
2580    pub fn print<R: crate::Renderable + ?Sized>(
2581        &mut self,
2582        renderable: &R,
2583        style: Option<Style>,
2584        justify: Option<JustifyMethod>,
2585        overflow: Option<OverflowMethod>,
2586        no_wrap: bool,
2587        end: &str,
2588    ) -> io::Result<()> {
2589        // Create a capture console to render
2590        let mut console = Console::with_writer(Vec::new(), self.console_options.clone());
2591
2592        // Render to the buffer
2593        console.print(renderable, style, justify, overflow, no_wrap, end)?;
2594
2595        // Append to our buffer
2596        self.buffer.extend_from_slice(console.get_captured_bytes());
2597        Ok(())
2598    }
2599
2600    /// Get the current buffer contents.
2601    pub fn get_buffer(&self) -> &[u8] {
2602        &self.buffer
2603    }
2604
2605    /// Get the buffer as a string.
2606    pub fn get_buffer_string(&self) -> String {
2607        String::from_utf8_lossy(&self.buffer).to_string()
2608    }
2609
2610    /// Manually send content to the pager.
2611    pub fn show(&self) -> io::Result<()> {
2612        use crate::pager::{Pager, SystemPager};
2613        let pager = SystemPager::with_styles(self.options.styles);
2614        let content = self.get_buffer_string();
2615        pager.show(&content)
2616    }
2617}
2618
2619impl Drop for PagerContext {
2620    fn drop(&mut self) {
2621        if !self.buffer.is_empty() {
2622            let _ = self.show();
2623        }
2624    }
2625}
2626
2627impl Console<Stdout> {
2628    /// Create a pager context that captures output and sends it to a pager.
2629    ///
2630    /// Similar to Python Rich's `with console.pager():` context manager.
2631    ///
2632    /// # Arguments
2633    ///
2634    /// * `options` - Optional pager options. Use `PagerOptions::new().with_styles(true)`
2635    ///   to preserve ANSI escape sequences in the pager.
2636    ///
2637    /// # Example
2638    ///
2639    /// ```no_run
2640    /// use rich_rs::{Console, PagerOptions};
2641    ///
2642    /// let console = Console::new();
2643    /// {
2644    ///     let mut pager = console.pager(Some(PagerOptions::new().with_styles(true)));
2645    ///     pager.print_text("Long content that should be paged...").unwrap();
2646    ///     // Content is sent to pager when `pager` is dropped
2647    /// }
2648    /// ```
2649    pub fn pager(&self, options: Option<PagerOptions>) -> PagerContext {
2650        PagerContext::new(self.options.clone(), options)
2651    }
2652
2653    // ========================================================================
2654    // Recording and Export Methods
2655    // ========================================================================
2656
2657    /// Check if recording is enabled.
2658    pub fn is_recording(&self) -> bool {
2659        self.record
2660    }
2661
2662    /// Enable or disable recording.
2663    ///
2664    /// When recording is enabled, all segments written via `print()` are
2665    /// captured in an internal buffer that can be exported as SVG/HTML.
2666    pub fn set_record(&mut self, record: bool) {
2667        self.record = record;
2668    }
2669
2670    /// Clear the record buffer.
2671    pub fn clear_record_buffer(&mut self) {
2672        if let Ok(mut buffer) = self.record_buffer.lock() {
2673            buffer.clear();
2674        }
2675    }
2676
2677    /// Get the current record buffer contents.
2678    ///
2679    /// Returns a clone of the recorded segments.
2680    pub fn get_record_buffer(&self) -> Vec<Segment> {
2681        self.record_buffer
2682            .lock()
2683            .map(|buf| buf.clone())
2684            .unwrap_or_default()
2685    }
2686
2687    /// Export console contents as SVG.
2688    ///
2689    /// Generates an SVG image from the recorded console output. Requires
2690    /// `record=true` to have been set (via `new_with_record()` or `set_record(true)`).
2691    ///
2692    /// # Arguments
2693    ///
2694    /// * `title` - The title shown in the terminal window chrome.
2695    /// * `theme` - Optional terminal theme for colors. Defaults to `SVG_EXPORT_THEME`.
2696    /// * `clear` - Whether to clear the record buffer after exporting.
2697    /// * `code_format` - Optional custom SVG template. Defaults to `CONSOLE_SVG_FORMAT`.
2698    /// * `font_aspect_ratio` - Width/height ratio of the font. Defaults to 0.61 (Fira Code).
2699    /// * `unique_id` - Optional unique ID for CSS classes. Auto-generated if not provided.
2700    ///
2701    /// # Example
2702    ///
2703    /// ```
2704    /// use rich_rs::Console;
2705    ///
2706    /// let mut console = Console::new_with_record();
2707    /// console.print_text("Hello, World!").unwrap();
2708    /// let svg = console.export_svg("Example", None, true, None, 0.61, None);
2709    /// assert!(svg.contains("Hello"));
2710    /// ```
2711    pub fn export_svg(
2712        &mut self,
2713        title: &str,
2714        theme: Option<&TerminalTheme>,
2715        clear: bool,
2716        code_format: Option<&str>,
2717        font_aspect_ratio: f64,
2718        unique_id: Option<&str>,
2719    ) -> String {
2720        let theme = theme.unwrap_or(&*SVG_EXPORT_THEME);
2721        let code_format = code_format.unwrap_or(CONSOLE_SVG_FORMAT);
2722
2723        // CSS rules cache - uses string key instead of Style (which doesn't implement Hash)
2724        let mut classes: HashMap<String, usize> = HashMap::new();
2725        let mut style_no = 1usize;
2726
2727        let width = self.width();
2728        let char_height = 20.0;
2729        let char_width = char_height * font_aspect_ratio;
2730        let line_height = char_height * 1.22;
2731
2732        let margin_top = 1.0;
2733        let margin_right = 1.0;
2734        let margin_bottom = 1.0;
2735        let margin_left = 1.0;
2736
2737        let padding_top = 40.0;
2738        let padding_right = 8.0;
2739        let padding_bottom = 8.0;
2740        let padding_left = 8.0;
2741
2742        let padding_width = padding_left + padding_right;
2743        let padding_height = padding_top + padding_bottom;
2744        let margin_width = margin_left + margin_right;
2745        let margin_height = margin_top + margin_bottom;
2746
2747        let mut text_backgrounds: Vec<String> = Vec::new();
2748        let mut text_group: Vec<String> = Vec::new();
2749
2750        // Get segments from record buffer
2751        let segments: Vec<Segment> = {
2752            let mut buffer = self.record_buffer.lock().unwrap();
2753            let segments: Vec<Segment> = buffer
2754                .iter()
2755                .filter(|s| s.control.is_none())
2756                .cloned()
2757                .collect();
2758            if clear {
2759                buffer.clear();
2760            }
2761            segments
2762        };
2763
2764        // Generate unique ID if not provided
2765        let unique_id = unique_id.map(|s| s.to_string()).unwrap_or_else(|| {
2766            let content: String = segments
2767                .iter()
2768                .map(|s| format!("{:?}", s))
2769                .collect::<Vec<_>>()
2770                .join("");
2771            let hash = adler32(&format!("{}{}", content, title));
2772            format!("terminal-{}", hash)
2773        });
2774
2775        // Split segments into lines
2776        let lines =
2777            Segment::split_and_crop_lines(Segments::from_iter(segments), width, None, false, false);
2778
2779        let mut y = 0usize;
2780        for line in &lines {
2781            let mut x = 0usize;
2782
2783            for segment in line {
2784                let style = segment.style.unwrap_or_default();
2785                let rules = get_svg_style_for_segment(&style, theme);
2786
2787                if !classes.contains_key(&rules) {
2788                    classes.insert(rules.clone(), style_no);
2789                    style_no += 1;
2790                }
2791                let class_name = format!("r{}", classes[&rules]);
2792
2793                // Check for background
2794                let has_background = if style.reverse.unwrap_or(false) {
2795                    true
2796                } else {
2797                    style.bgcolor.is_some() && !is_default_color(style.bgcolor)
2798                };
2799
2800                let background = if style.reverse.unwrap_or(false) {
2801                    style
2802                        .color
2803                        .map(|c| resolve_color_for_svg(c, theme, true))
2804                        .unwrap_or(theme.foreground_color)
2805                } else {
2806                    style
2807                        .bgcolor
2808                        .map(|c| resolve_color_for_svg(c, theme, false))
2809                        .unwrap_or(theme.background_color)
2810                };
2811
2812                let text_length = cell_len(&segment.text);
2813
2814                if has_background {
2815                    text_backgrounds.push(make_tag(
2816                        "rect",
2817                        None,
2818                        &[
2819                            ("fill", &background.hex()),
2820                            ("x", &format_number(x as f64 * char_width)),
2821                            ("y", &format_number(y as f64 * line_height + 1.5)),
2822                            ("width", &format_number(char_width * text_length as f64)),
2823                            ("height", &format_number(line_height + 0.25)),
2824                            ("shape-rendering", "crispEdges"),
2825                        ],
2826                    ));
2827                }
2828
2829                // Only add text if it's not all spaces
2830                if !segment.text.chars().all(|c| c == ' ') {
2831                    text_group.push(make_tag(
2832                        "text",
2833                        Some(&escape_text(&segment.text)),
2834                        &[
2835                            ("class", &format!("{}-{}", unique_id, class_name)),
2836                            ("x", &format_number(x as f64 * char_width)),
2837                            ("y", &format_number(y as f64 * line_height + char_height)),
2838                            (
2839                                "textLength",
2840                                &format_number(char_width * text_length as f64),
2841                            ),
2842                            ("clip-path", &format!("url(#{}-line-{})", unique_id, y)),
2843                        ],
2844                    ));
2845                }
2846
2847                x += text_length;
2848            }
2849            y += 1;
2850        }
2851
2852        // Generate clip paths for lines
2853        let line_offsets: Vec<f64> = (0..y)
2854            .map(|line_no| line_no as f64 * line_height + 1.5)
2855            .collect();
2856
2857        let lines_svg: String = line_offsets
2858            .iter()
2859            .enumerate()
2860            .map(|(line_no, offset)| {
2861                format!(
2862                    r#"<clipPath id="{}-line-{}">
2863    {}
2864            </clipPath>"#,
2865                    unique_id,
2866                    line_no,
2867                    make_tag(
2868                        "rect",
2869                        None,
2870                        &[
2871                            ("x", "0"),
2872                            ("y", &format_number(*offset)),
2873                            ("width", &format_number(char_width * width as f64)),
2874                            ("height", &format_number(line_height + 0.25)),
2875                        ],
2876                    )
2877                )
2878            })
2879            .collect::<Vec<_>>()
2880            .join("\n");
2881
2882        // Generate CSS styles
2883        let styles: String = classes
2884            .iter()
2885            .map(|(css, rule_no)| format!(".{}-r{} {{ {} }}", unique_id, rule_no, css))
2886            .collect::<Vec<_>>()
2887            .join("\n");
2888
2889        let backgrounds = text_backgrounds.join("");
2890        let matrix = text_group.join("\n");
2891
2892        let terminal_width = (width as f64 * char_width + padding_width).ceil();
2893        let terminal_height = (y as f64 + 1.0) * line_height + padding_height;
2894
2895        // Generate terminal chrome
2896        let mut chrome = make_tag(
2897            "rect",
2898            None,
2899            &[
2900                ("fill", &theme.background_color.hex()),
2901                ("stroke", "rgba(255,255,255,0.35)"),
2902                ("stroke-width", "1"),
2903                ("x", &format_number(margin_left)),
2904                ("y", &format_number(margin_top)),
2905                ("width", &format_number(terminal_width)),
2906                ("height", &format_number(terminal_height)),
2907                ("rx", "8"),
2908            ],
2909        );
2910
2911        // Add title if provided
2912        if !title.is_empty() {
2913            chrome.push_str(&make_tag(
2914                "text",
2915                Some(&escape_text(title)),
2916                &[
2917                    ("class", &format!("{}-title", unique_id)),
2918                    ("fill", &theme.foreground_color.hex()),
2919                    ("text-anchor", "middle"),
2920                    ("x", &format_number(terminal_width / 2.0)),
2921                    ("y", &format_number(margin_top + char_height + 6.0)),
2922                ],
2923            ));
2924        }
2925
2926        // Add window buttons
2927        chrome.push_str(
2928            r##"
2929            <g transform="translate(26,22)">
2930            <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
2931            <circle cx="22" cy="0" r="7" fill="#febc2e"/>
2932            <circle cx="44" cy="0" r="7" fill="#28c840"/>
2933            </g>
2934        "##,
2935        );
2936
2937        // Generate final SVG
2938        code_format
2939            .replace("{unique_id}", &unique_id)
2940            .replace("{char_width}", &format_number(char_width))
2941            .replace("{char_height}", &format_number(char_height))
2942            .replace("{line_height}", &format_number(line_height))
2943            .replace(
2944                "{terminal_width}",
2945                &format_number(char_width * width as f64 - 1.0),
2946            )
2947            .replace(
2948                "{terminal_height}",
2949                &format_number((y as f64 + 1.0) * line_height - 1.0),
2950            )
2951            .replace("{width}", &format_number(terminal_width + margin_width))
2952            .replace("{height}", &format_number(terminal_height + margin_height))
2953            .replace("{terminal_x}", &format_number(margin_left + padding_left))
2954            .replace("{terminal_y}", &format_number(margin_top + padding_top))
2955            .replace("{styles}", &styles)
2956            .replace("{chrome}", &chrome)
2957            .replace("{backgrounds}", &backgrounds)
2958            .replace("{matrix}", &matrix)
2959            .replace("{lines}", &lines_svg)
2960    }
2961
2962    /// Export console contents as HTML.
2963    ///
2964    /// Generates an HTML document from the recorded console output. Requires
2965    /// `record=true` to have been set (via `new_with_record()` or `set_record(true)`).
2966    ///
2967    /// This is modeled after Python Rich's `Console.export_html()`. Hyperlinks are
2968    /// emitted as `<a href="...">` when `StyleMeta.link` is present on segments.
2969    ///
2970    /// # Arguments
2971    ///
2972    /// * `theme` - Optional terminal theme for colors. Defaults to `DEFAULT_TERMINAL_THEME`.
2973    /// * `clear` - Whether to clear the record buffer after exporting.
2974    /// * `code_format` - Optional custom HTML template. Defaults to `CONSOLE_HTML_FORMAT`.
2975    ///
2976    /// # Example
2977    ///
2978    /// ```
2979    /// use rich_rs::Console;
2980    ///
2981    /// let mut console = Console::new_with_record();
2982    /// console.print_text("Hello, World!").unwrap();
2983    /// let html = console.export_html(None, true, None);
2984    /// assert!(html.contains("<!DOCTYPE html>"));
2985    /// assert!(html.contains("Hello, World!"));
2986    /// ```
2987    pub fn export_html(
2988        &mut self,
2989        theme: Option<&TerminalTheme>,
2990        clear: bool,
2991        code_format: Option<&str>,
2992    ) -> String {
2993        let theme = theme.unwrap_or(&*DEFAULT_TERMINAL_THEME);
2994        let code_format = code_format.unwrap_or(CONSOLE_HTML_FORMAT);
2995
2996        // CSS rules cache - uses string key instead of Style (which doesn't implement Hash).
2997        let mut classes: HashMap<String, usize> = HashMap::new();
2998        let mut style_no = 1usize;
2999
3000        // Get segments from record buffer.
3001        let segments: Vec<Segment> = {
3002            let mut buffer = self.record_buffer.lock().unwrap();
3003            let segments: Vec<Segment> = buffer
3004                .iter()
3005                .filter(|s| s.control.is_none())
3006                .cloned()
3007                .collect();
3008            if clear {
3009                buffer.clear();
3010            }
3011            segments
3012        };
3013
3014        // First pass: collect CSS classes needed.
3015        for segment in &segments {
3016            let style = segment.style.unwrap_or_default();
3017            let rules = get_html_style_for_segment(&style, theme);
3018            if !rules.is_empty() && !classes.contains_key(&rules) {
3019                classes.insert(rules, style_no);
3020                style_no += 1;
3021            }
3022        }
3023
3024        // Generate CSS styles.
3025        let stylesheet: String = classes
3026            .iter()
3027            .map(|(css, rule_no)| format!(".r{} {{ {} }}", rule_no, css))
3028            .collect::<Vec<_>>()
3029            .join("\n");
3030
3031        // Render HTML-encoded console content.
3032        #[derive(Debug, Clone, PartialEq, Eq)]
3033        enum OpenTag {
3034            Span {
3035                class_no: usize,
3036            },
3037            Link {
3038                class_no: Option<usize>,
3039                href: Arc<str>,
3040            },
3041        }
3042
3043        let mut code = String::new();
3044        let mut open: Option<OpenTag> = None;
3045
3046        let close_open = |code: &mut String, open: &mut Option<OpenTag>| {
3047            if let Some(tag) = open.take() {
3048                match tag {
3049                    OpenTag::Span { .. } => code.push_str("</span>"),
3050                    OpenTag::Link { .. } => code.push_str("</a>"),
3051                }
3052            }
3053        };
3054
3055        let open_tag = |code: &mut String, tag: &OpenTag| match tag {
3056            OpenTag::Span { class_no } => {
3057                code.push_str(&format!("<span class=\"r{}\">", class_no));
3058            }
3059            OpenTag::Link { class_no, href } => {
3060                let href_escaped = escape_html_attr(href);
3061                if let Some(class_no) = class_no {
3062                    code.push_str(&format!(
3063                        "<a class=\"r{}\" href=\"{}\">",
3064                        class_no, href_escaped
3065                    ));
3066                } else {
3067                    code.push_str(&format!("<a href=\"{}\">", href_escaped));
3068                }
3069            }
3070        };
3071
3072        for segment in &segments {
3073            let text = segment.text.as_ref();
3074            if text.is_empty() {
3075                continue;
3076            }
3077
3078            let style = segment.style.unwrap_or_default();
3079            let rules = get_html_style_for_segment(&style, theme);
3080            let class_no = if rules.is_empty() {
3081                None
3082            } else {
3083                Some(classes[&rules])
3084            };
3085
3086            let href = segment.meta.as_ref().and_then(|m| m.link.as_ref()).cloned();
3087
3088            let desired: Option<OpenTag> = if let Some(href) = href {
3089                Some(OpenTag::Link { class_no, href })
3090            } else if let Some(class_no) = class_no {
3091                Some(OpenTag::Span { class_no })
3092            } else {
3093                None
3094            };
3095
3096            if desired != open {
3097                close_open(&mut code, &mut open);
3098                if let Some(tag) = &desired {
3099                    open_tag(&mut code, tag);
3100                }
3101                open = desired;
3102            }
3103
3104            // HTML-escape text (do not replace spaces; <pre> preserves them).
3105            code.push_str(&escape_html_text(text));
3106        }
3107
3108        close_open(&mut code, &mut open);
3109
3110        // Generate final HTML.
3111        code_format
3112            .replace("{stylesheet}", &stylesheet)
3113            .replace("{foreground}", &theme.foreground_color.hex())
3114            .replace("{background}", &theme.background_color.hex())
3115            .replace("{code}", &code)
3116    }
3117
3118    /// Save console contents to an HTML file.
3119    ///
3120    /// This is a convenience method that calls `export_html()` and writes the result to a file.
3121    pub fn save_html(
3122        &mut self,
3123        path: &str,
3124        theme: Option<&TerminalTheme>,
3125        clear: bool,
3126        code_format: Option<&str>,
3127    ) -> io::Result<()> {
3128        let html = self.export_html(theme, clear, code_format);
3129        std::fs::write(path, html)
3130    }
3131
3132    /// Save console contents to an SVG file.
3133    ///
3134    /// This is a convenience method that calls `export_svg()` and writes
3135    /// the result to a file.
3136    ///
3137    /// # Arguments
3138    ///
3139    /// * `path` - The file path to write to.
3140    /// * `title` - The title shown in the terminal window chrome.
3141    /// * `theme` - Optional terminal theme for colors.
3142    /// * `clear` - Whether to clear the record buffer after exporting.
3143    /// * `font_aspect_ratio` - Width/height ratio of the font. Defaults to 0.61.
3144    /// * `unique_id` - Optional unique ID for CSS classes.
3145    ///
3146    /// # Example
3147    ///
3148    /// ```no_run
3149    /// use rich_rs::Console;
3150    ///
3151    /// let mut console = Console::new_with_record();
3152    /// console.print_text("Hello, World!").unwrap();
3153    /// console.save_svg("output.svg", "Example", None, true, 0.61, None).unwrap();
3154    /// ```
3155    pub fn save_svg(
3156        &mut self,
3157        path: &str,
3158        title: &str,
3159        theme: Option<&TerminalTheme>,
3160        clear: bool,
3161        font_aspect_ratio: f64,
3162        unique_id: Option<&str>,
3163    ) -> io::Result<()> {
3164        let svg = self.export_svg(title, theme, clear, None, font_aspect_ratio, unique_id);
3165        std::fs::write(path, svg)
3166    }
3167}
3168
3169// ============================================================================
3170// SVG Export Helper Functions
3171// ============================================================================
3172
3173/// Get CSS style rules for a segment style.
3174pub(crate) fn get_svg_style_for_segment(style: &Style, theme: &TerminalTheme) -> String {
3175    let mut css_rules = Vec::new();
3176
3177    // Get foreground color
3178    let fg_color = style
3179        .color
3180        .map(|c| resolve_color_for_svg(c, theme, true))
3181        .unwrap_or(theme.foreground_color);
3182
3183    // Get background color
3184    let bg_color = style
3185        .bgcolor
3186        .map(|c| resolve_color_for_svg(c, theme, false))
3187        .unwrap_or(theme.background_color);
3188
3189    // Handle reverse
3190    let (fg_color, bg_color) = if style.reverse.unwrap_or(false) {
3191        (bg_color, fg_color)
3192    } else {
3193        (fg_color, bg_color)
3194    };
3195
3196    // Handle dim
3197    let fg_color = if style.dim.unwrap_or(false) {
3198        blend_rgb_for_svg(fg_color, bg_color, 0.4)
3199    } else {
3200        fg_color
3201    };
3202
3203    css_rules.push(format!("fill: {}", fg_color.hex()));
3204
3205    if style.bold.unwrap_or(false) {
3206        css_rules.push("font-weight: bold".to_string());
3207    }
3208    if style.italic.unwrap_or(false) {
3209        css_rules.push("font-style: italic".to_string());
3210    }
3211    if style.underline.unwrap_or(false) {
3212        css_rules.push("text-decoration: underline".to_string());
3213    }
3214    if style.strike.unwrap_or(false) {
3215        css_rules.push("text-decoration: line-through".to_string());
3216    }
3217
3218    css_rules.join(";")
3219}
3220
3221/// Get CSS style rules for a segment style in HTML export.
3222fn get_html_style_for_segment(style: &Style, theme: &TerminalTheme) -> String {
3223    let mut css_rules: Vec<String> = Vec::new();
3224
3225    // Get foreground color
3226    let fg_color = style
3227        .color
3228        .map(|c| resolve_color_for_svg(c, theme, true))
3229        .unwrap_or(theme.foreground_color);
3230
3231    // Get background color
3232    let bg_color = style
3233        .bgcolor
3234        .map(|c| resolve_color_for_svg(c, theme, false))
3235        .unwrap_or(theme.background_color);
3236
3237    // Handle reverse
3238    let (fg_color, bg_color) = if style.reverse.unwrap_or(false) {
3239        (bg_color, fg_color)
3240    } else {
3241        (fg_color, bg_color)
3242    };
3243
3244    // Handle dim (match Python Rich export_html: blend 50% towards background)
3245    let fg_color = if style.dim.unwrap_or(false) {
3246        blend_rgb_for_svg(fg_color, bg_color, 0.5)
3247    } else {
3248        fg_color
3249    };
3250
3251    // Foreground color
3252    if style.color.is_some() || style.reverse.unwrap_or(false) || style.dim.unwrap_or(false) {
3253        css_rules.push(format!("color: {}", fg_color.hex()));
3254        css_rules.push(format!("text-decoration-color: {}", fg_color.hex()));
3255    }
3256
3257    // Background color only when explicitly set (or reverse forces it)
3258    let has_background = if style.reverse.unwrap_or(false) {
3259        true
3260    } else {
3261        style.bgcolor.is_some() && !is_default_color(style.bgcolor)
3262    };
3263    if has_background {
3264        css_rules.push(format!("background-color: {}", bg_color.hex()));
3265    }
3266
3267    // Attributes
3268    if style.bold.unwrap_or(false) {
3269        css_rules.push("font-weight: bold".to_string());
3270    }
3271    if style.italic.unwrap_or(false) {
3272        css_rules.push("font-style: italic".to_string());
3273    }
3274
3275    let mut decorations = Vec::new();
3276    if style.underline.unwrap_or(false) {
3277        decorations.push("underline");
3278    }
3279    if style.strike.unwrap_or(false) {
3280        decorations.push("line-through");
3281    }
3282    if !decorations.is_empty() {
3283        css_rules.push(format!("text-decoration: {}", decorations.join(" ")));
3284    }
3285
3286    css_rules.join("; ")
3287}
3288
3289/// Resolve a SimpleColor to a ColorTriplet using the terminal theme.
3290pub(crate) fn resolve_color_for_svg(
3291    color: SimpleColor,
3292    theme: &TerminalTheme,
3293    is_foreground: bool,
3294) -> ColorTriplet {
3295    match color {
3296        SimpleColor::Default => {
3297            if is_foreground {
3298                theme.foreground_color
3299            } else {
3300                theme.background_color
3301            }
3302        }
3303        SimpleColor::Standard(index) => theme.get_ansi_color(index as usize),
3304        SimpleColor::EightBit(index) => {
3305            // For 8-bit colors, use the 256-color palette lookup
3306            if let Some(triplet) = crate::color::EIGHT_BIT_PALETTE.get(index as usize) {
3307                triplet
3308            } else {
3309                theme.foreground_color
3310            }
3311        }
3312        SimpleColor::Rgb { r, g, b } => ColorTriplet::new(r, g, b),
3313    }
3314}
3315
3316/// Check if a color is the default color.
3317pub(crate) fn is_default_color(color: Option<SimpleColor>) -> bool {
3318    matches!(color, None | Some(SimpleColor::Default))
3319}
3320
3321/// Blend two colors for dim effect.
3322pub(crate) fn blend_rgb_for_svg(
3323    color: ColorTriplet,
3324    background: ColorTriplet,
3325    factor: f64,
3326) -> ColorTriplet {
3327    let r = (color.red as f64 + (background.red as f64 - color.red as f64) * factor) as u8;
3328    let g = (color.green as f64 + (background.green as f64 - color.green as f64) * factor) as u8;
3329    let b = (color.blue as f64 + (background.blue as f64 - color.blue as f64) * factor) as u8;
3330    ColorTriplet::new(r, g, b)
3331}
3332
3333/// Simple Adler-32 checksum for generating unique IDs.
3334pub(crate) fn adler32(data: &str) -> u32 {
3335    let mut a: u32 = 1;
3336    let mut b: u32 = 0;
3337    const MOD_ADLER: u32 = 65521;
3338
3339    for byte in data.bytes() {
3340        a = (a + byte as u32) % MOD_ADLER;
3341        b = (b + a) % MOD_ADLER;
3342    }
3343
3344    (b << 16) | a
3345}
3346
3347/// Escape text for SVG/HTML.
3348pub(crate) fn escape_text(text: &str) -> String {
3349    text.replace('&', "&amp;")
3350        .replace('<', "&lt;")
3351        .replace('>', "&gt;")
3352        .replace(' ', "&#160;")
3353}
3354
3355/// Escape text content for HTML (does not replace spaces; `<pre>` preserves them).
3356fn escape_html_text(text: &str) -> String {
3357    text.replace('&', "&amp;")
3358        .replace('<', "&lt;")
3359        .replace('>', "&gt;")
3360}
3361
3362/// Escape a string for use in an HTML attribute value (double-quoted).
3363fn escape_html_attr(text: &str) -> String {
3364    text.replace('&', "&amp;")
3365        .replace('<', "&lt;")
3366        .replace('>', "&gt;")
3367        .replace('"', "&quot;")
3368}
3369
3370/// Format a number for SVG attributes (removes trailing zeros).
3371pub(crate) fn format_number(value: f64) -> String {
3372    if value.fract() == 0.0 {
3373        format!("{}", value as i64)
3374    } else {
3375        format!("{:.2}", value)
3376            .trim_end_matches('0')
3377            .trim_end_matches('.')
3378            .to_string()
3379    }
3380}
3381
3382/// Make an SVG tag with attributes.
3383pub(crate) fn make_tag(name: &str, content: Option<&str>, attribs: &[(&str, &str)]) -> String {
3384    let attribs_str: String = attribs
3385        .iter()
3386        .map(|(k, v)| format!("{}=\"{}\"", k, v))
3387        .collect::<Vec<_>>()
3388        .join(" ");
3389
3390    if let Some(content) = content {
3391        format!("<{} {}>{}</{}>", name, attribs_str, content, name)
3392    } else {
3393        format!("<{} {}/>", name, attribs_str)
3394    }
3395}
3396
3397// ============================================================================
3398// Tests
3399// ============================================================================
3400
3401#[cfg(test)]
3402mod tests {
3403    use super::*;
3404    use crate::Control;
3405    use crate::StyleMeta;
3406
3407    // ==================== JustifyMethod tests ====================
3408
3409    #[test]
3410    fn test_justify_method_parse() {
3411        assert_eq!(JustifyMethod::parse("left"), Some(JustifyMethod::Left));
3412        assert_eq!(JustifyMethod::parse("CENTER"), Some(JustifyMethod::Center));
3413        assert_eq!(JustifyMethod::parse("Right"), Some(JustifyMethod::Right));
3414        assert_eq!(JustifyMethod::parse("full"), Some(JustifyMethod::Full));
3415        assert_eq!(
3416            JustifyMethod::parse("default"),
3417            Some(JustifyMethod::Default)
3418        );
3419        assert_eq!(JustifyMethod::parse("invalid"), None);
3420    }
3421
3422    // ==================== OverflowMethod tests ====================
3423
3424    #[test]
3425    fn test_overflow_method_parse() {
3426        assert_eq!(OverflowMethod::parse("fold"), Some(OverflowMethod::Fold));
3427        assert_eq!(OverflowMethod::parse("CROP"), Some(OverflowMethod::Crop));
3428        assert_eq!(
3429            OverflowMethod::parse("Ellipsis"),
3430            Some(OverflowMethod::Ellipsis)
3431        );
3432        assert_eq!(
3433            OverflowMethod::parse("ignore"),
3434            Some(OverflowMethod::Ignore)
3435        );
3436        assert_eq!(OverflowMethod::parse("invalid"), None);
3437    }
3438
3439    // ==================== ConsoleOptions tests ====================
3440
3441    #[test]
3442    fn test_console_options_default() {
3443        let options = ConsoleOptions::default();
3444        assert_eq!(options.size, (80, 24));
3445        assert_eq!(options.min_width, 1);
3446        assert_eq!(options.max_width, 80);
3447        assert_eq!(options.max_height, 24);
3448        assert!(options.is_terminal);
3449        assert_eq!(options.encoding, "utf-8");
3450    }
3451
3452    #[test]
3453    fn test_console_options_ascii_only() {
3454        let options = ConsoleOptions {
3455            encoding: "utf-8".to_string(),
3456            ..Default::default()
3457        };
3458        assert!(!options.ascii_only());
3459
3460        let options = ConsoleOptions {
3461            encoding: "ascii".to_string(),
3462            ..Default::default()
3463        };
3464        assert!(options.ascii_only());
3465
3466        let options = ConsoleOptions {
3467            encoding: "latin-1".to_string(),
3468            ..Default::default()
3469        };
3470        assert!(options.ascii_only());
3471    }
3472
3473    #[test]
3474    fn test_console_options_update_width() {
3475        let options = ConsoleOptions::default();
3476        let updated = options.update_width(120);
3477        assert_eq!(updated.min_width, 120);
3478        assert_eq!(updated.max_width, 120);
3479    }
3480
3481    #[test]
3482    fn test_console_options_update_height() {
3483        let options = ConsoleOptions::default();
3484        let updated = options.update_height(40);
3485        assert_eq!(updated.max_height, 40);
3486        assert_eq!(updated.height, Some(40));
3487    }
3488
3489    #[test]
3490    fn test_console_options_update_dimensions() {
3491        let options = ConsoleOptions::default();
3492        let updated = options.update_dimensions(100, 50);
3493        assert_eq!(updated.min_width, 100);
3494        assert_eq!(updated.max_width, 100);
3495        assert_eq!(updated.max_height, 50);
3496        assert_eq!(updated.height, Some(50));
3497    }
3498
3499    #[test]
3500    fn test_console_options_reset_height() {
3501        let options = ConsoleOptions {
3502            height: Some(40),
3503            ..Default::default()
3504        };
3505        let reset = options.reset_height();
3506        assert_eq!(reset.height, None);
3507    }
3508
3509    // ==================== Console capture tests ====================
3510
3511    #[test]
3512    fn test_console_capture() {
3513        let mut console = Console::capture();
3514        console.print_text("Hello, World!").unwrap();
3515        let output = console.get_captured();
3516        assert!(output.contains("Hello, World!"));
3517    }
3518
3519    #[test]
3520    fn test_console_capture_styled() {
3521        let mut console = Console::capture();
3522        let style = Style::new().with_bold(true);
3523        console.print_styled("Bold text", style).unwrap();
3524        let output = console.get_captured();
3525        assert!(output.contains("Bold text"));
3526    }
3527
3528    #[test]
3529    fn test_console_capture_clear() {
3530        let mut console = Console::capture();
3531        console.print_text("First").unwrap();
3532        console.clear_captured();
3533        console.print_text("Second").unwrap();
3534        let output = console.get_captured();
3535        assert!(!output.contains("First"));
3536        assert!(output.contains("Second"));
3537    }
3538
3539    #[test]
3540    fn test_console_capture_bytes() {
3541        let mut console = Console::capture();
3542        console.print_text("Test").unwrap();
3543        let bytes = console.get_captured_bytes();
3544        assert!(!bytes.is_empty());
3545    }
3546
3547    #[test]
3548    fn test_render_hook_runs_in_print_pipeline() {
3549        let mut console = Console::capture();
3550        console.push_render_hook(Box::new(|segments: &Segments| {
3551            Segments::from_iter(segments.iter().map(|seg| {
3552                if seg.control.is_some() {
3553                    seg.clone()
3554                } else {
3555                    Segment::new(seg.text.to_string().to_uppercase())
3556                }
3557            }))
3558        }));
3559
3560        console
3561            .print(&Text::plain("hooked"), None, None, None, false, "\n")
3562            .unwrap();
3563        let output = console.get_captured();
3564        assert!(output.contains("HOOKED"));
3565    }
3566
3567    #[test]
3568    fn test_render_hook_runs_for_live_renderables() {
3569        let mut console = Console::with_writer(
3570            Vec::new(),
3571            ConsoleOptions {
3572                is_terminal: true,
3573                ..Default::default()
3574            },
3575        );
3576        console.set_force_terminal(Some(true));
3577        if console.is_dumb_terminal() {
3578            return;
3579        }
3580        console.push_render_hook(Box::new(|segments: &Segments| {
3581            let mut out = Segments::new();
3582            for seg in segments.iter() {
3583                out.push(seg.clone());
3584            }
3585            out.push(Segment::new("!"));
3586            out
3587        }));
3588
3589        let (_id, _is_root) = console.live_start(
3590            Box::new(Text::plain("LIVE")),
3591            crate::live::VerticalOverflowMethod::Ellipsis,
3592        );
3593        console
3594            .print(&Control::new(), None, None, None, false, "")
3595            .unwrap();
3596
3597        let output = console.get_captured();
3598        assert!(
3599            output.contains("LIVE!"),
3600            "expected hooked live output in captured text, got: {:?}",
3601            output
3602        );
3603    }
3604
3605    #[test]
3606    fn test_console_status_honors_refresh_per_second() {
3607        let console = Console::capture();
3608        let status = console.status("Working...", None, None, None, Some(9.0));
3609        assert_eq!(status.refresh_per_second(), 9.0);
3610    }
3611
3612    #[test]
3613    fn test_live_wrap_emits_cursor_controls_after_first_render() {
3614        let mut console = Console::with_writer(
3615            Vec::new(),
3616            ConsoleOptions {
3617                is_terminal: true,
3618                ..Default::default()
3619            },
3620        );
3621        console.set_force_terminal(Some(true));
3622        if console.is_dumb_terminal() {
3623            // Live wrapping is disabled in dumb terminals.
3624            return;
3625        }
3626
3627        let (_id, _is_root) = console.live_start(
3628            Box::new(Text::plain("LIVE")),
3629            crate::live::VerticalOverflowMethod::Ellipsis,
3630        );
3631
3632        // First render establishes the live shape but does not emit cursor positioning.
3633        console
3634            .print(&Text::plain("A"), None, None, None, false, "\n")
3635            .unwrap();
3636        console.clear_captured();
3637
3638        // Second render takes the screen-buffer diff path (same shape), so it
3639        // repositions the cursor without a full erase.  Verify that cursor
3640        // repositioning is emitted (\r for carriage return).
3641        console
3642            .print(&Text::plain("B"), None, None, None, false, "\n")
3643            .unwrap();
3644        let out = console.get_captured();
3645        assert!(
3646            out.contains("\r"),
3647            "expected cursor repositioning (\\r) in second live render, got: {:?}",
3648            out,
3649        );
3650    }
3651
3652    #[test]
3653    fn test_set_alt_screen_emits_enable_and_home() {
3654        let mut console = Console::with_writer(
3655            Vec::new(),
3656            ConsoleOptions {
3657                is_terminal: true,
3658                ..Default::default()
3659            },
3660        );
3661        console.set_force_terminal(Some(true));
3662        if console.is_dumb_terminal() {
3663            // Alt screen isn't meaningful on dumb terminals.
3664            return;
3665        }
3666
3667        console.set_alt_screen(true).unwrap();
3668        let out = console.get_captured();
3669        assert!(out.contains("\x1b[?1049h"));
3670        assert!(out.contains("\x1b[H"));
3671    }
3672
3673    #[test]
3674    fn test_print_segments_does_not_emit_osc8_when_not_terminal() {
3675        let mut console = Console::capture();
3676        let mut segments = Segments::new();
3677        segments.push(Segment::new_with_meta(
3678            "X",
3679            StyleMeta::with_link("https://example.com"),
3680        ));
3681        console.print_segments(&segments).unwrap();
3682        let out = console.get_captured();
3683        assert!(out.contains("X"));
3684        assert!(!out.contains("\x1b]8;"));
3685    }
3686
3687    #[test]
3688    fn test_print_segments_emits_osc8_for_segment_meta_link() {
3689        let mut console = Console::with_writer(
3690            Vec::new(),
3691            ConsoleOptions {
3692                is_terminal: true,
3693                ..Default::default()
3694            },
3695        );
3696        console.set_force_terminal(Some(true));
3697        if console.is_dumb_terminal() {
3698            // OSC8 is not meaningful on dumb terminals.
3699            return;
3700        }
3701
3702        let mut segments = Segments::new();
3703        segments.push(Segment::new_with_meta(
3704            "X",
3705            StyleMeta::with_link("https://example.com"),
3706        ));
3707        console.print_segments(&segments).unwrap();
3708        let out = console.get_captured();
3709        assert!(out.contains("\x1b]8;"));
3710        assert!(out.contains("https://example.com"));
3711        assert!(out.contains("\x1b]8;;\x1b\\"));
3712    }
3713
3714    #[test]
3715    fn test_print_segments_osc8_link_id_stable_per_console() {
3716        let mut console = Console::with_writer(
3717            Vec::new(),
3718            ConsoleOptions {
3719                is_terminal: true,
3720                ..Default::default()
3721            },
3722        );
3723        console.set_force_terminal(Some(true));
3724        if console.is_dumb_terminal() {
3725            return;
3726        }
3727
3728        let mut segments = Segments::new();
3729        segments.push(Segment::new_with_meta(
3730            "X",
3731            StyleMeta::with_link("https://example.com"),
3732        ));
3733
3734        console.print_segments(&segments).unwrap();
3735        let out1 = console.get_captured();
3736        assert!(out1.contains("id=richrs-1;https://example.com"));
3737
3738        console.clear_captured();
3739        console.print_segments(&segments).unwrap();
3740        let out2 = console.get_captured();
3741        assert!(out2.contains("id=richrs-1;https://example.com"));
3742    }
3743
3744    #[test]
3745    fn test_print_text_from_ansi_emits_osc8_lifecycle_from_style_meta() {
3746        let mut console = Console::with_writer(
3747            Vec::new(),
3748            ConsoleOptions {
3749                is_terminal: true,
3750                ..Default::default()
3751            },
3752        );
3753        console.set_force_terminal(Some(true));
3754        if console.is_dumb_terminal() {
3755            return;
3756        }
3757
3758        let text = Text::from_ansi("\x1b]8;id=src42;https://example.com\x07Link\x1b]8;;\x07 done");
3759        console.print(&text, None, None, None, false, "").unwrap();
3760        let out = console.get_captured();
3761
3762        let open = "\x1b]8;id=src42;https://example.com\x1b\\";
3763        let close = "\x1b]8;;\x1b\\";
3764        assert!(out.contains(open));
3765        assert!(out.contains(close));
3766
3767        let open_pos = out.find(open).unwrap();
3768        let link_text_pos = out.find("Link").unwrap();
3769        let close_pos = out.find(close).unwrap();
3770        let plain_pos = out.rfind(" done").unwrap();
3771        assert!(open_pos < link_text_pos);
3772        assert!(link_text_pos < close_pos);
3773        assert!(close_pos < plain_pos);
3774    }
3775
3776    #[test]
3777    fn test_print_segments_closes_hyperlink_before_tail_reset() {
3778        let mut console = Console::with_writer(
3779            Vec::new(),
3780            ConsoleOptions {
3781                is_terminal: true,
3782                ..Default::default()
3783            },
3784        );
3785        console.set_force_terminal(Some(true));
3786        if console.is_dumb_terminal() {
3787            return;
3788        }
3789
3790        let mut segments = Segments::new();
3791        segments.push(Segment::styled_with_meta(
3792            "X",
3793            Style::new().with_bold(true),
3794            StyleMeta::with_link("https://example.com"),
3795        ));
3796
3797        console.print_segments(&segments).unwrap();
3798        let out = console.get_captured();
3799        let close_pos = out.find("\x1b]8;;\x1b\\").unwrap();
3800        let reset_pos = out.rfind("\x1b[0m").unwrap();
3801        assert!(close_pos < reset_pos);
3802    }
3803
3804    #[test]
3805    fn test_parse_windows_render_mode_defaults_to_streaming() {
3806        assert_eq!(
3807            parse_windows_render_mode(None),
3808            WindowsRenderMode::Streaming
3809        );
3810        assert_eq!(
3811            parse_windows_render_mode(Some("invalid")),
3812            WindowsRenderMode::Streaming
3813        );
3814    }
3815
3816    #[test]
3817    fn test_parse_windows_render_mode_values() {
3818        assert_eq!(
3819            parse_windows_render_mode(Some("segment")),
3820            WindowsRenderMode::Segment
3821        );
3822        assert_eq!(
3823            parse_windows_render_mode(Some("streaming")),
3824            WindowsRenderMode::Streaming
3825        );
3826        assert_eq!(
3827            parse_windows_render_mode(Some("  StReAmInG  ")),
3828            WindowsRenderMode::Streaming
3829        );
3830    }
3831
3832    // ==================== Console configuration tests ====================
3833
3834    #[test]
3835    fn test_console_width() {
3836        let console = Console::capture_with_options(ConsoleOptions {
3837            max_width: 120,
3838            ..Default::default()
3839        });
3840        assert_eq!(console.width(), 120);
3841    }
3842
3843    #[test]
3844    fn test_console_set_size() {
3845        let mut console = Console::capture();
3846        console.set_size(100, 50);
3847        assert_eq!(console.width(), 100);
3848        assert_eq!(console.height(), 50);
3849        assert_eq!(console.size(), (100, 50));
3850    }
3851
3852    #[test]
3853    fn test_console_force_terminal() {
3854        let mut console = Console::capture();
3855        assert!(!console.is_terminal()); // Capture is not terminal by default
3856
3857        console.set_force_terminal(Some(true));
3858        assert!(console.is_terminal());
3859
3860        console.set_force_terminal(Some(false));
3861        assert!(!console.is_terminal());
3862    }
3863
3864    #[test]
3865    fn test_console_quiet_mode() {
3866        let mut console = Console::capture();
3867        console.set_quiet(true);
3868        console.print_text("This should not appear").unwrap();
3869        assert!(console.get_captured().is_empty());
3870    }
3871
3872    #[test]
3873    fn test_console_markup_emoji_highlight() {
3874        let mut console = Console::capture();
3875
3876        assert!(console.is_markup_enabled());
3877        console.set_markup_enabled(false);
3878        assert!(!console.is_markup_enabled());
3879
3880        assert!(console.is_emoji_enabled());
3881        console.set_emoji_enabled(false);
3882        assert!(!console.is_emoji_enabled());
3883
3884        assert!(console.is_highlight_enabled());
3885        console.set_highlight_enabled(false);
3886        assert!(!console.is_highlight_enabled());
3887    }
3888
3889    #[test]
3890    fn test_console_tab_size() {
3891        let mut console = Console::capture();
3892        assert_eq!(console.tab_size(), 8);
3893        console.set_tab_size(4);
3894        assert_eq!(console.tab_size(), 4);
3895    }
3896
3897    #[test]
3898    fn test_console_encoding() {
3899        let mut console = Console::capture();
3900        assert_eq!(console.encoding(), "utf-8");
3901
3902        console.set_encoding("latin-1");
3903        assert_eq!(console.encoding(), "latin-1");
3904        assert_eq!(console.options().encoding, "latin-1");
3905    }
3906
3907    // ==================== Console render tests ====================
3908
3909    #[test]
3910    fn test_console_render_text() {
3911        // Use Console<Stdout> directly for render methods
3912        let console = Console::with_options(ConsoleOptions::default());
3913        let text = Text::plain("Hello, World!");
3914        let segments = console.render(&text);
3915        assert!(!segments.is_empty());
3916
3917        let combined: String = segments.iter().map(|s| s.text.to_string()).collect();
3918        assert_eq!(combined, "Hello, World!");
3919    }
3920
3921    #[test]
3922    fn test_console_render_str() {
3923        let console = Console::capture();
3924        let text = console.render_str("Hello", None, None, None, None);
3925        assert_eq!(text.plain_text(), "Hello");
3926    }
3927
3928    #[test]
3929    fn test_console_render_str_with_emoji() {
3930        let console = Console::capture();
3931        let text = console.render_str(":smile:", None, Some(true), None, None);
3932        // Should contain the emoji or the original text if emoji not found
3933        assert!(!text.plain_text().is_empty());
3934    }
3935
3936    // ==================== Console print tests ====================
3937
3938    #[test]
3939    fn test_console_print_renderable() {
3940        let mut console = Console::capture();
3941        let text = Text::plain("Hello");
3942        console.print(&text, None, None, None, false, "\n").unwrap();
3943        let output = console.get_captured();
3944        assert!(output.contains("Hello"));
3945    }
3946
3947    #[test]
3948    fn test_console_print_with_style() {
3949        let mut console = Console::capture();
3950        let text = Text::plain("Styled");
3951        let style = Style::new().with_bold(true);
3952        console
3953            .print(&text, Some(style), None, None, false, "\n")
3954            .unwrap();
3955        let output = console.get_captured();
3956        assert!(output.contains("Styled"));
3957    }
3958
3959    #[test]
3960    fn test_console_rule() {
3961        let mut console = Console::capture();
3962        console.rule(None).unwrap();
3963        let output = console.get_captured();
3964        assert!(output.contains("─"));
3965    }
3966
3967    #[test]
3968    fn test_console_rule_with_title() {
3969        let mut console = Console::capture();
3970        console.rule(Some("Title")).unwrap();
3971        let output = console.get_captured();
3972        assert!(output.contains("Title"));
3973        assert!(output.contains("─"));
3974    }
3975
3976    #[test]
3977    fn test_console_line() {
3978        let mut console = Console::capture();
3979        console.line(3).unwrap();
3980        let output = console.get_captured();
3981        assert_eq!(output.matches('\n').count(), 3);
3982    }
3983
3984    // ==================== Console measure tests ====================
3985
3986    #[test]
3987    fn test_console_measure() {
3988        let console = Console::capture();
3989        let text = Text::plain("Hello World");
3990        let measurement = console.measure(&text, None);
3991        assert!(measurement.minimum > 0);
3992        assert!(measurement.maximum >= measurement.minimum);
3993    }
3994
3995    // ==================== Console alt screen tests (unit only) ====================
3996
3997    #[test]
3998    fn test_console_alt_screen_tracking() {
3999        let console = Console::capture();
4000        assert!(!console.is_alt_screen());
4001        // Can't actually test enter/leave without a real terminal
4002    }
4003
4004    // ==================== Theme tests ====================
4005
4006    #[test]
4007    fn test_console_theme_stack() {
4008        let mut console = Console::capture();
4009        let theme = Theme::default();
4010        console.push_theme(theme.clone());
4011        assert!(console.pop_theme().is_ok());
4012    }
4013
4014    // ==================== Color system detection tests ====================
4015
4016    #[test]
4017    fn test_color_system_detection_no_terminal() {
4018        let result = Console::<Stdout>::detect_color_system_static(false);
4019        assert!(result.is_none());
4020    }
4021
4022    // ==================== State propagation tests ====================
4023
4024    #[test]
4025    fn test_console_setters_sync_to_options() {
4026        let mut console = Console::capture();
4027
4028        // Test set_markup_enabled
4029        console.set_markup_enabled(false);
4030        assert!(!console.is_markup_enabled());
4031        assert!(!console.options().markup_enabled);
4032
4033        // Test set_emoji_enabled
4034        console.set_emoji_enabled(false);
4035        assert!(!console.is_emoji_enabled());
4036        assert!(!console.options().emoji_enabled);
4037
4038        // Test set_highlight_enabled
4039        console.set_highlight_enabled(false);
4040        assert!(!console.is_highlight_enabled());
4041        assert!(!console.options().highlight_enabled);
4042
4043        // Test set_tab_size
4044        console.set_tab_size(4);
4045        assert_eq!(console.tab_size(), 4);
4046        assert_eq!(console.options().tab_size, 4);
4047
4048        // Test set_encoding
4049        console.set_encoding("cp1252");
4050        assert_eq!(console.encoding(), "cp1252");
4051        assert_eq!(console.options().encoding, "cp1252");
4052
4053        // Test set_color_system
4054        console.set_color_system(Some(ColorSystem::TrueColor));
4055        assert_eq!(console.color_system(), Some(ColorSystem::TrueColor));
4056        assert_eq!(console.options().color_system, Some(ColorSystem::TrueColor));
4057    }
4058
4059    #[test]
4060    fn test_console_options_with_state() {
4061        let mut console = Console::capture();
4062
4063        // Modify console state
4064        console.set_markup_enabled(false);
4065        console.set_tab_size(2);
4066
4067        // Get options with state
4068        let opts = console.options_with_state();
4069        assert!(!opts.markup_enabled);
4070        assert_eq!(opts.tab_size, 2);
4071    }
4072
4073    #[test]
4074    fn test_with_options_initializes_from_options() {
4075        // Create options with custom state
4076        let mut options = ConsoleOptions::default();
4077        options.markup_enabled = false;
4078        options.emoji_enabled = false;
4079        options.tab_size = 4;
4080        options.encoding = "ascii".to_string();
4081        options.color_system = Some(ColorSystem::Standard);
4082
4083        // Create console from options
4084        let console = Console::with_options(options);
4085
4086        // Console fields should match options
4087        assert!(!console.is_markup_enabled());
4088        assert!(!console.is_emoji_enabled());
4089        assert_eq!(console.tab_size(), 4);
4090        assert_eq!(console.encoding(), "ascii");
4091        assert_eq!(console.color_system(), Some(ColorSystem::Standard));
4092    }
4093
4094    #[test]
4095    fn test_sync_from_options() {
4096        let mut console = Console::capture();
4097
4098        // Modify options directly (not recommended, but supported)
4099        console.options_mut().markup_enabled = false;
4100        console.options_mut().tab_size = 2;
4101
4102        // Console fields are now out of sync
4103        assert!(console.is_markup_enabled()); // Still true!
4104        assert_eq!(console.tab_size(), 8); // Still 8!
4105
4106        // Sync from options
4107        console.sync_from_options();
4108
4109        // Now console fields match options
4110        assert!(!console.is_markup_enabled());
4111        assert_eq!(console.tab_size(), 2);
4112    }
4113
4114    #[test]
4115    fn test_sync_theme_to_options() {
4116        let mut console = Console::capture();
4117
4118        // Modify theme stack directly (not recommended, but supported)
4119        let mut custom_theme = Theme::empty();
4120        custom_theme.add_style("direct.style", Style::new().with_italic(true));
4121        console.theme_stack_mut().push_theme(custom_theme);
4122
4123        // Options theme stack is now out of sync
4124        assert!(console.theme_stack().get_style("direct.style").is_some());
4125        assert!(
4126            console
4127                .options()
4128                .theme_stack
4129                .get_style("direct.style")
4130                .is_none()
4131        ); // Out of sync!
4132
4133        // Sync theme to options
4134        console.sync_theme_to_options();
4135
4136        // Now options theme stack matches
4137        assert!(
4138            console
4139                .options()
4140                .theme_stack
4141                .get_style("direct.style")
4142                .is_some()
4143        );
4144    }
4145
4146    #[test]
4147    fn test_nested_renderable_gets_state() {
4148        // This test verifies that when Padding (which calls render_lines internally)
4149        // renders, the inner renderable gets the correct console state.
4150
4151        use crate::padding::Padding;
4152
4153        let mut console = Console::capture();
4154        console.set_markup_enabled(false);
4155
4156        // Create a simple padding around text
4157        let text = Text::plain("Hello");
4158        let padded = Padding::new(Box::new(text), 1);
4159
4160        // Render - if state doesn't propagate, this would crash or produce wrong output
4161        let options = console.options().clone();
4162        let segments = padded.render(&Console::with_options(options.clone()), &options);
4163
4164        // Verify rendering worked (basic check)
4165        assert!(!segments.is_empty());
4166    }
4167
4168    #[test]
4169    fn test_theme_push_syncs_to_options() {
4170        let mut console = Console::capture();
4171
4172        // Get initial depth
4173        let initial_depth = console.theme_stack().depth();
4174        let initial_opts_depth = console.options().theme_stack.depth();
4175        assert_eq!(initial_depth, initial_opts_depth);
4176
4177        // Push a theme
4178        let mut custom_theme = Theme::empty();
4179        custom_theme.add_style("test.style", Style::new().with_bold(true));
4180        console.push_theme(custom_theme);
4181
4182        // Both should have increased depth
4183        assert_eq!(console.theme_stack().depth(), initial_depth + 1);
4184        assert_eq!(console.options().theme_stack.depth(), initial_depth + 1);
4185
4186        // Both should see the new style
4187        assert!(console.theme_stack().get_style("test.style").is_some());
4188        assert!(
4189            console
4190                .options()
4191                .theme_stack
4192                .get_style("test.style")
4193                .is_some()
4194        );
4195
4196        // Pop the theme
4197        console.pop_theme().unwrap();
4198
4199        // Both should be back to original depth
4200        assert_eq!(console.theme_stack().depth(), initial_depth);
4201        assert_eq!(console.options().theme_stack.depth(), initial_depth);
4202    }
4203
4204    // ==================== Send + Sync assertions ====================
4205
4206    #[test]
4207    fn test_console_options_is_send_sync() {
4208        fn assert_send<T: Send>() {}
4209        fn assert_sync<T: Sync>() {}
4210        assert_send::<ConsoleOptions>();
4211        assert_sync::<ConsoleOptions>();
4212    }
4213
4214    // ==================== Pager context tests ====================
4215
4216    #[test]
4217    fn test_pager_options_default() {
4218        let opts = PagerOptions::default();
4219        assert!(!opts.styles);
4220    }
4221
4222    #[test]
4223    fn test_pager_options_with_styles() {
4224        let opts = PagerOptions::new().with_styles(true);
4225        assert!(opts.styles);
4226    }
4227
4228    #[test]
4229    fn test_pager_context_captures_text() {
4230        let console = Console::new();
4231        let mut pager = console.pager(None);
4232
4233        pager.print_text("Hello").unwrap();
4234        pager.print_text("World").unwrap();
4235
4236        let buffer = pager.get_buffer_string();
4237        assert!(buffer.contains("Hello"));
4238        assert!(buffer.contains("World"));
4239
4240        // Prevent the pager from actually running during the test
4241        pager.buffer.clear();
4242    }
4243
4244    #[test]
4245    fn test_pager_context_captures_renderable() {
4246        let console = Console::new();
4247        let mut pager = console.pager(None);
4248
4249        let text = Text::plain("Rendered text");
4250        pager.print(&text, None, None, None, false, "\n").unwrap();
4251
4252        let buffer = pager.get_buffer_string();
4253        assert!(buffer.contains("Rendered text"));
4254
4255        // Prevent the pager from actually running during the test
4256        pager.buffer.clear();
4257    }
4258
4259    // ==================== Recording and SVG export tests ====================
4260
4261    #[test]
4262    fn test_console_new_with_record() {
4263        let console = Console::new_with_record();
4264        assert!(console.is_recording());
4265    }
4266
4267    #[test]
4268    fn test_console_set_record() {
4269        let mut console = Console::new();
4270        assert!(!console.is_recording());
4271
4272        console.set_record(true);
4273        assert!(console.is_recording());
4274
4275        console.set_record(false);
4276        assert!(!console.is_recording());
4277    }
4278
4279    #[test]
4280    fn test_console_record_buffer() {
4281        let mut console = Console::new_with_record();
4282        console.options_mut().is_terminal = false;
4283        console.options_mut().max_width = 80;
4284
4285        // Print something
4286        console.print_text("Hello").unwrap();
4287
4288        // Check that something was recorded
4289        let buffer = console.get_record_buffer();
4290        assert!(!buffer.is_empty());
4291
4292        // Find the segment containing "Hello"
4293        let has_hello = buffer.iter().any(|s| s.text.contains("Hello"));
4294        assert!(has_hello, "Record buffer should contain 'Hello'");
4295
4296        // Clear the buffer
4297        console.clear_record_buffer();
4298        let buffer = console.get_record_buffer();
4299        assert!(buffer.is_empty());
4300    }
4301
4302    #[test]
4303    fn test_export_svg_basic() {
4304        let mut console = Console::new_with_record();
4305        console.options_mut().is_terminal = false;
4306        console.options_mut().max_width = 40;
4307
4308        console.print_text("Hello, World!").unwrap();
4309
4310        let svg = console.export_svg("Test", None, true, None, 0.61, None);
4311
4312        // Check SVG structure
4313        assert!(svg.contains("<svg"));
4314        assert!(svg.contains("</svg>"));
4315        assert!(svg.contains("Hello"));
4316        assert!(svg.contains("rich-terminal"));
4317
4318        // Buffer should be cleared
4319        let buffer = console.get_record_buffer();
4320        assert!(buffer.is_empty());
4321    }
4322
4323    #[test]
4324    fn test_export_svg_with_custom_title() {
4325        let mut console = Console::new_with_record();
4326        console.options_mut().is_terminal = false;
4327        console.options_mut().max_width = 40;
4328
4329        console.print_text("Test").unwrap();
4330
4331        let svg = console.export_svg("My Custom Title", None, true, None, 0.61, None);
4332
4333        assert!(svg.contains("My&#160;Custom&#160;Title"));
4334    }
4335
4336    #[test]
4337    fn test_export_svg_with_unique_id() {
4338        let mut console = Console::new_with_record();
4339        console.options_mut().is_terminal = false;
4340        console.options_mut().max_width = 40;
4341
4342        console.print_text("Test").unwrap();
4343
4344        let svg = console.export_svg("Test", None, true, None, 0.61, Some("my-unique-id"));
4345
4346        assert!(svg.contains("my-unique-id"));
4347    }
4348
4349    #[test]
4350    fn test_export_svg_escape_text() {
4351        let mut console = Console::new_with_record();
4352        console.options_mut().is_terminal = false;
4353        console.options_mut().max_width = 80;
4354
4355        console.print_text("<script>alert('XSS')</script>").unwrap();
4356
4357        let svg = console.export_svg("Test", None, true, None, 0.61, None);
4358
4359        // Should be escaped
4360        assert!(svg.contains("&lt;"));
4361        assert!(svg.contains("&gt;"));
4362        assert!(!svg.contains("<script>"));
4363    }
4364
4365    #[test]
4366    fn test_export_svg_no_clear() {
4367        let mut console = Console::new_with_record();
4368        console.options_mut().is_terminal = false;
4369        console.options_mut().max_width = 40;
4370
4371        console.print_text("Hello").unwrap();
4372
4373        // Export without clearing
4374        let _svg = console.export_svg("Test", None, false, None, 0.61, None);
4375
4376        // Buffer should still contain data
4377        let buffer = console.get_record_buffer();
4378        assert!(!buffer.is_empty());
4379    }
4380
4381    #[test]
4382    fn test_export_html_basic() {
4383        let mut console = Console::new_with_record();
4384        console.options_mut().is_terminal = false;
4385        console.options_mut().max_width = 40;
4386
4387        console.print_text("Hello, World!").unwrap();
4388
4389        let html = console.export_html(None, true, None);
4390        assert!(html.contains("<!DOCTYPE html>"));
4391        assert!(html.contains("Hello, World!"));
4392
4393        // Buffer should be cleared
4394        let buffer = console.get_record_buffer();
4395        assert!(buffer.is_empty());
4396    }
4397
4398    #[test]
4399    fn test_export_html_link_emits_anchor_tag() {
4400        let mut console = Console::new_with_record();
4401        console.options_mut().is_terminal = false;
4402
4403        let text =
4404            Text::from_markup("[link=https://textualize.io]Textualize.io[/link]", false).unwrap();
4405        console.print(&text, None, None, None, false, "\n").unwrap();
4406
4407        let html = console.export_html(None, true, None);
4408        assert!(html.contains("<a"));
4409        assert!(html.contains("href=\"https://textualize.io\""));
4410        assert!(html.contains("Textualize.io"));
4411    }
4412
4413    #[test]
4414    fn test_export_html_escapes_text() {
4415        let mut console = Console::new_with_record();
4416        console.options_mut().is_terminal = false;
4417
4418        console.print_text("<script>alert('XSS')</script>").unwrap();
4419        let html = console.export_html(None, true, None);
4420        assert!(html.contains("&lt;script&gt;"));
4421        assert!(!html.contains("<script>"));
4422    }
4423}