mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
## Summary - ship **Buzz Term** end to end: the terminal engine/runtime, mounted desktop substrate, and user-visible naming - add Quinn's tape-deck-inspired banner: a beveled chassis filled by the `buzz term` wordmark, surrounded by a complete-hex field - derive the wordmark's three-stop sweep from each theme's terminal palette so primary, secondary, and accent roles remain visibly distinct across all 62 shipped themes, including light themes - paint the banner once on its own pointer-transparent canvas; PTY rendering beneath it remains unchanged ## Banner behavior - uses the renderer's shared `8.4 × 17` cell metrics and production aspect ratio `2.0238` - regenerates only for viewport/theme changes; palette switches repaint correctly while the banner is visible - dismisses on non-empty output from the active terminal session; empty output and inactive sessions do not dismiss it - fails closed below **70 columns** rather than squeezing or clipping the wordmark - adds **8 lines** to `terminalRenderer.ts` for shared cell metrics and **zero lines inside `paint()`** ## Screenshots | Buzz (light) | Buzz Dark | |---|---| |  |  | | Kanagawa Lotus (light) | Red | |---|---| |  |  | Additional production-aspect finals: [Vesper](https://buzz.block.builderlab.xyz/media/9ca6514b63f8cfb2107a85ca46f16a940c0883848e6fbc718e411af94aa13100.png), [Min Dark](https://buzz.block.builderlab.xyz/media/f67bd2970e5d64ffb07b1ae78ab58c847e6ebc23e7e7a48e067eb024dba64ec8.png), and [Dark Plus](https://buzz.block.builderlab.xyz/media/290fee08924f37d064abc687ecf3e9526ab05b87e8e56d610f23048949793dbe.png). The screenshot harness was checked against the shipped painter at this exact head: all **2,541 draw calls** matched on color, glyph, x, and y; four deliberate divergence controls fired. ## Verification at `98ebc8f9048bd5f0ceb7e843b67874d642f0b7fd` - desktop tests: **3,946 / 3,946** - TypeScript: clean - checks: pass (two pre-existing informational `useTemplate` notices only) - integration/e2e: PASS (independent exact-SHA lane; artifacts recorded in the originating Buzz thread) - artifact/dead-path sweep: clean - redteam G1–G7: PASS - all six named banner emitter-deletion mutants die - independent handwritten five-row full-wordmark fixture kills Quinn's seven-mutant battery, including a one-pixel glyph change - real `112 × 46` canvas-rect dismissal tests separately cover active non-empty, active empty, and inactive non-empty output - layer-drop and zero-draw painter mutants die; z-order and pointer-events verified - CI's `tsc && vite build` includes all three banner modules - performance at DPR 2 (worst-case measured envelope): - one-time content paint: **~0.7–0.8 ms**, paid only when the banner is built or its palette changes - busy compositor, CSS `1277 × 697`, backing `2554 × 1394`: **470–497 µs/frame** for the full banner (**2.82–2.98%** of a 60 Hz frame) - busy compositor, CSS `1920 × 1080`, backing `3840 × 2160`: **1,139–1,212 µs/frame** (**6.83–7.27%**) - empty, one-glyph, and full-banner controls converge: compositor cost follows backing-layer area and DPR rather than painted-cell count - in the actual idle welcome state, cost is below both vsync-clamped rigs' resolution; it is not claimed as zero - **Pane cross-rig spread: resolved at matched loop rate.** Two independent rigs initially differed 2.3× (58–68 vs 136 µs/Mpx of backing store; pane, CSS 1277×697 / backing 2554×1394, DPR 2). The cause of *that* spread is rAF loop rate: the higher figure came from a free-running loop at ~1600fps. Throttled to ~200–236fps, both rigs read 58–68 µs/Mpx (1.25–1.44% of a 60Hz frame). The busy-composite figures quoted above remain the **unthrottled worst case** and are conservative by ~2.3× at the pane. Not established: the mechanism and sign of free-running distortion (one rig under-charges ~15%, the other over-charges 2.3×), and the 1080p figure has not been re-measured throttled. - the layer paints only on generation/theme/resize and dismisses on first non-empty active-session output, so the measurable busy cost is a short-lived worst case rather than a persistent PTY paint-path tax ## Follow-ups in this PR These are intentionally subsequent commits after the certified static-banner head, not claims about `98ebc8f90`: 1. close the compositor metrology: remeasure the 1080p point throttled and characterize the opposite-sign free-running rAF distortion, with each measurement regime stated 2. add Tyler's animated honeycomb color waves, gated by `prefers-reduced-motion`, a full 62-theme phase-sweep contrast check, and DPR-2 per-tick performance certification 3. land the already-proven mounted theme-switch regression probe from `RESEARCH/BUZZ_TERM_G3A_PROBE/` 4. bound the slow/hang-shaped G1-c mutant `waitFor` 5. optionally trim the generator to its ink bounding box, reducing the minimum viewport from 70 to 62 columns --------- Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@buzz.block.builderlab.xyz> Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
985 lines
31 KiB
Rust
985 lines
31 KiB
Rust
//! Rust-owned PTY sessions and the typed Tauri transport for Buzz Substrate.
|
|
|
|
use std::io::{Read, Write};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread::JoinHandle;
|
|
|
|
use buzz_terminal::context::{context_vars, GuiContext};
|
|
use buzz_terminal::damage::{Frame, Style};
|
|
use buzz_terminal::{Fences, SharedTerminal, Size, Terminal, Viewport};
|
|
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::ipc::Channel;
|
|
use uuid::Uuid;
|
|
|
|
use crate::terminal_transport::{FramePublisher, OfferError, Publication, SubscriptionId};
|
|
|
|
mod scroll_sign;
|
|
|
|
use scroll_sign::{scroll_by_dom_lines, DomLines};
|
|
|
|
const MAX_LIVE_SESSIONS: usize = 20;
|
|
const MAX_INPUT_BYTES: usize = 1024 * 1024;
|
|
|
|
type Result<T> = std::result::Result<T, String>;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct AttachRequest {
|
|
/// Present when a renderer remounts onto an existing PTY-backed tab.
|
|
session_id: Option<String>,
|
|
channel_id: String,
|
|
channel_name: String,
|
|
thread_id: Option<String>,
|
|
npub: String,
|
|
relay_url: String,
|
|
columns: u16,
|
|
rows: u16,
|
|
pixel_width: u16,
|
|
pixel_height: u16,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct WireViewport {
|
|
generation: u64,
|
|
columns: usize,
|
|
screen_lines: usize,
|
|
}
|
|
|
|
impl From<Viewport> for WireViewport {
|
|
fn from(value: Viewport) -> Self {
|
|
Self {
|
|
generation: value.generation,
|
|
columns: value.columns,
|
|
screen_lines: value.screen_lines,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<WireViewport> for Viewport {
|
|
fn from(value: WireViewport) -> Self {
|
|
Self {
|
|
generation: value.generation,
|
|
columns: value.columns,
|
|
screen_lines: value.screen_lines,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct AttachResponse {
|
|
session_id: String,
|
|
subscription_id: String,
|
|
viewport: WireViewport,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct WireStyle {
|
|
fg: u32,
|
|
bg: u32,
|
|
flags: u16,
|
|
}
|
|
|
|
impl From<Style> for WireStyle {
|
|
fn from(value: Style) -> Self {
|
|
Self {
|
|
fg: value.fg,
|
|
bg: value.bg,
|
|
flags: value.flags,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct WireCluster {
|
|
column: usize,
|
|
text: String,
|
|
width: u8,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct WireSpan {
|
|
style: WireStyle,
|
|
clusters: Vec<WireCluster>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct WireRow {
|
|
line: usize,
|
|
spans: Vec<WireSpan>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct WireCursor {
|
|
line: usize,
|
|
column: usize,
|
|
visible: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct FrameMessage {
|
|
subscription_id: String,
|
|
sequence: u64,
|
|
rows: Vec<WireRow>,
|
|
cursor: WireCursor,
|
|
full: bool,
|
|
viewport: WireViewport,
|
|
bracketed_paste: bool,
|
|
focus_reporting: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(tag = "type", content = "payload", rename_all = "camelCase")]
|
|
pub(crate) enum TerminalMessage {
|
|
Frame(FrameMessage),
|
|
Title(String),
|
|
ResetTitle,
|
|
Bell,
|
|
Exit,
|
|
}
|
|
|
|
fn wire_publication(publication: Publication) -> Result<FrameMessage> {
|
|
let frame = publication.frame;
|
|
let rows = frame
|
|
.rows
|
|
.into_iter()
|
|
.map(|row| {
|
|
let spans = row
|
|
.spans
|
|
.into_iter()
|
|
.map(|span| {
|
|
if !span.counts_are_consistent() {
|
|
return Err(
|
|
"terminal engine emitted an inconsistent cluster count".to_string()
|
|
);
|
|
}
|
|
let clusters = if span.cluster_count == 1 {
|
|
vec![WireCluster {
|
|
column: span.column,
|
|
text: span.text,
|
|
width: span.width,
|
|
}]
|
|
} else {
|
|
span.text
|
|
.chars()
|
|
.enumerate()
|
|
.map(|(index, ch)| WireCluster {
|
|
column: span.column + index * usize::from(span.width),
|
|
text: ch.to_string(),
|
|
width: span.width,
|
|
})
|
|
.collect()
|
|
};
|
|
Ok(WireSpan {
|
|
style: span.style.into(),
|
|
clusters,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
Ok(WireRow {
|
|
line: row.line,
|
|
spans,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
Ok(FrameMessage {
|
|
subscription_id: publication.subscription_id.to_string(),
|
|
sequence: publication.sequence,
|
|
rows,
|
|
cursor: WireCursor {
|
|
line: frame.cursor.line,
|
|
column: frame.cursor.column,
|
|
visible: frame.cursor.visible,
|
|
},
|
|
full: frame.full,
|
|
viewport: frame.viewport.into(),
|
|
bracketed_paste: false,
|
|
focus_reporting: false,
|
|
})
|
|
}
|
|
|
|
fn feed_and_drain(terminal: &SharedTerminal, bytes: &[u8]) -> bool {
|
|
let mut more = terminal.feed(bytes);
|
|
let deferred = more;
|
|
while more && !terminal.is_closing() {
|
|
more = terminal.drain();
|
|
}
|
|
deferred
|
|
}
|
|
|
|
struct ReaderThread {
|
|
handle: Option<JoinHandle<()>>,
|
|
terminal: Arc<SharedTerminal>,
|
|
stopping: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl buzz_terminal::lifecycle::DrainingReader for ReaderThread {
|
|
fn begin_closing(&self) {
|
|
self.terminal.begin_closing();
|
|
}
|
|
|
|
fn stop(&self) {
|
|
self.stopping.store(true, Ordering::Release);
|
|
}
|
|
|
|
fn join(mut self: Box<Self>) {
|
|
if let Some(handle) = self.handle.take() {
|
|
let _ = handle.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Session {
|
|
id: Uuid,
|
|
terminal: Arc<SharedTerminal>,
|
|
master: Option<Box<dyn MasterPty + Send>>,
|
|
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
|
pty_size: Arc<Mutex<PtySize>>,
|
|
child: Box<dyn portable_pty::Child + Send + Sync>,
|
|
reader: Option<Box<dyn buzz_terminal::lifecycle::DrainingReader + Send>>,
|
|
publisher: Arc<Mutex<FramePublisher>>,
|
|
channel: Arc<Mutex<Option<Channel<TerminalMessage>>>>,
|
|
}
|
|
|
|
impl Session {
|
|
fn publish(&self, publication: Publication) {
|
|
let subscription = publication.subscription_id;
|
|
let sent = wire_publication(publication).and_then(|message| {
|
|
let (bracketed_paste, focus_reporting) = self.terminal.input_modes();
|
|
let mut message = message;
|
|
message.bracketed_paste = bracketed_paste;
|
|
message.focus_reporting = focus_reporting;
|
|
self.channel
|
|
.lock()
|
|
.map_err(|e| e.to_string())?
|
|
.as_ref()
|
|
.ok_or_else(|| "terminal renderer detached".to_string())?
|
|
.send(TerminalMessage::Frame(message))
|
|
.map_err(|e| e.to_string())
|
|
});
|
|
if sent.is_err() {
|
|
if let Ok(mut channel) = self.channel.lock() {
|
|
*channel = None;
|
|
}
|
|
if let Ok(mut publisher) = self.publisher.lock() {
|
|
publisher.fault(subscription);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn shutdown(mut self) {
|
|
// Publication is detached before the reader enters close mode; the
|
|
// lifecycle helper then abandons parser work and keeps raw-draining
|
|
// through child termination and reap. Keep the renderer channel alive
|
|
// until the reader has reported Exit; close() removes the session from
|
|
// the runtime before shutdown begins, so this is its final message.
|
|
if let Ok(mut publisher) = self.publisher.lock() {
|
|
publisher.close();
|
|
}
|
|
if let Some(reader) = self.reader.take() {
|
|
#[cfg(unix)]
|
|
{
|
|
let _ = buzz_terminal::lifecycle::shutdown_draining(&mut self.child, reader);
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
reader.begin_closing();
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
reader.stop();
|
|
reader.join();
|
|
}
|
|
}
|
|
if let Ok(mut channel) = self.channel.lock() {
|
|
*channel = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub(crate) struct TerminalSessions(Mutex<Vec<Session>>);
|
|
|
|
impl TerminalSessions {
|
|
fn with_session<T>(&self, id: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
|
|
let id = Uuid::parse_str(id).map_err(|_| "invalid terminal session id".to_string())?;
|
|
let mut sessions = self.0.lock().map_err(|e| e.to_string())?;
|
|
let session = sessions
|
|
.iter_mut()
|
|
.find(|session| session.id == id)
|
|
.ok_or_else(|| "terminal session not found".to_string())?;
|
|
f(session)
|
|
}
|
|
|
|
pub(crate) fn shutdown_all(&self) {
|
|
let sessions = self
|
|
.0
|
|
.lock()
|
|
.map(|mut sessions| std::mem::take(&mut *sessions))
|
|
.unwrap_or_default();
|
|
for session in sessions {
|
|
session.shutdown();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn size(columns: u16, rows: u16) -> Result<Size> {
|
|
if columns == 0 || rows == 0 {
|
|
return Err("terminal dimensions must be non-zero".to_string());
|
|
}
|
|
Ok(Size {
|
|
columns: usize::from(columns),
|
|
screen_lines: usize::from(rows),
|
|
..Size::default()
|
|
})
|
|
}
|
|
|
|
fn pty_size(columns: u16, rows: u16, pixel_width: u16, pixel_height: u16) -> PtySize {
|
|
PtySize {
|
|
rows,
|
|
cols: columns,
|
|
pixel_width,
|
|
pixel_height,
|
|
}
|
|
}
|
|
|
|
fn offer_capture(
|
|
publisher: &Mutex<FramePublisher>,
|
|
frame: Frame,
|
|
snapshot: impl FnOnce() -> Frame,
|
|
) -> Option<Publication> {
|
|
let offered = publisher.lock().ok()?.offer(frame);
|
|
match offered {
|
|
Ok(publication) => publication,
|
|
Err(OfferError::PendingFrameMustBeSnapshot) => {
|
|
publisher.lock().ok()?.offer(snapshot()).ok().flatten()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn terminal_attach(
|
|
request: AttachRequest,
|
|
on_frame: Channel<TerminalMessage>,
|
|
state: tauri::State<'_, TerminalSessions>,
|
|
) -> Result<AttachResponse> {
|
|
let terminal_size = size(request.columns, request.rows)?;
|
|
let mut sessions = state.0.lock().map_err(|e| e.to_string())?;
|
|
|
|
if let Some(existing_id) = request.session_id.as_deref() {
|
|
let existing_id =
|
|
Uuid::parse_str(existing_id).map_err(|_| "invalid terminal session id".to_string())?;
|
|
let session = sessions
|
|
.iter_mut()
|
|
.find(|session| session.id == existing_id)
|
|
.ok_or_else(|| "terminal session not found".to_string())?;
|
|
let subscription = SubscriptionId::new();
|
|
let mut encoder = buzz_terminal::damage::Encoder::new();
|
|
let snapshot = session.terminal.snapshot(&mut encoder);
|
|
let viewport = snapshot.viewport;
|
|
*session.channel.lock().map_err(|e| e.to_string())? = Some(on_frame);
|
|
let bootstrap = session
|
|
.publisher
|
|
.lock()
|
|
.map_err(|e| e.to_string())?
|
|
.attach(subscription, snapshot)
|
|
.map_err(|_| "terminal snapshot rejected".to_string())?;
|
|
session.publish(bootstrap);
|
|
return Ok(AttachResponse {
|
|
session_id: existing_id.to_string(),
|
|
subscription_id: subscription.to_string(),
|
|
viewport: viewport.into(),
|
|
});
|
|
}
|
|
|
|
if sessions.len() >= MAX_LIVE_SESSIONS {
|
|
return Err("too many live terminal sessions".to_string());
|
|
}
|
|
|
|
let id = Uuid::new_v4();
|
|
let pair = native_pty_system()
|
|
.openpty(pty_size(
|
|
request.columns,
|
|
request.rows,
|
|
request.pixel_width,
|
|
request.pixel_height,
|
|
))
|
|
.map_err(|e| e.to_string())?;
|
|
let shell = {
|
|
#[cfg(unix)]
|
|
{
|
|
buzz_terminal::shell::resolve_shell(std::env::var("SHELL").ok().as_deref())
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
buzz_terminal::shell::resolve_shell(std::env::var("ComSpec").ok().as_deref())
|
|
}
|
|
};
|
|
let mut command = CommandBuilder::new_default_prog();
|
|
buzz_terminal::env_fence::fence_env(
|
|
&mut command,
|
|
&buzz_terminal::path::user_shell_path(),
|
|
&shell,
|
|
);
|
|
let context = GuiContext {
|
|
channel_id: request.channel_id,
|
|
channel_name: request.channel_name,
|
|
thread_id: request.thread_id,
|
|
npub: request.npub,
|
|
relay_url: request.relay_url,
|
|
session_id: id.to_string(),
|
|
};
|
|
for (key, value) in context_vars(&context) {
|
|
command.env(key, value);
|
|
}
|
|
let child = pair
|
|
.slave
|
|
.spawn_command(command)
|
|
.map_err(|e| e.to_string())?;
|
|
drop(pair.slave);
|
|
let mut reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?;
|
|
let writer = Arc::new(Mutex::new(
|
|
pair.master.take_writer().map_err(|e| e.to_string())?,
|
|
));
|
|
let (terminal, actions) = Terminal::new(terminal_size, Fences::default());
|
|
let terminal = Arc::new(SharedTerminal::new(terminal));
|
|
let publisher = Arc::new(Mutex::new(FramePublisher::new(terminal.lock().viewport())));
|
|
let channel = Arc::new(Mutex::new(Some(on_frame)));
|
|
let current_pty_size = Arc::new(Mutex::new(pty_size(
|
|
request.columns,
|
|
request.rows,
|
|
request.pixel_width,
|
|
request.pixel_height,
|
|
)));
|
|
|
|
// Emulator replies (DSR/size) must go back through the PTY even when no
|
|
// renderer is attached. The action thread ends when the terminal drops.
|
|
let action_writer = Arc::clone(&writer);
|
|
let action_size = Arc::clone(¤t_pty_size);
|
|
let action_channel = Arc::clone(&channel);
|
|
std::thread::spawn(move || {
|
|
while let Ok(action) = actions.recv() {
|
|
let presentation = match &action {
|
|
buzz_terminal::Action::Title(title) => Some(TerminalMessage::Title(title.clone())),
|
|
buzz_terminal::Action::ResetTitle => Some(TerminalMessage::ResetTitle),
|
|
buzz_terminal::Action::Bell => Some(TerminalMessage::Bell),
|
|
_ => None,
|
|
};
|
|
if let Some(message) = presentation {
|
|
let _ = action_channel
|
|
.lock()
|
|
.ok()
|
|
.and_then(|channel| channel.as_ref()?.send(message).ok());
|
|
continue;
|
|
}
|
|
let size = action_size.lock().map(|size| *size).unwrap_or_default();
|
|
if let Some(reply) = buzz_terminal::listener::reply(
|
|
action,
|
|
size.cols,
|
|
size.rows,
|
|
size.pixel_width,
|
|
size.pixel_height,
|
|
) {
|
|
if action_writer.lock().map_or(true, |mut writer| {
|
|
writer.write_all(reply.as_bytes()).is_err()
|
|
}) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let reader_terminal = Arc::clone(&terminal);
|
|
let reader_publisher = Arc::clone(&publisher);
|
|
let reader_channel = Arc::clone(&channel);
|
|
let reader_stopping = Arc::new(AtomicBool::new(false));
|
|
let thread_stopping = Arc::clone(&reader_stopping);
|
|
let reader_handle = std::thread::spawn(move || {
|
|
let mut buffer = [0u8; 16 * 1024];
|
|
let mut encoder = buzz_terminal::damage::Encoder::new();
|
|
let mut snapshot_encoder = buzz_terminal::damage::Encoder::new();
|
|
loop {
|
|
let count = match reader.read(&mut buffer) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(count) => count,
|
|
};
|
|
if thread_stopping.load(Ordering::Acquire) {
|
|
break;
|
|
}
|
|
if reader_terminal.is_closing() {
|
|
continue;
|
|
}
|
|
let _ = feed_and_drain(&reader_terminal, &buffer[..count]);
|
|
if reader_terminal.is_closing() {
|
|
continue;
|
|
}
|
|
let needs_snapshot = reader_publisher
|
|
.lock()
|
|
.map(|publisher| publisher.requires_snapshot())
|
|
.unwrap_or(false);
|
|
let frame = if needs_snapshot {
|
|
reader_terminal.snapshot(&mut snapshot_encoder)
|
|
} else {
|
|
reader_terminal.render(&mut encoder)
|
|
};
|
|
if frame.is_empty() {
|
|
continue;
|
|
}
|
|
let publication = offer_capture(&reader_publisher, frame, || {
|
|
reader_terminal.snapshot(&mut snapshot_encoder)
|
|
});
|
|
if let Some(publication) = publication {
|
|
let subscription = publication.subscription_id;
|
|
let result = wire_publication(publication).and_then(|mut message| {
|
|
let (bracketed_paste, focus_reporting) = reader_terminal.input_modes();
|
|
message.bracketed_paste = bracketed_paste;
|
|
message.focus_reporting = focus_reporting;
|
|
reader_channel
|
|
.lock()
|
|
.map_err(|e| e.to_string())?
|
|
.as_ref()
|
|
.ok_or_else(|| "detached".to_string())?
|
|
.send(TerminalMessage::Frame(message))
|
|
.map_err(|e| e.to_string())
|
|
});
|
|
if result.is_err() {
|
|
if let Ok(mut publisher) = reader_publisher.lock() {
|
|
publisher.fault(subscription);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let _ = reader_channel
|
|
.lock()
|
|
.ok()
|
|
.and_then(|channel| channel.as_ref()?.send(TerminalMessage::Exit).ok());
|
|
});
|
|
|
|
let subscription = SubscriptionId::new();
|
|
let mut snapshot_encoder = buzz_terminal::damage::Encoder::new();
|
|
let viewport = terminal.lock().viewport();
|
|
let bootstrap = publisher
|
|
.lock()
|
|
.map_err(|e| e.to_string())?
|
|
.attach(subscription, terminal.snapshot(&mut snapshot_encoder))
|
|
.map_err(|_| "terminal snapshot rejected".to_string())?;
|
|
let session = Session {
|
|
id,
|
|
terminal: Arc::clone(&terminal),
|
|
master: Some(pair.master),
|
|
writer,
|
|
pty_size: current_pty_size,
|
|
child,
|
|
reader: Some(Box::new(ReaderThread {
|
|
handle: Some(reader_handle),
|
|
terminal: Arc::clone(&terminal),
|
|
stopping: reader_stopping,
|
|
})),
|
|
publisher,
|
|
channel,
|
|
};
|
|
session.publish(bootstrap);
|
|
sessions.push(session);
|
|
Ok(AttachResponse {
|
|
session_id: id.to_string(),
|
|
subscription_id: subscription.to_string(),
|
|
viewport: viewport.into(),
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn terminal_detach(
|
|
session_id: String,
|
|
subscription_id: String,
|
|
state: tauri::State<'_, TerminalSessions>,
|
|
) -> Result<()> {
|
|
state.with_session(&session_id, |session| {
|
|
let id = SubscriptionId::parse(&subscription_id)?;
|
|
let detached = session
|
|
.publisher
|
|
.lock()
|
|
.map_err(|e| e.to_string())?
|
|
.fault(id);
|
|
if detached {
|
|
*session.channel.lock().map_err(|e| e.to_string())? = None;
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn terminal_close(
|
|
session_id: String,
|
|
state: tauri::State<'_, TerminalSessions>,
|
|
) -> Result<()> {
|
|
let id = Uuid::parse_str(&session_id).map_err(|_| "invalid terminal session id".to_string())?;
|
|
let session = {
|
|
let mut sessions = state.0.lock().map_err(|e| e.to_string())?;
|
|
let index = sessions
|
|
.iter()
|
|
.position(|session| session.id == id)
|
|
.ok_or_else(|| "terminal session not found".to_string())?;
|
|
sessions.remove(index)
|
|
};
|
|
session.shutdown();
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn terminal_input(
|
|
session_id: String,
|
|
data: String,
|
|
state: tauri::State<'_, TerminalSessions>,
|
|
) -> Result<()> {
|
|
if data.len() > MAX_INPUT_BYTES {
|
|
return Err("terminal input exceeds 1 MiB".to_string());
|
|
}
|
|
state.with_session(&session_id, |session| {
|
|
session
|
|
.writer
|
|
.lock()
|
|
.map_err(|e| e.to_string())?
|
|
.write_all(data.as_bytes())
|
|
.map_err(|e| e.to_string())?;
|
|
// Typing returns you to the live edge, as every terminal does. It has
|
|
// to be explicit: the grid pins a scrolled-back viewport and lets new
|
|
// output pile up above it, so the echo of this keystroke would land on
|
|
// a screen the user cannot see. Doing it here rather than in the
|
|
// renderer covers pasted text and encoded keys for free, and costs a
|
|
// comparison -- not a repaint -- when nothing was scrolled.
|
|
if session.terminal.scroll_to_bottom() {
|
|
publish_viewport(session)?;
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn terminal_resize(
|
|
session_id: String,
|
|
columns: u16,
|
|
rows: u16,
|
|
pixel_width: u16,
|
|
pixel_height: u16,
|
|
state: tauri::State<'_, TerminalSessions>,
|
|
) -> Result<WireViewport> {
|
|
let terminal_size = size(columns, rows)?;
|
|
state.with_session(&session_id, |session| {
|
|
let new_pty_size = pty_size(columns, rows, pixel_width, pixel_height);
|
|
session
|
|
.master
|
|
.as_ref()
|
|
.ok_or_else(|| "terminal is closing".to_string())?
|
|
.resize(new_pty_size)
|
|
.map_err(|e| e.to_string())?;
|
|
*session.pty_size.lock().map_err(|e| e.to_string())? = new_pty_size;
|
|
let viewport = session.terminal.resize(terminal_size);
|
|
let publication = {
|
|
let mut publisher = session.publisher.lock().map_err(|e| e.to_string())?;
|
|
publisher.resize_applied(viewport);
|
|
let mut encoder = buzz_terminal::damage::Encoder::new();
|
|
publisher
|
|
.offer(session.terminal.snapshot(&mut encoder))
|
|
.map_err(|_| "terminal resize snapshot rejected".to_string())?
|
|
};
|
|
if let Some(publication) = publication {
|
|
session.publish(publication);
|
|
}
|
|
Ok(viewport.into())
|
|
})
|
|
}
|
|
|
|
/// Republish the viewport after the *viewport itself* moved without the grid
|
|
/// changing.
|
|
///
|
|
/// A snapshot, not a damage capture, and that is load-bearing twice over.
|
|
/// `capture_all` deliberately leaves damage alone (`damage.rs`), so the
|
|
/// full-damage flag that `scroll_display` just set survives for the reader
|
|
/// thread's next `render()` to consume -- which is what clears *its* dedup
|
|
/// hashes. Take the damage here instead and those hashes would go on
|
|
/// describing the rows the renderer had before the scroll, free to suppress a
|
|
/// row that genuinely changed underneath it.
|
|
///
|
|
/// The encoder is fresh and local because its only job is to encode one frame
|
|
/// that is `full` by construction; sharing the reader's would be sharing dedup
|
|
/// state across two coordinate systems.
|
|
fn publish_viewport(session: &Session) -> Result<()> {
|
|
let mut encoder = buzz_terminal::damage::Encoder::new();
|
|
let publication = session
|
|
.publisher
|
|
.lock()
|
|
.map_err(|e| e.to_string())?
|
|
.offer(session.terminal.snapshot(&mut encoder))
|
|
.map_err(|_| "terminal scroll snapshot rejected".to_string())?;
|
|
if let Some(publication) = publication {
|
|
session.publish(publication);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Scroll the viewport through scrollback.
|
|
///
|
|
/// `lines` is a DOM wheel delta in cells; see [`scroll_by_dom_lines`] for the
|
|
/// sign convention and why it is written against `deltaY`.
|
|
#[tauri::command]
|
|
pub(crate) fn terminal_scroll(
|
|
session_id: String,
|
|
lines: DomLines,
|
|
state: tauri::State<'_, TerminalSessions>,
|
|
) -> Result<()> {
|
|
state.with_session(&session_id, |session| {
|
|
// Momentum keeps delivering events for a second or so after the
|
|
// fingers lift. Once history runs out the engine clamps and reports
|
|
// that nothing moved, so the tail costs a lock and a compare rather
|
|
// than a full-grid copy and a frame each.
|
|
if !scroll_by_dom_lines(&session.terminal, lines) {
|
|
return Ok(());
|
|
}
|
|
publish_viewport(session)
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn terminal_ack(
|
|
session_id: String,
|
|
subscription_id: String,
|
|
sequence: u64,
|
|
state: tauri::State<'_, TerminalSessions>,
|
|
) -> Result<()> {
|
|
state.with_session(&session_id, |session| {
|
|
let id = SubscriptionId::parse(&subscription_id)?;
|
|
let publication = session
|
|
.publisher
|
|
.lock()
|
|
.map_err(|e| e.to_string())?
|
|
.acknowledge(id, sequence);
|
|
if let Some(publication) = publication {
|
|
session.publish(publication);
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn terminal_viewport_ready(
|
|
session_id: String,
|
|
subscription_id: String,
|
|
viewport: WireViewport,
|
|
state: tauri::State<'_, TerminalSessions>,
|
|
) -> Result<()> {
|
|
state.with_session(&session_id, |session| {
|
|
let id = SubscriptionId::parse(&subscription_id)?;
|
|
let publication = session
|
|
.publisher
|
|
.lock()
|
|
.map_err(|e| e.to_string())?
|
|
.viewport_ready(id, viewport.into());
|
|
if let Some(publication) = publication {
|
|
session.publish(publication);
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn terminal_focus(
|
|
session_id: String,
|
|
focused: bool,
|
|
state: tauri::State<'_, TerminalSessions>,
|
|
) -> Result<()> {
|
|
state.with_session(&session_id, |session| {
|
|
let (_, enabled) = session.terminal.input_modes();
|
|
if enabled {
|
|
session
|
|
.writer
|
|
.lock()
|
|
.map_err(|e| e.to_string())?
|
|
.write_all(if focused { b"\x1b[I" } else { b"\x1b[O" })
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use buzz_terminal::damage::{CursorFrame, RowFrame, Span};
|
|
|
|
fn publication(spans: Vec<Span>) -> Publication {
|
|
Publication {
|
|
subscription_id: SubscriptionId::new(),
|
|
sequence: 7,
|
|
frame: buzz_terminal::damage::Frame {
|
|
rows: vec![RowFrame { line: 3, spans }],
|
|
cursor: CursorFrame {
|
|
line: 1,
|
|
column: 2,
|
|
visible: true,
|
|
},
|
|
cursor_changed: true,
|
|
full: true,
|
|
viewport: Viewport {
|
|
generation: 4,
|
|
columns: 80,
|
|
screen_lines: 24,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
fn style() -> Style {
|
|
Style {
|
|
fg: 1,
|
|
bg: 2,
|
|
flags: 3,
|
|
}
|
|
}
|
|
|
|
fn marker_frame(marker: usize, full: bool) -> Frame {
|
|
Frame {
|
|
rows: vec![RowFrame {
|
|
line: marker,
|
|
spans: Vec::new(),
|
|
}],
|
|
cursor: CursorFrame {
|
|
line: 0,
|
|
column: 0,
|
|
visible: true,
|
|
},
|
|
cursor_changed: true,
|
|
full,
|
|
viewport: Viewport {
|
|
generation: 0,
|
|
columns: 80,
|
|
screen_lines: 24,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn assert_post_snapshot_capture_survives_attach(mut publisher: FramePublisher) {
|
|
assert!(!publisher.requires_snapshot());
|
|
let bootstrap = marker_frame(1, true);
|
|
let post_snapshot_incremental = marker_frame(42, false);
|
|
let subscription = SubscriptionId::new();
|
|
publisher.attach(subscription, bootstrap).unwrap();
|
|
let publisher = Mutex::new(publisher);
|
|
|
|
assert_eq!(
|
|
offer_capture(&publisher, post_snapshot_incremental, || marker_frame(
|
|
42, true
|
|
)),
|
|
None
|
|
);
|
|
|
|
let successor = publisher
|
|
.lock()
|
|
.unwrap()
|
|
.acknowledge(subscription, 1)
|
|
.expect("post-snapshot PTY output was lost");
|
|
assert_eq!(successor.frame.rows[0].line, 42);
|
|
assert!(successor.frame.full);
|
|
}
|
|
|
|
#[test]
|
|
fn reader_pumps_a_deferred_tail_without_an_external_event() {
|
|
let (terminal, _actions) = Terminal::new(Size::default(), Fences::ALL);
|
|
let terminal = SharedTerminal::new(terminal);
|
|
let payload = "\u{1b}c".repeat(2_102_714);
|
|
|
|
assert!(
|
|
feed_and_drain(&terminal, payload.as_bytes()),
|
|
"fixture must defer parser work before the runtime pumps it"
|
|
);
|
|
|
|
let terminal = terminal.lock();
|
|
assert_eq!(terminal.pending_bytes(), 0);
|
|
assert_eq!(terminal.stats().completed_units, 2_102_714);
|
|
}
|
|
|
|
#[test]
|
|
fn initial_attach_retains_output_captured_after_its_bootstrap_snapshot() {
|
|
let viewport = marker_frame(0, true).viewport;
|
|
let publisher = FramePublisher::new(viewport);
|
|
assert_post_snapshot_capture_survives_attach(publisher);
|
|
}
|
|
|
|
#[test]
|
|
fn reattach_retains_output_captured_after_its_bootstrap_snapshot() {
|
|
let viewport = marker_frame(0, true).viewport;
|
|
let mut publisher = FramePublisher::new(viewport);
|
|
let old = SubscriptionId::new();
|
|
publisher.attach(old, marker_frame(0, true)).unwrap();
|
|
assert!(publisher.acknowledge(old, 1).is_none());
|
|
assert_post_snapshot_capture_survives_attach(publisher);
|
|
}
|
|
|
|
#[test]
|
|
fn mapper_expands_ascii_runs_without_unicode_classification() {
|
|
let message = wire_publication(publication(vec![Span {
|
|
column: 4,
|
|
text: "abc".into(),
|
|
width: 1,
|
|
cluster_count: 3,
|
|
style: style(),
|
|
}]))
|
|
.unwrap();
|
|
let clusters = &message.rows[0].spans[0].clusters;
|
|
assert_eq!(
|
|
clusters
|
|
.iter()
|
|
.map(|cluster| (cluster.column, cluster.text.as_str()))
|
|
.collect::<Vec<_>>(),
|
|
vec![(4, "a"), (5, "b"), (6, "c")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mapper_keeps_a_multi_char_cluster_atomic() {
|
|
let message = wire_publication(publication(vec![Span {
|
|
column: 9,
|
|
text: "1\u{fe0f}\u{20e3}".into(),
|
|
width: 2,
|
|
cluster_count: 1,
|
|
style: style(),
|
|
}]))
|
|
.unwrap();
|
|
let clusters = &message.rows[0].spans[0].clusters;
|
|
assert_eq!(clusters.len(), 1);
|
|
assert_eq!(clusters[0].column, 9);
|
|
assert_eq!(clusters[0].text, "1\u{fe0f}\u{20e3}");
|
|
assert_eq!(clusters[0].width, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn mapper_rejects_an_inconsistent_engine_span() {
|
|
let result = wire_publication(publication(vec![Span {
|
|
column: 0,
|
|
text: "ab".into(),
|
|
width: 1,
|
|
cluster_count: 3,
|
|
style: style(),
|
|
}]));
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn dimensions_reject_zero_and_preserve_scrollback_default() {
|
|
assert!(size(0, 24).is_err());
|
|
assert!(size(80, 0).is_err());
|
|
let size = size(100, 40).unwrap();
|
|
assert_eq!(
|
|
(size.columns, size.screen_lines, size.scrollback),
|
|
(100, 40, 10_000)
|
|
);
|
|
}
|
|
}
|