feat(terminal): add WebGL renderer and addon APIs
This commit is contained in:
@@ -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
33
app/src/terminal/ime.rs
Normal 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("");
|
||||
}
|
||||
}
|
||||
108
app/src/terminal/keyboard.rs
Normal file
108
app/src/terminal/keyboard.rs
Normal 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
|
||||
}
|
||||
@@ -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
110
app/src/terminal/mouse.rs
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
77
app/src/terminal/selection.rs
Normal file
77
app/src/terminal/selection.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user