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

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
}