use ftui_core::geometry::Rect;
use ftui_render::cell::{Cell as BufferCell, CellAttrs as BufferCellAttrs, PackedRgba, StyleFlags};
use ftui_render::frame::Frame;
use ftui_style::Color;
use ftui_widgets::{StatefulWidget, Widget};
use super::state::{Cell as TerminalCell, CellAttrs, Cursor, CursorShape, TerminalState};
#[derive(Debug, Default, Clone)]
pub struct TerminalEmulator {
show_cursor: bool,
cursor_visible_phase: bool,
}
impl TerminalEmulator {
#[must_use]
pub fn new() -> Self {
Self {
show_cursor: true,
cursor_visible_phase: true,
}
}
#[must_use]
pub fn show_cursor(mut self, show: bool) -> Self {
self.show_cursor = show;
self
}
#[must_use]
pub fn cursor_phase(mut self, visible: bool) -> Self {
self.cursor_visible_phase = visible;
self
}
fn convert_cell(&self, term_cell: &TerminalCell) -> BufferCell {
let ch = term_cell.ch;
let fg = term_cell
.fg
.map(color_to_packed)
.unwrap_or(PackedRgba::TRANSPARENT);
let bg = term_cell
.bg
.map(color_to_packed)
.unwrap_or(PackedRgba::TRANSPARENT);
let attrs = term_cell.attrs;
let mut flags = StyleFlags::empty();
if attrs.contains(CellAttrs::BOLD) {
flags |= StyleFlags::BOLD;
}
if attrs.contains(CellAttrs::DIM) {
flags |= StyleFlags::DIM;
}
if attrs.contains(CellAttrs::ITALIC) {
flags |= StyleFlags::ITALIC;
}
if attrs.contains(CellAttrs::UNDERLINE) {
flags |= StyleFlags::UNDERLINE;
}
if attrs.contains(CellAttrs::BLINK) {
flags |= StyleFlags::BLINK;
}
if attrs.contains(CellAttrs::REVERSE) {
flags |= StyleFlags::REVERSE;
}
if attrs.contains(CellAttrs::STRIKETHROUGH) {
flags |= StyleFlags::STRIKETHROUGH;
}
if attrs.contains(CellAttrs::HIDDEN) {
flags |= StyleFlags::HIDDEN;
}
let cell_attrs = BufferCellAttrs::new(flags, 0);
BufferCell::from_char(ch)
.with_fg(fg)
.with_bg(bg)
.with_attrs(cell_attrs)
}
fn apply_cursor(&self, cursor: &Cursor, x: u16, y: u16, frame: &mut Frame) {
if !self.show_cursor || !cursor.visible || !self.cursor_visible_phase {
return;
}
if x != cursor.x || y != cursor.y {
return;
}
if let Some(cell) = frame.buffer.get_mut(x, y) {
match cursor.shape {
CursorShape::Block | CursorShape::Bar => {
let new_attrs = cell
.attrs
.with_flags(cell.attrs.flags() | StyleFlags::REVERSE);
cell.attrs = new_attrs;
}
CursorShape::Underline => {
let new_attrs = cell
.attrs
.with_flags(cell.attrs.flags() | StyleFlags::UNDERLINE);
cell.attrs = new_attrs;
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct TerminalEmulatorState {
pub terminal: TerminalState,
pub scroll_offset: usize,
}
impl TerminalEmulatorState {
#[must_use]
pub fn new(width: u16, height: u16) -> Self {
Self {
terminal: TerminalState::new(width, height),
scroll_offset: 0,
}
}
#[must_use]
pub fn with_scrollback(width: u16, height: u16, max_scrollback: usize) -> Self {
Self {
terminal: TerminalState::with_scrollback(width, height, max_scrollback),
scroll_offset: 0,
}
}
#[must_use]
pub const fn terminal(&self) -> &TerminalState {
&self.terminal
}
pub fn terminal_mut(&mut self) -> &mut TerminalState {
&mut self.terminal
}
pub fn scroll_up(&mut self, lines: usize) {
let max_scroll = self.terminal.scrollback().len();
self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
}
pub fn scroll_down(&mut self, lines: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(lines);
}
pub fn reset_scroll(&mut self) {
self.scroll_offset = 0;
}
pub fn resize(&mut self, width: u16, height: u16) {
self.terminal.resize(width, height);
let max_scroll = self.terminal.scrollback().len();
self.scroll_offset = self.scroll_offset.min(max_scroll);
}
}
impl StatefulWidget for TerminalEmulator {
type State = TerminalEmulatorState;
fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
if area.width == 0 || area.height == 0 {
return;
}
let terminal = &state.terminal;
if state.scroll_offset > 0 {
let scrollback = terminal.scrollback();
let scroll_lines = state.scroll_offset.min(area.height as usize);
for y in 0..scroll_lines {
let scrollback_line_idx = state.scroll_offset - 1 - y;
if let Some(line) = scrollback.line(scrollback_line_idx) {
let buf_y = area.y + y as u16;
for (x, term_cell) in line.iter().enumerate() {
if x >= area.width as usize {
break;
}
let buf_x = area.x + x as u16;
let buf_cell = self.convert_cell(term_cell);
frame.buffer.set_fast(buf_x, buf_y, buf_cell);
}
}
}
let grid_start_y = scroll_lines as u16;
let grid_lines = area.height.saturating_sub(grid_start_y);
for y in 0..grid_lines.min(terminal.height()) {
for x in 0..area.width.min(terminal.width()) {
if let Some(term_cell) = terminal.cell(x, y) {
let buf_x = area.x + x;
let buf_y = area.y + grid_start_y + y;
let buf_cell = self.convert_cell(term_cell);
frame.buffer.set_fast(buf_x, buf_y, buf_cell);
}
}
}
} else {
for y in 0..area.height.min(terminal.height()) {
for x in 0..area.width.min(terminal.width()) {
if let Some(term_cell) = terminal.cell(x, y) {
let buf_x = area.x + x;
let buf_y = area.y + y;
let buf_cell = self.convert_cell(term_cell);
frame.buffer.set_fast(buf_x, buf_y, buf_cell);
}
}
}
let cursor = terminal.cursor();
let cursor_x = area.x + cursor.x;
let cursor_y = area.y + cursor.y;
if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
self.apply_cursor(cursor, cursor_x, cursor_y, frame);
}
}
}
}
impl Widget for TerminalEmulator {
fn render(&self, area: Rect, frame: &mut Frame) {
let empty = BufferCell::from_char(' ');
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
frame.buffer.set_fast(x, y, empty);
}
}
}
}
fn color_to_packed(color: Color) -> PackedRgba {
let rgb = color.to_rgb();
PackedRgba::rgba(rgb.r, rgb.g, rgb.b, 255)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emulator_state_new() {
let state = TerminalEmulatorState::new(80, 24);
assert_eq!(state.terminal.width(), 80);
assert_eq!(state.terminal.height(), 24);
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn test_scroll_up_down() {
let mut state = TerminalEmulatorState::with_scrollback(10, 5, 100);
for _ in 0..10 {
state.terminal.scroll_up(1);
}
state.scroll_up(5);
assert_eq!(state.scroll_offset, 5);
state.scroll_down(2);
assert_eq!(state.scroll_offset, 3);
state.reset_scroll();
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn test_scroll_clamps_to_scrollback_size() {
let mut state = TerminalEmulatorState::with_scrollback(10, 5, 100);
for _ in 0..3 {
state.terminal.scroll_up(1);
}
state.scroll_up(100);
assert_eq!(state.scroll_offset, 3); }
#[test]
fn test_resize() {
let mut state = TerminalEmulatorState::new(80, 24);
state.resize(120, 40);
assert_eq!(state.terminal.width(), 120);
assert_eq!(state.terminal.height(), 40);
}
#[test]
fn test_emulator_widget_defaults() {
let widget = TerminalEmulator::new();
assert!(widget.show_cursor);
assert!(widget.cursor_visible_phase);
}
#[test]
fn test_emulator_widget_builder() {
let widget = TerminalEmulator::new()
.show_cursor(false)
.cursor_phase(false);
assert!(!widget.show_cursor);
assert!(!widget.cursor_visible_phase);
}
#[test]
fn test_color_to_packed() {
let color = Color::rgb(100, 150, 200);
let packed = color_to_packed(color);
assert_eq!(packed.r(), 100);
assert_eq!(packed.g(), 150);
assert_eq!(packed.b(), 200);
assert_eq!(packed.a(), 255);
}
#[test]
fn test_scroll_down_clamps_at_zero() {
let mut state = TerminalEmulatorState::new(10, 5);
state.scroll_down(10);
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn test_convert_cell_maps_attrs() {
let widget = TerminalEmulator::new();
let term_cell = TerminalCell {
ch: 'X',
fg: Some(Color::rgb(255, 0, 0)),
bg: Some(Color::rgb(0, 0, 255)),
attrs: CellAttrs::BOLD.with(CellAttrs::ITALIC),
};
let buf_cell = widget.convert_cell(&term_cell);
assert_eq!(buf_cell.content.as_char(), Some('X'));
assert_eq!(buf_cell.fg.r(), 255);
assert_eq!(buf_cell.bg.b(), 255);
assert!(buf_cell.attrs.flags().contains(StyleFlags::BOLD));
assert!(buf_cell.attrs.flags().contains(StyleFlags::ITALIC));
}
#[test]
fn test_convert_cell_default_colors_transparent() {
let widget = TerminalEmulator::new();
let term_cell = TerminalCell::default();
let buf_cell = widget.convert_cell(&term_cell);
assert_eq!(buf_cell.fg, PackedRgba::TRANSPARENT);
assert_eq!(buf_cell.bg, PackedRgba::TRANSPARENT);
}
#[test]
fn test_resize_clamps_scroll_offset() {
let mut state = TerminalEmulatorState::with_scrollback(10, 5, 100);
for _ in 0..10 {
state.terminal.scroll_up(1);
}
state.scroll_up(8);
assert_eq!(state.scroll_offset, 8);
state.resize(10, 5);
assert!(state.scroll_offset <= state.terminal.scrollback().len());
}
#[test]
fn test_terminal_accessors() {
let mut state = TerminalEmulatorState::new(10, 5);
assert_eq!(state.terminal().width(), 10);
state.terminal_mut().put_char('A');
assert_eq!(state.terminal().cell(0, 0).unwrap().ch, 'A');
}
#[test]
fn default_emulator_has_cursor_hidden() {
let from_default = TerminalEmulator::default();
assert!(!from_default.show_cursor);
assert!(!from_default.cursor_visible_phase);
}
#[test]
fn widget_render_clears_area() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
frame.buffer.set(1, 1, BufferCell::from_char('Z'));
assert_eq!(frame.buffer.get(1, 1).unwrap().content.as_char(), Some('Z'));
Widget::render(&widget, Rect::new(0, 0, 10, 5), &mut frame);
assert_eq!(frame.buffer.get(1, 1).unwrap().content.as_char(), Some(' '));
}
#[test]
fn stateful_render_without_scroll() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new().show_cursor(false);
let mut state = TerminalEmulatorState::new(10, 5);
state.terminal_mut().put_char('H');
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
}
#[test]
fn stateful_render_zero_area_noop() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let mut state = TerminalEmulatorState::new(10, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
StatefulWidget::render(&widget, Rect::new(0, 0, 0, 5), &mut frame, &mut state);
StatefulWidget::render(&widget, Rect::new(0, 0, 10, 0), &mut frame, &mut state);
}
#[test]
fn convert_cell_all_attrs() {
let widget = TerminalEmulator::new();
let term_cell = TerminalCell {
ch: 'A',
fg: None,
bg: None,
attrs: CellAttrs::DIM
.with(CellAttrs::UNDERLINE)
.with(CellAttrs::BLINK)
.with(CellAttrs::REVERSE)
.with(CellAttrs::STRIKETHROUGH)
.with(CellAttrs::HIDDEN),
};
let buf_cell = widget.convert_cell(&term_cell);
let flags = buf_cell.attrs.flags();
assert!(flags.contains(StyleFlags::DIM));
assert!(flags.contains(StyleFlags::UNDERLINE));
assert!(flags.contains(StyleFlags::BLINK));
assert!(flags.contains(StyleFlags::REVERSE));
assert!(flags.contains(StyleFlags::STRIKETHROUGH));
assert!(flags.contains(StyleFlags::HIDDEN));
}
#[test]
fn with_scrollback_constructor() {
let state = TerminalEmulatorState::with_scrollback(20, 10, 500);
assert_eq!(state.terminal.width(), 20);
assert_eq!(state.terminal.height(), 10);
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn apply_cursor_block_sets_reverse() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let mut state = TerminalEmulatorState::new(10, 5);
state.terminal_mut().put_char('A');
state.terminal_mut().move_cursor(0, 0);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(
cell.attrs.flags().contains(StyleFlags::REVERSE),
"Block cursor should set REVERSE flag"
);
}
#[test]
fn apply_cursor_underline_sets_underline() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let cursor = Cursor {
x: 2,
y: 1,
visible: true,
shape: CursorShape::Underline,
saved: None,
};
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
widget.apply_cursor(&cursor, 2, 1, &mut frame);
let cell = frame.buffer.get(2, 1).unwrap();
assert!(
cell.attrs.flags().contains(StyleFlags::UNDERLINE),
"Underline cursor should set UNDERLINE flag"
);
}
#[test]
fn apply_cursor_bar_sets_reverse() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let cursor = Cursor {
x: 0,
y: 0,
visible: true,
shape: CursorShape::Bar,
saved: None,
};
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
widget.apply_cursor(&cursor, 0, 0, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(
cell.attrs.flags().contains(StyleFlags::REVERSE),
"Bar cursor should set REVERSE flag"
);
}
#[test]
fn apply_cursor_block_sets_reverse_directly() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let cursor = Cursor {
x: 5,
y: 3,
visible: true,
shape: CursorShape::Block,
saved: None,
};
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
widget.apply_cursor(&cursor, 5, 3, &mut frame);
let cell = frame.buffer.get(5, 3).unwrap();
assert!(
cell.attrs.flags().contains(StyleFlags::REVERSE),
"Block cursor should set REVERSE flag"
);
}
#[test]
fn apply_cursor_wrong_position_no_effect() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let cursor = Cursor {
x: 5,
y: 3,
visible: true,
shape: CursorShape::Block,
saved: None,
};
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
widget.apply_cursor(&cursor, 0, 0, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(
!cell.attrs.flags().contains(StyleFlags::REVERSE),
"Cursor should not modify cell at wrong position"
);
}
#[test]
fn apply_cursor_invisible_no_effect() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let cursor = Cursor {
x: 0,
y: 0,
visible: false,
shape: CursorShape::Block,
saved: None,
};
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
widget.apply_cursor(&cursor, 0, 0, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(
!cell.attrs.flags().contains(StyleFlags::REVERSE),
"Invisible cursor should not modify cell"
);
}
#[test]
fn cursor_hidden_when_show_cursor_false() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new().show_cursor(false);
let mut state = TerminalEmulatorState::new(10, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(
!cell.attrs.flags().contains(StyleFlags::REVERSE),
"Cursor should not render when show_cursor is false"
);
}
#[test]
fn cursor_hidden_when_phase_invisible() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new().cursor_phase(false);
let mut state = TerminalEmulatorState::new(10, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(
!cell.attrs.flags().contains(StyleFlags::REVERSE),
"Cursor should not render when blink phase is invisible"
);
}
#[test]
fn cursor_hidden_when_terminal_cursor_invisible() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let mut state = TerminalEmulatorState::new(10, 5);
state.terminal_mut().set_cursor_visible(false);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(
!cell.attrs.flags().contains(StyleFlags::REVERSE),
"Cursor should not render when terminal cursor is invisible"
);
}
#[test]
fn cursor_not_rendered_when_scrolled() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let mut state = TerminalEmulatorState::with_scrollback(10, 5, 100);
for _ in 0..3 {
state.terminal.scroll_up(1);
}
state.scroll_up(1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
let cursor = state.terminal.cursor();
let cursor_x = area.x + cursor.x;
let cursor_y = area.y + cursor.y;
if let Some(cell) = frame.buffer.get(cursor_x, cursor_y) {
assert!(
!cell.attrs.flags().contains(StyleFlags::REVERSE),
"Cursor should not render when view is scrolled into scrollback"
);
}
}
#[test]
fn stateful_render_with_scrollback() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new().show_cursor(false);
let mut state = TerminalEmulatorState::with_scrollback(10, 5, 100);
state.terminal_mut().put_char('S');
for _ in 0..5 {
state.terminal.scroll_up(1);
}
state.scroll_up(1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
assert!(frame.buffer.get(0, 0).is_some());
}
#[test]
fn render_partial_scrollback() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new().show_cursor(false);
let mut state = TerminalEmulatorState::with_scrollback(10, 5, 100);
for i in 0..8 {
let ch = (b'A' + i) as char;
state.terminal_mut().move_cursor(0, 0);
state.terminal_mut().put_char(ch);
state.terminal.scroll_up(1);
}
state.scroll_up(2);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
for y in 0..5 {
assert!(frame.buffer.get(0, y).is_some());
}
}
#[test]
fn render_area_smaller_than_terminal() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new().show_cursor(false);
let mut state = TerminalEmulatorState::new(10, 5);
for y in 0..5u16 {
for x in 0..10u16 {
state.terminal_mut().move_cursor(x, y);
state.terminal_mut().put_char('X');
}
}
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 3, 2);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('X'));
assert_eq!(frame.buffer.get(2, 1).unwrap().content.as_char(), Some('X'));
let outside = frame.buffer.get(5, 3).unwrap();
assert_eq!(outside.content.as_char(), None);
}
#[test]
fn render_area_with_offset() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new().show_cursor(false);
let mut state = TerminalEmulatorState::new(5, 3);
state.terminal_mut().move_cursor(0, 0);
state.terminal_mut().put_char('O');
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 10, &mut pool);
let area = Rect::new(5, 3, 5, 3);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
assert_eq!(frame.buffer.get(5, 3).unwrap().content.as_char(), Some('O'));
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), None);
}
#[test]
fn render_area_larger_than_terminal() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new().show_cursor(false);
let mut state = TerminalEmulatorState::new(3, 2);
state.terminal_mut().move_cursor(0, 0);
state.terminal_mut().put_char('T');
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 10, &mut pool);
let area = Rect::new(0, 0, 10, 10);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('T'));
assert_eq!(frame.buffer.get(5, 5).unwrap().content.as_char(), None);
}
#[test]
fn widget_render_clears_with_offset() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 10, &mut pool);
frame.buffer.set(7, 4, BufferCell::from_char('Z'));
Widget::render(&widget, Rect::new(5, 3, 10, 5), &mut frame);
assert_eq!(frame.buffer.get(7, 4).unwrap().content.as_char(), Some(' '));
}
#[test]
fn render_multiple_cells_varied_attrs() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new().show_cursor(false);
let mut state = TerminalEmulatorState::new(10, 5);
{
let pen = state.terminal_mut().pen_mut();
pen.attrs = CellAttrs::BOLD;
pen.fg = Some(Color::rgb(255, 0, 0));
}
state.terminal_mut().put_char('A');
{
let pen = state.terminal_mut().pen_mut();
pen.attrs = CellAttrs::ITALIC;
pen.fg = Some(Color::rgb(0, 255, 0));
}
state.terminal_mut().put_char('B');
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
let cell_a = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell_a.content.as_char(), Some('A'));
assert!(cell_a.attrs.flags().contains(StyleFlags::BOLD));
assert_eq!(cell_a.fg.r(), 255);
let cell_b = frame.buffer.get(1, 0).unwrap();
assert_eq!(cell_b.content.as_char(), Some('B'));
assert!(cell_b.attrs.flags().contains(StyleFlags::ITALIC));
assert_eq!(cell_b.fg.g(), 255);
}
#[test]
fn render_cell_with_bg_color() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new().show_cursor(false);
let mut state = TerminalEmulatorState::new(10, 5);
{
let pen = state.terminal_mut().pen_mut();
pen.bg = Some(Color::rgb(0, 0, 128));
}
state.terminal_mut().put_char('C');
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('C'));
assert_eq!(cell.bg.b(), 128);
assert_eq!(cell.bg.a(), 255);
}
#[test]
fn cursor_renders_at_non_origin_terminal_position() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let mut state = TerminalEmulatorState::new(10, 5);
state.terminal_mut().move_cursor(3, 2);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
let cell_at_cursor = frame.buffer.get(3, 2).unwrap();
assert!(
cell_at_cursor.attrs.flags().contains(StyleFlags::REVERSE),
"Cursor should render at terminal cursor position"
);
let cell_away = frame.buffer.get(0, 0).unwrap();
assert!(
!cell_away.attrs.flags().contains(StyleFlags::REVERSE),
"Cell away from cursor should not have REVERSE flag"
);
}
#[test]
fn cursor_at_last_valid_position() {
use ftui_render::grapheme_pool::GraphemePool;
let widget = TerminalEmulator::new();
let mut state = TerminalEmulatorState::new(10, 5);
state.terminal_mut().move_cursor(9, 4);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let area = Rect::new(0, 0, 10, 5);
StatefulWidget::render(&widget, area, &mut frame, &mut state);
let cell = frame.buffer.get(9, 4).unwrap();
assert!(
cell.attrs.flags().contains(StyleFlags::REVERSE),
"Cursor should render at bottom-right corner"
);
}
#[test]
fn color_to_packed_black() {
let packed = color_to_packed(Color::rgb(0, 0, 0));
assert_eq!(packed.r(), 0);
assert_eq!(packed.g(), 0);
assert_eq!(packed.b(), 0);
assert_eq!(packed.a(), 255);
}
#[test]
fn color_to_packed_white() {
let packed = color_to_packed(Color::rgb(255, 255, 255));
assert_eq!(packed.r(), 255);
assert_eq!(packed.g(), 255);
assert_eq!(packed.b(), 255);
assert_eq!(packed.a(), 255);
}
#[test]
fn scroll_up_zero_is_noop() {
let mut state = TerminalEmulatorState::with_scrollback(10, 5, 100);
for _ in 0..3 {
state.terminal.scroll_up(1);
}
state.scroll_up(0);
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn scroll_down_zero_is_noop() {
let mut state = TerminalEmulatorState::with_scrollback(10, 5, 100);
for _ in 0..3 {
state.terminal.scroll_up(1);
}
state.scroll_up(2);
assert_eq!(state.scroll_offset, 2);
state.scroll_down(0);
assert_eq!(state.scroll_offset, 2);
}
#[test]
fn resize_to_smaller_clamps_scroll() {
let mut state = TerminalEmulatorState::with_scrollback(10, 10, 100);
for _ in 0..20 {
state.terminal.scroll_up(1);
}
state.scroll_up(15);
state.resize(5, 5);
assert!(state.scroll_offset <= state.terminal.scrollback().len());
}
#[test]
fn resize_to_larger_preserves_scroll() {
let mut state = TerminalEmulatorState::with_scrollback(10, 5, 100);
for _ in 0..5 {
state.terminal.scroll_up(1);
}
state.scroll_up(3);
let offset_before = state.scroll_offset;
state.resize(20, 10);
assert!(state.scroll_offset <= offset_before);
}
#[test]
fn multiple_scroll_up_down_roundtrip() {
let mut state = TerminalEmulatorState::with_scrollback(10, 5, 100);
for _ in 0..10 {
state.terminal.scroll_up(1);
}
state.scroll_up(5);
state.scroll_up(3);
assert_eq!(state.scroll_offset, 8);
state.scroll_down(4);
assert_eq!(state.scroll_offset, 4);
state.scroll_down(10); assert_eq!(state.scroll_offset, 0);
}
}