feat: add rust browser terminal prototype

This commit is contained in:
zhangheng
2026-06-08 16:34:13 +08:00
commit fd056ce502
43 changed files with 10312 additions and 0 deletions

View 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,
}
}