use crossterm::event::{KeyCode, KeyModifiers, MouseEventKind};
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Span, Text},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::serial::InputMode;
use crate::state::{App, TerminalMode};
use super::common::{
draw_error_popup, draw_info_bar, help_bar_height, help_spans, info_bar_spans, sep_span,
};
pub fn draw(app: &mut App, frame: &mut Frame) {
let info_height = help_bar_height(info_bar_spans(app), frame.area().width).0;
let help_height = help_bar_height(
help_spans_for_mode(&app.terminal_mode, app.keyboard_enhanced),
frame.area().width,
)
.0;
let [info_area, output_area, help_area] = Layout::vertical([
Constraint::Length(info_height),
Constraint::Min(0),
Constraint::Length(help_height),
])
.areas(frame.area());
let inner = Rect {
x: output_area.x + 1,
y: output_area.y + 1,
width: output_area.width.saturating_sub(2),
height: output_area.height.saturating_sub(2),
};
app.resize_parser(inner.height, inner.width);
app.view.viewport_height = inner.height as usize;
app.view.output_rect = inner;
draw_info_bar(app, frame, info_area);
let lines_source = app
.view
.frozen_lines
.as_ref()
.unwrap_or(&app.view.scrollback);
let all_lines: Vec<&str> = lines_source
.iter()
.flat_map(|l| l.split_inclusive('\n'))
.flat_map(|l| l.strip_suffix('\n').or(Some(l)))
.collect();
let total = all_lines.len();
let height = inner.height as usize;
let max_offset = total.saturating_sub(height);
let scrolling = app.view.scroll_offset > 0;
let block = Block::new().borders(Borders::ALL);
frame.render_widget(block, output_area);
let selection = app.selection.range();
if scrolling {
let end = total.saturating_sub(app.view.scroll_offset.min(max_offset));
let start = end.saturating_sub(height);
let visible: Vec<&str> = all_lines[start..end].to_vec();
app.view.visible_lines = visible.iter().map(|l| l.to_string()).collect();
let lines: Vec<ratatui::text::Line<'static>> = visible
.iter()
.enumerate()
.map(|(row, line)| styled_line(line, row, selection))
.collect();
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
} else {
let screen = app.view.parser.screen();
let buf = frame.buffer_mut();
app.view.visible_lines = vec![String::new(); inner.height as usize];
for row in 0..inner.height {
for col in 0..inner.width {
if let Some(cell) = screen.cell(row, col) {
let ch = cell.contents();
if let Some(line) = app.view.visible_lines.get_mut(row as usize) {
line.push_str(if ch.is_empty() { " " } else { ch });
}
let selected = is_selected(selection, row as usize, col as usize);
if ch.is_empty() && !selected {
continue;
}
let mut style = Style::default();
style = style.fg(vt100_color(cell.fgcolor()));
style = style.bg(vt100_color(cell.bgcolor()));
if cell.bold() {
style = style.add_modifier(Modifier::BOLD);
}
if cell.italic() {
style = style.add_modifier(Modifier::ITALIC);
}
if cell.underline() {
style = style.add_modifier(Modifier::UNDERLINED);
}
if cell.inverse() {
style = style.add_modifier(Modifier::REVERSED);
}
if selected {
style = style.add_modifier(Modifier::REVERSED);
}
let symbol = if ch.is_empty() { " " } else { ch };
buf[(inner.x + col, inner.y + row)]
.set_symbol(symbol)
.set_style(style);
}
}
}
let (crow, ccol) = screen.cursor_position();
let (mut col, mut row) = (ccol, crow);
if app.input_mode == InputMode::Line {
let style = Style::default().fg(Color::Cyan);
let mut utf8 = [0u8; 4];
for ch in app.line.buffer.chars() {
if col >= inner.width {
col = 0;
row = row.saturating_add(1);
}
if row >= inner.height {
break;
}
let symbol = ch.encode_utf8(&mut utf8);
buf[(inner.x + col, inner.y + row)]
.set_symbol(symbol)
.set_style(style);
col += 1;
}
}
frame.set_cursor_position((
(inner.x + col).min(inner.x + inner.width - 1),
(inner.y + row).min(inner.y + inner.height - 1),
));
}
let (_, help_lines) = help_bar_height(
help_spans_for_mode(&app.terminal_mode, app.keyboard_enhanced),
help_area.width,
);
let help = Paragraph::new(Text::from(help_lines)).block(Block::new().borders(Borders::ALL));
frame.render_widget(help, help_area);
draw_error_popup(app, frame);
}
pub fn handle_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
match app.terminal_mode {
TerminalMode::Insert => handle_insert_mode(app, code, modifiers),
TerminalMode::Control => handle_control_mode(app, code),
}
}
pub fn handle_mouse(app: &mut App, mouse: crossterm::event::MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollUp => app.scroll(3),
MouseEventKind::ScrollDown => app.scroll(-3),
MouseEventKind::Down(_) => {
if let Some((row, col)) = cell_at(app, mouse.column, mouse.row) {
app.selection.start(row, col);
}
}
MouseEventKind::Drag(_) | MouseEventKind::Moved if app.selection.active => {
let (row, col) = clamp_cell(app, mouse.column, mouse.row);
app.selection.update(row, col);
}
MouseEventKind::Up(_) if app.selection.active => app.finish_selection(),
_ => {}
}
}
fn cell_at(app: &App, col: u16, row: u16) -> Option<(usize, usize)> {
let rect = app.view.output_rect;
if col < rect.x || col >= rect.x + rect.width || row < rect.y || row >= rect.y + rect.height {
return None;
}
Some(((row - rect.y) as usize, (col - rect.x) as usize))
}
fn clamp_cell(app: &App, col: u16, row: u16) -> (usize, usize) {
let rect = app.view.output_rect;
let col = col.clamp(rect.x, rect.x + rect.width.saturating_sub(1));
let row = row.clamp(rect.y, rect.y + rect.height.saturating_sub(1));
((row - rect.y) as usize, (col - rect.x) as usize)
}
fn help_spans_for_mode(mode: &TerminalMode, keyboard_enhanced: bool) -> Vec<Span<'static>> {
match mode {
TerminalMode::Insert => {
let mut spans = vec![
Span::styled(
" INSERT ",
Style::default().fg(Color::Black).bg(Color::Green).bold(),
),
sep_span(),
];
let escape_hint: &'static str = if keyboard_enhanced {
"Ctrl+Esc"
} else {
"Ctrl+Space"
};
spans.extend(help_spans(&[(escape_hint, "control mode")]));
spans
}
TerminalMode::Control => {
let mut spans = vec![
Span::styled(
" CONTROL ",
Style::default().fg(Color::Black).bg(Color::Cyan).bold(),
),
sep_span(),
];
spans.extend(help_spans(&[
("a", "insert"),
("↑↓", "scroll"),
("esc", "bottom"),
("f", "flush"),
("c", "copy"),
("+/-", "baud"),
("p", "pause"),
("s", "settings"),
("del", "port select"),
("q", "quit"),
]));
spans
}
}
}
fn handle_insert_mode(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
let is_escape =
(app.keyboard_enhanced && code == KeyCode::Esc && modifiers == KeyModifiers::CONTROL)
|| (code == KeyCode::Char(' ') && modifiers == KeyModifiers::CONTROL);
if is_escape {
app.terminal_mode = TerminalMode::Control;
return;
}
if code == KeyCode::Esc {
let had_selection = app.selection.anchor.is_some();
app.selection.clear();
if app.view.scroll_offset > 0 {
app.scroll_to_bottom();
return;
}
if had_selection {
return;
}
}
if app.input_mode == InputMode::Line && !modifiers.contains(KeyModifiers::CONTROL) {
match code {
KeyCode::Char(c) => app.line.buffer.push(c),
KeyCode::Backspace => {
app.line.buffer.pop();
}
KeyCode::Enter => app.send_line(),
KeyCode::Up => app.history_prev(),
KeyCode::Down => app.history_next(),
_ => {}
}
return;
}
let key_event = crossterm::event::Event::Key(crossterm::event::KeyEvent::new(code, modifiers));
if let Ok(t_event) = terminput_crossterm::to_terminput(key_event) {
let mut buf = [0u8; 16];
if let Ok(n) = t_event.encode(&mut buf, terminput::Encoding::Xterm) {
let mut bytes = buf[..n].to_vec();
if app.local_echo {
app.echo_local(&bytes);
}
if bytes == [0x0d] {
use crate::serial::NewlineEncoding;
match app.outgoing_newline {
NewlineEncoding::CR => {}
NewlineEncoding::LF => bytes[0] = 0x0a,
NewlineEncoding::CRLF => bytes.push(0x0a),
}
}
app.send_bytes(bytes);
}
}
}
fn handle_control_mode(app: &mut App, code: KeyCode) {
match code {
KeyCode::Char('a') | KeyCode::Char('i') => {
app.scroll_to_bottom();
app.terminal_mode = TerminalMode::Insert;
}
KeyCode::Up | KeyCode::Char('k') => app.scroll(3),
KeyCode::Down | KeyCode::Char('j') => app.scroll(-3),
KeyCode::Esc => {
app.selection.clear();
app.scroll_to_bottom();
}
KeyCode::Backspace | KeyCode::Delete => app.disconnect(),
KeyCode::Char('f') => app.flush_screen(),
KeyCode::Char('c') => app.copy_to_clipboard(),
KeyCode::Char('+') => app.change_baud(1),
KeyCode::Char('-') => app.change_baud(-1),
KeyCode::Char('p') => app.toggle_pause(),
KeyCode::Char('s') => app.settings.show = true,
KeyCode::Char('q') => {
app.disconnect();
app.exit = true;
}
_ => {}
}
}
fn is_selected(
selection: Option<((usize, usize), (usize, usize))>,
row: usize,
col: usize,
) -> bool {
let Some((start, end)) = selection else {
return false;
};
if row < start.0 || row > end.0 {
return false;
}
let from = if row == start.0 { start.1 } else { 0 };
let to = if row == end.0 { end.1 } else { usize::MAX };
col >= from && col <= to
}
fn styled_line(
line: &str,
row: usize,
selection: Option<((usize, usize), (usize, usize))>,
) -> ratatui::text::Line<'static> {
let Some((start, end)) = selection else {
return ratatui::text::Line::from(line.to_string());
};
if row < start.0 || row > end.0 {
return ratatui::text::Line::from(line.to_string());
}
let chars: Vec<char> = line.chars().collect();
let from = (if row == start.0 { start.1 } else { 0 }).min(chars.len());
let to = (if row == end.0 { end.1 + 1 } else { chars.len() })
.max(from)
.min(chars.len());
let before: String = chars[..from].iter().collect();
let selected: String = chars[from..to].iter().collect();
let after: String = chars[to..].iter().collect();
let mut spans = Vec::new();
if !before.is_empty() {
spans.push(Span::raw(before));
}
spans.push(Span::styled(
selected,
Style::default().add_modifier(Modifier::REVERSED),
));
if !after.is_empty() {
spans.push(Span::raw(after));
}
ratatui::text::Line::from(spans)
}
fn vt100_color(color: vt100::Color) -> Color {
match color {
vt100::Color::Default => Color::Reset,
vt100::Color::Idx(i) => Color::Indexed(i),
vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
}
}