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

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
}