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, next_window_offset: (i32, i32), } struct WindowState { window: Arc, renderer: TerminalRenderer, terminal: TerminalEmulator, clipboard: Option, 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 = 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 = Vec::with_capacity(state.lines); let mut cursor: Option = 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 = 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) } }