use std::io::{self, Write};
use crossterm::event::KeyEvent;
use crate::tui::Component;
use crate::tui::container::Container;
use crate::tui::overlay::{OverlayAnchor, OverlayEntry, OverlayLayout, OverlayOptions, SizeValue};
use crate::tui::screen::Screen;
use crate::tui::util::{
extract_segments, normalize_terminal_output, slice_by_column, visible_width,
};
const SEGMENT_RESET: &str = "\x1b[0m\x1b]8;;\x07";
pub const CURSOR_MARKER: &str = "\x1b_pi:c\x07";
pub struct TUI {
pub root: Container,
screen: Screen,
width: usize,
height: usize,
dirty: bool,
overlay_stack: Vec<OverlayEntry>,
next_overlay_id: u64,
focus_order_counter: u64,
focused_component: Option<usize>,
}
impl TUI {
pub fn new() -> Self {
Self {
root: Container::new(),
screen: Screen::new(),
width: 80,
height: 24,
dirty: true,
overlay_stack: Vec::new(),
next_overlay_id: 0,
focus_order_counter: 0,
focused_component: None,
}
}
pub fn screen_mut(&mut self) -> &mut Screen {
&mut self.screen
}
pub fn full_redraw_count(&self) -> usize {
self.screen.full_redraw_count()
}
pub fn set_clear_on_shrink(&mut self, enabled: bool) {
self.screen.set_clear_on_shrink(enabled);
}
pub fn set_dimensions(&mut self, width: usize, height: usize) {
self.width = width;
self.height = height;
}
pub fn get_dimensions(&self) -> (usize, usize) {
(self.width, self.height)
}
pub fn request_render(&mut self) {
self.dirty = true;
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn show_overlay(&mut self, component: Box<dyn Component>, options: OverlayOptions) -> u64 {
let id = self.next_overlay_id;
self.next_overlay_id += 1;
let is_capturing = !options.non_capturing;
let entry = OverlayEntry {
component,
options,
pre_focus: self.focused_component,
hidden: false,
focus_order: self.focus_order_counter,
id,
};
self.focus_order_counter += 1;
self.overlay_stack.push(entry);
if is_capturing {
let idx = self.overlay_stack.len() - 1;
self.focused_component = Some(idx);
}
self.dirty = true;
id
}
pub fn hide_overlay(&mut self, id: u64) {
let pos = self.overlay_stack.iter().position(|e| e.id == id);
if let Some(idx) = pos {
let entry = self.overlay_stack.remove(idx);
if self.focused_component == Some(idx) {
let restored = self.topmost_visible_overlay();
self.focused_component = restored.or(entry.pre_focus);
} else if let Some(focused) = self.focused_component {
if focused > idx {
self.focused_component = Some(focused - 1);
}
}
self.dirty = true;
}
}
pub fn pop_overlay(&mut self) {
if let Some(entry) = self.overlay_stack.pop() {
if self.focused_component == Some(self.overlay_stack.len()) {
let restored = self.topmost_visible_overlay();
self.focused_component = restored.or(entry.pre_focus);
}
self.dirty = true;
}
}
pub fn has_overlays(&self) -> bool {
self.overlay_stack.iter().any(|e| !e.hidden)
}
fn topmost_visible_overlay(&self) -> Option<usize> {
self.overlay_stack
.iter()
.enumerate()
.rev()
.find(|(_, e)| !e.hidden && !e.options.non_capturing)
.map(|(i, _)| i)
}
pub fn set_focus(&mut self, overlay_idx: Option<usize>) {
self.focused_component = overlay_idx;
}
pub fn focused_overlay(&self) -> Option<usize> {
self.focused_component
}
pub fn route_input(&mut self, key: &KeyEvent) -> bool {
if let Some(idx) = self.focused_component
&& let Some(entry) = self.overlay_stack.get_mut(idx)
&& !entry.hidden
&& entry.component.handle_input(key)
{
return true;
}
for entry in self.overlay_stack.iter_mut().rev() {
if !entry.hidden && entry.options.non_capturing && entry.component.handle_input(key) {
return true;
}
}
false
}
pub fn route_paste(&mut self, text: &str) -> bool {
if let Some(idx) = self.focused_component
&& let Some(entry) = self.overlay_stack.get_mut(idx)
&& !entry.hidden
{
entry.component.handle_paste(text);
return true;
}
false
}
pub fn render(
&mut self,
width: usize,
height: usize,
writer: &mut dyn Write,
) -> io::Result<()> {
self.width = width;
self.height = height;
let mut lines = self.root.render(width);
if !self.overlay_stack.is_empty() {
lines = self.composite_overlays(&lines, width, height);
}
let cursor_pos = self.extract_cursor_position(&mut lines, height);
for line in lines.iter_mut() {
*line = normalize_terminal_output(line);
}
self.screen
.render(lines.clone(), width as u16, height as u16, writer)?;
if let Some((row, col)) = cursor_pos {
self.position_hard_cursor(row, col, writer)?;
self.screen.set_hardware_cursor_row(row);
}
self.dirty = false;
Ok(())
}
pub fn finalize(&mut self, writer: &mut dyn Write) -> io::Result<()> {
self.screen.finalize(writer)
}
fn composite_overlays(
&self,
base_lines: &[String],
term_width: usize,
term_height: usize,
) -> Vec<String> {
let mut result = base_lines.to_vec();
let mut visible: Vec<&OverlayEntry> =
self.overlay_stack.iter().filter(|e| !e.hidden).collect();
visible.sort_by_key(|e| e.focus_order);
let mut min_lines_needed = result.len();
struct RenderedOverlay {
overlay_lines: Vec<String>,
layout: OverlayLayout,
}
let rendered: Vec<RenderedOverlay> = visible
.iter()
.map(|entry| {
let layout =
self.resolve_overlay_layout(&entry.options, 0, term_width, term_height);
let mut overlay_lines = entry.component.render(layout.width);
let overlay_height = if let Some(max_h) = layout.max_height {
overlay_lines.truncate(max_h);
overlay_lines.len()
} else {
overlay_lines.len()
};
let layout = self.resolve_overlay_layout(
&entry.options,
overlay_height,
term_width,
term_height,
);
min_lines_needed = min_lines_needed.max(layout.row + overlay_lines.len());
RenderedOverlay {
overlay_lines,
layout,
}
})
.collect();
let working_height = result.len().max(term_height).max(min_lines_needed);
while result.len() < working_height {
result.push(String::new());
}
let viewport_start = working_height.saturating_sub(term_height);
for ro in &rendered {
for (i, overlay_line) in ro.overlay_lines.iter().enumerate() {
let idx = viewport_start + ro.layout.row + i;
if idx < result.len() {
let truncated = if visible_width(overlay_line) > ro.layout.width {
slice_by_column(overlay_line, 0, ro.layout.width)
} else {
overlay_line.clone()
};
result[idx] = self.composite_line_at(
&result[idx],
&truncated,
ro.layout.col,
ro.layout.width,
term_width,
);
}
}
}
result
}
fn composite_line_at(
&self,
base_line: &str,
overlay_line: &str,
start_col: usize,
overlay_width: usize,
total_width: usize,
) -> String {
let after_start = start_col + overlay_width;
let (before, before_width, after, after_width) = extract_segments(
base_line,
start_col,
after_start,
total_width.saturating_sub(after_start),
true,
);
let overlay = slice_by_column(overlay_line, 0, overlay_width);
let overlay_vis = visible_width(&overlay);
let before_pad = start_col.saturating_sub(before_width);
let overlay_pad = overlay_width.saturating_sub(overlay_vis);
let actual_before_width = before_width.max(start_col);
let actual_overlay_width = overlay_vis.max(overlay_width);
let after_target = total_width.saturating_sub(actual_before_width + actual_overlay_width);
let after_pad = after_target.saturating_sub(after_width);
let mut result = String::new();
result.push_str(&before);
result.push_str(&" ".repeat(before_pad));
result.push_str(SEGMENT_RESET);
result.push_str(&overlay);
result.push_str(&" ".repeat(overlay_pad));
result.push_str(SEGMENT_RESET);
result.push_str(&after);
result.push_str(&" ".repeat(after_pad));
let rw = visible_width(&result);
if rw > total_width {
result = slice_by_column(&result, 0, total_width);
}
result
}
fn resolve_overlay_layout(
&self,
options: &OverlayOptions,
overlay_height: usize,
term_width: usize,
term_height: usize,
) -> OverlayLayout {
let margin = options.margin.unwrap_or_default();
let margin_top = margin.top;
let margin_right = margin.right;
let margin_bottom = margin.bottom;
let margin_left = margin.left;
let avail_width = (term_width - margin_left - margin_right).max(1);
let avail_height = (term_height - margin_top - margin_bottom).max(1);
let width = options
.width
.map(|sv| sv.resolve(term_width))
.unwrap_or_else(|| 80.min(avail_width));
let width = options.min_width.map(|mw| width.max(mw)).unwrap_or(width);
let width = width.max(1).min(avail_width);
let max_height = options.max_height.map(|sv| sv.resolve(term_height));
let max_height = max_height.map(|mh| mh.max(1).min(avail_height));
let effective_height = match max_height {
Some(mh) => overlay_height.min(mh),
None => overlay_height,
};
let row = if let Some(ref row_sv) = options.row {
match row_sv {
SizeValue::Absolute(r) => *r,
SizeValue::Percent(p) => {
let max_row = avail_height - effective_height;
margin_top + ((max_row as f64 * p / 100.0).floor() as usize)
}
}
} else {
let anchor = options.anchor.unwrap_or_default();
self.resolve_anchor_row(anchor, effective_height, avail_height, margin_top)
};
let col = if let Some(ref col_sv) = options.col {
match col_sv {
SizeValue::Absolute(c) => *c,
SizeValue::Percent(p) => {
let max_col = avail_width - width;
margin_left + ((max_col as f64 * p / 100.0).floor() as usize)
}
}
} else {
let anchor = options.anchor.unwrap_or_default();
self.resolve_anchor_col(anchor, width, avail_width, margin_left)
};
let row = (row as isize + options.offset_y.unwrap_or(0)) as usize;
let col = (col as isize + options.offset_x.unwrap_or(0)) as usize;
let row = row
.max(margin_top)
.min(term_height - margin_bottom - effective_height);
let col = col.max(margin_left).min(term_width - margin_right - width);
OverlayLayout {
width,
row,
col,
max_height,
}
}
fn resolve_anchor_row(
&self,
anchor: OverlayAnchor,
height: usize,
avail_height: usize,
margin_top: usize,
) -> usize {
match anchor {
OverlayAnchor::TopLeft | OverlayAnchor::TopCenter | OverlayAnchor::TopRight => {
margin_top
}
OverlayAnchor::BottomLeft
| OverlayAnchor::BottomCenter
| OverlayAnchor::BottomRight => margin_top + avail_height - height,
OverlayAnchor::LeftCenter | OverlayAnchor::Center | OverlayAnchor::RightCenter => {
margin_top + (avail_height - height) / 2
}
}
}
fn resolve_anchor_col(
&self,
anchor: OverlayAnchor,
width: usize,
avail_width: usize,
margin_left: usize,
) -> usize {
match anchor {
OverlayAnchor::TopLeft | OverlayAnchor::LeftCenter | OverlayAnchor::BottomLeft => {
margin_left
}
OverlayAnchor::TopRight | OverlayAnchor::RightCenter | OverlayAnchor::BottomRight => {
margin_left + avail_width - width
}
OverlayAnchor::TopCenter | OverlayAnchor::Center | OverlayAnchor::BottomCenter => {
margin_left + (avail_width - width) / 2
}
}
}
fn extract_cursor_position(
&self,
lines: &mut [String],
height: usize,
) -> Option<(usize, usize)> {
let viewport_top = lines.len().saturating_sub(height);
for row in (viewport_top..lines.len()).rev() {
let line = &lines[row];
if let Some(marker_idx) = line.find(CURSOR_MARKER) {
let col = visible_width(&line[..marker_idx]);
let before = &line[..marker_idx];
let after = &line[marker_idx + CURSOR_MARKER.len()..];
lines[row] = format!("{}{}", before, after);
return Some((row, col));
}
}
None
}
fn position_hard_cursor(
&self,
row: usize,
col: usize,
writer: &mut dyn Write,
) -> io::Result<()> {
let viewport_top = self.screen.prev_viewport_top();
if row < viewport_top {
return Ok(());
}
let screen_row = row - viewport_top;
if screen_row >= self.height {
return Ok(());
}
let screen_col = col.min(self.width - 1);
write!(writer, "\x1b[{};{}H", screen_row + 1, screen_col + 1)?;
writer.flush()?;
Ok(())
}
}
impl Default for TUI {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::overlay::{OverlayMargin, OverlayOptions};
struct TestComponent {
text: String,
}
impl Component for TestComponent {
fn render(&self, _width: usize) -> Vec<String> {
vec![self.text.clone()]
}
fn handle_input(&mut self, _key: &crossterm::event::KeyEvent) -> bool {
false
}
fn invalidate(&mut self) {}
}
#[test]
fn test_tui_new() {
let tui = TUI::new();
assert!(!tui.has_overlays());
assert_eq!(tui.full_redraw_count(), 0);
}
#[test]
fn test_show_and_hide_overlay() {
let mut tui = TUI::new();
let id = tui.show_overlay(
Box::new(TestComponent {
text: "overlay".into(),
}),
OverlayOptions::default(),
);
assert!(tui.has_overlays());
tui.hide_overlay(id);
assert!(!tui.has_overlays());
}
#[test]
fn test_pop_overlay() {
let mut tui = TUI::new();
tui.show_overlay(
Box::new(TestComponent { text: "a".into() }),
OverlayOptions::default(),
);
tui.show_overlay(
Box::new(TestComponent { text: "b".into() }),
OverlayOptions::default(),
);
assert!(tui.has_overlays());
tui.pop_overlay();
assert!(tui.has_overlays()); tui.pop_overlay();
assert!(!tui.has_overlays());
}
#[test]
fn test_cursor_marker_extraction() {
let tui = TUI::new();
let mut lines = vec![
"line 1".to_string(),
format!("before{}after", CURSOR_MARKER),
"line 3".to_string(),
];
let pos = tui.extract_cursor_position(&mut lines, 10);
assert!(pos.is_some());
let (row, col) = pos.unwrap();
assert_eq!(row, 1);
assert_eq!(col, 6); assert_eq!(lines[1], "beforeafter");
assert!(!lines[1].contains(CURSOR_MARKER));
}
#[test]
fn test_cursor_marker_outside_viewport() {
let tui = TUI::new();
let mut lines = vec![
format!("{}marker", CURSOR_MARKER),
"b".to_string(),
"c".to_string(),
"d".to_string(),
"e".to_string(),
];
let pos = tui.extract_cursor_position(&mut lines, 2);
assert!(pos.is_none()); }
#[test]
fn test_composite_line_at_basic() {
let tui = TUI::new();
let result = tui.composite_line_at("hello world", "!!", 6, 2, 13);
assert_eq!(visible_width(&result), 13);
assert!(result.contains("!!"));
}
#[test]
fn test_composite_line_at_no_overflow() {
let tui = TUI::new();
let result = tui.composite_line_at("abcdefghij", "12345", 2, 5, 12);
assert_eq!(visible_width(&result), 12);
}
#[test]
fn test_overlay_layout_center_default() {
let tui = TUI::new();
let layout = tui.resolve_overlay_layout(&OverlayOptions::default(), 5, 80, 24);
assert_eq!(layout.width, 80);
assert_eq!(layout.row, 9);
assert_eq!(layout.col, 0);
assert!(layout.max_height.is_none());
}
#[test]
fn test_overlay_layout_percent_width() {
let tui = TUI::new();
let opts = OverlayOptions {
width: Some(SizeValue::Percent(50.0)),
..Default::default()
};
let layout = tui.resolve_overlay_layout(&opts, 5, 80, 24);
assert_eq!(layout.width, 40); }
#[test]
fn test_overlay_layout_margin() {
let tui = TUI::new();
let opts = OverlayOptions {
margin: Some(OverlayMargin {
top: 2,
right: 2,
bottom: 2,
left: 2,
}),
anchor: Some(OverlayAnchor::TopLeft),
..Default::default()
};
let layout = tui.resolve_overlay_layout(&opts, 5, 80, 24);
assert_eq!(layout.row, 2);
assert_eq!(layout.col, 2);
}
}