feat(terminal): add WebGL renderer and addon APIs
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -49,6 +49,8 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width",
|
||||||
"vt100",
|
"vt100",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
|||||||
10
Cargo.toml
10
Cargo.toml
@@ -34,11 +34,14 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
validator = { version = "0.20", features = ["derive"] }
|
validator = { version = "0.20", features = ["derive"] }
|
||||||
vt100 = "0.15"
|
vt100 = "0.15"
|
||||||
heck = "0.5"
|
heck = "0.5"
|
||||||
|
unicode-segmentation = "1.13"
|
||||||
|
unicode-width = "0.1"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
web-sys = { version = "0.3", default-features = false, features = [
|
web-sys = { version = "0.3", default-features = false, features = [
|
||||||
"BinaryType",
|
"BinaryType",
|
||||||
|
"CanvasRenderingContext2d",
|
||||||
"Clipboard",
|
"Clipboard",
|
||||||
"ClipboardEvent",
|
"ClipboardEvent",
|
||||||
"CloseEvent",
|
"CloseEvent",
|
||||||
@@ -52,6 +55,7 @@ web-sys = { version = "0.3", default-features = false, features = [
|
|||||||
"ErrorEvent",
|
"ErrorEvent",
|
||||||
"Event",
|
"Event",
|
||||||
"EventTarget",
|
"EventTarget",
|
||||||
|
"HtmlCanvasElement",
|
||||||
"HtmlElement",
|
"HtmlElement",
|
||||||
"InputEvent",
|
"InputEvent",
|
||||||
"KeyboardEvent",
|
"KeyboardEvent",
|
||||||
@@ -61,6 +65,12 @@ web-sys = { version = "0.3", default-features = false, features = [
|
|||||||
"Navigator",
|
"Navigator",
|
||||||
"ResizeObserver",
|
"ResizeObserver",
|
||||||
"ResizeObserverEntry",
|
"ResizeObserverEntry",
|
||||||
|
"WebGl2RenderingContext",
|
||||||
|
"WebGlBuffer",
|
||||||
|
"WebGlProgram",
|
||||||
|
"WebGlShader",
|
||||||
|
"WebGlTexture",
|
||||||
|
"WebGlUniformLocation",
|
||||||
"WebSocket",
|
"WebSocket",
|
||||||
"WheelEvent",
|
"WheelEvent",
|
||||||
"Window",
|
"Window",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ redis = { workspace = true, optional = true }
|
|||||||
reqwest = { workspace = true, optional = true }
|
reqwest = { workspace = true, optional = true }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
unicode-segmentation.workspace = true
|
||||||
|
unicode-width.workspace = true
|
||||||
vt100.workspace = true
|
vt100.workspace = true
|
||||||
wasm-bindgen.workspace = true
|
wasm-bindgen.workspace = true
|
||||||
wasm-bindgen-futures.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 component;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
|
pub mod ime;
|
||||||
|
pub mod keyboard;
|
||||||
|
pub mod mouse;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod scrollback;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ServerTerminalMessage {
|
pub enum ServerTerminalMessage {
|
||||||
Output {
|
Output { data: String },
|
||||||
data: String,
|
Exit { code: Option<i32> },
|
||||||
},
|
Error { message: String },
|
||||||
Exit {
|
|
||||||
code: Option<i32>,
|
|
||||||
},
|
|
||||||
Error {
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
Pong,
|
Pong,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const LIVE_SCROLL_THRESHOLD_PX: i32 = 24;
|
|||||||
pub enum ViewMode {
|
pub enum ViewMode {
|
||||||
#[default]
|
#[default]
|
||||||
Live,
|
Live,
|
||||||
History { scroll_offset: usize },
|
History {
|
||||||
|
scroll_offset: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ViewMode {
|
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
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use portable_pty::{
|
use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||||
ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system,
|
|
||||||
};
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
@@ -43,9 +41,7 @@ pub struct PtySession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PtySession {
|
impl PtySession {
|
||||||
pub fn spawn(
|
pub fn spawn(size: TerminalSize) -> Result<(Self, mpsc::UnboundedReceiver<Vec<u8>>), String> {
|
||||||
size: TerminalSize,
|
|
||||||
) -> Result<(Self, mpsc::UnboundedReceiver<Vec<u8>>), String> {
|
|
||||||
let pty_system = native_pty_system();
|
let pty_system = native_pty_system();
|
||||||
let pair = pty_system
|
let pair = pty_system
|
||||||
.openpty(size.into_pty_size())
|
.openpty(size.into_pty_size())
|
||||||
@@ -89,9 +85,8 @@ impl PtySession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = tx.send(
|
let _ =
|
||||||
format!("\r\n[terminal read error] {error}\r\n").into_bytes(),
|
tx.send(format!("\r\n[terminal read error] {error}\r\n").into_bytes());
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,12 +98,11 @@ impl PtySession {
|
|||||||
.and_then(|mut child| child.wait().ok())
|
.and_then(|mut child| child.wait().ok())
|
||||||
.map(|status| status.exit_code() as i32);
|
.map(|status| status.exit_code() as i32);
|
||||||
|
|
||||||
let payload = serde_json::to_vec(&app::terminal::protocol::ServerTerminalMessage::Exit {
|
let payload =
|
||||||
code: exit_code,
|
serde_json::to_vec(&app::terminal::protocol::ServerTerminalMessage::Exit {
|
||||||
})
|
code: exit_code,
|
||||||
.unwrap_or_else(|_| {
|
})
|
||||||
br#"{"type":"exit","code":null}"#.to_vec()
|
.unwrap_or_else(|_| br#"{"type":"exit","code":null}"#.to_vec());
|
||||||
});
|
|
||||||
let _ = tx.send(payload);
|
let _ = tx.send(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ async fn handle_terminal_socket(socket: WebSocket) {
|
|||||||
match client_message {
|
match client_message {
|
||||||
ClientTerminalMessage::Input { data } => {
|
ClientTerminalMessage::Input { data } => {
|
||||||
if let Err(error) = session.write(&data) {
|
if let Err(error) = session.write(&data) {
|
||||||
let _ = server_tx
|
let _ = server_tx.send(ServerTerminalMessage::Error { message: error });
|
||||||
.send(ServerTerminalMessage::Error { message: error });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClientTerminalMessage::Resize {
|
ClientTerminalMessage::Resize {
|
||||||
@@ -83,8 +82,7 @@ async fn handle_terminal_socket(socket: WebSocket) {
|
|||||||
pixel_width,
|
pixel_width,
|
||||||
pixel_height,
|
pixel_height,
|
||||||
}) {
|
}) {
|
||||||
let _ = server_tx
|
let _ = server_tx.send(ServerTerminalMessage::Error { message: error });
|
||||||
.send(ServerTerminalMessage::Error { message: error });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClientTerminalMessage::Ping => {
|
ClientTerminalMessage::Ping => {
|
||||||
|
|||||||
@@ -590,6 +590,7 @@
|
|||||||
|
|
||||||
.terminal-screen {
|
.terminal-screen {
|
||||||
@apply relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded;
|
@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;
|
padding: 16px;
|
||||||
border: 1px solid #17202f;
|
border: 1px solid #17202f;
|
||||||
background:
|
background:
|
||||||
@@ -597,12 +598,14 @@
|
|||||||
linear-gradient(180deg, #0b1220 0%, #0f172a 100%);
|
linear-gradient(180deg, #0b1220 0%, #0f172a 100%);
|
||||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%);
|
box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%);
|
||||||
outline: none;
|
outline: none;
|
||||||
color: #dbeafe;
|
color: var(--terminal-fg, #dbeafe);
|
||||||
font-family:
|
font-family: var(
|
||||||
|
--terminal-font-family,
|
||||||
"JetBrains Mono", "Fira Code", "Cascadia Code", "SFMono-Regular",
|
"JetBrains Mono", "Fira Code", "Cascadia Code", "SFMono-Regular",
|
||||||
"Consolas", monospace;
|
"Consolas", monospace
|
||||||
font-size: 13px;
|
);
|
||||||
line-height: 18px;
|
font-size: var(--terminal-font-size, 13px);
|
||||||
|
line-height: var(--terminal-line-height, 18px);
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
@@ -636,15 +639,24 @@
|
|||||||
line-height: 1px;
|
line-height: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-text {
|
.terminal-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
color: #dbeafe;
|
color: var(--terminal-fg, #dbeafe);
|
||||||
font-family:
|
font-family: var(
|
||||||
|
--terminal-font-family,
|
||||||
"JetBrains Mono", "Fira Code", "Cascadia Code", "SFMono-Regular",
|
"JetBrains Mono", "Fira Code", "Cascadia Code", "SFMono-Regular",
|
||||||
"Consolas", monospace;
|
"Consolas", monospace
|
||||||
font-size: 13px;
|
);
|
||||||
line-height: 18px;
|
font-size: var(--terminal-font-size, 13px);
|
||||||
|
line-height: var(--terminal-line-height, 18px);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -659,9 +671,9 @@
|
|||||||
.terminal-row {
|
.terminal-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
height: 18px;
|
height: var(--terminal-line-height, 18px);
|
||||||
min-height: 18px;
|
min-height: var(--terminal-line-height, 18px);
|
||||||
max-height: 18px;
|
max-height: var(--terminal-line-height, 18px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
@@ -674,11 +686,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.terminal-cursor {
|
.terminal-cursor {
|
||||||
background: #dbeafe !important;
|
position: relative;
|
||||||
color: #0f172a !important;
|
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%);
|
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
|
/* Absolute-positioning virtual scroll: the sizer holds the full content
|
||||||
height so the scroll geometry is constant; the rows layer is translated
|
height so the scroll geometry is constant; the rows layer is translated
|
||||||
to the visible window's offset. */
|
to the visible window's offset. */
|
||||||
@@ -698,9 +744,32 @@
|
|||||||
|
|
||||||
.terminal-selection {
|
.terminal-selection {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: rgb(56 189 248 / 30%);
|
background: var(--terminal-selection-bg, rgb(56 189 248 / 30%));
|
||||||
pointer-events: none;
|
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 {
|
.terminal-measure {
|
||||||
@@ -708,6 +777,13 @@
|
|||||||
left: -9999px;
|
left: -9999px;
|
||||||
top: 0;
|
top: 0;
|
||||||
white-space: pre;
|
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;
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user