Skip to main content

boxmux_lib/components/
box_renderer.rs

1use crate::color_utils::{get_bg_color_transparent, get_fg_color_transparent, should_draw_color};
2use crate::components::choice_menu::ChoiceMenu;
3use crate::components::renderable_content::{RenderableContent, SensitiveZone};
4use crate::components::{
5    ChartComponent, ChartConfig, ChartType, ComponentDimensions, HorizontalScrollbar,
6    VerticalScrollbar,
7};
8use crate::draw_utils::{
9    content_size, draw_horizontal_line, draw_horizontal_line_with_tabs, draw_vertical_line,
10    fill_muxbox, print_with_color_and_background_at, render_wrapped_content, wrap_text_to_width,
11};
12use crate::model::choice::Choice;
13use crate::model::common::{Cell, ChoicesStreamTrait, ContentStreamTrait, StreamType};
14use crate::{AppContext, AppGraph, Bounds, MuxBox, ScreenBuffer};
15use std::collections::HashMap;
16
17/// Box dimensions and coordinate system definitions
18///
19/// This module formalizes the coordinate systems and size measurements for boxes:
20/// - **Box Span**: Total space including borders, tabs, scrollbars, all attachments
21/// - **Content Area**: Space available for renderable content (inside borders/scrollbars)
22/// - **Screen Coordinates**: Absolute terminal coordinates (0,0 = top-left terminal)
23/// - **Inbox Coordinates**: Content-local coordinates (0,0 = top-left content cell)
24#[derive(Debug, Clone)]
25pub struct BoxDimensions {
26    /// Total box span including all attachments (borders, scrollbars, tabs)
27    pub total_bounds: Bounds,
28
29    /// Content area bounds (where renderable content is placed)
30    pub content_bounds: Bounds,
31
32    /// Dimensions of the viewable content area
33    pub viewable_width: usize,
34    pub viewable_height: usize,
35
36    /// Actual content dimensions (may exceed viewable area)
37    pub content_width: usize,
38    pub content_height: usize,
39
40    /// Current scroll positions (0-100 percentage)
41    pub horizontal_scroll: f64,
42    pub vertical_scroll: f64,
43
44    /// Border thickness
45    pub border_thickness: usize,
46
47    /// Tab bar height (if tabs are present)
48    pub tab_height: usize,
49
50    /// Scrollbar dimensions
51    pub vertical_scrollbar_width: usize,
52    pub horizontal_scrollbar_height: usize,
53}
54
55impl BoxDimensions {
56    /// Create new BoxDimensions from MuxBox state with explicit content size.
57    ///
58    /// This is the single canonical coordinate authority shared by rendering and
59    /// hit-testing. `content_bounds` is computed purely from the box's border and
60    /// tab presence (inclusive coordinates), and one column/row is always reserved
61    /// for scrollbars so the viewable region never depends on whether a scrollbar
62    /// happens to be visible. `inbox_to_screen`/`screen_to_inbox` are exact
63    /// inverses over this region, so any cell a renderer draws via `inbox_to_screen`
64    /// is hit-tested back to the same inbox coordinate.
65    pub fn new(
66        muxbox: &crate::model::muxbox::MuxBox,
67        bounds: &Bounds,
68        content_width: usize,
69        content_height: usize,
70    ) -> Self {
71        // The border is always drawn (calc_border defaults to a visible color, so a
72        // box without an explicit border_color still renders one). The previous
73        // `border_color.is_some()` check used the raw field (None for default boxes),
74        // giving border_thickness=0 — so content/choices rendered ON the top
75        // border/tab row and overdrew it. The tab bar is drawn ON that same top row
76        // (it replaces the border line), so it never adds a second row.
77        let border_thickness = 1;
78        let tab_height = 0;
79
80        // Standard scrollbar reservation: always reserve one column on the right
81        // and one row on the bottom of the content area.
82        let vertical_scrollbar_width = 1;
83        let horizontal_scrollbar_height = 1;
84
85        // Content bounds: inside the border, below the tab row. Inclusive coords.
86        let content_left = bounds.left() + border_thickness;
87        let content_top = bounds.top() + border_thickness + tab_height;
88        let content_right = bounds.right().saturating_sub(border_thickness);
89        let content_bottom = bounds.bottom().saturating_sub(border_thickness);
90        let content_bounds = Bounds::new(content_left, content_top, content_right, content_bottom);
91
92        // Viewable region excludes the reserved scrollbar column/row.
93        let viewable_width = content_bounds.width().saturating_sub(vertical_scrollbar_width);
94        let viewable_height = content_bounds
95            .height()
96            .saturating_sub(horizontal_scrollbar_height);
97
98        Self {
99            total_bounds: bounds.clone(),
100            content_bounds,
101            viewable_width,
102            viewable_height,
103            content_width,
104            content_height,
105            horizontal_scroll: muxbox.current_horizontal_scroll(),
106            vertical_scroll: muxbox.current_vertical_scroll(),
107            border_thickness,
108            tab_height,
109            vertical_scrollbar_width,
110            horizontal_scrollbar_height,
111        }
112    }
113
114    /// Convert screen coordinates to box span coordinates
115    /// Box span includes borders, tabs, scrollbars - the total area the box occupies
116    pub fn screen_to_box_span(&self, screen_x: usize, screen_y: usize) -> Option<(usize, usize)> {
117        if screen_x < self.total_bounds.left()
118            || screen_x >= self.total_bounds.right()
119            || screen_y < self.total_bounds.top()
120            || screen_y >= self.total_bounds.bottom()
121        {
122            return None;
123        }
124
125        let box_x = screen_x - self.total_bounds.left();
126        let box_y = screen_y - self.total_bounds.top();
127        Some((box_x, box_y))
128    }
129
130    /// Convert box span coordinates to screen coordinates
131    pub fn box_span_to_screen(&self, box_x: usize, box_y: usize) -> (usize, usize) {
132        (
133            self.total_bounds.left() + box_x,
134            self.total_bounds.top() + box_y,
135        )
136    }
137
138    /// Scroll offsets (in content cells) for the current scroll percentages.
139    /// This is the single definition used by every coordinate method so the
140    /// forward and inverse mappings can never disagree.
141    fn scroll_offsets(&self) -> (usize, usize) {
142        let horizontal_offset = if self.content_width > self.viewable_width {
143            ((self.content_width - self.viewable_width) as f64 * self.horizontal_scroll / 100.0)
144                .round() as usize
145        } else {
146            0
147        };
148        let vertical_offset = if self.content_height > self.viewable_height {
149            ((self.content_height - self.viewable_height) as f64 * self.vertical_scroll / 100.0)
150                .round() as usize
151        } else {
152            0
153        };
154        (horizontal_offset, vertical_offset)
155    }
156
157    /// Convert screen coordinates to inbox (content-local) coordinates.
158    /// Exact inverse of `inbox_to_screen` over the viewable window. The viewable
159    /// window is `[content_left, content_left + viewable_width)` horizontally and
160    /// `[content_top, content_top + viewable_height)` vertically, so the last
161    /// viewable cell is reachable (no off-by-one) and the reserved scrollbar
162    /// column/row is excluded.
163    pub fn screen_to_inbox(&self, screen_x: usize, screen_y: usize) -> Option<(usize, usize)> {
164        let left = self.content_bounds.left();
165        let top = self.content_bounds.top();
166
167        if screen_x < left
168            || screen_x >= left + self.viewable_width
169            || screen_y < top
170            || screen_y >= top + self.viewable_height
171        {
172            return None;
173        }
174
175        let (horizontal_offset, vertical_offset) = self.scroll_offsets();
176        let inbox_x = (screen_x - left) + horizontal_offset;
177        let inbox_y = (screen_y - top) + vertical_offset;
178
179        if inbox_x >= self.content_width || inbox_y >= self.content_height {
180            return None;
181        }
182
183        Some((inbox_x, inbox_y))
184    }
185
186    /// Convert inbox coordinates to screen coordinates. Returns `(usize::MAX,
187    /// usize::MAX)` when the inbox cell is outside content or scrolled out of the
188    /// viewable window. Exact inverse of `screen_to_inbox`.
189    pub fn inbox_to_screen(&self, inbox_x: usize, inbox_y: usize) -> (usize, usize) {
190        if inbox_x >= self.content_width || inbox_y >= self.content_height {
191            return (usize::MAX, usize::MAX);
192        }
193
194        let (horizontal_offset, vertical_offset) = self.scroll_offsets();
195        if inbox_x < horizontal_offset || inbox_y < vertical_offset {
196            return (usize::MAX, usize::MAX);
197        }
198
199        let viewable_x = inbox_x - horizontal_offset;
200        let viewable_y = inbox_y - vertical_offset;
201        if viewable_x >= self.viewable_width || viewable_y >= self.viewable_height {
202            return (usize::MAX, usize::MAX);
203        }
204
205        (
206            self.content_bounds.left() + viewable_x,
207            self.content_bounds.top() + viewable_y,
208        )
209    }
210
211    /// Check if screen coordinates are within the content area
212    pub fn contains_screen_point(&self, screen_x: usize, screen_y: usize) -> bool {
213        self.screen_to_inbox(screen_x, screen_y).is_some()
214    }
215
216    /// Check if screen coordinates are within the total box span
217    pub fn contains_screen_point_in_span(&self, screen_x: usize, screen_y: usize) -> bool {
218        screen_x >= self.total_bounds.left()
219            && screen_x < self.total_bounds.right()
220            && screen_y >= self.total_bounds.top()
221            && screen_y < self.total_bounds.bottom()
222    }
223    /// Calculate BoxDimensions from MuxBox and bounds
224    pub fn calculate_from_muxbox(muxbox: &MuxBox, total_bounds: Bounds) -> Self {
225        // Border thickness determined by presence of border_color
226        let border_thickness = if muxbox.border_color.is_some() { 1 } else { 0 };
227
228        // Tab bar height (calculated based on stream presence)
229        let tab_height = if muxbox.streams.len() > 1 { 1 } else { 0 };
230
231        // Scrollbar dimensions (standard sizes)
232        let vertical_scrollbar_width = 1;
233        let horizontal_scrollbar_height = 1;
234
235        // Calculate content bounds accounting for borders and tabs
236        let content_left = total_bounds.left() + border_thickness;
237        let content_top = total_bounds.top() + border_thickness + tab_height;
238        let content_right = total_bounds.right().saturating_sub(border_thickness);
239        let content_bottom = total_bounds.bottom().saturating_sub(border_thickness);
240
241        let content_bounds = Bounds::new(content_left, content_top, content_right, content_bottom);
242
243        // Calculate viewable dimensions (content area minus scrollbars if needed)
244        let viewable_width = content_bounds
245            .width()
246            .saturating_sub(vertical_scrollbar_width);
247        let viewable_height = content_bounds
248            .height()
249            .saturating_sub(horizontal_scrollbar_height);
250
251        // Content dimensions from muxbox (if available) or default to viewable
252        let (content_width, content_height) =
253            Self::get_content_dimensions(muxbox, viewable_width, viewable_height);
254
255        Self {
256            total_bounds,
257            content_bounds,
258            viewable_width,
259            viewable_height,
260            content_width,
261            content_height,
262            horizontal_scroll: muxbox.horizontal_scroll.unwrap_or(0.0),
263            vertical_scroll: muxbox.vertical_scroll.unwrap_or(0.0),
264            border_thickness,
265            tab_height,
266            vertical_scrollbar_width,
267            horizontal_scrollbar_height,
268        }
269    }
270
271    /// Get content dimensions from muxbox content
272    fn get_content_dimensions(
273        muxbox: &MuxBox,
274        default_width: usize,
275        default_height: usize,
276    ) -> (usize, usize) {
277        // Get content from selected stream or static content
278        let content_lines = if let Some(selected_stream) = muxbox.get_selected_stream() {
279            selected_stream.content.clone()
280        } else {
281            // Convert static content string to Vec<String>
282            let static_content = muxbox.content.clone().unwrap_or_default();
283            if static_content.is_empty() {
284                Vec::new()
285            } else {
286                static_content
287                    .lines()
288                    .map(|line| line.to_string())
289                    .collect()
290            }
291        };
292
293        if content_lines.is_empty() {
294            return (default_width, default_height);
295        }
296
297        let content_height = content_lines.len();
298        let content_width = content_lines
299            .iter()
300            .map(|line| line.len())
301            .max()
302            .unwrap_or(0);
303
304        (content_width, content_height)
305    }
306
307    // REMOVED: Duplicate screen_to_inbox and inbox_to_screen methods - using BoxDimensions implementation instead
308
309    /// Check if inbox coordinates are currently visible in the viewable area
310    pub fn is_inbox_visible(&self, inbox_x: usize, inbox_y: usize) -> bool {
311        if inbox_x >= self.content_width || inbox_y >= self.content_height {
312            return false;
313        }
314        let (horizontal_offset, vertical_offset) = self.scroll_offsets();
315        inbox_x >= horizontal_offset
316            && inbox_x < horizontal_offset + self.viewable_width
317            && inbox_y >= vertical_offset
318            && inbox_y < vertical_offset + self.viewable_height
319    }
320
321    /// Get the visible inbox region (scroll window)
322    pub fn get_visible_inbox_region(&self) -> (usize, usize, usize, usize) {
323        let (horizontal_offset, vertical_offset) = self.scroll_offsets();
324
325        (
326            horizontal_offset,                       // left
327            vertical_offset,                         // top
328            horizontal_offset + self.viewable_width, // right
329            vertical_offset + self.viewable_height,  // bottom
330        )
331    }
332}
333
334/// Overflow behavior types for unified content rendering
335#[derive(Debug, Clone, PartialEq)]
336pub enum UnifiedOverflowBehavior {
337    /// Standard scrolling with scrollbars
338    Scroll,
339    /// Text wrapping with line breaks
340    Wrap,
341    /// Fill entire box with solid pattern
342    Fill(char),
343    /// Cross out content with X pattern
344    CrossOut,
345    /// Remove/hide content completely
346    Removed,
347    /// Clip content without scrollbars (default)
348    Clip,
349}
350
351impl UnifiedOverflowBehavior {
352    /// Parse overflow behavior from string
353    pub fn from_behavior_str(behavior: &str) -> Self {
354        match behavior {
355            "scroll" => Self::Scroll,
356            "wrap" => Self::Wrap,
357            "fill" => Self::Fill('█'),
358            "cross_out" => Self::CrossOut,
359            "removed" => Self::Removed,
360            _ => Self::Clip,
361        }
362    }
363}
364
365/// BoxRenderer - Unified rendering component with integrated overflow handling
366///
367/// This component consolidates ALL overflow logic from OverflowRenderer and provides
368/// unified content rendering for text, choices, and charts using existing scrollbar components.
369///
370/// **IMPORTANT**: This is a VISUAL COMPONENT ONLY - it does not replace the
371/// logical MuxBox struct. It queries the MuxBox for state and renders accordingly.
372pub struct BoxRenderer<'a> {
373    /// Reference to the logical MuxBox this renderer represents
374    muxbox: &'a MuxBox,
375    /// Component ID for this renderer instance
376    component_id: String,
377    /// Formalized dimensions and coordinate system
378    dimensions: Option<BoxDimensions>,
379    /// Translated sensitive zones in absolute screen coordinates
380    sensitive_zones: Vec<SensitiveZone>,
381}
382
383impl<'a> BoxRenderer<'a> {
384    /// Create a new BoxRenderer for the given MuxBox
385    pub fn new(muxbox: &'a MuxBox, component_id: String) -> Self {
386        Self {
387            muxbox,
388            component_id,
389            dimensions: None,
390            sensitive_zones: Vec::new(),
391        }
392    }
393
394    /// Initialize dimensions for this box renderer
395    pub fn initialize_dimensions(&mut self, bounds: Bounds) {
396        self.dimensions = Some(BoxDimensions::calculate_from_muxbox(self.muxbox, bounds));
397    }
398
399    /// Get the formalized dimensions for this box
400    pub fn get_dimensions(&self) -> Option<&BoxDimensions> {
401        self.dimensions.as_ref()
402    }
403
404    /// Translate screen coordinates to inbox coordinates using formalized system
405    /// This is the primary method all coordinate translation should use
406    pub fn screen_to_inbox_coords(
407        &self,
408        screen_x: usize,
409        screen_y: usize,
410    ) -> Option<(usize, usize)> {
411        self.dimensions
412            .as_ref()?
413            .screen_to_inbox(screen_x, screen_y)
414    }
415
416    /// Translate inbox coordinates to screen coordinates using formalized system
417    /// This is the primary method all coordinate translation should use
418    pub fn inbox_to_screen_coords(&self, inbox_x: usize, inbox_y: usize) -> Option<(usize, usize)> {
419        if let Some(dimensions) = &self.dimensions {
420            let (screen_x, screen_y) = dimensions.inbox_to_screen(inbox_x, inbox_y);
421            // Return None if coordinates are out of visible area
422            if screen_x == usize::MAX || screen_y == usize::MAX {
423                None
424            } else {
425                Some((screen_x, screen_y))
426            }
427        } else {
428            None
429        }
430    }
431
432    /// Check if inbox coordinates are currently visible
433    pub fn is_inbox_coords_visible(&self, inbox_x: usize, inbox_y: usize) -> bool {
434        self.dimensions
435            .as_ref()
436            .map(|d| d.is_inbox_visible(inbox_x, inbox_y))
437            .unwrap_or(false)
438    }
439
440    /// Generate chart content if the muxbox has chart configuration
441    /// This moves chart rendering responsibility from MuxBox to BoxRenderer
442    fn generate_chart_content(&self, bounds: &Bounds) -> Option<String> {
443        if let (Some(chart_type_str), Some(chart_data)) =
444            (&self.muxbox.chart_type, &self.muxbox.chart_data)
445        {
446            let data = ChartComponent::parse_chart_data(chart_data);
447
448            // Parse chart type with support for all variants including pie and scatter
449            let chart_type = chart_type_str
450                .parse::<ChartType>()
451                .unwrap_or(ChartType::Bar);
452
453            let config = ChartConfig {
454                chart_type,
455                width: ComponentDimensions::new(*bounds).content_bounds().width(),
456                height: ComponentDimensions::new(*bounds).content_bounds().height(),
457                title: None, // Don't show chart title since muxbox already has the title
458                color: "blue".to_string(),
459                show_title: false, // Muxbox already shows title
460                show_values: true,
461                show_grid: false,
462            };
463
464            let chart = ChartComponent::with_data_and_config(
465                format!("muxbox_chart_{}", self.muxbox.id),
466                data,
467                config,
468            );
469
470            // Generate with muxbox title context to avoid duplication
471            Some(chart.generate_with_muxbox_title(self.muxbox.title.as_deref()))
472        } else {
473            None
474        }
475    }
476
477    /// Main rendering function that orchestrates all box drawing
478    ///
479    /// This replaces the logic that was in draw_muxbox() and render_muxbox()
480    /// but preserves ALL existing functionality and behavior.
481    pub fn render(
482        &mut self,
483        app_context: &AppContext,
484        app_graph: &AppGraph,
485        adjusted_bounds: &HashMap<String, HashMap<String, Bounds>>,
486        layout: &crate::Layout,
487        buffer: &mut ScreenBuffer,
488    ) -> bool {
489        // Get bounds for this muxbox (same logic as draw_muxbox)
490        let layout_adjusted_bounds = adjusted_bounds.get(&layout.id);
491        let muxbox_adjusted_bounds =
492            layout_adjusted_bounds.and_then(|bounds| bounds.get(&self.muxbox.id));
493
494        let Some(bounds) = muxbox_adjusted_bounds else {
495            log::error!("Calculated bounds for muxbox {} not found", &self.muxbox.id);
496            return false;
497        };
498
499        // Calculate all colors and properties (same logic as draw_muxbox)
500        let muxbox_parent = app_graph.get_parent(&layout.id, &self.muxbox.id);
501        let bg_color = self.muxbox.calc_bg_color(app_context, app_graph);
502        let parent_bg_color = if let Some(parent) = muxbox_parent {
503            parent
504                .calc_bg_color(app_context, app_graph)
505                .map(|s| s.to_string())
506        } else {
507            layout.bg_color.clone().map(|s| s.to_string())
508        };
509        let fg_color = self.muxbox.calc_fg_color(app_context, app_graph);
510        let title_bg_color = self.muxbox.calc_title_bg_color(app_context, app_graph);
511        let title_fg_color = self.muxbox.calc_title_fg_color(app_context, app_graph);
512        let border = self.muxbox.calc_border(app_context, app_graph);
513
514        // F0135: PTY Error States - Use different colors based on PTY status
515        let border_color = if self.muxbox.execution_mode.is_pty() {
516            if let Some(pty_manager) = &app_context.pty_manager {
517                if pty_manager.is_pty_dead(&self.muxbox.id) {
518                    Some("red".to_string())
519                } else if pty_manager.is_pty_in_error_state(&self.muxbox.id) {
520                    Some("yellow".to_string())
521                } else {
522                    Some("bright_cyan".to_string())
523                }
524            } else {
525                Some("bright_cyan".to_string())
526            }
527        } else {
528            self.muxbox
529                .calc_border_color(app_context, app_graph)
530                .map(|s| s.to_string())
531        };
532
533        let fill_char = self.muxbox.calc_fill_char(app_context, app_graph);
534
535        // Draw fill (same logic as draw_muxbox)
536        fill_muxbox(bounds, border, &bg_color, &None, fill_char, buffer);
537
538        // Calculate overflow behavior (same logic as draw_muxbox)
539        let mut overflow_behavior = self.muxbox.calc_overflow_behavior(app_context, app_graph);
540        if self.muxbox.next_focus_id.is_some() && self.muxbox.has_scrollable_content() {
541            overflow_behavior = "scroll".to_string();
542        }
543
544        // Generate tab labels and close buttons (same logic as draw_muxbox)
545        let tab_labels = self.muxbox.get_tab_labels();
546        let tab_close_buttons = self.muxbox.get_tab_close_buttons();
547
548        // Call the main rendering function with all calculated parameters
549        self.render_box_contents(
550            bounds,
551            &border_color,
552            &bg_color,
553            &parent_bg_color,
554            &self.muxbox.streams,
555            self.muxbox.get_active_tab_index(),
556            self.muxbox.tab_scroll_offset,
557            &title_fg_color,
558            &title_bg_color,
559            &self.muxbox.calc_title_position(app_context, app_graph),
560            &self.muxbox.calc_menu_fg_color(app_context, app_graph),
561            &self.muxbox.calc_menu_bg_color(app_context, app_graph),
562            &self
563                .muxbox
564                .calc_selected_menu_fg_color(app_context, app_graph),
565            &self
566                .muxbox
567                .calc_selected_menu_bg_color(app_context, app_graph),
568            &self
569                .muxbox
570                .calc_highlighted_menu_fg_color(app_context, app_graph),
571            &self
572                .muxbox
573                .calc_highlighted_menu_bg_color(app_context, app_graph),
574            &fg_color,
575            &overflow_behavior,
576            self.muxbox.current_horizontal_scroll(),
577            self.muxbox.current_vertical_scroll(),
578            app_context.config.locked,
579            &tab_labels,
580            &tab_close_buttons,
581            buffer,
582        );
583
584        true
585    }
586
587    /// Internal function that contains all the rendering logic from render_muxbox()
588    ///
589    /// This is essentially the render_muxbox() function but as a method of BoxRenderer
590    /// Preserves ALL existing functionality and behavior.
591    fn render_box_contents(
592        &mut self,
593        bounds: &Bounds,
594        border_color: &Option<String>,
595        bg_color: &Option<String>,
596        parent_bg_color: &Option<String>,
597        streams: &indexmap::IndexMap<String, crate::model::common::Stream>,
598        active_tab_index: usize,
599        tab_scroll_offset: usize,
600        title_fg_color: &Option<String>,
601        title_bg_color: &Option<String>,
602        title_position: &str,
603        menu_fg_color: &Option<String>,
604        menu_bg_color: &Option<String>,
605        selected_menu_fg_color: &Option<String>,
606        selected_menu_bg_color: &Option<String>,
607        highlighted_menu_fg_color: &Option<String>,
608        highlighted_menu_bg_color: &Option<String>,
609        fg_color: &Option<String>,
610        overflow_behavior: &str,
611        horizontal_scroll: f64,
612        vertical_scroll: f64,
613        locked: bool,
614        tab_labels: &[String],
615        tab_close_buttons: &[bool],
616        buffer: &mut ScreenBuffer,
617    ) {
618        // EXACT copy of render_muxbox() logic - preserves ALL functionality
619
620        // Check for chart content first - charts take priority over streams
621        let chart_content = self.generate_chart_content(bounds);
622
623        // F0217: Extract content from streams using trait-based approach
624        let (should_render_choices, content_str) = if chart_content.is_some() {
625            // Chart content overrides stream content
626            (false, chart_content)
627        } else if !streams.is_empty() {
628            let selected_stream = self.muxbox.get_selected_stream();
629            let should_render_choices = if let Some(stream) = selected_stream {
630                matches!(stream.stream_type, StreamType::Choices)
631            } else {
632                false
633            };
634
635            let content_str = if should_render_choices {
636                None
637            } else if let Some(stream) = selected_stream {
638                match stream.stream_type {
639                    StreamType::Content
640                    | StreamType::RedirectedOutput(_)
641                    | StreamType::PTY
642                    | StreamType::Plugin(_)
643                    | StreamType::ChoiceExecution(_)
644                    | StreamType::PtySession(_)
645                    | StreamType::OwnScript => Some(stream.get_content_lines().join("\n")),
646                    _ => None,
647                }
648            } else {
649                None
650            };
651
652            (should_render_choices, content_str)
653        } else {
654            (false, None)
655        };
656
657        let content = content_str.as_deref();
658
659        // Extract choices from streams for legacy rendering logic compatibility
660        let _choices = if should_render_choices {
661            streams
662                .values()
663                .find(|s| matches!(s.stream_type, StreamType::Choices))
664                .map(|stream| stream.get_choices().clone())
665        } else {
666            None
667        };
668
669        // Border visibility now determined by transparent color support
670        let mut _overflowing = false;
671        let mut scrollbars_drawn = false;
672
673        // Ensure bounds stay within screen limits
674        let screen_bounds = crate::utils::screen_bounds();
675        let bounds = bounds
676            .intersection(&screen_bounds)
677            .unwrap_or_else(|| bounds.clone());
678
679        // F0208: Draw top border with tabs
680        if !tab_labels.is_empty() {
681            draw_horizontal_line_with_tabs(
682                bounds.top(),
683                bounds.left(),
684                bounds.right(),
685                border_color,
686                bg_color,
687                None,
688                title_fg_color,
689                title_bg_color,
690                title_position,
691                tab_labels,
692                tab_close_buttons,
693                active_tab_index,
694                self.muxbox.selected.unwrap_or(false),
695                tab_scroll_offset,
696                self.muxbox.hovered_tab_target.as_ref(),
697                buffer,
698            );
699        } else if should_draw_color(border_color) || should_draw_color(bg_color) {
700            draw_horizontal_line(
701                bounds.top(),
702                bounds.left(),
703                bounds.right(),
704                border_color,
705                bg_color,
706                buffer,
707            );
708        }
709
710        // F0206: Render choices from streams as content using ChoiceMenu component
711        if should_render_choices {
712            let choices_stream = streams
713                .values()
714                .find(|s| matches!(s.stream_type, StreamType::Choices));
715            if let Some(stream) = choices_stream {
716                let choices = stream.get_choices();
717                if !choices.is_empty() {
718                    // Create ChoiceMenu component to generate content and sensitive zones
719                    let choice_menu =
720                        ChoiceMenu::new(format!("{}_choice_menu", self.component_id), choices)
721                            .with_selection(self.muxbox.selected_choice_index())
722                            .with_focus(self.muxbox.focused_choice_index());
723
724                    // Content size measured exactly as rendered (formatted lines, in
725                    // display characters), so dimensions/zones/render all agree.
726                    let (content_width, content_height) = choice_menu.get_dimensions();
727                    let dims = BoxDimensions::new(self.muxbox, &bounds, content_width, content_height);
728
729                    log::trace!("CHOICE RENDER: BoxRenderer creating ChoiceMenu for muxbox '{}' with {} choices, dimensions: {:?}",
730                             self.muxbox.id, choices.len(), (content_width, content_height));
731
732                    scrollbars_drawn = self.render_choices_with_hover_states(
733                        &bounds,
734                        choices,
735                        menu_fg_color,
736                        menu_bg_color,
737                        selected_menu_fg_color,
738                        selected_menu_bg_color,
739                        highlighted_menu_fg_color,
740                        highlighted_menu_bg_color,
741                        border_color,
742                        parent_bg_color,
743                        UnifiedOverflowBehavior::Scroll,
744                        0,
745                        0,
746                        buffer,
747                    );
748
749                    // Sensitive zones are translated through the SAME canonical
750                    // mapping the renderer uses, so every drawn choice cell hit-tests
751                    // back to that choice.
752                    let box_relative_zones = choice_menu.get_box_relative_sensitive_zones();
753                    let translated_zones = self.translate_box_relative_zones_to_absolute(
754                        &box_relative_zones,
755                        &bounds,
756                        content_width,
757                        content_height,
758                        dims.viewable_width,
759                        dims.viewable_height,
760                        dims.horizontal_scroll,
761                        dims.vertical_scroll,
762                        false, // not wrapped
763                    );
764
765                    // Store translated zones for click detection
766                    self.store_translated_sensitive_zones(translated_zones);
767                }
768            }
769        } else if let Some(content) = content {
770            let (content_width, content_height) = content_size(content);
771            let component_dims = ComponentDimensions::new(bounds);
772            let content_bounds = component_dims.content_bounds();
773            let viewable_width = content_bounds.width();
774            let viewable_height = content_bounds.height();
775            _overflowing = content_width > viewable_width || content_height > viewable_height;
776
777            scrollbars_drawn = self.render_content(
778                &bounds,
779                content,
780                fg_color,
781                bg_color,
782                border_color,
783                parent_bg_color,
784                overflow_behavior,
785                horizontal_scroll,
786                vertical_scroll,
787                buffer,
788            );
789        }
790
791        // Special overflow behaviors are now handled within the unified content rendering path
792
793        // Pass the actual rendered content for proper scrollbar detection
794        let rendered_content = if should_render_choices {
795            // For choices, use the choice content that was actually rendered
796            streams
797                .values()
798                .find(|s| matches!(s.stream_type, StreamType::Choices))
799                .map(|stream| {
800                    let choices = stream.get_choices();
801                    let choice_menu =
802                        ChoiceMenu::new(format!("{}_choice_menu", self.component_id), choices)
803                            .with_selection(self.muxbox.selected_choice_index())
804                            .with_focus(self.muxbox.focused_choice_index());
805
806                    log::trace!(
807                        "CHOICE RENDER: Secondary ChoiceMenu created for content only, muxbox '{}'",
808                        self.muxbox.id
809                    );
810                    choice_menu.get_raw_content()
811                })
812        } else {
813            content.map(|s| s.to_string())
814        };
815
816        self.render_borders(
817            &bounds,
818            border_color,
819            bg_color,
820            scrollbars_drawn,
821            locked,
822            rendered_content.as_deref(),
823            buffer,
824        );
825    }
826
827    /// Render content with scrolling and overflow handling
828    fn render_content(
829        &self,
830        bounds: &Bounds,
831        content: &str,
832        fg_color: &Option<String>,
833        bg_color: &Option<String>,
834        border_color: &Option<String>,
835        parent_bg_color: &Option<String>,
836        overflow_behavior: &str,
837        horizontal_scroll: f64,
838        vertical_scroll: f64,
839        buffer: &mut ScreenBuffer,
840    ) -> bool {
841        let (content_width, content_height) = content_size(content);
842        let component_dims = ComponentDimensions::new(*bounds);
843        let content_bounds = component_dims.content_bounds();
844        let viewable_width = content_bounds.width();
845        let viewable_height = content_bounds.height();
846
847        let content_lines: Vec<&str> = content.lines().collect();
848        let max_content_width = content_lines
849            .iter()
850            .map(|line| line.len())
851            .max()
852            .unwrap_or(0);
853        let max_content_height = content_lines.len();
854
855        let _overflowing = content_width > viewable_width || content_height > viewable_height;
856
857        if _overflowing && overflow_behavior == "scroll" {
858            self.render_scrollable_content(
859                bounds,
860                &content_lines,
861                max_content_width,
862                max_content_height,
863                viewable_width,
864                viewable_height,
865                horizontal_scroll,
866                vertical_scroll,
867                fg_color,
868                bg_color,
869                border_color,
870                buffer,
871            );
872            // Return true if any scrollbar is drawn
873            max_content_height > viewable_height || max_content_width > viewable_width
874        } else if _overflowing && overflow_behavior == "wrap" {
875            self.render_wrapped_content(
876                bounds,
877                content,
878                vertical_scroll,
879                fg_color,
880                bg_color,
881                border_color,
882                parent_bg_color,
883                buffer,
884            );
885            // In wrap mode, we need to tell border renderer about horizontal scrollbar space
886            // even though no scrollbar is drawn, to ensure border fills the reserved space
887            max_content_width > viewable_width
888        } else {
889            self.render_normal_content(
890                bounds,
891                &content_lines,
892                max_content_width,
893                viewable_width,
894                viewable_height,
895                fg_color,
896                bg_color,
897                buffer,
898            );
899
900            // HORIZONTAL SCROLLBAR FIX: Draw horizontal scrollbar even in normal content mode if needed
901            // This ensures consistency with border space reservation logic
902            if max_content_width > viewable_width {
903                use crate::components::horizontal_scrollbar::HorizontalScrollbar;
904                let horizontal_scrollbar = HorizontalScrollbar::new("content".to_string());
905                horizontal_scrollbar.draw(
906                    bounds,
907                    max_content_width,
908                    viewable_width,
909                    horizontal_scroll,
910                    border_color,
911                    bg_color,
912                    buffer,
913                );
914                true // Return true to indicate scrollbar was drawn
915            } else {
916                false
917            }
918        }
919    }
920
921    /// Render scrollable content with scrollbars
922    fn render_scrollable_content(
923        &self,
924        bounds: &Bounds,
925        content_lines: &[&str],
926        max_content_width: usize,
927        max_content_height: usize,
928        viewable_width: usize,
929        _viewable_height: usize,
930        horizontal_scroll: f64,
931        vertical_scroll: f64,
932        fg_color: &Option<String>,
933        bg_color: &Option<String>,
934        border_color: &Option<String>,
935        buffer: &mut ScreenBuffer,
936    ) {
937        let component_dims = ComponentDimensions::new(*bounds);
938        let content_bounds = component_dims.content_bounds();
939        let viewable_height = content_bounds.height();
940
941        let max_horizontal_offset = max_content_width.saturating_sub(viewable_width);
942        let max_vertical_offset = max_content_height.saturating_sub(viewable_height);
943
944        let horizontal_offset =
945            ((horizontal_scroll / 100.0) * max_horizontal_offset as f64).floor() as usize;
946        let vertical_offset =
947            ((vertical_scroll / 100.0) * max_vertical_offset as f64).floor() as usize;
948
949        let horizontal_offset = horizontal_offset.min(max_horizontal_offset);
950        let vertical_offset = vertical_offset.min(max_vertical_offset);
951
952        let visible_lines = content_lines
953            .iter()
954            .skip(vertical_offset)
955            .take(viewable_height);
956
957        let total_lines = content_lines.len();
958        let vertical_padding = (viewable_height.saturating_sub(total_lines)) / 2;
959        let horizontal_padding = (viewable_width.saturating_sub(max_content_width)) / 2;
960
961        for (line_idx, line) in visible_lines.enumerate() {
962            let visible_part = line
963                .chars()
964                .skip(horizontal_offset)
965                .take(viewable_width)
966                .collect::<String>();
967
968            print_with_color_and_background_at(
969                content_bounds.top() + line_idx + vertical_padding,
970                content_bounds.left() + 1 + horizontal_padding,
971                fg_color,
972                bg_color,
973                &visible_part,
974                buffer,
975            );
976        }
977
978        if should_draw_color(border_color) || should_draw_color(bg_color) {
979            draw_horizontal_line(
980                bounds.bottom(),
981                bounds.left(),
982                bounds.right(),
983                border_color,
984                bg_color,
985                buffer,
986            );
987
988            draw_vertical_line(
989                bounds.right(),
990                ComponentDimensions::new(*bounds)
991                    .vertical_scrollbar_track_bounds()
992                    .y1,
993                ComponentDimensions::new(*bounds)
994                    .vertical_scrollbar_track_bounds()
995                    .y2,
996                border_color,
997                bg_color,
998                buffer,
999            );
1000        }
1001
1002        // Draw scrollbars using components only when needed
1003        if max_content_height > viewable_height {
1004            let vertical_scrollbar = VerticalScrollbar::new("content".to_string());
1005            vertical_scrollbar.draw(
1006                bounds,
1007                max_content_height,
1008                viewable_height,
1009                vertical_scroll,
1010                border_color,
1011                bg_color,
1012                buffer,
1013            );
1014        }
1015
1016        if max_content_width > viewable_width {
1017            let horizontal_scrollbar = HorizontalScrollbar::new("content".to_string());
1018            horizontal_scrollbar.draw(
1019                bounds,
1020                max_content_width,
1021                viewable_width,
1022                horizontal_scroll,
1023                border_color,
1024                bg_color,
1025                buffer,
1026            );
1027        }
1028    }
1029
1030    /// Render wrapped content using integrated overflow logic
1031    fn render_wrapped_content(
1032        &self,
1033        bounds: &Bounds,
1034        content: &str,
1035        vertical_scroll: f64,
1036        fg_color: &Option<String>,
1037        bg_color: &Option<String>,
1038        border_color: &Option<String>,
1039        _parent_bg_color: &Option<String>,
1040        buffer: &mut ScreenBuffer,
1041    ) -> bool {
1042        let component_dims = ComponentDimensions::new(*bounds);
1043        let content_bounds = component_dims.content_bounds();
1044        let viewable_width = content_bounds.width();
1045        let wrapped_content = wrap_text_to_width(content, viewable_width);
1046
1047        let viewable_height = content_bounds.height();
1048        let wrapped_overflows_vertically = wrapped_content.len() > viewable_height;
1049
1050        render_wrapped_content(
1051            &wrapped_content,
1052            bounds,
1053            vertical_scroll,
1054            fg_color,
1055            bg_color,
1056            buffer,
1057        );
1058
1059        // Draw vertical scrollbar if wrapped content overflows
1060        if wrapped_overflows_vertically && should_draw_color(border_color) {
1061            let vertical_scrollbar =
1062                VerticalScrollbar::new(format!("{}_wrapped_text", self.component_id));
1063            vertical_scrollbar.draw(
1064                bounds,
1065                wrapped_content.len(),
1066                viewable_height,
1067                vertical_scroll,
1068                border_color,
1069                bg_color,
1070                buffer,
1071            );
1072            true
1073        } else {
1074            false
1075        }
1076    }
1077
1078    /// Render normal (non-overflowing) content
1079    fn render_normal_content(
1080        &self,
1081        bounds: &Bounds,
1082        content_lines: &[&str],
1083        max_content_width: usize,
1084        viewable_width: usize,
1085        viewable_height: usize,
1086        fg_color: &Option<String>,
1087        bg_color: &Option<String>,
1088        buffer: &mut ScreenBuffer,
1089    ) {
1090        let component_dims = ComponentDimensions::new(*bounds);
1091        let content_bounds = component_dims.content_bounds();
1092        let total_lines = content_lines.len();
1093        let vertical_padding = (viewable_height.saturating_sub(total_lines)) / 2;
1094        let horizontal_padding = (viewable_width.saturating_sub(max_content_width)) / 2;
1095
1096        for (i, line) in content_lines.iter().enumerate().take(viewable_height) {
1097            let visible_line = &line.chars().take(viewable_width).collect::<String>();
1098
1099            print_with_color_and_background_at(
1100                content_bounds.top() + vertical_padding + i,
1101                content_bounds.left() + 1 + horizontal_padding,
1102                fg_color,
1103                bg_color,
1104                visible_line,
1105                buffer,
1106            );
1107        }
1108    }
1109
1110    /// Render borders with proper corner handling - supports transparent colors
1111    fn render_borders(
1112        &self,
1113        bounds: &Bounds,
1114        border_color: &Option<String>,
1115        bg_color: &Option<String>,
1116        _scrollbars_drawn: bool,
1117        locked: bool,
1118        content: Option<&str>,
1119        buffer: &mut ScreenBuffer,
1120    ) {
1121        // Skip border drawing if border color is transparent
1122        if !should_draw_color(border_color) {
1123            return;
1124        }
1125
1126        let border_color_code = get_fg_color_transparent(border_color);
1127        let bg_color_code = get_bg_color_transparent(bg_color);
1128
1129        // Check if we need horizontal scrollbar
1130        let has_horizontal_scrollbar = content.is_some() && {
1131            if let Some(content_str) = content {
1132                let content_lines: Vec<&str> = content_str.lines().collect();
1133                let max_content_width = content_lines
1134                    .iter()
1135                    .map(|line| line.len())
1136                    .max()
1137                    .unwrap_or(0);
1138                let component_dims = ComponentDimensions::new(*bounds);
1139                let content_bounds = component_dims.content_bounds();
1140                let viewable_width = content_bounds.width();
1141                max_content_width > viewable_width
1142            } else {
1143                false
1144            }
1145        };
1146
1147        // Draw bottom border
1148        if has_horizontal_scrollbar {
1149            // Skip middle section for horizontal scrollbar
1150            draw_horizontal_line(
1151                bounds.bottom(),
1152                bounds.left(),
1153                ComponentDimensions::new(*bounds)
1154                    .horizontal_scrollbar_track_bounds()
1155                    .x1,
1156                border_color,
1157                bg_color,
1158                buffer,
1159            );
1160            draw_horizontal_line(
1161                bounds.bottom(),
1162                ComponentDimensions::new(*bounds)
1163                    .horizontal_scrollbar_track_bounds()
1164                    .x2,
1165                bounds.right(),
1166                border_color,
1167                bg_color,
1168                buffer,
1169            );
1170        } else {
1171            draw_horizontal_line(
1172                bounds.bottom(),
1173                bounds.left(),
1174                bounds.right(),
1175                border_color,
1176                bg_color,
1177                buffer,
1178            );
1179        }
1180
1181        // Draw left border
1182        draw_vertical_line(
1183            bounds.left(),
1184            ComponentDimensions::new(*bounds)
1185                .vertical_scrollbar_track_bounds()
1186                .y1,
1187            ComponentDimensions::new(*bounds)
1188                .vertical_scrollbar_track_bounds()
1189                .y2,
1190            border_color,
1191            bg_color,
1192            buffer,
1193        );
1194
1195        // Draw right border - skip if vertical scrollbars are drawn
1196        let has_vertical_scrollbar = content.is_some() && {
1197            if let Some(content_str) = content {
1198                let content_lines: Vec<&str> = content_str.lines().collect();
1199                let content_height = content_lines.len();
1200                let viewable_height = ComponentDimensions::new(*bounds).content_bounds().height();
1201                content_height > viewable_height
1202            } else {
1203                false
1204            }
1205        };
1206
1207        if !has_vertical_scrollbar {
1208            draw_vertical_line(
1209                bounds.right(),
1210                ComponentDimensions::new(*bounds)
1211                    .vertical_scrollbar_track_bounds()
1212                    .y1,
1213                ComponentDimensions::new(*bounds)
1214                    .vertical_scrollbar_track_bounds()
1215                    .y2,
1216                border_color,
1217                bg_color,
1218                buffer,
1219            );
1220        }
1221
1222        // Draw corners
1223        buffer.update(
1224            bounds.left(),
1225            bounds.top(),
1226            Cell {
1227                fg_color: border_color_code.clone(),
1228                bg_color: bg_color_code.clone(),
1229                ch: '┌',
1230            },
1231        );
1232        buffer.update(
1233            bounds.right(),
1234            bounds.top(),
1235            Cell {
1236                fg_color: border_color_code.clone(),
1237                bg_color: bg_color_code.clone(),
1238                ch: '┐',
1239            },
1240        );
1241        buffer.update(
1242            bounds.left(),
1243            bounds.bottom(),
1244            Cell {
1245                fg_color: border_color_code.clone(),
1246                bg_color: bg_color_code.clone(),
1247                ch: '└',
1248            },
1249        );
1250
1251        // Bottom-right corner: show resize knob when unlocked
1252        buffer.update(
1253            bounds.right(),
1254            bounds.bottom(),
1255            Cell {
1256                fg_color: border_color_code.clone(),
1257                bg_color: bg_color_code.clone(),
1258                ch: if locked { '┘' } else { '⋱' },
1259            },
1260        );
1261    }
1262
1263    /// Translate box-relative sensitive zones to absolute screen coordinates
1264    /// This handles centering, scroll offsets, and coordinate translation
1265    /// CRITICAL FIX: Updated to use BoxDimensions for proper coordinate translation
1266    pub fn translate_box_relative_zones_to_absolute(
1267        &self,
1268        box_relative_zones: &[SensitiveZone],
1269        bounds: &Bounds,
1270        content_width: usize,
1271        content_height: usize,
1272        viewable_width: usize,
1273        viewable_height: usize,
1274        horizontal_scroll: f64,
1275        vertical_scroll: f64,
1276        _is_wrapped: bool,
1277    ) -> Vec<SensitiveZone> {
1278        // Use the single canonical coordinate authority so zones land exactly where
1279        // content is rendered (correct border/tab/scrollbar handling for this box).
1280        let _ = (viewable_width, viewable_height);
1281        let dimensions = BoxDimensions::new(self.muxbox, bounds, content_width, content_height);
1282        let dimensions = BoxDimensions {
1283            horizontal_scroll,
1284            vertical_scroll,
1285            ..dimensions
1286        };
1287
1288        let mut translated_zones = Vec::new();
1289
1290        let (visible_left, visible_top, visible_right, visible_bottom) =
1291            dimensions.get_visible_inbox_region();
1292
1293        log::trace!(
1294            "TRANSLATE ZONES: content={}x{}, viewable={}x{}, scroll={}%x{}%, visible_region=({},{} to {},{})",
1295            content_width,
1296            content_height,
1297            viewable_width,
1298            viewable_height,
1299            horizontal_scroll,
1300            vertical_scroll,
1301            visible_left,
1302            visible_top,
1303            visible_right,
1304            visible_bottom
1305        );
1306
1307        // Last inbox cell that is currently visible in each direction. Zone
1308        // extents are clamped to this window so a zone never translates a cell
1309        // that is scrolled out (which would otherwise produce a usize::MAX
1310        // corner). This mirrors the renderer, which truncates content to the
1311        // viewable width/height.
1312        let max_visible_x = visible_right.saturating_sub(1);
1313        let max_visible_y = visible_bottom.saturating_sub(1);
1314
1315        for zone in box_relative_zones {
1316            // Skip zones whose start is scrolled out of view.
1317            if !dimensions.is_inbox_visible(zone.bounds.x1, zone.bounds.y1) {
1318                continue;
1319            }
1320
1321            let clamped_x2 = zone.bounds.x2.min(max_visible_x);
1322            let clamped_y2 = zone.bounds.y2.min(max_visible_y);
1323
1324            // Single canonical coordinate translation, shared with the renderer.
1325            let (absolute_x1, absolute_y1) =
1326                dimensions.inbox_to_screen(zone.bounds.x1, zone.bounds.y1);
1327            let (absolute_x2, absolute_y2) = dimensions.inbox_to_screen(clamped_x2, clamped_y2);
1328
1329            let translated_bounds = Bounds::new(absolute_x1, absolute_y1, absolute_x2, absolute_y2);
1330
1331            log::trace!(
1332                "TRANSLATE ZONE: '{}' inbox=({},{} to {},{}) -> screen=({},{} to {},{})",
1333                zone.content_id,
1334                zone.bounds.x1,
1335                zone.bounds.y1,
1336                zone.bounds.x2,
1337                zone.bounds.y2,
1338                absolute_x1,
1339                absolute_y1,
1340                absolute_x2,
1341                absolute_y2
1342            );
1343
1344            let mut translated_zone = zone.clone();
1345            translated_zone.bounds = translated_bounds;
1346            translated_zones.push(translated_zone);
1347        }
1348
1349        translated_zones
1350    }
1351
1352    /// Store sensitive zones for click detection
1353    /// CRITICAL FIX: Now stores zones in screen coordinates after proper translation
1354    pub fn store_translated_sensitive_zones(&mut self, zones: Vec<SensitiveZone>) {
1355        self.sensitive_zones = zones;
1356        log::trace!(
1357            "STORE ZONES: Stored {} screen-coordinate sensitive zones for muxbox '{}'",
1358            self.sensitive_zones.len(),
1359            self.muxbox.id
1360        );
1361    }
1362
1363    /// Get sensitive zones in absolute screen coordinates
1364    pub fn get_sensitive_zones(&self) -> &[SensitiveZone] {
1365        &self.sensitive_zones
1366    }
1367
1368    /// Translate inbox coordinates to screen coordinates
1369    /// Inbox coordinates: content-local (0,0 = top-left of content area)
1370    /// Screen coordinates: absolute terminal coordinates
1371    pub fn translate_inbox_to_screen_coordinates(
1372        &self,
1373        inbox_x: usize,
1374        inbox_y: usize,
1375        bounds: &Bounds,
1376        content_width: usize,
1377        content_height: usize,
1378        viewable_width: usize,
1379        viewable_height: usize,
1380        horizontal_scroll: f64,
1381        vertical_scroll: f64,
1382    ) -> (usize, usize) {
1383        // Calculate padding offsets
1384        let vertical_padding = (viewable_height.saturating_sub(content_height)) / 2;
1385        let horizontal_padding = (viewable_width.saturating_sub(content_width)) / 2;
1386
1387        // Calculate scroll offsets
1388        let horizontal_offset = if content_width > viewable_width {
1389            ((content_width - viewable_width + 3) as f64 * horizontal_scroll / 100.0).round()
1390                as usize
1391        } else {
1392            0
1393        };
1394
1395        let vertical_offset = if content_height > viewable_height {
1396            ((content_height - viewable_height) as f64 * vertical_scroll / 100.0).round() as usize
1397        } else {
1398            0
1399        };
1400
1401        // Forward translate: inbox coordinates -> screen coordinates
1402        let component_dims = ComponentDimensions::new(*bounds);
1403        let content_bounds = component_dims.content_bounds();
1404        let screen_x = (content_bounds.left() + 1 + horizontal_padding + inbox_x)
1405            .wrapping_sub(horizontal_offset);
1406        let screen_y =
1407            (content_bounds.top() + vertical_padding + inbox_y).wrapping_sub(vertical_offset);
1408
1409        (screen_x, screen_y)
1410    }
1411
1412    /// Translate screen coordinates to inbox coordinates
1413    /// Screen coordinates: absolute terminal coordinates
1414    /// Inbox coordinates: content-local (0,0 = top-left of content area)
1415
1416    /// Handle click using formalized coordinate system
1417    /// Returns Some(choice_index) if a choice was clicked, None otherwise
1418    pub fn handle_click_with_dimensions(
1419        &mut self,
1420        screen_x: usize,
1421        screen_y: usize,
1422        dimensions: &BoxDimensions,
1423    ) -> Option<usize> {
1424        // Use formalized coordinate translation
1425        if let Some((inbox_x, inbox_y)) = dimensions.screen_to_inbox(screen_x, screen_y) {
1426            // Check sensitive zones in screen coordinates
1427            for zone in &self.sensitive_zones {
1428                if zone.bounds.contains_point(screen_x, screen_y) {
1429                    log::info!(
1430                        "CLICK: Screen ({},{}) -> Inbox ({},{}) on zone '{}'",
1431                        screen_x,
1432                        screen_y,
1433                        inbox_x,
1434                        inbox_y,
1435                        zone.content_id
1436                    );
1437
1438                    // Get clicked choice index if valid
1439                    if let Some(choice_index) =
1440                        self.get_clicked_choice_index(inbox_x, inbox_y, &zone.content_id)
1441                    {
1442                        return Some(choice_index);
1443                    }
1444                }
1445            }
1446        }
1447        None
1448    }
1449
1450    /// Handle mouse move using formalized coordinate system
1451    pub fn handle_mouse_move_with_dimensions(
1452        &mut self,
1453        screen_x: usize,
1454        screen_y: usize,
1455        dimensions: &BoxDimensions,
1456    ) -> bool {
1457        if let Some((inbox_x, inbox_y)) = dimensions.screen_to_inbox(screen_x, screen_y) {
1458            log::info!(
1459                "MOUSE_MOVE: Screen ({},{}) -> Inbox ({},{})",
1460                screen_x,
1461                screen_y,
1462                inbox_x,
1463                inbox_y
1464            );
1465
1466            // TODO: Create ContentEvent with inbox coordinates and pass to RenderableContent
1467            // let event = ContentEvent::new_mouse_move(None, (inbox_x, inbox_y), None);
1468            // let result = renderable_content.handle_event(&event);
1469
1470            return true;
1471        }
1472        false
1473    }
1474
1475    /// Handle mouse hover using formalized coordinate system
1476    pub fn handle_mouse_hover_with_dimensions(
1477        &mut self,
1478        screen_x: usize,
1479        screen_y: usize,
1480        dimensions: &BoxDimensions,
1481    ) -> bool {
1482        // Check sensitive zones using screen coordinates
1483        for zone in &self.sensitive_zones {
1484            if zone.bounds.contains_point(screen_x, screen_y) {
1485                if let Some((inbox_x, inbox_y)) = dimensions.screen_to_inbox(screen_x, screen_y) {
1486                    log::info!(
1487                        "HOVER: Screen ({},{}) -> Inbox ({},{}) on zone '{}'",
1488                        screen_x,
1489                        screen_y,
1490                        inbox_x,
1491                        inbox_y,
1492                        zone.content_id
1493                    );
1494
1495                    // TODO: Create ContentEvent with inbox coordinates
1496                    // let event = ContentEvent::new_hover((inbox_x, inbox_y), Some(zone.content_id.clone()));
1497                    // let result = renderable_content.handle_event(&event);
1498
1499                    return true;
1500                }
1501            }
1502        }
1503        false
1504    }
1505
1506    /// Handle mouse drag using formalized coordinate system
1507    pub fn handle_mouse_drag_with_dimensions(
1508        &mut self,
1509        from_screen_x: usize,
1510        from_screen_y: usize,
1511        to_screen_x: usize,
1512        to_screen_y: usize,
1513        dimensions: &BoxDimensions,
1514    ) -> bool {
1515        // Translate both coordinates to inbox coordinates
1516        if let (Some((from_inbox_x, from_inbox_y)), Some((to_inbox_x, to_inbox_y))) = (
1517            dimensions.screen_to_inbox(from_screen_x, from_screen_y),
1518            dimensions.screen_to_inbox(to_screen_x, to_screen_y),
1519        ) {
1520            log::info!(
1521                "DRAG: Screen ({},{}) -> ({},{}) = Inbox ({},{}) -> ({},{})",
1522                from_screen_x,
1523                from_screen_y,
1524                to_screen_x,
1525                to_screen_y,
1526                from_inbox_x,
1527                from_inbox_y,
1528                to_inbox_x,
1529                to_inbox_y
1530            );
1531
1532            // TODO: Create ContentEvent with inbox coordinates
1533            // let event = ContentEvent::new_mouse_drag((from_inbox_x, from_inbox_y), (to_inbox_x, to_inbox_y), MouseButton::Left, None);
1534            // let result = renderable_content.handle_event(&event);
1535
1536            return true;
1537        }
1538        false
1539    }
1540
1541    /// Get the clicked choice index if a valid choice was clicked
1542    pub fn get_clicked_choice_index(
1543        &self,
1544        inbox_x: usize,
1545        inbox_y: usize,
1546        zone_id: &str,
1547    ) -> Option<usize> {
1548        use crate::components::choice_menu::ChoiceMenu;
1549        use crate::components::renderable_content::ContentEvent;
1550
1551        // Get the current streams to determine content type
1552        let streams = &self.muxbox.streams;
1553
1554        // Check if this is choice content
1555        if let Some(stream) = streams
1556            .values()
1557            .find(|s| matches!(s.stream_type, crate::model::common::StreamType::Choices))
1558        {
1559            let choices = stream.get_choices();
1560            let mut choice_menu =
1561                ChoiceMenu::new(format!("{}_choice_menu", self.component_id), choices)
1562                    .with_selection(self.muxbox.selected_choice_index())
1563                    .with_focus(self.muxbox.focused_choice_index());
1564
1565            // Create click event with inbox coordinates
1566            let event =
1567                ContentEvent::new_click(Some((inbox_x, inbox_y)), Some(zone_id.to_string()));
1568
1569            log::info!(
1570                "CLICK EVENT: Created event for zone '{}' at inbox coords ({},{})",
1571                zone_id,
1572                inbox_x,
1573                inbox_y
1574            );
1575
1576            // Pass event to choice menu to validate the click
1577            let result = choice_menu.handle_event(&event);
1578
1579            // If event was handled, parse and return the choice index
1580            if matches!(
1581                result,
1582                crate::components::renderable_content::EventResult::Handled
1583            ) {
1584                if let Some(index_str) = zone_id.strip_prefix("choice_") {
1585                    if let Ok(choice_index) = index_str.parse::<usize>() {
1586                        log::info!("CLICK EVENT: Valid choice {} clicked", choice_index);
1587                        return Some(choice_index);
1588                    }
1589                }
1590            }
1591        }
1592
1593        None
1594    }
1595
1596    /// Render choices with hover state support
1597    /// This method replaces the generic content rendering for choices
1598    /// and applies appropriate colors based on choice state (selected > hovered > normal)
1599    fn render_choices_with_hover_states(
1600        &self,
1601        bounds: &Bounds,
1602        choices: &[Choice],
1603        menu_fg_color: &Option<String>,
1604        menu_bg_color: &Option<String>,
1605        selected_menu_fg_color: &Option<String>,
1606        selected_menu_bg_color: &Option<String>,
1607        highlighted_menu_fg_color: &Option<String>,
1608        highlighted_menu_bg_color: &Option<String>,
1609        border_color: &Option<String>,
1610        parent_bg_color: &Option<String>,
1611        _overflow_behavior: UnifiedOverflowBehavior,
1612        _horizontal_scroll: usize,
1613        _vertical_scroll: usize,
1614        buffer: &mut ScreenBuffer,
1615    ) -> bool {
1616        use crate::components::choice_menu::ChoiceMenu;
1617
1618        // Build the same formatted lines (with selection/focus indicator prefix)
1619        // that the sensitive-zone generation uses, so rendered glyphs and hit
1620        // regions are derived from identical content.
1621        let choice_menu = ChoiceMenu::new(format!("{}_choice_menu", self.component_id), choices)
1622            .with_selection(self.muxbox.selected_choice_index())
1623            .with_focus(self.muxbox.focused_choice_index());
1624        let choice_lines = choice_menu.generate_choice_lines();
1625
1626        let max_content_width = choice_lines
1627            .iter()
1628            .map(|line| line.chars().count())
1629            .max()
1630            .unwrap_or(0);
1631        let content_height = choice_lines.len();
1632
1633        // Single canonical coordinate authority — identical to the one used to
1634        // translate sensitive zones, so render position ≡ hit position.
1635        let dims = BoxDimensions::new(self.muxbox, bounds, max_content_width, content_height);
1636        let viewable_width = dims.viewable_width;
1637        let viewable_height = dims.viewable_height;
1638
1639        let needs_horizontal_scrollbar = max_content_width > viewable_width;
1640        let needs_vertical_scrollbar = content_height > viewable_height;
1641
1642        // Visible scroll window in content (inbox) coordinates.
1643        let (vis_left, vis_top, _vis_right, vis_bottom) = dims.get_visible_inbox_region();
1644        let last_row = vis_bottom.min(choice_lines.len());
1645
1646        for choice_index in vis_top..last_row {
1647            let choice = &choices[choice_index];
1648
1649            // Determine colors based on choice state (priority: selected > hovered > normal)
1650            let (fg_color, bg_color) = if choice.selected {
1651                (selected_menu_fg_color, selected_menu_bg_color)
1652            } else if choice.hovered {
1653                (highlighted_menu_fg_color, highlighted_menu_bg_color)
1654            } else {
1655                (menu_fg_color, menu_bg_color)
1656            };
1657
1658            // Screen position of the first visible cell of this choice row, via the
1659            // shared mapping. Skipped if scrolled out of the viewable window.
1660            let (screen_x, screen_y) = dims.inbox_to_screen(vis_left, choice_index);
1661            if screen_x == usize::MAX {
1662                continue;
1663            }
1664
1665            let visible_line: String = choice_lines[choice_index]
1666                .chars()
1667                .skip(vis_left)
1668                .take(viewable_width)
1669                .collect();
1670
1671            print_with_color_and_background_at(
1672                screen_y,
1673                screen_x,
1674                fg_color,
1675                bg_color,
1676                &visible_line,
1677                buffer,
1678            );
1679        }
1680
1681        // Draw scrollbars if needed
1682        let mut scrollbars_drawn = false;
1683
1684        if needs_vertical_scrollbar {
1685            use crate::components::vertical_scrollbar::VerticalScrollbar;
1686            let vertical_scrollbar = VerticalScrollbar::new("choices".to_string());
1687            vertical_scrollbar.draw(
1688                bounds,
1689                content_height,
1690                viewable_height,
1691                dims.vertical_scroll,
1692                border_color,
1693                parent_bg_color,
1694                buffer,
1695            );
1696            scrollbars_drawn = true;
1697        }
1698
1699        if needs_horizontal_scrollbar {
1700            use crate::components::horizontal_scrollbar::HorizontalScrollbar;
1701            let horizontal_scrollbar = HorizontalScrollbar::new("choices".to_string());
1702            horizontal_scrollbar.draw(
1703                bounds,
1704                max_content_width,
1705                viewable_width,
1706                dims.horizontal_scroll,
1707                border_color,
1708                parent_bg_color,
1709                buffer,
1710            );
1711            scrollbars_drawn = true;
1712        }
1713
1714        scrollbars_drawn
1715    }
1716}