use std::any::{Any, TypeId};
use std::time::Duration;
use crate::component::{Component, EventResult, Tracked};
use crate::element::Elements;
use crate::escape::CursorState;
use crate::frame::Frame;
use crate::node::NodeId;
use crate::renderer::Renderer;
pub struct InlineRenderer {
renderer: Renderer,
cursor: CursorState,
prev_frame: Option<Frame>,
emitted_rows: u16,
terminal_height: u16,
}
impl InlineRenderer {
pub fn new(width: u16) -> Self {
let terminal_height = crossterm::terminal::size()
.map(|(_, h)| h)
.unwrap_or(u16::MAX);
Self::new_with_height(width, terminal_height)
}
pub fn new_with_height(width: u16, terminal_height: u16) -> Self {
Self {
renderer: Renderer::new(width),
cursor: CursorState::new(),
prev_frame: None,
emitted_rows: 0,
terminal_height,
}
}
pub fn root(&self) -> NodeId {
self.renderer.root()
}
pub fn append_child<C: Component>(&mut self, parent: NodeId, component: C) -> NodeId {
self.renderer.append_child(parent, component)
}
pub fn push<C: Component>(&mut self, component: C) -> NodeId {
self.renderer.push(component)
}
pub fn state_mut<C: Component>(&mut self, id: NodeId) -> &mut Tracked<C::State> {
self.renderer.state_mut::<C>(id)
}
pub fn set_root_context<T: Any + Send + Sync>(&mut self, value: T) {
self.renderer.set_root_context(value);
}
pub(crate) fn set_root_context_raw(
&mut self,
type_id: TypeId,
value: Box<dyn Any + Send + Sync>,
) {
self.renderer.set_root_context_raw(type_id, value);
}
pub fn swap_component<C: Component>(&mut self, id: NodeId, component: C) {
self.renderer.swap_component(id, component)
}
pub fn freeze(&mut self, id: NodeId) {
self.renderer.freeze(id)
}
pub fn remove(&mut self, id: NodeId) {
self.renderer.remove(id)
}
pub fn children(&self, id: NodeId) -> &[NodeId] {
self.renderer.children(id)
}
pub fn rebuild(&mut self, parent: NodeId, elements: Elements) {
self.renderer.rebuild(parent, elements)
}
pub fn find_by_key(&self, parent: NodeId, key: &str) -> Option<NodeId> {
self.renderer.find_by_key(parent, key)
}
pub fn register_tick<C: Component>(
&mut self,
id: NodeId,
interval: Duration,
handler: impl Fn(&mut C::State) + Send + Sync + 'static,
) {
self.renderer.register_tick::<C>(id, interval, handler)
}
pub fn unregister_tick(&mut self, id: NodeId) {
self.renderer.unregister_tick(id)
}
pub fn tick(&mut self) -> bool {
self.renderer.tick()
}
pub fn has_active(&self) -> bool {
self.renderer.has_active()
}
pub fn on_mount<C: Component>(
&mut self,
id: NodeId,
handler: impl Fn(&mut C::State) + Send + Sync + 'static,
) {
self.renderer.on_mount::<C>(id, handler)
}
pub fn on_unmount<C: Component>(
&mut self,
id: NodeId,
handler: impl Fn(&mut C::State) + Send + Sync + 'static,
) {
self.renderer.on_unmount::<C>(id, handler)
}
pub fn set_focus(&mut self, id: NodeId) {
self.renderer.set_focus(id);
}
pub fn clear_focus(&mut self) {
self.renderer.clear_focus();
}
pub fn focus(&self) -> Option<NodeId> {
self.renderer.focus()
}
pub fn handle_event(&mut self, event: &crossterm::event::Event) -> EventResult {
self.renderer.handle_event(event)
}
pub fn resize(&mut self, new_width: u16) -> Vec<u8> {
let mut output = Vec::new();
output.extend_from_slice(b"\x1b[2J\x1b[H");
self.renderer.set_width(new_width);
self.cursor = CursorState::new();
self.prev_frame = None;
self.emitted_rows = 0;
if let Ok((_, h)) = crossterm::terminal::size() {
self.terminal_height = h;
}
let render_output = self.render();
output.extend_from_slice(&render_output);
output
}
pub fn render(&mut self) -> Vec<u8> {
let new_frame = self.renderer.render();
let new_height = new_frame.area().height;
if self.prev_frame.is_none() {
if new_height == 0 {
self.prev_frame = Some(new_frame);
return Vec::new();
}
let empty = Frame::new(ratatui_core::buffer::Buffer::empty(
ratatui_core::layout::Rect::new(0, 0, self.renderer.width(), 0),
));
let mut diff = new_frame.diff(&empty);
let mut output = Vec::new();
let stream_until = new_height.saturating_sub(self.terminal_height);
self.stream_rows_into_scrollback(&new_frame, 0, stream_until, &mut output);
let new_rows_needed = new_height.saturating_sub(self.emitted_rows);
if new_rows_needed > 0 {
let newline_count = if self.emitted_rows == 0 {
new_rows_needed.saturating_sub(1)
} else {
new_rows_needed
} as usize;
if self.emitted_rows > 0 && newline_count > 0 {
output.push(b'\r');
self.cursor.col = 0;
}
output.resize(output.len() + newline_count, b'\n');
self.emitted_rows = new_height;
self.cursor.row = new_height.saturating_sub(1);
self.cursor.col = 0;
}
let scrolled_past = self.emitted_rows.saturating_sub(self.terminal_height);
diff.retain_visible(scrolled_past);
let escape_bytes = diff.to_escape_sequences(&mut self.cursor);
output.extend_from_slice(&escape_bytes);
self.append_cursor_position(&mut output);
self.prev_frame = Some(new_frame);
return output;
}
let prev = self.prev_frame.as_ref().unwrap();
let mut diff = new_frame.diff(prev);
if diff.is_empty() && !diff.grew() {
let mut output = Vec::new();
self.append_cursor_position(&mut output);
self.prev_frame = Some(new_frame);
return output;
}
let mut output = Vec::new();
let old_scrolled_past = self.emitted_rows.saturating_sub(self.terminal_height);
let new_scrolled_past = new_height.saturating_sub(self.terminal_height);
self.stream_rows_into_scrollback(
&new_frame,
old_scrolled_past,
new_scrolled_past,
&mut output,
);
let new_rows_needed = new_height.saturating_sub(self.emitted_rows);
if new_rows_needed > 0 {
let current_bottom = self.emitted_rows.saturating_sub(1);
if self.cursor.row < current_bottom {
let down = current_bottom - self.cursor.row;
output.extend_from_slice(format!("\x1b[{}B", down).as_bytes());
}
self.cursor.row = current_bottom;
output.push(b'\r');
self.cursor.col = 0;
output.resize(output.len() + new_rows_needed as usize, b'\n');
self.emitted_rows += new_rows_needed;
self.cursor.row += new_rows_needed;
}
let scrolled_past = self.emitted_rows.saturating_sub(self.terminal_height);
diff.retain_visible(scrolled_past);
let escape_bytes = diff.to_escape_sequences(&mut self.cursor);
output.extend_from_slice(&escape_bytes);
self.append_cursor_position(&mut output);
self.prev_frame = Some(new_frame);
output
}
fn stream_rows_into_scrollback(
&mut self,
frame: &Frame,
start: u16,
end: u16,
output: &mut Vec<u8>,
) {
let frame_height = frame.area().height;
let end = end.min(frame_height);
if start >= end {
return;
}
crate::escape::write_relative_move(output, &mut self.cursor, start, 0);
for row in start..end {
frame.write_committed_row(row, output, &mut self.cursor);
output.extend_from_slice(b"\r\n");
self.cursor.row = row.saturating_add(1);
self.cursor.col = 0;
self.emitted_rows = self.emitted_rows.max(self.cursor.row.saturating_add(1));
}
}
pub fn emitted_rows(&self) -> u16 {
self.emitted_rows
}
pub fn set_terminal_height(&mut self, height: u16) {
self.terminal_height = height;
}
pub fn node_last_height(&self, id: NodeId) -> u16 {
self.renderer.node_last_height(id)
}
pub fn detect_committed(
&self,
container: NodeId,
terminal_height: u16,
) -> Vec<(usize, Option<String>)> {
let scrollback_rows = self.emitted_rows.saturating_sub(terminal_height);
if scrollback_rows == 0 {
return Vec::new();
}
let children = self.renderer.children(container);
let mut accumulated: u16 = 0;
let mut committed = Vec::new();
for (i, &child_id) in children.iter().enumerate() {
let child_height = self.renderer.node_last_height(child_id);
accumulated = accumulated.saturating_add(child_height);
if accumulated <= scrollback_rows {
let key = self.renderer.node_key(child_id).map(|s| s.to_string());
committed.push((i, key));
} else {
break;
}
}
committed
}
pub fn commit(&mut self, container: NodeId, count: usize, committed_height: u16) {
if count == 0 || committed_height == 0 {
return;
}
if let Some(focused) = self.renderer.focus() {
let children = self.renderer.children(container);
let committed_ids: Vec<NodeId> = children[..count].to_vec();
if committed_ids.contains(&focused) {
self.renderer.clear_focus();
}
}
let children: Vec<NodeId> = self.renderer.children(container)[..count].to_vec();
for child_id in children {
self.renderer.remove(child_id);
}
if let Some(ref prev) = self.prev_frame {
self.prev_frame = Some(prev.slice_top_rows(committed_height));
}
self.emitted_rows = self.emitted_rows.saturating_sub(committed_height);
self.cursor.row = self.cursor.row.saturating_sub(committed_height);
}
pub fn finalize(&mut self) -> Vec<u8> {
let current_height = self
.prev_frame
.as_ref()
.map(|f| f.area().height)
.unwrap_or(0);
if current_height >= self.emitted_rows || self.emitted_rows == 0 {
return Vec::new();
}
let scrolled_past = self.emitted_rows.saturating_sub(self.terminal_height);
let target_row = current_height.max(scrolled_past);
if target_row >= self.emitted_rows {
return Vec::new();
}
let mut output = Vec::new();
output.extend_from_slice(b"\r");
if self.cursor.row > target_row {
let up = self.cursor.row - target_row;
output.extend_from_slice(format!("\x1b[{}F", up).as_bytes());
} else if self.cursor.row < target_row {
let down = target_row - self.cursor.row;
output.extend_from_slice(format!("\x1b[{}E", down).as_bytes());
}
output.extend_from_slice(b"\x1b[J");
self.cursor.row = target_row;
self.cursor.col = 0;
self.emitted_rows = target_row;
output
}
fn append_cursor_position(&mut self, output: &mut Vec<u8>) {
if let Some((col, row)) = self.renderer.cursor_hint() {
crate::escape::write_relative_move(output, &mut self.cursor, row, col);
output.extend_from_slice(b"\x1b[?25h");
} else {
output.extend_from_slice(b"\x1b[?25l");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::component::Component;
use ratatui_core::{buffer::Buffer, layout::Rect};
use ratatui_widgets::paragraph::Paragraph;
struct TextBlock;
impl Component for TextBlock {
type State = Vec<String>;
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let lines: Vec<ratatui_core::text::Line> = state
.iter()
.map(|s| ratatui_core::text::Line::raw(s.as_str()))
.collect();
let para = Paragraph::new(lines);
ratatui_core::widgets::Widget::render(para, area, buf);
}
fn initial_state(&self) -> Option<Vec<String>> {
Some(vec![])
}
}
struct WrappingBlock;
impl Component for WrappingBlock {
type State = String;
fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
let text = ratatui_core::text::Text::raw(state.as_str());
ratatui_core::widgets::Widget::render(crate::wrap::wrapping_paragraph(text), area, buf);
}
fn desired_height(&self, width: u16, state: &Self::State) -> Option<u16> {
let text = ratatui_core::text::Text::raw(state.as_str());
Some(crate::wrap::wrapped_line_count(&text, width))
}
fn initial_state(&self) -> Option<String> {
Some(String::new())
}
}
struct TestTerminal {
parser: vte::Parser,
width: usize,
height: usize,
cursor_row: usize,
cursor_col: usize,
pending_wrap: bool,
viewport: Vec<Vec<char>>,
scrollback: Vec<String>,
}
impl TestTerminal {
fn new(width: usize, height: usize) -> Self {
Self {
parser: vte::Parser::new(),
width,
height,
cursor_row: 0,
cursor_col: 0,
pending_wrap: false,
viewport: vec![vec![' '; width]; height],
scrollback: Vec::new(),
}
}
fn feed(&mut self, bytes: &[u8]) {
let mut parser = std::mem::replace(&mut self.parser, vte::Parser::new());
parser.advance(self, bytes);
self.parser = parser;
}
fn scrollback_lines(&self) -> Vec<String> {
self.scrollback.clone()
}
fn viewport_lines(&self) -> Vec<String> {
self.viewport
.iter()
.map(|line| trimmed_line(line))
.collect()
}
fn linefeed(&mut self) {
if self.height == 0 {
return;
}
if self.cursor_row + 1 >= self.height {
let top = self.viewport.remove(0);
self.scrollback.push(trimmed_line(&top));
self.viewport.push(vec![' '; self.width]);
self.cursor_row = self.height - 1;
} else {
self.cursor_row += 1;
}
self.pending_wrap = false;
}
fn put_char(&mut self, c: char) {
if self.height == 0 || self.width == 0 {
return;
}
if self.pending_wrap {
self.linefeed();
self.cursor_col = 0;
}
self.viewport[self.cursor_row][self.cursor_col] = c;
if self.cursor_col + 1 >= self.width {
self.pending_wrap = true;
} else {
self.cursor_col += 1;
self.pending_wrap = false;
}
}
fn csi_param(params: &vte::Params, default: usize) -> usize {
params
.iter()
.next()
.and_then(|values| values.first().copied())
.map(usize::from)
.filter(|&n| n > 0)
.unwrap_or(default)
}
}
fn trimmed_line(chars: &[char]) -> String {
let mut line: String = chars.iter().collect();
while line.ends_with(' ') {
line.pop();
}
line
}
impl vte::Perform for TestTerminal {
fn print(&mut self, c: char) {
self.put_char(c);
}
fn execute(&mut self, byte: u8) {
match byte {
b'\r' => {
self.cursor_col = 0;
self.pending_wrap = false;
}
b'\n' => self.linefeed(),
b'\x08' => {
self.cursor_col = self.cursor_col.saturating_sub(1);
self.pending_wrap = false;
}
_ => {}
}
}
fn hook(
&mut self,
_params: &vte::Params,
_intermediates: &[u8],
_ignore: bool,
_action: char,
) {
}
fn put(&mut self, _byte: u8) {}
fn unhook(&mut self) {}
fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {}
fn csi_dispatch(
&mut self,
params: &vte::Params,
_intermediates: &[u8],
_ignore: bool,
action: char,
) {
let n = Self::csi_param(params, 1);
match action {
'A' => self.cursor_row = self.cursor_row.saturating_sub(n),
'B' => {
self.cursor_row = (self.cursor_row + n).min(self.height.saturating_sub(1));
self.pending_wrap = false;
}
'C' => {
self.cursor_col = (self.cursor_col + n).min(self.width.saturating_sub(1));
self.pending_wrap = false;
}
'D' => {
self.cursor_col = self.cursor_col.saturating_sub(n);
self.pending_wrap = false;
}
'E' => {
self.cursor_row = (self.cursor_row + n).min(self.height.saturating_sub(1));
self.cursor_col = 0;
self.pending_wrap = false;
}
'F' => {
self.cursor_row = self.cursor_row.saturating_sub(n);
self.cursor_col = 0;
self.pending_wrap = false;
}
'H' => {
self.cursor_row = 0;
self.cursor_col = 0;
self.pending_wrap = false;
}
'J' => {
for row in self.cursor_row..self.height {
let start_col = if row == self.cursor_row {
self.cursor_col
} else {
0
};
for col in start_col..self.width {
self.viewport[row][col] = ' ';
}
}
}
'h' | 'l' | 'm' => {}
_ => {}
}
}
fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {}
}
#[test]
fn first_render_empty_produces_nothing() {
let mut ir = InlineRenderer::new_with_height(10, 24);
let _id = ir.push(TextBlock);
let output = ir.render();
assert!(output.is_empty());
}
#[test]
fn first_render_with_content_produces_output() {
let mut ir = InlineRenderer::new_with_height(10, 24);
let id = ir.push(TextBlock);
ir.state_mut::<TextBlock>(id).push("hello".to_string());
let output = ir.render();
assert!(!output.is_empty());
let s = String::from_utf8_lossy(&output);
assert!(s.contains("\x1b[?2026h"));
assert!(s.contains("\x1b[?2026l"));
assert!(s.contains("hello"));
}
#[test]
fn no_change_produces_minimal_output() {
let mut ir = InlineRenderer::new_with_height(10, 24);
let id = ir.push(TextBlock);
ir.state_mut::<TextBlock>(id).push("hello".to_string());
let _first = ir.render();
let second = ir.render();
let s = String::from_utf8_lossy(&second);
assert!(!s.contains("\x1b[?2026h"));
}
#[test]
fn growing_content_emits_newlines() {
let mut ir = InlineRenderer::new_with_height(10, 24);
let id = ir.push(TextBlock);
ir.state_mut::<TextBlock>(id).push("line1".to_string());
let _first = ir.render();
ir.state_mut::<TextBlock>(id).push("line2".to_string());
let second = ir.render();
let s = String::from_utf8_lossy(&second);
assert!(s.contains('\n'));
assert!(s.contains("line2"));
}
#[test]
fn first_render_burst_populates_scrollback() {
let mut terminal = TestTerminal::new(20, 3);
let mut ir = InlineRenderer::new_with_height(20, 3);
let id = ir.push(TextBlock);
let lines: Vec<String> = (0..10).map(|i| format!("line {i:02}")).collect();
ir.state_mut::<TextBlock>(id).extend(lines.iter().cloned());
let output = ir.render();
terminal.feed(&output);
assert_eq!(terminal.scrollback_lines(), lines[..7]);
assert_eq!(terminal.viewport_lines(), lines[7..]);
}
#[test]
fn full_width_rows_do_not_create_blank_scrollback_gaps() {
let mut terminal = TestTerminal::new(10, 3);
let mut ir = InlineRenderer::new_with_height(10, 3);
let id = ir.push(TextBlock);
let lines: Vec<String> = (0..8).map(|i| format!("row-{i:06}")).collect();
assert!(lines.iter().all(|line| line.len() == 10));
ir.state_mut::<TextBlock>(id).extend(lines.iter().cloned());
terminal.feed(&ir.render());
assert_eq!(terminal.scrollback_lines(), lines[..5]);
assert_eq!(terminal.viewport_lines(), lines[5..]);
}
#[test]
fn subsequent_burst_append_populates_scrollback_once() {
let mut terminal = TestTerminal::new(20, 5);
let mut ir = InlineRenderer::new_with_height(20, 5);
let id = ir.push(TextBlock);
let initial: Vec<String> = (0..3).map(|i| format!("line {i:02}")).collect();
ir.state_mut::<TextBlock>(id).extend(initial);
terminal.feed(&ir.render());
let lines: Vec<String> = (0..12).map(|i| format!("line {i:02}")).collect();
ir.state_mut::<TextBlock>(id).clear();
ir.state_mut::<TextBlock>(id).extend(lines.iter().cloned());
terminal.feed(&ir.render());
assert_eq!(terminal.scrollback_lines(), lines[..7]);
assert_eq!(terminal.viewport_lines(), lines[7..]);
}
#[test]
fn subsequent_burst_insert_above_live_tail_populates_scrollback() {
let mut terminal = TestTerminal::new(20, 5);
let mut ir = InlineRenderer::new_with_height(20, 5);
let id = ir.push(TextBlock);
let footer = ["footer 0", "footer 1", "footer 2"];
let mut initial: Vec<String> = (0..2).map(|i| format!("line {i:02}")).collect();
initial.extend(footer.iter().map(|s| s.to_string()));
ir.state_mut::<TextBlock>(id).extend(initial);
terminal.feed(&ir.render());
let mut lines: Vec<String> = (0..12).map(|i| format!("line {i:02}")).collect();
lines.extend(footer.iter().map(|s| s.to_string()));
ir.state_mut::<TextBlock>(id).clear();
ir.state_mut::<TextBlock>(id).extend(lines.iter().cloned());
terminal.feed(&ir.render());
assert_eq!(terminal.scrollback_lines(), lines[..10]);
assert_eq!(terminal.viewport_lines(), lines[10..]);
}
#[test]
fn burst_insert_above_live_tail_survives_footer_removal_finalize() {
let mut terminal = TestTerminal::new(20, 5);
let mut ir = InlineRenderer::new_with_height(20, 5);
let id = ir.push(TextBlock);
let footer = ["footer 0", "footer 1", "footer 2"];
let mut initial: Vec<String> = (0..2).map(|i| format!("line {i:02}")).collect();
initial.extend(footer.iter().map(|s| s.to_string()));
ir.state_mut::<TextBlock>(id).extend(initial);
terminal.feed(&ir.render());
let transcript: Vec<String> = (0..12).map(|i| format!("line {i:02}")).collect();
let mut live_lines = transcript.clone();
live_lines.extend(footer.iter().map(|s| s.to_string()));
ir.state_mut::<TextBlock>(id).clear();
ir.state_mut::<TextBlock>(id).extend(live_lines);
terminal.feed(&ir.render());
ir.state_mut::<TextBlock>(id).clear();
ir.state_mut::<TextBlock>(id)
.extend(transcript.iter().cloned());
terminal.feed(&ir.render());
terminal.feed(&ir.finalize());
let mut actual = terminal.scrollback_lines();
actual.extend(terminal.viewport_lines());
actual.retain(|line| !line.is_empty());
assert_eq!(actual, transcript);
}
#[test]
fn wrapped_text_burst_populates_scrollback() {
let mut terminal = TestTerminal::new(5, 3);
let mut ir = InlineRenderer::new_with_height(5, 3);
let id = ir.push(WrappingBlock);
ir.state_mut::<WrappingBlock>(id)
.push_str("aa bb cc dd ee ff gg hh ii jj kk ll mm nn oo pp");
terminal.feed(&ir.render());
assert_eq!(
terminal.scrollback_lines(),
["aa bb", "cc dd", "ee ff", "gg hh", "ii jj"]
);
assert_eq!(terminal.viewport_lines(), ["kk ll", "mm nn", "oo pp"]);
}
#[test]
fn finalize_reclaims_trailing_blank_rows() {
let mut ir = InlineRenderer::new_with_height(20, 24);
let id = ir.push(TextBlock);
ir.state_mut::<TextBlock>(id)
.extend(["a", "b", "c"].map(String::from));
let _first = ir.render();
assert_eq!(ir.emitted_rows(), 3);
ir.state_mut::<TextBlock>(id).truncate(1);
let _second = ir.render();
assert_eq!(ir.emitted_rows(), 3);
let output = ir.finalize();
assert!(!output.is_empty());
assert_eq!(ir.emitted_rows(), 1);
let s = String::from_utf8_lossy(&output);
assert!(s.contains("\x1b[J"));
}
#[test]
fn finalize_noop_when_no_trailing_blanks() {
let mut ir = InlineRenderer::new_with_height(20, 24);
let id = ir.push(TextBlock);
ir.state_mut::<TextBlock>(id).push("hello".to_string());
let _first = ir.render();
assert_eq!(ir.emitted_rows(), 1);
let output = ir.finalize();
assert!(output.is_empty());
assert_eq!(ir.emitted_rows(), 1);
}
#[test]
fn finalize_respects_scrollback_boundary() {
let mut ir = InlineRenderer::new_with_height(20, 3);
let id = ir.push(TextBlock);
ir.state_mut::<TextBlock>(id)
.extend(["a", "b", "c", "d", "e"].map(String::from));
let _first = ir.render();
assert_eq!(ir.emitted_rows(), 5);
ir.state_mut::<TextBlock>(id).truncate(1);
let _second = ir.render();
let output = ir.finalize();
assert!(!output.is_empty());
assert_eq!(ir.emitted_rows(), 2);
let s = String::from_utf8_lossy(&output);
assert!(s.contains("\x1b[J"));
}
struct BorderBox;
impl Component for BorderBox {
type State = ();
fn render(&self, area: Rect, buf: &mut Buffer, _state: &()) {
if area.width >= 2 && area.height >= 2 {
for x in area.x..area.x + area.width {
buf[(x, area.y)].set_char('─');
}
let bot = area.y + area.height - 1;
for x in area.x..area.x + area.width {
buf[(x, bot)].set_char('─');
}
for y in area.y..area.y + area.height {
buf[(area.x, y)].set_char('│');
buf[(area.x + area.width - 1, y)].set_char('│');
}
}
}
fn content_inset(&self, _state: &()) -> crate::insets::Insets {
crate::insets::Insets::all(1) }
fn view(&self, _state: &(), children: Elements) -> Elements {
children
}
}
crate::impl_slot_children!(BorderBox);
#[test]
fn finalize_after_declarative_removal() {
let mut ir = InlineRenderer::new_with_height(40, 24);
let container = ir.push(crate::component::VStack);
let mut els = crate::element::Elements::new();
let content = TextBlock;
els.add(content).key("content");
let input = TextBlock;
els.add(input).key("input");
ir.rebuild(container, els);
let content_id = ir.find_by_key(container, "content").unwrap();
ir.state_mut::<TextBlock>(content_id)
.extend(["line 1", "line 2", "line 3", "line 4", "line 5"].map(String::from));
let input_id = ir.find_by_key(container, "input").unwrap();
ir.state_mut::<TextBlock>(input_id)
.extend(["┌──────┐", "│ text │", "└──────┘"].map(String::from));
let _first = ir.render();
assert_eq!(ir.emitted_rows(), 8);
let mut els = crate::element::Elements::new();
let content = TextBlock;
els.add(content).key("content");
ir.rebuild(container, els);
let content_id = ir.find_by_key(container, "content").unwrap();
assert_eq!(ir.state_mut::<TextBlock>(content_id).len(), 5);
let _second = ir.render();
assert_eq!(ir.emitted_rows(), 8);
let output = ir.finalize();
assert!(!output.is_empty());
assert_eq!(
ir.emitted_rows(),
5,
"finalize should leave exactly 5 rows (content), not {}",
ir.emitted_rows()
);
}
#[test]
fn finalize_with_bordered_input_removal() {
let mut ir = InlineRenderer::new_with_height(40, 24);
let container = ir.push(crate::component::VStack);
let mut els = crate::element::Elements::new();
els.add(TextBlock).key("content");
let mut input_children = crate::element::Elements::new();
input_children.add(TextBlock).key("input-text");
els.add_with_children(BorderBox, input_children)
.key("input-box");
ir.rebuild(container, els);
let content_id = ir.find_by_key(container, "content").unwrap();
ir.state_mut::<TextBlock>(content_id)
.extend(["line 1", "line 2", "line 3", "line 4", "line 5"].map(String::from));
let input_box_id = ir.find_by_key(container, "input-box").unwrap();
let input_text_id = ir.find_by_key(input_box_id, "input-text").unwrap();
ir.state_mut::<TextBlock>(input_text_id)
.push("Type here...".to_string());
let _first = ir.render();
assert_eq!(
ir.emitted_rows(),
8,
"should be 5 content + 3 bordered input = 8 rows"
);
let mut els = crate::element::Elements::new();
els.add(TextBlock).key("content");
ir.rebuild(container, els);
let content_id = ir.find_by_key(container, "content").unwrap();
assert_eq!(ir.state_mut::<TextBlock>(content_id).len(), 5);
let _second = ir.render();
assert_eq!(ir.emitted_rows(), 8);
let output = ir.finalize();
assert!(!output.is_empty());
assert_eq!(
ir.emitted_rows(),
5,
"finalize should leave exactly 5 content rows, not {}",
ir.emitted_rows()
);
}
#[test]
fn shrink_then_grow_does_not_emit_extra_newlines() {
let mut ir = InlineRenderer::new_with_height(40, 24);
let id = ir.push(TextBlock);
ir.state_mut::<TextBlock>(id)
.extend(["line1", "line2", "line3"].map(String::from));
let _first = ir.render();
assert_eq!(ir.emitted_rows(), 3);
ir.state_mut::<TextBlock>(id).pop();
let _second = ir.render();
assert_eq!(
ir.emitted_rows(),
3,
"emitted_rows should not decrease on shrink"
);
ir.state_mut::<TextBlock>(id).push("line3".to_string());
let _third = ir.render();
assert_eq!(
ir.emitted_rows(),
3,
"emitted_rows should stay at 3 — we already have the row claimed"
);
ir.state_mut::<TextBlock>(id).pop();
let _r4 = ir.render();
ir.state_mut::<TextBlock>(id).push("line3".to_string());
let _r5 = ir.render();
assert_eq!(
ir.emitted_rows(),
3,
"emitted_rows must remain stable across repeated shrink/grow cycles"
);
}
#[test]
fn growth_emits_cr_before_newlines() {
let mut ir = InlineRenderer::new_with_height(40, 24);
let id = ir.push(TextBlock);
ir.state_mut::<TextBlock>(id).push("hello".to_string());
let first = ir.render();
let first_str = String::from_utf8_lossy(&first);
assert!(first_str.contains("hello"));
ir.state_mut::<TextBlock>(id).push("world".to_string());
let second = ir.render();
let _second_str = String::from_utf8_lossy(&second);
let raw = &second;
if let Some(newline_pos) = raw.iter().position(|&b| b == b'\n') {
assert!(
newline_pos > 0 && raw[newline_pos - 1] == b'\r',
"expected \\r immediately before the growth \\n, got byte {:?}",
raw.get(newline_pos.wrapping_sub(1))
);
} else {
panic!("expected a newline in the growth output");
}
}
}