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",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"vt100",
|
||||
"wasm-bindgen",
|
||||
"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"] }
|
||||
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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
let payload =
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user