1use std::io::{self, Write};
2
3use crossterm::event::KeyEvent;
4
5use crate::tui::Component;
6use crate::tui::container::Container;
7use crate::tui::overlay::{OverlayAnchor, OverlayEntry, OverlayLayout, OverlayOptions, SizeValue};
8use crate::tui::screen::Screen;
9use crate::tui::util::{
10 extract_segments, normalize_terminal_output, slice_by_column, visible_width,
11};
12
13const SEGMENT_RESET: &str = "\x1b[0m\x1b]8;;\x07";
15
16pub const CURSOR_MARKER: &str = "\x1b_pi:c\x07";
18
19pub struct TUI {
28 pub root: Container,
32
33 screen: Screen,
35 width: usize,
37 height: usize,
38 dirty: bool,
40
41 overlay_stack: Vec<OverlayEntry>,
43 next_overlay_id: u64,
44 focus_order_counter: u64,
45
46 focused_component: Option<usize>,
49}
50
51impl TUI {
52 pub fn new() -> Self {
53 Self {
54 root: Container::new(),
55 screen: Screen::new(),
56 width: 80,
57 height: 24,
58 dirty: true,
59 overlay_stack: Vec::new(),
60 next_overlay_id: 0,
61 focus_order_counter: 0,
62 focused_component: None,
63 }
64 }
65
66 pub fn screen_mut(&mut self) -> &mut Screen {
69 &mut self.screen
70 }
71
72 pub fn full_redraw_count(&self) -> usize {
73 self.screen.full_redraw_count()
74 }
75
76 pub fn set_clear_on_shrink(&mut self, enabled: bool) {
77 self.screen.set_clear_on_shrink(enabled);
78 }
79
80 pub fn set_dimensions(&mut self, width: usize, height: usize) {
81 self.width = width;
82 self.height = height;
83 }
84
85 pub fn get_dimensions(&self) -> (usize, usize) {
86 (self.width, self.height)
87 }
88
89 pub fn request_render(&mut self) {
90 self.dirty = true;
91 }
92
93 pub fn is_dirty(&self) -> bool {
94 self.dirty
95 }
96
97 pub fn show_overlay(&mut self, component: Box<dyn Component>, options: OverlayOptions) -> u64 {
102 let id = self.next_overlay_id;
103 self.next_overlay_id += 1;
104
105 let is_capturing = !options.non_capturing;
106
107 let entry = OverlayEntry {
108 component,
109 options,
110 pre_focus: self.focused_component,
111 hidden: false,
112 focus_order: self.focus_order_counter,
113 id,
114 };
115 self.focus_order_counter += 1;
116 self.overlay_stack.push(entry);
117
118 if is_capturing {
120 let idx = self.overlay_stack.len() - 1;
121 self.focused_component = Some(idx);
122 }
123
124 self.dirty = true;
125 id
126 }
127
128 pub fn hide_overlay(&mut self, id: u64) {
130 let pos = self.overlay_stack.iter().position(|e| e.id == id);
131 if let Some(idx) = pos {
132 let entry = self.overlay_stack.remove(idx);
133
134 if self.focused_component == Some(idx) {
136 let restored = self.topmost_visible_overlay();
137 self.focused_component = restored.or(entry.pre_focus);
138 } else if let Some(focused) = self.focused_component {
139 if focused > idx {
141 self.focused_component = Some(focused - 1);
142 }
143 }
144
145 self.dirty = true;
146 }
147 }
148
149 pub fn pop_overlay(&mut self) {
151 if let Some(entry) = self.overlay_stack.pop() {
152 if self.focused_component == Some(self.overlay_stack.len()) {
153 let restored = self.topmost_visible_overlay();
154 self.focused_component = restored.or(entry.pre_focus);
155 }
156 self.dirty = true;
157 }
158 }
159
160 pub fn has_overlays(&self) -> bool {
162 self.overlay_stack.iter().any(|e| !e.hidden)
163 }
164
165 fn topmost_visible_overlay(&self) -> Option<usize> {
167 self.overlay_stack
168 .iter()
169 .enumerate()
170 .rev()
171 .find(|(_, e)| !e.hidden && !e.options.non_capturing)
172 .map(|(i, _)| i)
173 }
174
175 pub fn set_focus(&mut self, overlay_idx: Option<usize>) {
179 self.focused_component = overlay_idx;
180 }
181
182 pub fn focused_overlay(&self) -> Option<usize> {
184 self.focused_component
185 }
186
187 pub fn route_input(&mut self, key: &KeyEvent) -> bool {
195 if let Some(idx) = self.focused_component
197 && let Some(entry) = self.overlay_stack.get_mut(idx)
198 && !entry.hidden
199 && entry.component.handle_input(key)
200 {
201 return true;
202 }
203
204 for entry in self.overlay_stack.iter_mut().rev() {
206 if !entry.hidden && entry.options.non_capturing && entry.component.handle_input(key) {
207 return true;
208 }
209 }
210
211 false
212 }
213
214 pub fn route_paste(&mut self, text: &str) -> bool {
217 if let Some(idx) = self.focused_component
218 && let Some(entry) = self.overlay_stack.get_mut(idx)
219 && !entry.hidden
220 {
221 entry.component.handle_paste(text);
222 return true;
223 }
224 false
225 }
226
227 pub fn render(
237 &mut self,
238 width: usize,
239 height: usize,
240 writer: &mut dyn Write,
241 ) -> io::Result<()> {
242 self.width = width;
243 self.height = height;
244
245 let mut lines = self.root.render(width);
249
250 if !self.overlay_stack.is_empty() {
252 lines = self.composite_overlays(&lines, width, height);
253 }
254
255 let cursor_pos = self.extract_cursor_position(&mut lines, height);
257
258 for line in lines.iter_mut() {
260 *line = normalize_terminal_output(line);
261 }
262
263 self.screen
265 .render(lines.clone(), width as u16, height as u16, writer)?;
266
267 if let Some((row, col)) = cursor_pos {
269 self.position_hard_cursor(row, col, writer)?;
270 self.screen.set_hardware_cursor_row(row);
272 }
273
274 self.dirty = false;
275 Ok(())
276 }
277
278 pub fn finalize(&mut self, writer: &mut dyn Write) -> io::Result<()> {
280 self.screen.finalize(writer)
281 }
282
283 fn composite_overlays(
291 &self,
292 base_lines: &[String],
293 term_width: usize,
294 term_height: usize,
295 ) -> Vec<String> {
296 let mut result = base_lines.to_vec();
297
298 let mut visible: Vec<&OverlayEntry> =
300 self.overlay_stack.iter().filter(|e| !e.hidden).collect();
301 visible.sort_by_key(|e| e.focus_order);
302
303 let mut min_lines_needed = result.len();
304
305 struct RenderedOverlay {
307 overlay_lines: Vec<String>,
308 layout: OverlayLayout,
309 }
310
311 let rendered: Vec<RenderedOverlay> = visible
312 .iter()
313 .map(|entry| {
314 let layout =
316 self.resolve_overlay_layout(&entry.options, 0, term_width, term_height);
317
318 let mut overlay_lines = entry.component.render(layout.width);
320
321 let overlay_height = if let Some(max_h) = layout.max_height {
323 overlay_lines.truncate(max_h);
324 overlay_lines.len()
325 } else {
326 overlay_lines.len()
327 };
328
329 let layout = self.resolve_overlay_layout(
331 &entry.options,
332 overlay_height,
333 term_width,
334 term_height,
335 );
336
337 min_lines_needed = min_lines_needed.max(layout.row + overlay_lines.len());
338
339 RenderedOverlay {
340 overlay_lines,
341 layout,
342 }
343 })
344 .collect();
345
346 let working_height = result.len().max(term_height).max(min_lines_needed);
348 while result.len() < working_height {
349 result.push(String::new());
350 }
351
352 let viewport_start = working_height.saturating_sub(term_height);
353
354 for ro in &rendered {
356 for (i, overlay_line) in ro.overlay_lines.iter().enumerate() {
357 let idx = viewport_start + ro.layout.row + i;
358 if idx < result.len() {
359 let truncated = if visible_width(overlay_line) > ro.layout.width {
360 slice_by_column(overlay_line, 0, ro.layout.width)
361 } else {
362 overlay_line.clone()
363 };
364 result[idx] = self.composite_line_at(
365 &result[idx],
366 &truncated,
367 ro.layout.col,
368 ro.layout.width,
369 term_width,
370 );
371 }
372 }
373 }
374
375 result
376 }
377
378 fn composite_line_at(
381 &self,
382 base_line: &str,
383 overlay_line: &str,
384 start_col: usize,
385 overlay_width: usize,
386 total_width: usize,
387 ) -> String {
388 let after_start = start_col + overlay_width;
389
390 let (before, before_width, after, after_width) = extract_segments(
392 base_line,
393 start_col,
394 after_start,
395 total_width.saturating_sub(after_start),
396 true,
397 );
398
399 let overlay = slice_by_column(overlay_line, 0, overlay_width);
401 let overlay_vis = visible_width(&overlay);
402
403 let before_pad = start_col.saturating_sub(before_width);
405 let overlay_pad = overlay_width.saturating_sub(overlay_vis);
406 let actual_before_width = before_width.max(start_col);
407 let actual_overlay_width = overlay_vis.max(overlay_width);
408 let after_target = total_width.saturating_sub(actual_before_width + actual_overlay_width);
409 let after_pad = after_target.saturating_sub(after_width);
410
411 let mut result = String::new();
413 result.push_str(&before);
414 result.push_str(&" ".repeat(before_pad));
415 result.push_str(SEGMENT_RESET);
416 result.push_str(&overlay);
417 result.push_str(&" ".repeat(overlay_pad));
418 result.push_str(SEGMENT_RESET);
419 result.push_str(&after);
420 result.push_str(&" ".repeat(after_pad));
421
422 let rw = visible_width(&result);
424 if rw > total_width {
425 result = slice_by_column(&result, 0, total_width);
426 }
427
428 result
429 }
430
431 fn resolve_overlay_layout(
433 &self,
434 options: &OverlayOptions,
435 overlay_height: usize,
436 term_width: usize,
437 term_height: usize,
438 ) -> OverlayLayout {
439 let margin = options.margin.unwrap_or_default();
441 let margin_top = margin.top;
442 let margin_right = margin.right;
443 let margin_bottom = margin.bottom;
444 let margin_left = margin.left;
445
446 let avail_width = (term_width - margin_left - margin_right).max(1);
447 let avail_height = (term_height - margin_top - margin_bottom).max(1);
448
449 let width = options
451 .width
452 .map(|sv| sv.resolve(term_width))
453 .unwrap_or_else(|| 80.min(avail_width));
454 let width = options.min_width.map(|mw| width.max(mw)).unwrap_or(width);
455 let width = width.max(1).min(avail_width);
456
457 let max_height = options.max_height.map(|sv| sv.resolve(term_height));
459 let max_height = max_height.map(|mh| mh.max(1).min(avail_height));
460
461 let effective_height = match max_height {
463 Some(mh) => overlay_height.min(mh),
464 None => overlay_height,
465 };
466
467 let row = if let Some(ref row_sv) = options.row {
469 match row_sv {
470 SizeValue::Absolute(r) => *r,
471 SizeValue::Percent(p) => {
472 let max_row = avail_height - effective_height;
473 margin_top + ((max_row as f64 * p / 100.0).floor() as usize)
474 }
475 }
476 } else {
477 let anchor = options.anchor.unwrap_or_default();
478 self.resolve_anchor_row(anchor, effective_height, avail_height, margin_top)
479 };
480
481 let col = if let Some(ref col_sv) = options.col {
482 match col_sv {
483 SizeValue::Absolute(c) => *c,
484 SizeValue::Percent(p) => {
485 let max_col = avail_width - width;
486 margin_left + ((max_col as f64 * p / 100.0).floor() as usize)
487 }
488 }
489 } else {
490 let anchor = options.anchor.unwrap_or_default();
491 self.resolve_anchor_col(anchor, width, avail_width, margin_left)
492 };
493
494 let row = (row as isize + options.offset_y.unwrap_or(0)) as usize;
496 let col = (col as isize + options.offset_x.unwrap_or(0)) as usize;
497
498 let row = row
500 .max(margin_top)
501 .min(term_height - margin_bottom - effective_height);
502 let col = col.max(margin_left).min(term_width - margin_right - width);
503
504 OverlayLayout {
505 width,
506 row,
507 col,
508 max_height,
509 }
510 }
511
512 fn resolve_anchor_row(
513 &self,
514 anchor: OverlayAnchor,
515 height: usize,
516 avail_height: usize,
517 margin_top: usize,
518 ) -> usize {
519 match anchor {
520 OverlayAnchor::TopLeft | OverlayAnchor::TopCenter | OverlayAnchor::TopRight => {
521 margin_top
522 }
523 OverlayAnchor::BottomLeft
524 | OverlayAnchor::BottomCenter
525 | OverlayAnchor::BottomRight => margin_top + avail_height - height,
526 OverlayAnchor::LeftCenter | OverlayAnchor::Center | OverlayAnchor::RightCenter => {
527 margin_top + (avail_height - height) / 2
528 }
529 }
530 }
531
532 fn resolve_anchor_col(
533 &self,
534 anchor: OverlayAnchor,
535 width: usize,
536 avail_width: usize,
537 margin_left: usize,
538 ) -> usize {
539 match anchor {
540 OverlayAnchor::TopLeft | OverlayAnchor::LeftCenter | OverlayAnchor::BottomLeft => {
541 margin_left
542 }
543 OverlayAnchor::TopRight | OverlayAnchor::RightCenter | OverlayAnchor::BottomRight => {
544 margin_left + avail_width - width
545 }
546 OverlayAnchor::TopCenter | OverlayAnchor::Center | OverlayAnchor::BottomCenter => {
547 margin_left + (avail_width - width) / 2
548 }
549 }
550 }
551
552 fn extract_cursor_position(
556 &self,
557 lines: &mut [String],
558 height: usize,
559 ) -> Option<(usize, usize)> {
560 let viewport_top = lines.len().saturating_sub(height);
561 for row in (viewport_top..lines.len()).rev() {
562 let line = &lines[row];
563 if let Some(marker_idx) = line.find(CURSOR_MARKER) {
564 let col = visible_width(&line[..marker_idx]);
565 let before = &line[..marker_idx];
567 let after = &line[marker_idx + CURSOR_MARKER.len()..];
568 lines[row] = format!("{}{}", before, after);
569 return Some((row, col));
570 }
571 }
572 None
573 }
574
575 fn position_hard_cursor(
577 &self,
578 row: usize,
579 col: usize,
580 writer: &mut dyn Write,
581 ) -> io::Result<()> {
582 let viewport_top = self.screen.prev_viewport_top();
584 if row < viewport_top {
585 return Ok(());
586 }
587 let screen_row = row - viewport_top;
588 if screen_row >= self.height {
589 return Ok(());
590 }
591 let screen_col = col.min(self.width - 1);
592
593 write!(writer, "\x1b[{};{}H", screen_row + 1, screen_col + 1)?;
595 writer.flush()?;
596 Ok(())
597 }
598}
599
600impl Default for TUI {
601 fn default() -> Self {
602 Self::new()
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609 use crate::tui::overlay::{OverlayMargin, OverlayOptions};
610
611 struct TestComponent {
612 text: String,
613 }
614
615 impl Component for TestComponent {
616 fn render(&self, _width: usize) -> Vec<String> {
617 vec![self.text.clone()]
618 }
619
620 fn handle_input(&mut self, _key: &crossterm::event::KeyEvent) -> bool {
621 false
622 }
623
624 fn invalidate(&mut self) {}
625 }
626
627 #[test]
628 fn test_tui_new() {
629 let tui = TUI::new();
630 assert!(!tui.has_overlays());
631 assert_eq!(tui.full_redraw_count(), 0);
632 }
633
634 #[test]
635 fn test_show_and_hide_overlay() {
636 let mut tui = TUI::new();
637 let id = tui.show_overlay(
638 Box::new(TestComponent {
639 text: "overlay".into(),
640 }),
641 OverlayOptions::default(),
642 );
643 assert!(tui.has_overlays());
644 tui.hide_overlay(id);
645 assert!(!tui.has_overlays());
646 }
647
648 #[test]
649 fn test_pop_overlay() {
650 let mut tui = TUI::new();
651 tui.show_overlay(
652 Box::new(TestComponent { text: "a".into() }),
653 OverlayOptions::default(),
654 );
655 tui.show_overlay(
656 Box::new(TestComponent { text: "b".into() }),
657 OverlayOptions::default(),
658 );
659 assert!(tui.has_overlays());
660 tui.pop_overlay();
661 assert!(tui.has_overlays()); tui.pop_overlay();
663 assert!(!tui.has_overlays());
664 }
665
666 #[test]
667 fn test_cursor_marker_extraction() {
668 let tui = TUI::new();
669 let mut lines = vec![
670 "line 1".to_string(),
671 format!("before{}after", CURSOR_MARKER),
672 "line 3".to_string(),
673 ];
674 let pos = tui.extract_cursor_position(&mut lines, 10);
675 assert!(pos.is_some());
676 let (row, col) = pos.unwrap();
677 assert_eq!(row, 1);
678 assert_eq!(col, 6); assert_eq!(lines[1], "beforeafter");
680 assert!(!lines[1].contains(CURSOR_MARKER));
681 }
682
683 #[test]
684 fn test_cursor_marker_outside_viewport() {
685 let tui = TUI::new();
686 let mut lines = vec![
688 format!("{}marker", CURSOR_MARKER),
689 "b".to_string(),
690 "c".to_string(),
691 "d".to_string(),
692 "e".to_string(),
693 ];
694 let pos = tui.extract_cursor_position(&mut lines, 2);
695 assert!(pos.is_none()); }
697
698 #[test]
699 fn test_composite_line_at_basic() {
700 let tui = TUI::new();
701 let result = tui.composite_line_at("hello world", "!!", 6, 2, 13);
702 assert_eq!(visible_width(&result), 13);
703 assert!(result.contains("!!"));
704 }
705
706 #[test]
707 fn test_composite_line_at_no_overflow() {
708 let tui = TUI::new();
709 let result = tui.composite_line_at("abcdefghij", "12345", 2, 5, 12);
710 assert_eq!(visible_width(&result), 12);
711 }
712
713 #[test]
714 fn test_overlay_layout_center_default() {
715 let tui = TUI::new();
716 let layout = tui.resolve_overlay_layout(&OverlayOptions::default(), 5, 80, 24);
717 assert_eq!(layout.width, 80);
719 assert_eq!(layout.row, 9);
721 assert_eq!(layout.col, 0);
722 assert!(layout.max_height.is_none());
723 }
724
725 #[test]
726 fn test_overlay_layout_percent_width() {
727 let tui = TUI::new();
728 let opts = OverlayOptions {
729 width: Some(SizeValue::Percent(50.0)),
730 ..Default::default()
731 };
732 let layout = tui.resolve_overlay_layout(&opts, 5, 80, 24);
733 assert_eq!(layout.width, 40); }
735
736 #[test]
737 fn test_overlay_layout_margin() {
738 let tui = TUI::new();
739 let opts = OverlayOptions {
740 margin: Some(OverlayMargin {
741 top: 2,
742 right: 2,
743 bottom: 2,
744 left: 2,
745 }),
746 anchor: Some(OverlayAnchor::TopLeft),
747 ..Default::default()
748 };
749 let layout = tui.resolve_overlay_layout(&opts, 5, 80, 24);
750 assert_eq!(layout.row, 2);
751 assert_eq!(layout.col, 2);
752 }
753}