#![allow(clippy::explicit_counter_loop)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_write_char_private() {
let mut terminal = Terminal::new(80, 24);
terminal.write_char('a');
assert_eq!(terminal.input_buffer, "");
assert_eq!(terminal.lines[0].cells.len(), 1);
}
#[test]
fn test_put_cell_private() {
let mut terminal = Terminal::new(80, 24);
terminal.write("test");
assert_eq!(terminal.lines[0].cells.len(), 4);
}
#[test]
fn test_newline_private() {
let mut terminal = Terminal::new(80, 24);
terminal.writeln("line1");
terminal.writeln("line2");
assert_eq!(terminal.lines.len(), 24); assert_eq!(terminal.lines[0].cells.len(), 5); assert_eq!(terminal.lines[1].cells.len(), 5); }
#[test]
fn test_trim_scrollback_private() {
let mut terminal = Terminal::new(80, 24);
terminal.max_scrollback = 5;
for i in 0..10 {
terminal.writeln(&format!("line {}", i));
}
assert!(terminal.lines.len() <= terminal.height as usize + terminal.max_scrollback);
}
#[test]
fn test_visible_range_private() {
let terminal = Terminal::new(80, 24);
let range = terminal.visible_range();
assert!(range.start <= range.end);
}
#[test]
fn test_render_cursor_private() {
let mut terminal = Terminal::new(80, 24);
terminal.focus();
let mut buffer = crate::render::Buffer::new(80, 24);
let area = crate::layout::Rect::new(0, 0, 80, 24);
let mut ctx = crate::widget::traits::RenderContext::new(&mut buffer, area);
terminal.render(&mut ctx);
}
}
use super::ansi::AnsiParser;
use super::types::{CursorStyle, TermCell, TermLine, TerminalAction};
use crate::event::{Key, KeyEvent};
use crate::render::Cell;
use crate::style::Color;
use crate::widget::theme::{DARK_BG, EDITOR_BG, MUTED_TEXT, SECONDARY_TEXT, SEPARATOR_COLOR};
use crate::widget::traits::{RenderContext, View, WidgetProps};
use crate::{impl_props_builders, impl_styled_view};
pub struct Terminal {
lines: Vec<TermLine>,
cursor_row: usize,
cursor_col: usize,
scroll_offset: usize,
max_scrollback: usize,
width: u16,
height: u16,
parser: AnsiParser,
default_fg: Color,
default_bg: Color,
show_cursor: bool,
cursor_style: CursorStyle,
title: Option<String>,
input_buffer: String,
history: Vec<String>,
history_pos: usize,
focused: bool,
props: WidgetProps,
}
impl Terminal {
pub fn new(width: u16, height: u16) -> Self {
let width = width.min(1000);
let height = height.min(1000);
let mut lines = Vec::with_capacity(height as usize);
for _ in 0..height {
lines.push(TermLine::with_capacity(width as usize));
}
Self {
lines,
cursor_row: 0,
cursor_col: 0,
scroll_offset: 0,
max_scrollback: 10000,
width,
height,
parser: AnsiParser::new(),
default_fg: Color::WHITE,
default_bg: Color::BLACK,
show_cursor: true,
cursor_style: CursorStyle::Block,
title: None,
input_buffer: String::new(),
history: Vec::new(),
history_pos: 0,
focused: false,
props: WidgetProps::new(),
}
}
pub fn max_scrollback(mut self, lines: usize) -> Self {
self.max_scrollback = lines;
self
}
pub fn default_fg(mut self, color: Color) -> Self {
self.default_fg = color;
self.parser.reset_fg(color);
self
}
pub fn default_bg(mut self, color: Color) -> Self {
self.default_bg = color;
self.parser.reset_bg(color);
self
}
pub fn show_cursor(mut self, show: bool) -> Self {
self.show_cursor = show;
self
}
pub fn cursor_style(mut self, style: CursorStyle) -> Self {
self.cursor_style = style;
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn get_title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn focus(&mut self) {
self.focused = true;
}
pub fn blur(&mut self) {
self.focused = false;
}
pub fn is_focused(&self) -> bool {
self.focused
}
pub fn write(&mut self, text: &str) {
for ch in text.chars() {
self.write_char(ch);
}
}
fn write_char(&mut self, ch: char) {
if ch == '\n' {
self.newline();
return;
}
if ch == '\r' {
self.cursor_col = 0;
return;
}
if ch == '\t' {
let next_tab = ((self.cursor_col / 8) + 1) * 8;
while self.cursor_col < next_tab && self.cursor_col < self.width as usize {
self.write_char(' ');
}
return;
}
if let Some(cell) = self.parser.parse(ch) {
self.put_cell(cell);
}
}
fn put_cell(&mut self, cell: TermCell) {
while self.cursor_row >= self.lines.len() {
self.lines
.push(TermLine::with_capacity(self.width as usize));
}
let line = &mut self.lines[self.cursor_row];
while line.cells.len() <= self.cursor_col {
line.cells.push(TermCell::default());
}
line.cells[self.cursor_col] = cell;
self.cursor_col += 1;
if self.cursor_col >= self.width as usize {
self.cursor_col = 0;
self.cursor_row += 1;
if self.cursor_row >= self.lines.len() {
let mut new_line = TermLine::with_capacity(self.width as usize);
new_line.wrapped = true;
self.lines.push(new_line);
} else {
self.lines[self.cursor_row].wrapped = true;
}
self.trim_scrollback();
}
}
fn newline(&mut self) {
self.cursor_col = 0;
self.cursor_row += 1;
while self.cursor_row >= self.lines.len() {
self.lines
.push(TermLine::with_capacity(self.width as usize));
}
self.trim_scrollback();
}
fn trim_scrollback(&mut self) {
let total_lines = self.height as usize + self.max_scrollback;
while self.lines.len() > total_lines {
self.lines.remove(0);
if self.cursor_row > 0 {
self.cursor_row -= 1;
}
}
}
pub fn writeln(&mut self, text: &str) {
self.write(text);
self.write("\n");
}
pub fn clear(&mut self) {
self.lines.clear();
for _ in 0..self.height {
self.lines
.push(TermLine::with_capacity(self.width as usize));
}
self.cursor_row = 0;
self.cursor_col = 0;
self.scroll_offset = 0;
}
pub fn clear_line(&mut self) {
if self.cursor_row < self.lines.len() {
self.lines[self.cursor_row].cells.clear();
}
self.cursor_col = 0;
}
pub fn scroll_up(&mut self, lines: usize) {
let max_scroll = self.lines.len().saturating_sub(self.height as usize);
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 scroll_to_bottom(&mut self) {
self.scroll_offset = 0;
}
pub fn scroll_to_top(&mut self) {
self.scroll_offset = self.lines.len().saturating_sub(self.height as usize);
}
pub fn get_input(&self) -> &str {
&self.input_buffer
}
pub fn clear_input(&mut self) {
self.input_buffer.clear();
}
pub fn handle_key(&mut self, key: KeyEvent) -> Option<TerminalAction> {
match key.key {
Key::Char(c) => {
self.input_buffer.push(c);
None
}
Key::Backspace => {
self.input_buffer.pop();
None
}
Key::Enter => {
let input = std::mem::take(&mut self.input_buffer);
if !input.is_empty() {
self.history.push(input.clone());
self.history_pos = self.history.len();
}
Some(TerminalAction::Submit(input))
}
Key::Up => {
if self.history_pos > 0 {
self.history_pos -= 1;
self.input_buffer = self
.history
.get(self.history_pos)
.cloned()
.unwrap_or_default();
}
None
}
Key::Down => {
if self.history_pos < self.history.len() {
self.history_pos += 1;
self.input_buffer = self
.history
.get(self.history_pos)
.cloned()
.unwrap_or_default();
}
None
}
Key::PageUp => {
self.scroll_up(self.height as usize / 2);
None
}
Key::PageDown => {
self.scroll_down(self.height as usize / 2);
None
}
Key::Home => {
self.input_buffer.clear();
None
}
Key::End => {
self.scroll_to_bottom();
None
}
Key::Escape => Some(TerminalAction::Cancel),
Key::Tab => Some(TerminalAction::TabComplete(self.input_buffer.clone())),
_ => None,
}
}
pub fn resize(&mut self, width: u16, height: u16) {
self.width = width;
self.height = height;
while self.lines.len() < height as usize {
self.lines.push(TermLine::with_capacity(width as usize));
}
}
fn visible_range(&self) -> std::ops::Range<usize> {
let total = self.lines.len();
let visible = self.height as usize;
let end = total.saturating_sub(self.scroll_offset);
let start = end.saturating_sub(visible);
start..end
}
pub fn shell(width: u16, height: u16) -> Self {
Self::new(width, height)
.default_fg(SECONDARY_TEXT)
.default_bg(EDITOR_BG)
.cursor_style(CursorStyle::Block)
}
pub fn log_viewer(width: u16, height: u16) -> Self {
Self::new(width, height)
.default_fg(MUTED_TEXT)
.default_bg(Color::rgb(20, 20, 20))
.show_cursor(false)
.max_scrollback(50000)
}
}
impl Default for Terminal {
fn default() -> Self {
Self::new(80, 24)
}
}
impl View for Terminal {
crate::impl_view_meta!("Terminal");
fn render(&self, ctx: &mut RenderContext) {
let area = ctx.area;
if area.width < 1 || area.height < 1 {
return;
}
for y in 0..area.height {
for x in 0..area.width {
ctx.set(x, y, Cell::new(' ').bg(self.default_bg));
}
}
let visible = self.visible_range();
let mut render_y = 0u16;
for line_idx in visible {
if render_y >= area.height {
break;
}
if let Some(line) = self.lines.get(line_idx) {
for (col, cell) in line.cells.iter().enumerate() {
if col >= area.width as usize {
break;
}
let mut render_cell = Cell::new(cell.ch).fg(cell.fg).bg(cell.bg);
render_cell.modifier = cell.modifiers;
ctx.set(col as u16, render_y, render_cell);
}
}
render_y += 1;
}
if self.show_cursor && self.focused && self.scroll_offset == 0 {
let cursor_screen_row = self
.cursor_row
.saturating_sub(self.lines.len().saturating_sub(self.height as usize));
if cursor_screen_row < area.height as usize && self.cursor_col < area.width as usize {
let cursor_x = self.cursor_col as u16;
let cursor_y = cursor_screen_row as u16;
let cursor_char = match self.cursor_style {
CursorStyle::Block => '█',
CursorStyle::Underline => '_',
CursorStyle::Bar => '│',
};
ctx.set(
cursor_x,
cursor_y,
Cell::new(cursor_char).fg(self.default_fg),
);
}
}
if !self.input_buffer.is_empty() && self.focused {
let input_y = area.height - 1;
let prompt = "> ";
for x in 0..area.width {
ctx.set(x, input_y, Cell::new(' ').bg(DARK_BG));
}
let mut px: u16 = 0;
for ch in prompt.chars() {
let cw = crate::utils::char_width(ch) as u16;
if px + cw > area.width {
break;
}
ctx.set(px, input_y, Cell::new(ch).fg(Color::CYAN).bg(DARK_BG));
px += cw;
}
let mut ix = px;
for ch in self.input_buffer.chars() {
let cw = crate::utils::char_width(ch) as u16;
if ix + cw > area.width {
break;
}
ctx.set(ix, input_y, Cell::new(ch).fg(Color::WHITE).bg(DARK_BG));
ix += cw;
}
}
if self.scroll_offset > 0 {
let indicator = format!("↑{}", self.scroll_offset);
let indicator_len = indicator.len() as u16;
if area.width > indicator_len + 1 {
let start_x = area.width - indicator_len - 1;
for (i, ch) in indicator.chars().enumerate() {
ctx.set(
start_x + i as u16,
0,
Cell::new(ch).fg(Color::YELLOW).bg(SEPARATOR_COLOR),
);
}
}
}
}
}
impl_styled_view!(Terminal);
impl_props_builders!(Terminal);
pub fn terminal(width: u16, height: u16) -> Terminal {
Terminal::new(width, height)
}