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#[derive(Debug, Clone)]
25pub struct BoxDimensions {
26 pub total_bounds: Bounds,
28
29 pub content_bounds: Bounds,
31
32 pub viewable_width: usize,
34 pub viewable_height: usize,
35
36 pub content_width: usize,
38 pub content_height: usize,
39
40 pub horizontal_scroll: f64,
42 pub vertical_scroll: f64,
43
44 pub border_thickness: usize,
46
47 pub tab_height: usize,
49
50 pub vertical_scrollbar_width: usize,
52 pub horizontal_scrollbar_height: usize,
53}
54
55impl BoxDimensions {
56 pub fn new(
66 muxbox: &crate::model::muxbox::MuxBox,
67 bounds: &Bounds,
68 content_width: usize,
69 content_height: usize,
70 ) -> Self {
71 let border_thickness = 1;
78 let tab_height = 0;
79
80 let vertical_scrollbar_width = 1;
83 let horizontal_scrollbar_height = 1;
84
85 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 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 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 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 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 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 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 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 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 pub fn calculate_from_muxbox(muxbox: &MuxBox, total_bounds: Bounds) -> Self {
225 let border_thickness = if muxbox.border_color.is_some() { 1 } else { 0 };
227
228 let tab_height = if muxbox.streams.len() > 1 { 1 } else { 0 };
230
231 let vertical_scrollbar_width = 1;
233 let horizontal_scrollbar_height = 1;
234
235 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 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 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 fn get_content_dimensions(
273 muxbox: &MuxBox,
274 default_width: usize,
275 default_height: usize,
276 ) -> (usize, usize) {
277 let content_lines = if let Some(selected_stream) = muxbox.get_selected_stream() {
279 selected_stream.content.clone()
280 } else {
281 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 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 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, vertical_offset, horizontal_offset + self.viewable_width, vertical_offset + self.viewable_height, )
331 }
332}
333
334#[derive(Debug, Clone, PartialEq)]
336pub enum UnifiedOverflowBehavior {
337 Scroll,
339 Wrap,
341 Fill(char),
343 CrossOut,
345 Removed,
347 Clip,
349}
350
351impl UnifiedOverflowBehavior {
352 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
365pub struct BoxRenderer<'a> {
373 muxbox: &'a MuxBox,
375 component_id: String,
377 dimensions: Option<BoxDimensions>,
379 sensitive_zones: Vec<SensitiveZone>,
381}
382
383impl<'a> BoxRenderer<'a> {
384 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 pub fn initialize_dimensions(&mut self, bounds: Bounds) {
396 self.dimensions = Some(BoxDimensions::calculate_from_muxbox(self.muxbox, bounds));
397 }
398
399 pub fn get_dimensions(&self) -> Option<&BoxDimensions> {
401 self.dimensions.as_ref()
402 }
403
404 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 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 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 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 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 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, color: "blue".to_string(),
459 show_title: false, 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 Some(chart.generate_with_muxbox_title(self.muxbox.title.as_deref()))
472 } else {
473 None
474 }
475 }
476
477 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 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 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 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 fill_muxbox(bounds, border, &bg_color, &None, fill_char, buffer);
537
538 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 let tab_labels = self.muxbox.get_tab_labels();
546 let tab_close_buttons = self.muxbox.get_tab_close_buttons();
547
548 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 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 let chart_content = self.generate_chart_content(bounds);
622
623 let (should_render_choices, content_str) = if chart_content.is_some() {
625 (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 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 let mut _overflowing = false;
671 let mut scrollbars_drawn = false;
672
673 let screen_bounds = crate::utils::screen_bounds();
675 let bounds = bounds
676 .intersection(&screen_bounds)
677 .unwrap_or_else(|| bounds.clone());
678
679 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 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 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 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 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, );
764
765 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 let rendered_content = if should_render_choices {
795 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 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 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 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 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 } else {
916 false
917 }
918 }
919 }
920
921 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 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 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 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 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 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 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 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 if has_horizontal_scrollbar {
1149 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_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 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 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 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 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 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 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 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 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 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 pub fn get_sensitive_zones(&self) -> &[SensitiveZone] {
1365 &self.sensitive_zones
1366 }
1367
1368 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 let vertical_padding = (viewable_height.saturating_sub(content_height)) / 2;
1385 let horizontal_padding = (viewable_width.saturating_sub(content_width)) / 2;
1386
1387 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 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 pub fn handle_click_with_dimensions(
1419 &mut self,
1420 screen_x: usize,
1421 screen_y: usize,
1422 dimensions: &BoxDimensions,
1423 ) -> Option<usize> {
1424 if let Some((inbox_x, inbox_y)) = dimensions.screen_to_inbox(screen_x, screen_y) {
1426 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 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 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 return true;
1471 }
1472 false
1473 }
1474
1475 pub fn handle_mouse_hover_with_dimensions(
1477 &mut self,
1478 screen_x: usize,
1479 screen_y: usize,
1480 dimensions: &BoxDimensions,
1481 ) -> bool {
1482 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 return true;
1500 }
1501 }
1502 }
1503 false
1504 }
1505
1506 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 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 return true;
1537 }
1538 false
1539 }
1540
1541 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 let streams = &self.muxbox.streams;
1553
1554 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 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 let result = choice_menu.handle_event(&event);
1578
1579 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 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 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 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 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 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 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 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}