Initial commit: VT220 emulator with multi-window support
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
3149
Cargo.lock
generated
Normal file
3149
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "vt220-emulator"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
alacritty_terminal = "0.26.0"
|
||||
copypasta = "0.10.2"
|
||||
env_logger = "0.11.10"
|
||||
glyphon = "0.11.0"
|
||||
log = "0.4.29"
|
||||
pollster = "0.4.0"
|
||||
wgpu = "29.0.3"
|
||||
winit = "0.30.13"
|
||||
bytemuck = "1.21"
|
||||
unicode-width = "0.2"
|
||||
59
README.md
Normal file
59
README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# VT220 Emulator
|
||||
|
||||
Emulador de terminal tipo VT220 construido en Rust con soporte para **múltiples ventanas**, scrollback, copiar/pegar, colores ANSI y renderizado acelerado por GPU.
|
||||
|
||||
## Características
|
||||
|
||||
- **Múltiples ventanas**: Abre tantas terminales como necesites, cada una con su propio shell y estado independiente (similar a Wave CLI).
|
||||
- **Emulación VT220 completa**: Usa `alacritty_terminal` como motor de emulación, soportando la mayoría de secuencias de escape VT220/ANSI/XTERM.
|
||||
- **Scrollback**: 10,000 líneas de historial (configurable).
|
||||
- **Copiar / Pegar**: `Ctrl+Shift+C` para copiar selección, `Ctrl+Shift+V` para pegar desde el portapapeles del sistema.
|
||||
- **Colores ANSI**: Soporte para colores nombrados, indexados (256) y RGB de 24 bits.
|
||||
- **Renderizado GPU**: Usa `wgpu` + `glyphon` (cosmic-text) para renderizado de texto rápido y moderno.
|
||||
- **Scroll con rueda del ratón**: Permite desplazarse por el historial de la terminal.
|
||||
- **Cursor invertido**: El cursor se dibuja invirtiendo los colores de la celda actual.
|
||||
|
||||
## Dependencias
|
||||
|
||||
- Rust >= 1.70
|
||||
- Linux: librerías de desarrollo de X11/Wayland (`libxcb-*`, `libwayland-dev`, `libxkbcommon-dev`)
|
||||
- Una GPU compatible con Vulkan/Metal/DX12 o un renderer de software como `lavapipe`.
|
||||
|
||||
## Compilación
|
||||
|
||||
```bash
|
||||
cd vt220-emulator
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Ejecución
|
||||
|
||||
```bash
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
El emulador abrirá una ventana de escritorio y lanzará el shell predeterminado del sistema (`$SHELL`).
|
||||
|
||||
## Atajos de teclado
|
||||
|
||||
| Atajo | Acción |
|
||||
|-------|--------|
|
||||
| `Ctrl+Shift+N` | **Nueva ventana** de terminal |
|
||||
| `Ctrl+Shift+C` | Copiar selección al portapapeles |
|
||||
| `Ctrl+Shift+V` | Pegar desde el portapapeles |
|
||||
| Flechas | Enviar códigos de cursor VT220 (`ESC [ A/B/C/D`) |
|
||||
| `PageUp` / `PageDown` | Enviar códigos correspondientes |
|
||||
| `MouseWheel` | Scrollback del historial |
|
||||
|
||||
## Arquitectura
|
||||
|
||||
- **`src/terminal.rs`**: Wrapper sobre `alacritty_terminal`. Maneja el PTY, el event loop y el estado de la terminal.
|
||||
- **`src/renderer.rs`**: Frontend gráfico con `winit` + `wgpu` + `glyphon`. Convierte el estado de la grid de la terminal en líneas de texto renderizadas.
|
||||
- **`src/main.rs`**: Loop de eventos de winit, manejo de input de teclado/ratón, integración con clipboard y copiar/pegar.
|
||||
|
||||
## Notas y limitaciones
|
||||
|
||||
- Este es un MVP funcional. Algunas secuencias de escape muy específicas de VT220 pueden no estar soportadas dependiendo de la versión de `alacritty_terminal`.
|
||||
- La selección con mouse aún no está implementada (se puede agregar extendiendo el manejo de eventos de ratón).
|
||||
- El dibujado del fondo de cada celda usa el color de fondo de la terminal global, no por celda (para simplificar el renderizado). El color de primer plano sí es por celda.
|
||||
- En entornos sin GPU, puedes usar `WGPU_BACKEND=gl` o un driver de software como `lavapipe`.
|
||||
504
src/main.rs
Normal file
504
src/main.rs
Normal file
@@ -0,0 +1,504 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
305
src/renderer.rs
Normal file
305
src/renderer.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
use glyphon::{
|
||||
Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache,
|
||||
TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use wgpu::{
|
||||
CommandEncoderDescriptor, CompositeAlphaMode, Device, DeviceDescriptor, Instance,
|
||||
InstanceDescriptor, LoadOp, MultisampleState, Operations, PresentMode, Queue,
|
||||
RenderPassColorAttachment, RenderPassDescriptor, RequestAdapterOptions, Surface,
|
||||
SurfaceConfiguration, TextureFormat, TextureUsages, TextureViewDescriptor,
|
||||
};
|
||||
use winit::{
|
||||
dpi::PhysicalSize,
|
||||
event_loop::ActiveEventLoop,
|
||||
window::Window,
|
||||
};
|
||||
|
||||
pub const FONT_SIZE: f32 = 14.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CellColor {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl CellColor {
|
||||
pub fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self { r, g, b }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextSpan {
|
||||
pub text: String,
|
||||
pub fg: CellColor,
|
||||
}
|
||||
|
||||
pub struct TerminalLine {
|
||||
pub spans: Vec<TextSpan>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CursorInfo {
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
pub struct TerminalRenderer {
|
||||
pub device: Device,
|
||||
pub queue: Queue,
|
||||
pub surface: Surface<'static>,
|
||||
pub surface_config: SurfaceConfiguration,
|
||||
pub instance: Instance,
|
||||
|
||||
pub font_system: FontSystem,
|
||||
pub swash_cache: SwashCache,
|
||||
pub viewport: Viewport,
|
||||
pub atlas: TextAtlas,
|
||||
pub text_renderer: TextRenderer,
|
||||
pub line_buffers: Vec<Buffer>,
|
||||
|
||||
pub cell_width: f32,
|
||||
pub cell_height: f32,
|
||||
pub _metrics: Metrics,
|
||||
|
||||
pub window: Arc<Window>,
|
||||
}
|
||||
|
||||
impl TerminalRenderer {
|
||||
pub async fn new(window: Arc<Window>, event_loop: &ActiveEventLoop) -> Self {
|
||||
let physical_size = window.inner_size();
|
||||
let scale_factor = window.scale_factor() as f32;
|
||||
|
||||
let instance = Instance::new(InstanceDescriptor::new_with_display_handle(Box::new(
|
||||
event_loop.owned_display_handle(),
|
||||
)));
|
||||
let adapter = instance
|
||||
.request_adapter(&RequestAdapterOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let (device, queue) = adapter
|
||||
.request_device(&DeviceDescriptor::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let surface = instance
|
||||
.create_surface(window.clone())
|
||||
.expect("Create surface");
|
||||
let swapchain_format = TextureFormat::Bgra8UnormSrgb;
|
||||
let surface_config = SurfaceConfiguration {
|
||||
usage: TextureUsages::RENDER_ATTACHMENT,
|
||||
format: swapchain_format,
|
||||
width: physical_size.width,
|
||||
height: physical_size.height,
|
||||
present_mode: PresentMode::Fifo,
|
||||
alpha_mode: CompositeAlphaMode::Opaque,
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
};
|
||||
surface.configure(&device, &surface_config);
|
||||
|
||||
let mut font_system = FontSystem::new();
|
||||
let swash_cache = SwashCache::new();
|
||||
let cache = Cache::new(&device);
|
||||
let viewport = Viewport::new(&device, &cache);
|
||||
let mut atlas = TextAtlas::new(&device, &queue, &cache, swapchain_format);
|
||||
let text_renderer =
|
||||
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
|
||||
|
||||
let metrics = Metrics::new(FONT_SIZE * scale_factor, FONT_SIZE * 1.4 * scale_factor);
|
||||
let mut measure_buffer = Buffer::new(&mut font_system, metrics);
|
||||
measure_buffer.set_size(&mut font_system, Some(1000.0), Some(1000.0));
|
||||
measure_buffer.set_text(
|
||||
&mut font_system,
|
||||
"M",
|
||||
&Attrs::new().family(Family::Monospace),
|
||||
Shaping::Basic,
|
||||
None,
|
||||
);
|
||||
measure_buffer.shape_until_scroll(&mut font_system, false);
|
||||
|
||||
let cell_width = measure_buffer
|
||||
.lines
|
||||
.first()
|
||||
.and_then(|l| l.layout_opt())
|
||||
.and_then(|layouts| layouts.first())
|
||||
.map(|layout| layout.w)
|
||||
.unwrap_or(FONT_SIZE * scale_factor * 0.6);
|
||||
|
||||
let cell_height = metrics.line_height;
|
||||
|
||||
let max_lines = 200;
|
||||
let mut line_buffers = Vec::with_capacity(max_lines);
|
||||
for _ in 0..max_lines {
|
||||
line_buffers.push(Buffer::new(&mut font_system, metrics));
|
||||
}
|
||||
|
||||
Self {
|
||||
device,
|
||||
queue,
|
||||
surface,
|
||||
surface_config,
|
||||
instance,
|
||||
font_system,
|
||||
swash_cache,
|
||||
viewport,
|
||||
atlas,
|
||||
text_renderer,
|
||||
line_buffers,
|
||||
cell_width,
|
||||
cell_height,
|
||||
_metrics: metrics,
|
||||
window,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, size: PhysicalSize<u32>) {
|
||||
self.surface_config.width = size.width;
|
||||
self.surface_config.height = size.height;
|
||||
self.surface.configure(&self.device, &self.surface_config);
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&mut self,
|
||||
lines: &[TerminalLine],
|
||||
cursor: Option<CursorInfo>,
|
||||
) {
|
||||
let width = self.surface_config.width;
|
||||
let height = self.surface_config.height;
|
||||
|
||||
self.viewport.update(
|
||||
&self.queue,
|
||||
Resolution { width, height },
|
||||
);
|
||||
|
||||
let scale_factor = self.window.scale_factor() as f32;
|
||||
let buf_width = width as f32 * scale_factor;
|
||||
|
||||
// Phase 1: Update all line buffers.
|
||||
for (line_idx, line) in lines.iter().enumerate() {
|
||||
if line_idx >= self.line_buffers.len() {
|
||||
break;
|
||||
}
|
||||
let buffer = &mut self.line_buffers[line_idx];
|
||||
buffer.set_size(&mut self.font_system, Some(buf_width), Some(self.cell_height));
|
||||
|
||||
if line.spans.is_empty() {
|
||||
buffer.set_text(&mut self.font_system, " ", &Attrs::new(), Shaping::Basic, None);
|
||||
} else {
|
||||
let spans_iter = line.spans.iter().map(|span| {
|
||||
let color = Color::rgb(span.fg.r, span.fg.g, span.fg.b);
|
||||
let attrs = Attrs::new().family(Family::Monospace).color(color);
|
||||
(span.text.as_str(), attrs)
|
||||
});
|
||||
buffer.set_rich_text(&mut self.font_system, spans_iter, &Attrs::new(), Shaping::Basic, None);
|
||||
}
|
||||
buffer.shape_until_scroll(&mut self.font_system, false);
|
||||
}
|
||||
|
||||
// Phase 2: Build text areas.
|
||||
let text_areas: Vec<TextArea> = self
|
||||
.line_buffers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(lines.len())
|
||||
.map(|(line_idx, buffer)| {
|
||||
let top = line_idx as f32 * self.cell_height;
|
||||
TextArea {
|
||||
buffer,
|
||||
left: 0.0,
|
||||
top,
|
||||
scale: 1.0,
|
||||
bounds: TextBounds {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width as i32,
|
||||
bottom: height as i32,
|
||||
},
|
||||
default_color: Color::rgb(255, 255, 255),
|
||||
custom_glyphs: &[],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Phase 3: Prepare.
|
||||
if let Err(e) = self.text_renderer.prepare(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut self.font_system,
|
||||
&mut self.atlas,
|
||||
&self.viewport,
|
||||
text_areas,
|
||||
&mut self.swash_cache,
|
||||
) {
|
||||
log::warn!("Text prepare error: {:?}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(frame) => frame,
|
||||
wgpu::CurrentSurfaceTexture::Timeout
|
||||
| wgpu::CurrentSurfaceTexture::Occluded => {
|
||||
self.window.request_redraw();
|
||||
return;
|
||||
}
|
||||
wgpu::CurrentSurfaceTexture::Outdated
|
||||
| wgpu::CurrentSurfaceTexture::Suboptimal(_) => {
|
||||
self.surface.configure(&self.device, &self.surface_config);
|
||||
self.window.request_redraw();
|
||||
return;
|
||||
}
|
||||
wgpu::CurrentSurfaceTexture::Lost => {
|
||||
self.surface = self.instance.create_surface(self.window.clone()).unwrap();
|
||||
self.surface.configure(&self.device, &self.surface_config);
|
||||
self.window.request_redraw();
|
||||
return;
|
||||
}
|
||||
wgpu::CurrentSurfaceTexture::Validation => panic!("validation error"),
|
||||
};
|
||||
|
||||
let view = frame.texture.create_view(&TextureViewDescriptor::default());
|
||||
let mut encoder = self.device.create_command_encoder(&CommandEncoderDescriptor { label: None });
|
||||
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: Operations {
|
||||
load: LoadOp::Clear(wgpu::Color {
|
||||
r: 0.05,
|
||||
g: 0.05,
|
||||
b: 0.05,
|
||||
a: 1.0,
|
||||
}),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
if cursor.visible && cursor.y < lines.len() {
|
||||
let _x = cursor.x as f32 * self.cell_width;
|
||||
let _y = cursor.y as f32 * self.cell_height;
|
||||
// TODO: draw cursor quad.
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.text_renderer.render(&self.atlas, &self.viewport, &mut pass) {
|
||||
log::warn!("Text render error: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
self.queue.submit(Some(encoder.finish()));
|
||||
frame.present();
|
||||
self.atlas.trim();
|
||||
}
|
||||
}
|
||||
128
src/terminal.rs
Normal file
128
src/terminal.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use alacritty_terminal::{
|
||||
event::{Event, EventListener, WindowSize},
|
||||
event_loop::{EventLoop, Msg},
|
||||
sync::FairMutex,
|
||||
term::{Config, Term},
|
||||
tty::{self, Options},
|
||||
};
|
||||
use std::{
|
||||
io,
|
||||
sync::{Arc, mpsc},
|
||||
};
|
||||
|
||||
/// Proxy that forwards alacritty_terminal events into an MPSC channel.
|
||||
#[derive(Clone)]
|
||||
pub struct EventProxy {
|
||||
sender: mpsc::Sender<Event>,
|
||||
}
|
||||
|
||||
impl EventListener for EventProxy {
|
||||
fn send_event(&self, event: Event) {
|
||||
let _ = self.sender.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level wrapper around alacritty_terminal.
|
||||
pub struct TerminalEmulator {
|
||||
terminal: Arc<FairMutex<Term<EventProxy>>>,
|
||||
event_sender: alacritty_terminal::event_loop::EventLoopSender,
|
||||
event_receiver: mpsc::Receiver<Event>,
|
||||
}
|
||||
|
||||
impl TerminalEmulator {
|
||||
pub fn new(columns: usize, lines: usize) -> io::Result<Self> {
|
||||
let (event_sender, event_receiver) = mpsc::channel();
|
||||
let proxy = EventProxy {
|
||||
sender: event_sender,
|
||||
};
|
||||
|
||||
let size = TermSize {
|
||||
columns,
|
||||
screen_lines: lines,
|
||||
};
|
||||
|
||||
let term = Term::new(Config::default(), &size, proxy.clone());
|
||||
let terminal = Arc::new(FairMutex::new(term));
|
||||
|
||||
let window_size = WindowSize {
|
||||
num_lines: lines as u16,
|
||||
num_cols: columns as u16,
|
||||
cell_width: 10,
|
||||
cell_height: 20,
|
||||
};
|
||||
|
||||
let pty = tty::new(&Options::default(), window_size, 0)?;
|
||||
|
||||
let event_loop = EventLoop::new(terminal.clone(), proxy, pty, false, false)?;
|
||||
let tx = event_loop.channel();
|
||||
|
||||
// Spawn the PTY reader thread.
|
||||
let _handle = event_loop.spawn();
|
||||
|
||||
Ok(Self {
|
||||
terminal,
|
||||
event_sender: tx,
|
||||
event_receiver,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resize(&self, columns: usize, lines: usize, cell_width: u16, cell_height: u16) {
|
||||
let window_size = WindowSize {
|
||||
num_lines: lines as u16,
|
||||
num_cols: columns as u16,
|
||||
cell_width,
|
||||
cell_height,
|
||||
};
|
||||
|
||||
self.terminal.lock().resize(TermSize { columns, screen_lines: lines });
|
||||
let _ = self.event_sender.send(Msg::Resize(window_size));
|
||||
}
|
||||
|
||||
pub fn send_input(&self, data: &[u8]) {
|
||||
let _ = self
|
||||
.event_sender
|
||||
.send(Msg::Input(std::borrow::Cow::Owned(data.to_vec())));
|
||||
}
|
||||
|
||||
pub fn read_events(&self) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
while let Ok(event) = self.event_receiver.try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
pub fn with_term<F, R>(&self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut Term<EventProxy>) -> R,
|
||||
{
|
||||
let mut term = self.terminal.lock();
|
||||
f(&mut term)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalEmulator {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.event_sender.send(Msg::Shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TermSize {
|
||||
pub columns: usize,
|
||||
pub screen_lines: usize,
|
||||
}
|
||||
|
||||
impl alacritty_terminal::grid::Dimensions for TermSize {
|
||||
fn total_lines(&self) -> usize {
|
||||
self.screen_lines
|
||||
}
|
||||
|
||||
fn screen_lines(&self) -> usize {
|
||||
self.screen_lines
|
||||
}
|
||||
|
||||
fn columns(&self) -> usize {
|
||||
self.columns
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user