feat: add rust browser terminal prototype
This commit is contained in:
662
app/src/terminal/component.rs
Normal file
662
app/src/terminal/component.rs
Normal file
@@ -0,0 +1,662 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use app_config::SiteConfig;
|
||||
use leptos::html;
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::js_sys::{Array, Function};
|
||||
use web_sys::{
|
||||
BinaryType, ErrorEvent, Event, HtmlElement, KeyboardEvent, MessageEvent,
|
||||
ResizeObserver, ResizeObserverEntry, WebSocket,
|
||||
};
|
||||
|
||||
use super::core::{
|
||||
DEFAULT_COLS, DEFAULT_ROWS, TerminalCore, TerminalRow, TerminalSegment,
|
||||
TerminalSnapshot,
|
||||
};
|
||||
use super::protocol::{ClientTerminalMessage, ServerTerminalMessage};
|
||||
use super::scrollback::{ScrollMetrics, ScrollbackModel};
|
||||
|
||||
const DEFAULT_CELL_WIDTH: f64 = 8.0;
|
||||
const DEFAULT_CELL_HEIGHT: f64 = 18.0;
|
||||
const MIN_COLS: u16 = 40;
|
||||
const MIN_ROWS: u16 = 12;
|
||||
const MEASURE_SAMPLE_TEXT: &str =
|
||||
"WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW";
|
||||
const SCROLL_LINE_HEIGHT: f64 = 18.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct TerminalViewport {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
pixel_width: u16,
|
||||
pixel_height: u16,
|
||||
}
|
||||
|
||||
impl Default for TerminalViewport {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cols: DEFAULT_COLS,
|
||||
rows: DEFAULT_ROWS,
|
||||
pixel_width: (f64::from(DEFAULT_COLS) * DEFAULT_CELL_WIDTH).round()
|
||||
as u16,
|
||||
pixel_height: (f64::from(DEFAULT_ROWS) * DEFAULT_CELL_HEIGHT)
|
||||
.round() as u16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TerminalPanel() -> impl IntoView {
|
||||
let terminal_ref = NodeRef::<html::Div>::new();
|
||||
let measure_ref = NodeRef::<html::Span>::new();
|
||||
let snapshot = RwSignal::new(TerminalSnapshot::default());
|
||||
let connection_status = RwSignal::new("Connecting".to_owned());
|
||||
let ws_signal = RwSignal::new(None::<WebSocket>);
|
||||
let current_viewport = RwSignal::new(TerminalViewport::default());
|
||||
let output_updates = RwSignal::new(0_u64);
|
||||
let scrollback_model = RwSignal::new(ScrollbackModel::default());
|
||||
let ignore_next_scroll = RwSignal::new(false);
|
||||
let terminal_core = Rc::new(RefCell::new(TerminalCore::default()));
|
||||
|
||||
Effect::new({
|
||||
let terminal_core = terminal_core.clone();
|
||||
|
||||
move |_| {
|
||||
let url = websocket_url();
|
||||
let Ok(socket) = WebSocket::new(&url) else {
|
||||
connection_status.set("Failed to connect".to_owned());
|
||||
return;
|
||||
};
|
||||
|
||||
socket.set_binary_type(BinaryType::Arraybuffer);
|
||||
let socket = Rc::new(socket);
|
||||
|
||||
let open_socket = socket.clone();
|
||||
let open_status = connection_status;
|
||||
let open_ws = ws_signal;
|
||||
let open_viewport = current_viewport;
|
||||
let open_ref = terminal_ref;
|
||||
let open_measure = measure_ref;
|
||||
let open_snapshot = snapshot;
|
||||
let open_model = scrollback_model;
|
||||
let open_ignore_scroll = ignore_next_scroll;
|
||||
let open_core = terminal_core.clone();
|
||||
let on_open = Closure::<dyn Fn(Event)>::new(move |_| {
|
||||
open_status.set("Connected".to_owned());
|
||||
open_ws.set(Some((*open_socket).clone()));
|
||||
|
||||
let viewport = measure_terminal_viewport(open_ref, open_measure);
|
||||
open_viewport.set(viewport);
|
||||
|
||||
{
|
||||
let mut core = open_core.borrow_mut();
|
||||
resize_terminal_core(&mut core, viewport);
|
||||
let mut model = open_model.get_untracked();
|
||||
model.enter_live(metrics_from_core(viewport, &mut core));
|
||||
open_model.set(model);
|
||||
let next_snapshot = snapshot_for_model(&mut core, model, viewport);
|
||||
open_snapshot.set(next_snapshot.clone());
|
||||
sync_scroll_to_model(
|
||||
open_ref,
|
||||
next_snapshot,
|
||||
model,
|
||||
viewport,
|
||||
open_ignore_scroll,
|
||||
);
|
||||
}
|
||||
|
||||
send_resize_message(&open_socket, viewport);
|
||||
focus_terminal(open_ref);
|
||||
});
|
||||
socket.set_onopen(Some(on_open.as_ref().unchecked_ref()));
|
||||
on_open.forget();
|
||||
|
||||
let message_snapshot = snapshot;
|
||||
let message_core = terminal_core.clone();
|
||||
let message_status = connection_status;
|
||||
let message_ref = terminal_ref;
|
||||
let message_updates = output_updates;
|
||||
let message_viewport = current_viewport;
|
||||
let message_model = scrollback_model;
|
||||
let message_ignore_scroll = ignore_next_scroll;
|
||||
let on_message =
|
||||
Closure::<dyn Fn(MessageEvent)>::new(move |event: MessageEvent| {
|
||||
let Some(text) = event.data().as_string() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(message) =
|
||||
serde_json::from_str::<ServerTerminalMessage>(&text)
|
||||
else {
|
||||
message_status.set("Protocol error".to_owned());
|
||||
return;
|
||||
};
|
||||
|
||||
match message {
|
||||
ServerTerminalMessage::Output { data } => {
|
||||
let viewport = message_viewport.get_untracked();
|
||||
let mut core = message_core.borrow_mut();
|
||||
core.process(data.as_bytes());
|
||||
|
||||
let mut model = message_model.get_untracked();
|
||||
model.normalize(metrics_from_core(viewport, &mut core));
|
||||
message_model.set(model);
|
||||
|
||||
let next_snapshot =
|
||||
snapshot_for_model(&mut core, model, viewport);
|
||||
message_snapshot.set(next_snapshot.clone());
|
||||
message_updates.update(|count| *count += 1);
|
||||
|
||||
if model.is_live() {
|
||||
sync_scroll_to_model(
|
||||
message_ref,
|
||||
next_snapshot,
|
||||
model,
|
||||
viewport,
|
||||
message_ignore_scroll,
|
||||
);
|
||||
}
|
||||
|
||||
focus_terminal(message_ref);
|
||||
}
|
||||
ServerTerminalMessage::Exit { code } => {
|
||||
message_status.set(match code {
|
||||
Some(code) => format!("Exited ({code})"),
|
||||
None => "Exited".to_owned(),
|
||||
});
|
||||
}
|
||||
ServerTerminalMessage::Error { message } => {
|
||||
message_status.set(format!("Error: {message}"));
|
||||
}
|
||||
ServerTerminalMessage::Pong => {}
|
||||
}
|
||||
});
|
||||
socket.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
||||
on_message.forget();
|
||||
|
||||
let close_status = connection_status;
|
||||
let close_ws = ws_signal;
|
||||
let on_close = Closure::<dyn Fn(Event)>::new(move |_| {
|
||||
close_status.set("Disconnected".to_owned());
|
||||
close_ws.set(None);
|
||||
});
|
||||
socket.set_onclose(Some(on_close.as_ref().unchecked_ref()));
|
||||
on_close.forget();
|
||||
|
||||
let error_status = connection_status;
|
||||
let on_error = Closure::<dyn Fn(ErrorEvent)>::new(move |_| {
|
||||
error_status.set("Connection error".to_owned());
|
||||
});
|
||||
socket.set_onerror(Some(on_error.as_ref().unchecked_ref()));
|
||||
on_error.forget();
|
||||
}
|
||||
});
|
||||
|
||||
Effect::new({
|
||||
let terminal_core = terminal_core.clone();
|
||||
|
||||
move |_| {
|
||||
let Some(element) = terminal_ref.get() else {
|
||||
return;
|
||||
};
|
||||
let Some(socket) = ws_signal.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let resize_socket = socket.clone();
|
||||
let resize_viewport = current_viewport;
|
||||
let resize_ref = terminal_ref;
|
||||
let resize_measure = measure_ref;
|
||||
let resize_snapshot = snapshot;
|
||||
let resize_model = scrollback_model;
|
||||
let resize_ignore_scroll = ignore_next_scroll;
|
||||
let resize_core = terminal_core.clone();
|
||||
let callback = Closure::<dyn FnMut(Array, ResizeObserver)>::new(
|
||||
move |entries: Array, _observer: ResizeObserver| {
|
||||
let Some(entry) = entries
|
||||
.get(0)
|
||||
.dyn_into::<ResizeObserverEntry>()
|
||||
.ok()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let rect = entry.content_rect();
|
||||
let viewport = measure_terminal_viewport_from_rect(
|
||||
rect.width(),
|
||||
rect.height(),
|
||||
resize_measure,
|
||||
);
|
||||
|
||||
if viewport != resize_viewport.get_untracked() {
|
||||
resize_viewport.set(viewport);
|
||||
|
||||
let mut core = resize_core.borrow_mut();
|
||||
resize_terminal_core(&mut core, viewport);
|
||||
|
||||
let mut model = resize_model.get_untracked();
|
||||
model.normalize(metrics_from_core(viewport, &mut core));
|
||||
resize_model.set(model);
|
||||
|
||||
let next_snapshot =
|
||||
snapshot_for_model(&mut core, model, viewport);
|
||||
resize_snapshot.set(next_snapshot.clone());
|
||||
sync_scroll_to_model(
|
||||
resize_ref,
|
||||
next_snapshot,
|
||||
model,
|
||||
viewport,
|
||||
resize_ignore_scroll,
|
||||
);
|
||||
|
||||
send_resize_message(&resize_socket, viewport);
|
||||
}
|
||||
|
||||
focus_terminal(resize_ref);
|
||||
},
|
||||
);
|
||||
|
||||
let Ok(observer) = ResizeObserver::new(
|
||||
callback.as_ref().unchecked_ref::<Function>(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
observer.observe(&element);
|
||||
callback.forget();
|
||||
std::mem::forget(observer);
|
||||
}
|
||||
});
|
||||
|
||||
let handle_keydown = {
|
||||
let terminal_core = terminal_core.clone();
|
||||
let keydown_viewport = current_viewport;
|
||||
let keydown_model = scrollback_model;
|
||||
let keydown_ignore_scroll = ignore_next_scroll;
|
||||
|
||||
move |event: KeyboardEvent| {
|
||||
let Some(socket) = ws_signal.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(data) = map_key_to_terminal_input(&event) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let model = keydown_model.get_untracked();
|
||||
if !model.is_live() {
|
||||
let viewport = keydown_viewport.get_untracked();
|
||||
let mut core = terminal_core.borrow_mut();
|
||||
let mut next_model = model;
|
||||
next_model.enter_live(metrics_from_core(viewport, &mut core));
|
||||
keydown_model.set(next_model);
|
||||
|
||||
let live_snapshot =
|
||||
snapshot_for_model(&mut core, next_model, viewport);
|
||||
snapshot.set(live_snapshot.clone());
|
||||
sync_scroll_to_model(
|
||||
terminal_ref,
|
||||
live_snapshot,
|
||||
next_model,
|
||||
viewport,
|
||||
keydown_ignore_scroll,
|
||||
);
|
||||
}
|
||||
|
||||
event.prevent_default();
|
||||
send_terminal_message(&socket, &ClientTerminalMessage::Input { data });
|
||||
}
|
||||
};
|
||||
|
||||
let handle_scroll = {
|
||||
let terminal_core = terminal_core.clone();
|
||||
let scroll_viewport = current_viewport;
|
||||
let scroll_model = scrollback_model;
|
||||
let scroll_ignore_next = ignore_next_scroll;
|
||||
|
||||
move |_| {
|
||||
if scroll_ignore_next.get_untracked() {
|
||||
scroll_ignore_next.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(element) = terminal_ref.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let viewport = scroll_viewport.get_untracked();
|
||||
let mut core = terminal_core.borrow_mut();
|
||||
let metrics = metrics_from_core(viewport, &mut core);
|
||||
|
||||
let mut model = scroll_model.get_untracked();
|
||||
model.apply_scroll_top(
|
||||
element.scroll_top(),
|
||||
element.scroll_height(),
|
||||
element.client_height(),
|
||||
SCROLL_LINE_HEIGHT,
|
||||
metrics,
|
||||
);
|
||||
model.normalize(metrics);
|
||||
scroll_model.set(model);
|
||||
|
||||
let next_snapshot = snapshot_for_model(&mut core, model, viewport);
|
||||
snapshot.set(next_snapshot.clone());
|
||||
sync_scroll_to_model(
|
||||
terminal_ref,
|
||||
next_snapshot,
|
||||
model,
|
||||
viewport,
|
||||
scroll_ignore_next,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let handle_click = move |_| {
|
||||
focus_terminal(terminal_ref);
|
||||
};
|
||||
|
||||
view! {
|
||||
<section class="terminal-shell-card">
|
||||
<div class="terminal-header">
|
||||
<div>
|
||||
<h2 class="terminal-title">"Rust Browser Terminal"</h2>
|
||||
<p class="terminal-subtitle">
|
||||
"Axum + portable-pty backend, Leptos + Rust/WASM client."
|
||||
</p>
|
||||
</div>
|
||||
<div class="terminal-status-stack">
|
||||
<span class="terminal-badge">{move || connection_status.get()}</span>
|
||||
<span class="terminal-metrics">
|
||||
{move || {
|
||||
let viewport = current_viewport.get();
|
||||
let buffer = snapshot.get();
|
||||
let model = scrollback_model.get();
|
||||
format!(
|
||||
"view {} x {} / buffer {} x {} / scroll {} of {} / mode {} / {} updates",
|
||||
viewport.cols,
|
||||
viewport.rows,
|
||||
buffer.terminal_cols,
|
||||
buffer.terminal_rows,
|
||||
buffer.scrollback_offset,
|
||||
buffer.max_scrollback,
|
||||
if model.is_live() { "live" } else { "history" },
|
||||
output_updates.get()
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
node_ref=terminal_ref
|
||||
class="terminal-screen"
|
||||
tabindex="0"
|
||||
on:click=handle_click
|
||||
on:keydown=handle_keydown
|
||||
on:scroll=handle_scroll
|
||||
>
|
||||
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
||||
{MEASURE_SAMPLE_TEXT}
|
||||
</span>
|
||||
<div class="terminal-grid">
|
||||
{move || render_terminal_view(snapshot.get())}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
fn metrics_from_core(
|
||||
viewport: TerminalViewport,
|
||||
core: &mut TerminalCore,
|
||||
) -> ScrollMetrics {
|
||||
let snapshot = core.snapshot_live();
|
||||
ScrollMetrics {
|
||||
viewport_rows: usize::from(viewport.rows),
|
||||
scrollback_rows: snapshot.max_scrollback,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_for_model(
|
||||
core: &mut TerminalCore,
|
||||
model: ScrollbackModel,
|
||||
viewport: TerminalViewport,
|
||||
) -> TerminalSnapshot {
|
||||
let metrics = metrics_from_core(viewport, core);
|
||||
if model.is_live() {
|
||||
core.snapshot_live()
|
||||
} else {
|
||||
core.snapshot_for_top_row(model.top_row().min(metrics.max_top_row()))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_terminal_view(snapshot: TerminalSnapshot) -> impl IntoView {
|
||||
let metrics = ScrollMetrics {
|
||||
viewport_rows: usize::from(snapshot.terminal_rows),
|
||||
scrollback_rows: snapshot.max_scrollback,
|
||||
};
|
||||
|
||||
let mut model = ScrollbackModel::default();
|
||||
if snapshot.scrollback_offset == 0 {
|
||||
model.enter_live(metrics);
|
||||
} else {
|
||||
let top_row = metrics
|
||||
.max_top_row()
|
||||
.saturating_sub(snapshot.scrollback_offset);
|
||||
model.apply_scroll_top(
|
||||
(top_row as f64 * SCROLL_LINE_HEIGHT).round() as i32,
|
||||
(metrics.total_rows() as f64 * SCROLL_LINE_HEIGHT).round() as i32,
|
||||
(metrics.viewport_rows as f64 * SCROLL_LINE_HEIGHT).round() as i32,
|
||||
SCROLL_LINE_HEIGHT,
|
||||
metrics,
|
||||
);
|
||||
}
|
||||
let plan = model.render_plan(metrics);
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="terminal-scroll-spacer"
|
||||
style=format!("height:{}px;", plan.top_spacer_rows as f64 * SCROLL_LINE_HEIGHT)
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{snapshot
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(render_terminal_row)
|
||||
.collect_view()}
|
||||
<div
|
||||
class="terminal-scroll-spacer"
|
||||
style=format!("height:{}px;", plan.bottom_spacer_rows as f64 * SCROLL_LINE_HEIGHT)
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_terminal_row(row: TerminalRow) -> impl IntoView {
|
||||
view! {
|
||||
<div class="terminal-row">
|
||||
{row
|
||||
.segments
|
||||
.into_iter()
|
||||
.map(render_terminal_segment)
|
||||
.collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_terminal_segment(segment: TerminalSegment) -> impl IntoView {
|
||||
let style = format!(
|
||||
"color:{};background:{};font-weight:{};font-style:{};text-decoration:{};",
|
||||
segment.style.color,
|
||||
segment.style.background,
|
||||
if segment.style.bold { "700" } else { "400" },
|
||||
if segment.style.italic { "italic" } else { "normal" },
|
||||
if segment.style.underline { "underline" } else { "none" },
|
||||
);
|
||||
|
||||
view! {
|
||||
<span
|
||||
class="terminal-segment"
|
||||
class:terminal-cursor=segment.is_cursor
|
||||
style=style
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
fn websocket_url() -> String {
|
||||
let window = window();
|
||||
let location = window.location();
|
||||
let protocol = location.protocol().unwrap_or_else(|_| "http:".to_owned());
|
||||
let host = location.host().unwrap_or_else(|_| "127.0.0.1:3100".to_owned());
|
||||
let scheme = if protocol == "https:" { "wss" } else { "ws" };
|
||||
format!("{scheme}://{host}{}/terminal/ws", SiteConfig::BASE_PATH)
|
||||
}
|
||||
|
||||
fn send_terminal_message(socket: &WebSocket, message: &ClientTerminalMessage) {
|
||||
if let Ok(payload) = serde_json::to_string(message) {
|
||||
let _ = socket.send_with_str(&payload);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_resize_message(socket: &WebSocket, viewport: TerminalViewport) {
|
||||
send_terminal_message(
|
||||
socket,
|
||||
&ClientTerminalMessage::Resize {
|
||||
cols: viewport.cols,
|
||||
rows: viewport.rows,
|
||||
pixel_width: viewport.pixel_width,
|
||||
pixel_height: viewport.pixel_height,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn resize_terminal_core(core: &mut TerminalCore, viewport: TerminalViewport) {
|
||||
core.resize(viewport.rows, viewport.cols);
|
||||
}
|
||||
|
||||
fn measure_terminal_viewport(
|
||||
terminal_ref: NodeRef<html::Div>,
|
||||
measure_ref: NodeRef<html::Span>,
|
||||
) -> TerminalViewport {
|
||||
let Some(element) = terminal_ref.get_untracked() else {
|
||||
return TerminalViewport::default();
|
||||
};
|
||||
let Some(element) = element.dyn_ref::<HtmlElement>() else {
|
||||
return TerminalViewport::default();
|
||||
};
|
||||
|
||||
measure_terminal_viewport_from_rect(
|
||||
f64::from(element.client_width().max(0)),
|
||||
f64::from(element.client_height().max(0)),
|
||||
measure_ref,
|
||||
)
|
||||
}
|
||||
|
||||
fn measure_terminal_viewport_from_rect(
|
||||
width: f64,
|
||||
height: f64,
|
||||
measure_ref: NodeRef<html::Span>,
|
||||
) -> TerminalViewport {
|
||||
let (cell_width, cell_height) = measure_cell_size(measure_ref);
|
||||
let safe_width = (width - 2.0).max(cell_width);
|
||||
let safe_height = (height - 2.0).max(cell_height);
|
||||
let cols = ((safe_width / cell_width).floor().max(1.0) as u16).max(MIN_COLS);
|
||||
let rows = ((safe_height / cell_height).floor().max(1.0) as u16).max(MIN_ROWS);
|
||||
|
||||
TerminalViewport {
|
||||
cols,
|
||||
rows,
|
||||
pixel_width: safe_width.floor().max(1.0) as u16,
|
||||
pixel_height: safe_height.floor().max(1.0) as u16,
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_cell_size(measure_ref: NodeRef<html::Span>) -> (f64, f64) {
|
||||
let Some(element) = measure_ref.get_untracked() else {
|
||||
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
|
||||
};
|
||||
let Some(element) = element.dyn_ref::<HtmlElement>() else {
|
||||
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
|
||||
};
|
||||
|
||||
let width = f64::from(element.offset_width().max(0));
|
||||
let height = f64::from(element.offset_height().max(0));
|
||||
|
||||
if width <= 0.0 || height <= 0.0 {
|
||||
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
|
||||
}
|
||||
|
||||
(
|
||||
width / (MEASURE_SAMPLE_TEXT.chars().count() as f64),
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
fn focus_terminal(terminal_ref: NodeRef<html::Div>) {
|
||||
if let Some(element) = terminal_ref.get_untracked() {
|
||||
let _ = element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_scroll_to_model(
|
||||
terminal_ref: NodeRef<html::Div>,
|
||||
snapshot: TerminalSnapshot,
|
||||
model: ScrollbackModel,
|
||||
viewport: TerminalViewport,
|
||||
ignore_next_scroll: RwSignal<bool>,
|
||||
) {
|
||||
let callback = Closure::<dyn FnMut()>::wrap(Box::new(move || {
|
||||
let Some(element) = terminal_ref.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let metrics = ScrollMetrics {
|
||||
viewport_rows: usize::from(viewport.rows),
|
||||
scrollback_rows: snapshot.max_scrollback,
|
||||
};
|
||||
let scroll_top = model.target_scroll_top(
|
||||
element.scroll_height(),
|
||||
element.client_height(),
|
||||
SCROLL_LINE_HEIGHT,
|
||||
metrics,
|
||||
);
|
||||
ignore_next_scroll.set(true);
|
||||
element.set_scroll_top(scroll_top);
|
||||
}));
|
||||
|
||||
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
callback.as_ref().unchecked_ref(),
|
||||
0,
|
||||
);
|
||||
callback.forget();
|
||||
}
|
||||
|
||||
fn map_key_to_terminal_input(event: &KeyboardEvent) -> Option<String> {
|
||||
let key = event.key();
|
||||
|
||||
if event.ctrl_key() && !event.alt_key() {
|
||||
return match key.as_str() {
|
||||
"c" | "C" => Some("\u{3}".to_owned()),
|
||||
"d" | "D" => Some("\u{4}".to_owned()),
|
||||
"l" | "L" => Some("\u{c}".to_owned()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
match key.as_str() {
|
||||
"Enter" => Some("\r".to_owned()),
|
||||
"Backspace" => Some("\u{7f}".to_owned()),
|
||||
"Tab" => Some("\t".to_owned()),
|
||||
"ArrowUp" => Some("\u{1b}[A".to_owned()),
|
||||
"ArrowDown" => Some("\u{1b}[B".to_owned()),
|
||||
"ArrowRight" => Some("\u{1b}[C".to_owned()),
|
||||
"ArrowLeft" => Some("\u{1b}[D".to_owned()),
|
||||
"Escape" => Some("\u{1b}".to_owned()),
|
||||
_ if key.chars().count() == 1 && !event.meta_key() => Some(key),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user