505 lines
18 KiB
Rust
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)
|
|
}
|
|
}
|