Files
vt220-emulator/src/main.rs

505 lines
18 KiB
Rust

mod renderer;
mod terminal;
use copypasta::{ClipboardContext, ClipboardProvider};
use renderer::{CellColor, CursorInfo, TerminalLine, TerminalRenderer, TextSpan};
use std::collections::HashMap;
use std::sync::Arc;
use terminal::TerminalEmulator;
use winit::{
application::ApplicationHandler,
dpi::LogicalSize,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
keyboard::{Key, NamedKey, ModifiersState},
window::{Window, WindowId},
};
fn main() {
env_logger::init();
let event_loop = EventLoop::new().unwrap();
let mut app = App {
windows: HashMap::new(),
next_window_offset: (50, 50),
};
event_loop.run_app(&mut app).unwrap();
}
struct App {
windows: HashMap<WindowId, WindowState>,
next_window_offset: (i32, i32),
}
struct WindowState {
window: Arc<Window>,
renderer: TerminalRenderer,
terminal: TerminalEmulator,
clipboard: Option<ClipboardContext>,
columns: usize,
lines: usize,
needs_redraw: bool,
modifiers: ModifiersState,
}
impl App {
fn create_window(&mut self, event_loop: &ActiveEventLoop) {
let (ox, oy) = self.next_window_offset;
self.next_window_offset = ((ox + 30) % 300, (oy + 30) % 300);
let window_attributes = Window::default_attributes()
.with_inner_size(LogicalSize::new(800.0, 600.0))
.with_title("VT220 Emulator")
.with_position(winit::dpi::PhysicalPosition::new(ox, oy));
let window = Arc::new(event_loop.create_window(window_attributes).unwrap());
let renderer = pollster::block_on(TerminalRenderer::new(window.clone(), event_loop));
let inner = window.inner_size();
let cols = (inner.width as f32 / renderer.cell_width).max(2.0) as usize;
let lines = (inner.height as f32 / renderer.cell_height).max(1.0) as usize;
let terminal = TerminalEmulator::new(cols, lines).expect("Failed to create terminal");
let clipboard = ClipboardContext::new().ok();
let window_id = window.id();
let state = WindowState {
window,
renderer,
terminal,
clipboard,
columns: cols,
lines,
needs_redraw: true,
modifiers: ModifiersState::empty(),
};
self.windows.insert(window_id, state);
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.windows.is_empty() {
self.create_window(event_loop);
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
) {
// Handle window destruction events first to avoid borrow issues.
match event {
WindowEvent::CloseRequested | WindowEvent::Destroyed => {
self.windows.remove(&window_id);
if self.windows.is_empty() {
event_loop.exit();
}
return;
}
_ => {}
}
let Some(state) = self.windows.get_mut(&window_id) else {
return;
};
match event {
WindowEvent::Resized(size) => {
state.renderer.resize(size);
let cols = (size.width as f32 / state.renderer.cell_width).max(2.0) as usize;
let lines = (size.height as f32 / state.renderer.cell_height).max(1.0) as usize;
state.columns = cols;
state.lines = lines;
state.terminal.resize(
cols,
lines,
state.renderer.cell_width as u16,
state.renderer.cell_height as u16,
);
state.window.request_redraw();
}
WindowEvent::KeyboardInput { event, .. } => {
let logical_key = event.logical_key;
let mods = state.modifiers;
// New window shortcut.
if mods.control_key() && mods.shift_key() {
if let Key::Character(c) = &logical_key {
if c.as_str().eq_ignore_ascii_case("n") {
self.create_window(event_loop);
return;
}
}
}
// Copy / Paste shortcuts.
if mods.control_key() && mods.shift_key() {
if let Key::Character(c) = &logical_key {
let ch = c.as_str();
if ch.eq_ignore_ascii_case("c") {
let clipboard = &mut state.clipboard;
state.terminal.with_term(|term| {
if let Some(text) = term.selection_to_string() {
if let Some(cb) = clipboard {
let _ = cb.set_contents(text);
}
}
});
return;
}
if ch.eq_ignore_ascii_case("v") {
if let Some(cb) = &mut state.clipboard {
if let Ok(text) = cb.get_contents() {
state.terminal.send_input(text.as_bytes());
}
}
return;
}
}
}
let mut input_bytes: Vec<u8> = Vec::new();
match logical_key {
Key::Named(NamedKey::Enter) => {
input_bytes.push(b'\r');
}
Key::Named(NamedKey::Backspace) => {
input_bytes.push(0x7f);
}
Key::Named(NamedKey::Tab) => {
input_bytes.push(b'\t');
}
Key::Named(NamedKey::Escape) => {
input_bytes.push(0x1b);
}
Key::Named(NamedKey::ArrowUp) => {
input_bytes.extend_from_slice(b"\x1b[A");
}
Key::Named(NamedKey::ArrowDown) => {
input_bytes.extend_from_slice(b"\x1b[B");
}
Key::Named(NamedKey::ArrowRight) => {
input_bytes.extend_from_slice(b"\x1b[C");
}
Key::Named(NamedKey::ArrowLeft) => {
input_bytes.extend_from_slice(b"\x1b[D");
}
Key::Named(NamedKey::Home) => {
input_bytes.extend_from_slice(b"\x1b[H");
}
Key::Named(NamedKey::End) => {
input_bytes.extend_from_slice(b"\x1b[F");
}
Key::Named(NamedKey::Delete) => {
input_bytes.extend_from_slice(b"\x1b[3~");
}
Key::Named(NamedKey::Insert) => {
input_bytes.extend_from_slice(b"\x1b[2~");
}
Key::Named(NamedKey::PageUp) => {
input_bytes.extend_from_slice(b"\x1b[5~");
}
Key::Named(NamedKey::PageDown) => {
input_bytes.extend_from_slice(b"\x1b[6~");
}
Key::Named(NamedKey::F1) => {
input_bytes.extend_from_slice(b"\x1bOP");
}
Key::Named(NamedKey::F2) => {
input_bytes.extend_from_slice(b"\x1bOQ");
}
Key::Named(NamedKey::F3) => {
input_bytes.extend_from_slice(b"\x1bOR");
}
Key::Named(NamedKey::F4) => {
input_bytes.extend_from_slice(b"\x1bOS");
}
Key::Character(c) => {
let s = c.as_str();
if s.len() == 1 {
let b = s.as_bytes()[0];
input_bytes.push(b);
} else {
input_bytes.extend_from_slice(s.as_bytes());
}
}
_ => {}
}
if !input_bytes.is_empty() {
state.terminal.send_input(&input_bytes);
state.window.request_redraw();
}
}
WindowEvent::ModifiersChanged(mods) => {
state.modifiers = mods.state();
}
WindowEvent::MouseWheel { delta, .. } => {
let lines = match delta {
winit::event::MouseScrollDelta::LineDelta(_, y) => -y as i32,
winit::event::MouseScrollDelta::PixelDelta(pos) => {
-(pos.y / state.renderer.cell_height as f64) as i32
}
};
if lines != 0 {
state.terminal.with_term(|term| {
term.scroll_display(alacritty_terminal::grid::Scroll::Delta(lines));
});
state.window.request_redraw();
}
}
WindowEvent::RedrawRequested => {
state.needs_redraw = true;
}
_ => {}
}
if state.needs_redraw {
state.needs_redraw = false;
draw(state);
}
}
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
let mut windows_to_redraw = Vec::new();
for (window_id, state) in &mut self.windows {
let events = state.terminal.read_events();
let mut need_redraw = false;
for event in events {
match event {
alacritty_terminal::event::Event::Wakeup => {
need_redraw = true;
}
alacritty_terminal::event::Event::ClipboardStore(_ty, text) => {
if let Some(cb) = &mut state.clipboard {
let _ = cb.set_contents(text);
}
}
alacritty_terminal::event::Event::ClipboardLoad(_ty, formatter) => {
if let Some(cb) = &mut state.clipboard {
if let Ok(content) = cb.get_contents() {
let formatted = formatter(&content);
state.terminal.send_input(formatted.as_bytes());
}
}
}
alacritty_terminal::event::Event::Title(title) => {
state.window.set_title(&format!("VT220 Emulator - {}", title));
}
alacritty_terminal::event::Event::Exit => {
windows_to_redraw.push(*window_id);
}
_ => {}
}
}
if need_redraw {
state.window.request_redraw();
}
}
for window_id in windows_to_redraw {
self.windows.remove(&window_id);
}
if self.windows.is_empty() {
_event_loop.exit();
}
}
}
fn draw(state: &mut WindowState) {
let mut lines: Vec<TerminalLine> = Vec::with_capacity(state.lines);
let mut cursor: Option<CursorInfo> = None;
state.terminal.with_term(|term| {
let grid = term.grid();
let colors = term.colors();
let mode = *term.mode();
let display_offset = grid.display_offset() as i32;
let mut current_line_spans: Vec<TextSpan> = Vec::new();
let mut current_text = String::new();
let mut current_fg = CellColor::new(255, 255, 255);
let mut last_line = 0i32;
let cursor_point = grid.cursor.point;
let cursor_viewport_y = cursor_point.line.0 + display_offset;
let cursor_info = CursorInfo {
x: cursor_point.column.0,
y: cursor_viewport_y as usize,
visible: mode.contains(alacritty_terminal::term::TermMode::SHOW_CURSOR),
};
cursor = Some(cursor_info);
for indexed in grid.display_iter() {
let cell = indexed.cell;
let point = indexed.point;
let line_in_viewport = point.line.0 + display_offset;
if line_in_viewport != last_line {
if !current_text.is_empty() {
current_line_spans.push(TextSpan {
text: current_text.clone(),
fg: current_fg,
});
}
lines.push(TerminalLine {
spans: std::mem::take(&mut current_line_spans),
});
current_text.clear();
last_line = line_in_viewport;
}
let fg = resolve_color(cell.fg, colors, true);
let bg = resolve_color(cell.bg, colors, false);
let is_cursor = cursor_info.visible
&& cursor_info.x == point.column.0
&& cursor_info.y == line_in_viewport as usize;
if is_cursor {
if !current_text.is_empty() {
current_line_spans.push(TextSpan {
text: current_text.clone(),
fg: current_fg,
});
current_text.clear();
}
let ch = if cell.c == '\0' { ' ' } else { cell.c };
current_line_spans.push(TextSpan {
text: ch.to_string(),
fg: bg,
});
current_fg = fg;
} else {
if fg != current_fg && !current_text.is_empty() {
current_line_spans.push(TextSpan {
text: current_text.clone(),
fg: current_fg,
});
current_text.clear();
}
current_fg = fg;
let ch = if cell.c == '\0' { ' ' } else { cell.c };
current_text.push(ch);
if let Some(zw) = cell.zerowidth() {
for &z in zw {
current_text.push(z);
}
}
}
}
if !current_text.is_empty() {
current_line_spans.push(TextSpan {
text: current_text,
fg: current_fg,
});
}
if !current_line_spans.is_empty() {
lines.push(TerminalLine {
spans: current_line_spans,
});
}
while lines.len() < state.lines {
lines.push(TerminalLine { spans: vec![] });
}
});
state.renderer.render(&lines, cursor);
}
fn resolve_color(
color: alacritty_terminal::vte::ansi::Color,
colors: &alacritty_terminal::term::color::Colors,
is_fg: bool,
) -> CellColor {
use alacritty_terminal::vte::ansi::Color;
let rgb = match color {
Color::Named(named) => {
if let Some(rgb) = colors[named] {
rgb
} else {
return named_color_default(named, is_fg);
}
}
Color::Spec(rgb) => rgb,
Color::Indexed(idx) => {
if let Some(rgb) = colors[idx as usize] {
rgb
} else {
return indexed_color_default(idx);
}
}
};
CellColor::new(rgb.r, rgb.g, rgb.b)
}
fn named_color_default(
named: alacritty_terminal::vte::ansi::NamedColor,
is_fg: bool,
) -> CellColor {
use alacritty_terminal::vte::ansi::NamedColor;
match named {
NamedColor::Black => CellColor::new(0, 0, 0),
NamedColor::Red => CellColor::new(205, 0, 0),
NamedColor::Green => CellColor::new(0, 205, 0),
NamedColor::Yellow => CellColor::new(205, 205, 0),
NamedColor::Blue => CellColor::new(0, 0, 238),
NamedColor::Magenta => CellColor::new(205, 0, 205),
NamedColor::Cyan => CellColor::new(0, 205, 205),
NamedColor::White => CellColor::new(229, 229, 229),
NamedColor::BrightBlack => CellColor::new(127, 127, 127),
NamedColor::BrightRed => CellColor::new(255, 0, 0),
NamedColor::BrightGreen => CellColor::new(0, 255, 0),
NamedColor::BrightYellow => CellColor::new(255, 255, 0),
NamedColor::BrightBlue => CellColor::new(92, 92, 255),
NamedColor::BrightMagenta => CellColor::new(255, 0, 255),
NamedColor::BrightCyan => CellColor::new(0, 255, 255),
NamedColor::BrightWhite => CellColor::new(255, 255, 255),
NamedColor::Foreground => {
if is_fg {
CellColor::new(229, 229, 229)
} else {
CellColor::new(0, 0, 0)
}
}
NamedColor::Background => {
if is_fg {
CellColor::new(229, 229, 229)
} else {
CellColor::new(0, 0, 0)
}
}
_ => {
if is_fg {
CellColor::new(229, 229, 229)
} else {
CellColor::new(0, 0, 0)
}
}
}
}
fn indexed_color_default(idx: u8) -> CellColor {
if idx < 16 {
return CellColor::new(128, 128, 128);
} else if idx < 232 {
let i = idx - 16;
let r = (i / 36) * 51;
let g = ((i % 36) / 6) * 51;
let b = (i % 6) * 51;
CellColor::new(r, g, b)
} else {
let gray = (idx - 232) * 10 + 8;
CellColor::new(gray, gray, gray)
}
}