feat(terminal): add WebGL renderer and addon APIs

This commit is contained in:
zhangheng
2026-06-23 16:48:53 +08:00
parent f76d341501
commit 69a670939d
15 changed files with 4199 additions and 740 deletions

2
Cargo.lock generated
View File

@@ -49,6 +49,8 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"unicode-segmentation",
"unicode-width",
"vt100",
"wasm-bindgen",
"wasm-bindgen-futures",

View File

@@ -34,11 +34,14 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
validator = { version = "0.20", features = ["derive"] }
vt100 = "0.15"
heck = "0.5"
unicode-segmentation = "1.13"
unicode-width = "0.1"
js-sys = "0.3"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", default-features = false, features = [
"BinaryType",
"CanvasRenderingContext2d",
"Clipboard",
"ClipboardEvent",
"CloseEvent",
@@ -52,6 +55,7 @@ web-sys = { version = "0.3", default-features = false, features = [
"ErrorEvent",
"Event",
"EventTarget",
"HtmlCanvasElement",
"HtmlElement",
"InputEvent",
"KeyboardEvent",
@@ -61,6 +65,12 @@ web-sys = { version = "0.3", default-features = false, features = [
"Navigator",
"ResizeObserver",
"ResizeObserverEntry",
"WebGl2RenderingContext",
"WebGlBuffer",
"WebGlProgram",
"WebGlShader",
"WebGlTexture",
"WebGlUniformLocation",
"WebSocket",
"WheelEvent",
"Window",

View File

@@ -23,6 +23,8 @@ redis = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true }
serde.workspace = true
serde_json.workspace = true
unicode-segmentation.workspace = true
unicode-width.workspace = true
vt100.workspace = true
wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

33
app/src/terminal/ime.rs Normal file
View File

@@ -0,0 +1,33 @@
use leptos::html;
use leptos::prelude::{GetUntracked, NodeRef};
use super::core::TerminalCore;
pub(crate) fn ime_cursor_cell(core: &TerminalCore) -> (usize, usize) {
let (row, col) = core.cursor_position();
let buffer_row = if core.alternate_screen() {
usize::from(row)
} else {
core.history_rows().len() + usize::from(row)
};
(buffer_row, usize::from(col))
}
/// Focus the hidden IME textarea so keyboard input and composition go there.
pub(crate) fn focus_ime_textarea(ime_ref: &NodeRef<html::Textarea>) {
if let Some(element) = ime_ref.get_untracked() {
let _ = element.focus();
}
}
/// Read the current value of the hidden IME textarea.
pub(crate) fn ime_ref_text(ime_ref: &NodeRef<html::Textarea>) -> Option<String> {
ime_ref.get_untracked().map(|t| t.value())
}
/// Clear the hidden IME textarea after sending composed/typed text.
pub(crate) fn clear_ime_textarea(ime_ref: &NodeRef<html::Textarea>) {
if let Some(t) = ime_ref.get_untracked() {
t.set_value("");
}
}

View File

@@ -0,0 +1,108 @@
use web_sys::KeyboardEvent;
/// Normalises line endings to CR (`\r`), the byte a terminal expects for line
/// submission (Enter sends `\r`). When the application enabled bracketed paste,
/// wraps the text in `ESC[200~ ... ESC[201~`.
pub(crate) fn prepare_paste(raw: &str, bracketed: bool) -> String {
let normalized = raw.replace("\r\n", "\r").replace('\n', "\r");
if bracketed {
format!("\u{1b}[200~{normalized}\u{1b}[201~")
} else {
normalized
}
}
/// Build a cursor/nav key sequence, SS3 (`ESC O <b>`) when the app is in
/// application-cursor mode, else CSI (`ESC [ <b>`).
pub(crate) fn cursor_seq(final_byte: char, app_cursor: bool) -> String {
if app_cursor {
format!("\u{1b}O{final_byte}")
} else {
format!("\u{1b}[{final_byte}")
}
}
/// Pure key-table: map a key + modifier state + application-cursor flag to
/// the bytes a terminal would receive for the same key.
pub(crate) fn key_to_bytes(
key: &str,
ctrl: bool,
alt: bool,
meta: bool,
app_cursor: bool,
) -> Option<String> {
if ctrl && !alt {
if key.chars().count() != 1 {
return None;
}
let c = key.chars().next()?;
let code = match c {
'a'..='z' => (c.to_ascii_uppercase() as u8) & 0x1f,
'A'..='Z' => (c as u8) & 0x1f,
'@' => 0x00,
'[' => 0x1b,
'\\' => 0x1c,
']' => 0x1d,
'^' => 0x1e,
'_' => 0x1f,
_ => return None,
};
if matches!(code, 0x1a | 0x1c) {
return None;
}
return Some((code as char).to_string());
}
if let (true, Some(c)) = (alt && !ctrl && !meta, key.chars().next()) {
return Some(format!("\u{1b}{c}"));
}
match key {
"Enter" => Some("\r".to_owned()),
"Backspace" => Some("\u{7f}".to_owned()),
"Tab" => Some("\t".to_owned()),
"Escape" => Some("\u{1b}".to_owned()),
"ArrowUp" => Some(cursor_seq('A', app_cursor)),
"ArrowDown" => Some(cursor_seq('B', app_cursor)),
"ArrowRight" => Some(cursor_seq('C', app_cursor)),
"ArrowLeft" => Some(cursor_seq('D', app_cursor)),
"Home" => Some(cursor_seq('H', app_cursor)),
"End" => Some(cursor_seq('F', app_cursor)),
"Insert" => Some("\u{1b}[2~".to_owned()),
"Delete" => Some("\u{1b}[3~".to_owned()),
"PageUp" => Some("\u{1b}[5~".to_owned()),
"PageDown" => Some("\u{1b}[6~".to_owned()),
"F1" => Some("\u{1b}OP".to_owned()),
"F2" => Some("\u{1b}OQ".to_owned()),
"F3" => Some("\u{1b}OR".to_owned()),
"F4" => Some("\u{1b}OS".to_owned()),
"F5" => Some("\u{1b}[15~".to_owned()),
"F6" => Some("\u{1b}[17~".to_owned()),
"F7" => Some("\u{1b}[18~".to_owned()),
"F8" => Some("\u{1b}[19~".to_owned()),
"F9" => Some("\u{1b}[20~".to_owned()),
"F10" => Some("\u{1b}[21~".to_owned()),
"F11" => Some("\u{1b}[23~".to_owned()),
"F12" => Some("\u{1b}[24~".to_owned()),
_ if key.chars().count() == 1 && !meta => Some(key.to_owned()),
_ => None,
}
}
/// Map a browser keyboard event to the bytes a terminal would receive for
/// the same key. Thin wrapper around `key_to_bytes`.
pub(crate) fn map_key_to_terminal_input(event: &KeyboardEvent, app_cursor: bool) -> Option<String> {
key_to_bytes(
&event.key(),
event.ctrl_key(),
event.alt_key(),
event.meta_key(),
app_cursor,
)
}
/// Whether a keydown event represents a regular printable character that
/// should be handled by the IME textarea's `input` event.
pub(crate) fn is_regular_text_input(key: &str, ctrl: bool, alt: bool, meta: bool) -> bool {
key.chars().count() == 1 && !ctrl && !alt && !meta
}

View File

@@ -1,6 +1,15 @@
pub mod component;
pub mod core;
pub mod ime;
pub mod keyboard;
pub mod mouse;
pub mod protocol;
pub mod scrollback;
pub mod selection;
pub use component::TerminalPanel;
pub use component::{
Renderer, TerminalCursorOptions, TerminalCursorStyle, TerminalFontOptions, TerminalHandle,
TerminalOptions, TerminalPanel, TerminalRenderEvent, TerminalResizeEvent,
TerminalSelectionEvent, TerminalTheme,
};
pub use core::{SearchDirection, TerminalLink, TerminalSearchMatch};

110
app/src/terminal/mouse.rs Normal file
View File

@@ -0,0 +1,110 @@
use super::core::MouseProtocolEncoding;
/// Number of rows to scroll for a wheel event, from its delta and delta mode.
pub(crate) fn wheel_rows_for_delta(
delta_y: f64,
delta_mode: u32,
max_rows: usize,
line_height_px: f64,
) -> usize {
let wheel_pixels_per_row = line_height_px.max(1.0);
const WHEEL_LINES_PER_PAGE: f64 = 8.0;
let magnitude = delta_y.abs();
if magnitude == 0.0 {
return 0;
}
let rows = match delta_mode {
1 => magnitude,
2 => magnitude * WHEEL_LINES_PER_PAGE,
_ => magnitude / wheel_pixels_per_row,
};
let rows = rows.round() as usize;
rows.clamp(1, max_rows.max(1))
}
/// What kind of mouse action is being reported. Wheel ticks are reported as
/// a `Press` of button 64/65.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MouseAction {
Press,
Release,
Motion,
}
/// Map a browser mouse button index (`MouseEvent.button`) to the terminal's
/// low button code, or `None` for buttons we don't report.
pub(crate) fn mouse_button_base(browser_button: i16) -> Option<u8> {
match browser_button {
0..=2 => Some(browser_button as u8),
_ => None,
}
}
/// OR the modifier bits into a mouse button code, matching xterm encoding.
pub(crate) fn with_mouse_modifiers(button: u8, shift: bool, meta: bool, ctrl: bool) -> u8 {
let mut b = button;
if shift {
b |= 4;
}
if meta {
b |= 8;
}
if ctrl {
b |= 16;
}
b
}
/// Encode a mouse event as the bytes a terminal would receive.
pub(crate) fn encode_mouse_report(
button: u8,
action: MouseAction,
col: usize,
row: usize,
encoding: MouseProtocolEncoding,
) -> String {
match encoding {
MouseProtocolEncoding::Sgr => {
let suffix = if action == MouseAction::Release {
'm'
} else {
'M'
};
format!("\u{1b}[<{button};{};{}{suffix}", col + 1, row + 1)
}
MouseProtocolEncoding::Default | MouseProtocolEncoding::Utf8 => {
let b = if action == MouseAction::Release {
3
} else {
button
};
let c = col.min(94) as u8 + 1;
let r = row.min(94) as u8 + 1;
format!(
"\u{1b}M{}{}{}",
char::from_u32(u32::from(b) + 32).unwrap_or(' '),
char::from_u32(u32::from(c) + 32).unwrap_or(' '),
char::from_u32(u32::from(r) + 32).unwrap_or(' '),
)
}
}
}
/// Map a pixel position inside the terminal content box to 0-based visible
/// grid coordinates (row, col), clamped to the viewport.
pub(crate) fn pixel_to_grid(
local_x: f64,
local_y: f64,
cell_w: f64,
cell_h: f64,
cols: usize,
rows: usize,
padding: f64,
) -> (usize, usize) {
let x = (local_x - padding).max(0.0);
let y = (local_y - padding).max(0.0);
let col = ((x / cell_w.max(1.0)).floor() as usize).min(cols.saturating_sub(1));
let row = ((y / cell_h.max(1.0)).floor() as usize).min(rows.saturating_sub(1));
(row, col)
}

View File

@@ -18,14 +18,8 @@ pub enum ClientTerminalMessage {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerTerminalMessage {
Output {
data: String,
},
Exit {
code: Option<i32>,
},
Error {
message: String,
},
Output { data: String },
Exit { code: Option<i32> },
Error { message: String },
Pong,
}

View File

@@ -10,7 +10,9 @@ const LIVE_SCROLL_THRESHOLD_PX: i32 = 24;
pub enum ViewMode {
#[default]
Live,
History { scroll_offset: usize },
History {
scroll_offset: usize,
},
}
impl ViewMode {

View File

@@ -0,0 +1,77 @@
/// A text selection in logical buffer coordinates `(row, col)`. `anchor` is
/// where the drag began, `head` follows the pointer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Selection {
pub(crate) anchor: (usize, usize),
pub(crate) head: (usize, usize),
}
impl Selection {
/// (top-left, bottom-right) ordered for rendering / extraction.
pub(crate) fn ordered(self) -> ((usize, usize), (usize, usize)) {
if self.anchor <= self.head {
(self.anchor, self.head)
} else {
(self.head, self.anchor)
}
}
/// True when the selection is empty (anchor == head): nothing to copy.
pub(crate) fn is_empty(self) -> bool {
self.anchor == self.head
}
}
/// A highlight rectangle for one selected row, positioned relative to the top
/// of the rendered rows layer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct SelectionRect {
pub(crate) top_px: f64,
pub(crate) left_px: f64,
pub(crate) width_px: f64,
pub(crate) height_px: f64,
}
/// Compute the per-row highlight rects for `sel`, clipped to the rendered
/// window `[window_start, window_end)`.
pub(crate) fn selection_rects(
sel: Option<Selection>,
window_start: usize,
window_end: usize,
cols: usize,
cell_w: f64,
line_height_px: f64,
) -> Vec<SelectionRect> {
let Some(sel) = sel else {
return Vec::new();
};
if sel.is_empty() {
return Vec::new();
}
let ((sr, sc), (er, ec)) = sel.ordered();
let first = sr.max(window_start);
let last = er.min(window_end.saturating_sub(1));
let mut rects = Vec::new();
for r in first..=last {
if r >= window_end {
break;
}
let (col_a, col_b) = if sr == er {
(sc, ec)
} else if r == sr {
(sc, cols)
} else if r == er {
(0, ec)
} else {
(0, cols)
};
let col_b = col_b.max(col_a);
rects.push(SelectionRect {
top_px: (r - window_start) as f64 * line_height_px.max(1.0),
left_px: col_a as f64 * cell_w,
width_px: (col_b - col_a) as f64 * cell_w,
height_px: line_height_px.max(1.0),
});
}
rects
}

View File

@@ -1,9 +1,7 @@
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use portable_pty::{
ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system,
};
use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system};
use tokio::sync::mpsc;
#[derive(Clone, Copy, Debug)]
@@ -43,9 +41,7 @@ pub struct PtySession {
}
impl PtySession {
pub fn spawn(
size: TerminalSize,
) -> Result<(Self, mpsc::UnboundedReceiver<Vec<u8>>), String> {
pub fn spawn(size: TerminalSize) -> Result<(Self, mpsc::UnboundedReceiver<Vec<u8>>), String> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(size.into_pty_size())
@@ -89,9 +85,8 @@ impl PtySession {
}
}
Err(error) => {
let _ = tx.send(
format!("\r\n[terminal read error] {error}\r\n").into_bytes(),
);
let _ =
tx.send(format!("\r\n[terminal read error] {error}\r\n").into_bytes());
break;
}
}
@@ -103,12 +98,11 @@ impl PtySession {
.and_then(|mut child| child.wait().ok())
.map(|status| status.exit_code() as i32);
let payload = serde_json::to_vec(&app::terminal::protocol::ServerTerminalMessage::Exit {
code: exit_code,
})
.unwrap_or_else(|_| {
br#"{"type":"exit","code":null}"#.to_vec()
});
let payload =
serde_json::to_vec(&app::terminal::protocol::ServerTerminalMessage::Exit {
code: exit_code,
})
.unwrap_or_else(|_| br#"{"type":"exit","code":null}"#.to_vec());
let _ = tx.send(payload);
});

View File

@@ -67,8 +67,7 @@ async fn handle_terminal_socket(socket: WebSocket) {
match client_message {
ClientTerminalMessage::Input { data } => {
if let Err(error) = session.write(&data) {
let _ = server_tx
.send(ServerTerminalMessage::Error { message: error });
let _ = server_tx.send(ServerTerminalMessage::Error { message: error });
}
}
ClientTerminalMessage::Resize {
@@ -83,8 +82,7 @@ async fn handle_terminal_socket(socket: WebSocket) {
pixel_width,
pixel_height,
}) {
let _ = server_tx
.send(ServerTerminalMessage::Error { message: error });
let _ = server_tx.send(ServerTerminalMessage::Error { message: error });
}
}
ClientTerminalMessage::Ping => {

View File

@@ -590,6 +590,7 @@
.terminal-screen {
@apply relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded;
min-height: 250px; /* 12 rows * 18px + 16px vertical padding + 1px border */
padding: 16px;
border: 1px solid #17202f;
background:
@@ -597,12 +598,14 @@
linear-gradient(180deg, #0b1220 0%, #0f172a 100%);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%);
outline: none;
color: #dbeafe;
font-family:
color: var(--terminal-fg, #dbeafe);
font-family: var(
--terminal-font-family,
"JetBrains Mono", "Fira Code", "Cascadia Code", "SFMono-Regular",
"Consolas", monospace;
font-size: 13px;
line-height: 18px;
"Consolas", monospace
);
font-size: var(--terminal-font-size, 13px);
line-height: var(--terminal-line-height, 18px);
white-space: pre;
letter-spacing: 0;
scrollbar-gutter: stable;
@@ -636,15 +639,24 @@
line-height: 1px;
}
.terminal-canvas {
position: absolute;
left: 0;
z-index: 0;
pointer-events: none;
}
.terminal-text {
margin: 0;
min-height: 100%;
color: #dbeafe;
font-family:
color: var(--terminal-fg, #dbeafe);
font-family: var(
--terminal-font-family,
"JetBrains Mono", "Fira Code", "Cascadia Code", "SFMono-Regular",
"Consolas", monospace;
font-size: 13px;
line-height: 18px;
"Consolas", monospace
);
font-size: var(--terminal-font-size, 13px);
line-height: var(--terminal-line-height, 18px);
white-space: pre-wrap;
word-break: break-word;
}
@@ -659,9 +671,9 @@
.terminal-row {
position: relative;
z-index: 1;
height: 18px;
min-height: 18px;
max-height: 18px;
height: var(--terminal-line-height, 18px);
min-height: var(--terminal-line-height, 18px);
max-height: var(--terminal-line-height, 18px);
overflow: hidden;
white-space: pre;
}
@@ -674,11 +686,45 @@
}
.terminal-cursor {
background: #dbeafe !important;
color: #0f172a !important;
position: relative;
animation: var(--terminal-cursor-blink, none) 1s steps(2, start) infinite;
}
.terminal-cursor::after {
position: absolute;
inset: 0;
display: block;
content: "";
pointer-events: none;
background: var(--terminal-cursor-bg, #dbeafe);
}
.terminal-shell-card[style*="--terminal-cursor-style:block"] .terminal-cursor {
background: var(--terminal-cursor-bg, #dbeafe) !important;
color: var(--terminal-cursor-fg, #0f172a) !important;
box-shadow: 0 0 0 1px rgb(219 234 254 / 30%);
}
.terminal-shell-card[style*="--terminal-cursor-style:block"] .terminal-cursor::after {
display: none;
}
.terminal-shell-card[style*="--terminal-cursor-style:underline"] .terminal-cursor::after {
top: auto;
height: 3px;
}
.terminal-shell-card[style*="--terminal-cursor-style:bar"] .terminal-cursor::after {
right: auto;
width: 2px;
}
@keyframes terminal-cursor-blink {
50% {
opacity: 0;
}
}
/* Absolute-positioning virtual scroll: the sizer holds the full content
height so the scroll geometry is constant; the rows layer is translated
to the visible window's offset. */
@@ -698,9 +744,32 @@
.terminal-selection {
position: absolute;
background: rgb(56 189 248 / 30%);
background: var(--terminal-selection-bg, rgb(56 189 248 / 30%));
pointer-events: none;
z-index: 0;
z-index: 1;
}
.terminal-search-match {
position: absolute;
background: rgb(250 204 21 / 28%);
box-shadow: inset 0 0 0 1px rgb(250 204 21 / 55%);
pointer-events: none;
z-index: 1;
}
.terminal-link-hitbox {
position: absolute;
display: block;
padding: 0;
border: 0;
border-bottom: 1px solid rgb(125 211 252 / 75%);
background: transparent;
cursor: pointer;
z-index: 3;
}
.terminal-link-hitbox:hover {
background: rgb(125 211 252 / 14%);
}
.terminal-measure {
@@ -708,6 +777,13 @@
left: -9999px;
top: 0;
white-space: pre;
font-family: var(
--terminal-font-family,
"JetBrains Mono", "Fira Code", "Cascadia Code", "SFMono-Regular",
"Consolas", monospace
);
font-size: var(--terminal-font-size, 13px);
line-height: var(--terminal-line-height, 18px);
pointer-events: none;
opacity: 0;
}