1243 lines
50 KiB
Rust
1243 lines
50 KiB
Rust
// frontend.rs --- crossterm-driven TUI backend.
|
|
|
|
//! TUI frontend: terminal raw-mode setup, input parsing, escape-sequence
|
|
//! emission for `InstanceMessage` deltas. crossterm directly --- no ratatui
|
|
//! (its widget model competes with the cell grid).
|
|
//!
|
|
//! Per T M5.2 (spec §sec:v01-remote-scope deliverable 1), the cell-buffer
|
|
//! diff happens *instance-side* in
|
|
//! [`crate::instance_render::RenderState`], not here. The frontend is a
|
|
//! transport sink: it consumes [`InstanceMessage`] values and emits
|
|
//! escape sequences. The same [`InstanceMessage`] stream that drives
|
|
//! the local TUI today drives the SSH transport in T M5.7.
|
|
//!
|
|
//! # Lifecycle
|
|
//!
|
|
//! [`Frontend::new`] enables raw mode, enters the alternate screen, hides
|
|
//! the cursor, and enables bracketed paste. [`Frontend::drop`] tears all of
|
|
//! that down even on panic. A panic hook installed at process start
|
|
//! (`install_panic_hook`) ensures the terminal is restored before the panic
|
|
//! message hits stderr.
|
|
//!
|
|
//! # Frames
|
|
//!
|
|
//! [`Frontend::present_messages`] wraps the supplied
|
|
//! [`InstanceMessage`]s in DEC mode 2026 synchronized output and applies
|
|
//! each. [`InstanceMessage::CellDelta`] emits escape sequences for the
|
|
//! span; [`InstanceMessage::Cursor`] moves and shows/hides the cursor;
|
|
//! the `ModeLine`, `Signal`, and `Goodbye` variants are reserved for
|
|
//! v0.3 and ignored by the v0.1 TUI.
|
|
//!
|
|
//! # Threading
|
|
//!
|
|
//! Main thread only.
|
|
//!
|
|
//! # Status overlay (T M5.8)
|
|
//!
|
|
//! [`Frontend::draw_status_overlay`] paints a one-row banner across the
|
|
//! bottom of the screen in reverse video --- used by the attach
|
|
//! reconnect loop to indicate "disconnected, reconnecting in 4s,
|
|
//! Ctrl-C to exit". It is *not* an [`InstanceMessage`]: the daemon has
|
|
//! no opinion about reconnect status, since reconnect is a frontend
|
|
//! concern. The overlay is cleared by [`Frontend::clear_status_overlay`]
|
|
//! once a reattach succeeds; the subsequent full-grid resync repaints
|
|
//! whatever the daemon's view places in that row.
|
|
|
|
use std::io::{self, BufWriter, Stdout, Write};
|
|
use std::time::Duration;
|
|
|
|
#[cfg(feature = "crdt")]
|
|
use crossterm::cursor::MoveLeft;
|
|
use crossterm::{
|
|
cursor::{self, MoveTo},
|
|
event::{
|
|
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
|
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
|
},
|
|
queue,
|
|
style::{
|
|
Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
|
|
SetForegroundColor,
|
|
},
|
|
terminal::{
|
|
BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, EnterAlternateScreen,
|
|
LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, size as terminal_size,
|
|
},
|
|
};
|
|
|
|
use crate::cell::{CellSize, Color, DiffSpan, Glyph, Style};
|
|
use crate::protocol::{InstanceMessage, InstanceSignal};
|
|
|
|
// Re-export the input event types so callers don't depend on crossterm
|
|
// directly. M2's keymap will translate these into normalized commands.
|
|
pub use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Panic hook
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Install a panic hook that restores the terminal before forwarding to the
|
|
/// previous hook.
|
|
///
|
|
/// Call this once at process start, before constructing the [`Frontend`].
|
|
/// Idempotent: calling it twice replaces the previous hook with one that
|
|
/// still chains to the original.
|
|
pub fn install_panic_hook() {
|
|
let previous = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(move |info| {
|
|
restore_terminal_on_panic();
|
|
previous(info);
|
|
}));
|
|
}
|
|
|
|
fn restore_terminal_on_panic() {
|
|
let mut out = io::stdout();
|
|
// Pop the keyboard enhancement first --- terminals that ignored the
|
|
// push will likewise ignore the pop, so this is unconditionally
|
|
// safe even if `Frontend::new` never ran or never reached the push.
|
|
let _ = queue!(
|
|
out,
|
|
PopKeyboardEnhancementFlags,
|
|
DisableBracketedPaste,
|
|
DisableMouseCapture,
|
|
cursor::Show,
|
|
LeaveAlternateScreen,
|
|
);
|
|
let _ = out.flush();
|
|
let _ = disable_raw_mode();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Frontend
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// TUI backend wrapping crossterm.
|
|
//
|
|
// The four lifecycle bools each track an independent terminal state we may
|
|
// have entered (or not) at init time. We need them separately so teardown
|
|
// only undoes what setup actually did. Collapsing them into a single state
|
|
// machine would be ceremony without benefit, since the transitions are not
|
|
// ordered and the flags are private to the struct.
|
|
#[allow(
|
|
clippy::struct_excessive_bools,
|
|
reason = "lifecycle flags are independent"
|
|
)]
|
|
pub struct Frontend {
|
|
out: BufWriter<Stdout>,
|
|
size: CellSize,
|
|
/// Whether raw mode was entered (for teardown).
|
|
raw_mode: bool,
|
|
/// Whether alternate screen was entered (for teardown).
|
|
alt_screen: bool,
|
|
/// Whether bracketed paste was enabled (for teardown).
|
|
bracketed_paste: bool,
|
|
/// Whether mouse capture was enabled (for teardown).
|
|
mouse: bool,
|
|
/// Whether the kitty keyboard-enhancement flags were pushed.
|
|
/// Disambiguates `Ctrl+/`, `Shift+Tab`, etc., that the legacy
|
|
/// terminal protocol mangles. Best-effort: terminals that don't
|
|
/// support it ignore the CSI; we still record the push so we know
|
|
/// to balance with a Pop on teardown.
|
|
keyboard_enhancement: bool,
|
|
}
|
|
|
|
impl Frontend {
|
|
/// Construct a frontend, taking over the controlling terminal.
|
|
///
|
|
/// On error the terminal is left in its original state.
|
|
pub fn new() -> io::Result<Self> {
|
|
let (cols, rows) = terminal_size()?;
|
|
let size = CellSize::new(u32::from(rows), u32::from(cols));
|
|
|
|
let stdout = io::stdout();
|
|
let out = BufWriter::new(stdout);
|
|
enable_raw_mode()?;
|
|
let mut me = Self {
|
|
out,
|
|
size,
|
|
raw_mode: true,
|
|
alt_screen: false,
|
|
bracketed_paste: false,
|
|
mouse: false,
|
|
keyboard_enhancement: false,
|
|
};
|
|
// Best-effort sequence of init steps. Each step that succeeds
|
|
// is recorded so teardown can skip steps that never ran.
|
|
if let Err(e) = queue!(
|
|
me.out,
|
|
EnterAlternateScreen,
|
|
Clear(ClearType::All),
|
|
cursor::Hide,
|
|
EnableBracketedPaste,
|
|
EnableMouseCapture,
|
|
) {
|
|
me.teardown();
|
|
return Err(e);
|
|
}
|
|
me.alt_screen = true;
|
|
me.bracketed_paste = true;
|
|
me.mouse = true;
|
|
|
|
// Kitty keyboard protocol (best-effort). Terminals that support
|
|
// it (kitty, foot, WezTerm, alacritty, modern xterm, ...) start
|
|
// delivering disambiguated key events: `Ctrl+/` arrives as the
|
|
// literal `/` with CONTROL instead of the byte-roulette legacy
|
|
// protocols produce. Terminals that don't ignore the CSI; we
|
|
// push the flag anyway so the Pop on teardown is balanced.
|
|
//
|
|
// We deliberately do NOT push `REPORT_ALL_KEYS_AS_ESCAPE_CODES`.
|
|
// That flag tells the terminal to send every key (including
|
|
// printable letters) as a CSI sequence carrying the unshifted
|
|
// base key plus modifier bits, e.g. `Shift+a` arrives as
|
|
// `Char('a') + SHIFT` rather than `Char('A')`. Pmacs has no
|
|
// keyboard-layout knowledge to translate `9 + SHIFT` into `(`
|
|
// on a US layout (or `É` on a French layout, etc.); the
|
|
// terminal does. Letting the terminal apply layout-aware shift
|
|
// translation is correct; receiving the post-shift character
|
|
// is what every typing-driven path (self-insert, minibuffer,
|
|
// search) expects. `DISAMBIGUATE_ESCAPE_CODES` alone still
|
|
// gives us the C-i/Tab and C-m/Enter disambiguation we want.
|
|
let kitty_flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
|
|
if queue!(me.out, PushKeyboardEnhancementFlags(kitty_flags)).is_ok() {
|
|
me.keyboard_enhancement = true;
|
|
}
|
|
|
|
if let Err(e) = me.out.flush() {
|
|
me.teardown();
|
|
return Err(e);
|
|
}
|
|
Ok(me)
|
|
}
|
|
|
|
/// Current size of the terminal in cells.
|
|
#[must_use]
|
|
pub fn size(&self) -> CellSize {
|
|
self.size
|
|
}
|
|
|
|
/// Apply a sequence of [`InstanceMessage`]s, wrapped in DEC mode 2026
|
|
/// synchronized output so partial frames never appear when the
|
|
/// terminal supports it. Terminals that don't understand the
|
|
/// brackets silently ignore them.
|
|
pub fn present_messages(&mut self, msgs: &[InstanceMessage]) -> io::Result<()> {
|
|
queue!(self.out, BeginSynchronizedUpdate)?;
|
|
for m in msgs {
|
|
self.apply_message(m)?;
|
|
}
|
|
queue!(self.out, EndSynchronizedUpdate)?;
|
|
self.out.flush()
|
|
}
|
|
|
|
/// T M10.10 Day 3 step 5 Path β — paint an optimistic insert.
|
|
///
|
|
/// The character is written at the terminal's current cursor
|
|
/// position; the terminal advances the cursor by one column.
|
|
/// This is the visual half of the optimistic-apply path:
|
|
/// `BufferMirror::apply_local_insert` updated the CRDT mirror;
|
|
/// this method updates the user-visible display in the same
|
|
/// keystroke.
|
|
///
|
|
/// Called only when the cursor is at end-of-line for the active
|
|
/// buffer (per `BufferMirror::cursor_at_end_of_line`). End-of-
|
|
/// line is the dominant typing case and the only case where the
|
|
/// daemon's eventual `CellDelta` matches a single-Print
|
|
/// optimistic paint exactly (no cells right of cursor to shift).
|
|
///
|
|
/// # Post-audit round 2 (F15): style-blindness
|
|
///
|
|
/// This paint is **default-style only**. We explicitly reset
|
|
/// terminal attributes before the `Print` so the painted glyph
|
|
/// is deterministic and doesn't inherit leftover SGR state from
|
|
/// a prior `emit_span`. The `emit_span` epilogue already issues
|
|
/// `ResetColor + SetAttribute(Attribute::Reset)`, but the
|
|
/// invariant is fragile across crossterm versions and we'd
|
|
/// rather pay one extra reset than re-flash whatever style the
|
|
/// previous span set.
|
|
///
|
|
/// **Honest scope**: if the cell the daemon will eventually
|
|
/// paint into has a non-default style (e.g., a diagnostic
|
|
/// region, a syntax-highlighted token in a future milestone),
|
|
/// the optimistic glyph briefly renders default-styled until the
|
|
/// authoritative `CellDelta` arrives (within one frame target).
|
|
/// For v0.1 there is no syntax-highlighting pipeline; styled
|
|
/// regions are restricted to diagnostics squiggles, completion
|
|
/// popups, and overlays — none of which typically sit on the
|
|
/// end-of-line cell that Path β paints into. A future milestone
|
|
/// that introduces in-buffer styled content should track the
|
|
/// cursor-cell's pending style from the previous `CellDelta` and
|
|
/// apply it here, or suppress the optimistic paint on styled
|
|
/// cells altogether. The right fix needs per-cell style memory
|
|
/// the attach loop doesn't carry today.
|
|
#[cfg(feature = "crdt")]
|
|
pub fn paint_optimistic_insert(&mut self, c: char) -> io::Result<()> {
|
|
queue!(
|
|
self.out,
|
|
ResetColor,
|
|
SetAttribute(Attribute::Reset),
|
|
Print(c)
|
|
)?;
|
|
self.out.flush()
|
|
}
|
|
|
|
/// T M10.10 Day 3 step 5 Path β — paint an optimistic
|
|
/// delete-back.
|
|
///
|
|
/// Sequence: move cursor one column left, overwrite the cell
|
|
/// with a space, retreat cursor one column to its final
|
|
/// position. Matches what the daemon's eventual `CellDelta`
|
|
/// will carry: the last char of the line becomes a space at
|
|
/// the cursor's pre-edit column.
|
|
///
|
|
/// Called only when the cursor is at end-of-line and there's a
|
|
/// previous character to erase. Mid-line backspace falls
|
|
/// through to v0.1 round-trip per Path β scope.
|
|
///
|
|
/// # Post-audit round 2 (F15): style-blindness
|
|
///
|
|
/// The space is painted with default style (explicit reset
|
|
/// before `Print`). For end-of-line backspace this is correct
|
|
/// in nearly all v0.1 cases: the cell becomes empty / cleared,
|
|
/// and the daemon's eventual `CellDelta` for an empty cell is
|
|
/// itself default-styled. Same scope caveat as
|
|
/// [`Self::paint_optimistic_insert`] for any future milestone
|
|
/// where the post-erase cell might re-render with a non-default
|
|
/// background or syntax style.
|
|
#[cfg(feature = "crdt")]
|
|
pub fn paint_optimistic_delete_back(&mut self) -> io::Result<()> {
|
|
queue!(
|
|
self.out,
|
|
MoveLeft(1),
|
|
ResetColor,
|
|
SetAttribute(Attribute::Reset),
|
|
Print(' '),
|
|
MoveLeft(1)
|
|
)?;
|
|
self.out.flush()
|
|
}
|
|
|
|
/// Apply a single [`InstanceMessage`] to the terminal.
|
|
///
|
|
/// `CellDelta` emits one cursor-move + run-of-glyphs sequence per
|
|
/// span (via [`emit_span`]). `Cursor` moves the terminal cursor
|
|
/// and toggles its visibility. `ModeLine`, `Signal`, and `Goodbye`
|
|
/// are reserved for v0.3 (GUI / multi-frontend) and ignored here.
|
|
pub fn apply_message(&mut self, msg: &InstanceMessage) -> io::Result<()> {
|
|
match msg {
|
|
InstanceMessage::CellDelta { spans, full_grid } => {
|
|
emit_cell_delta(&mut self.out, spans, *full_grid)?;
|
|
}
|
|
InstanceMessage::Cursor(state) => match state {
|
|
Some(cs) if cs.visible => {
|
|
queue!(
|
|
self.out,
|
|
MoveTo(cs.coord.col as u16, cs.coord.row as u16),
|
|
cursor::Show
|
|
)?;
|
|
}
|
|
_ => {
|
|
queue!(self.out, cursor::Hide)?;
|
|
}
|
|
},
|
|
// Q#CM6 — publish to the OS clipboard via OSC 52. Writes are
|
|
// reliable in modern terminals; OSC 52 *reads* are widely
|
|
// disabled for security, which is why inbound paste rides
|
|
// bracketed paste rather than querying the clipboard here.
|
|
InstanceMessage::Signal(InstanceSignal::Clipboard(data)) => {
|
|
let payload = format!("\x1b]52;c;{}\x07", osc52_base64(data));
|
|
queue!(self.out, Print(payload))?;
|
|
}
|
|
InstanceMessage::Signal(InstanceSignal::Bell) => {
|
|
queue!(self.out, Print("\x07"))?;
|
|
}
|
|
InstanceMessage::ModeLine(_)
|
|
// Window-title requests remain metadata-only.
|
|
| InstanceMessage::Signal(_)
|
|
| InstanceMessage::Goodbye(_)
|
|
// T M10.5: CrdtOp's wire shape exists; the v1.0 TUI doesn't
|
|
// maintain a local CRDT state yet (M10.8 wires that). A v2
|
|
// daemon shouldn't send CrdtOp to this frontend because our
|
|
// FrontendCapabilities advertise crdt_replica: false. If one
|
|
// arrives anyway, drop it silently — same v0.1-ignored
|
|
// category as ModeLine / Signal / Goodbye for now.
|
|
| InstanceMessage::CrdtOp { .. }
|
|
// T M10.6: PresenceUpdate joins the v0.1-ignored category.
|
|
// The peer-cursor overlay renderer is M10.8 work; until
|
|
// then any incoming PresenceUpdate is dropped silently.
|
|
| InstanceMessage::PresenceUpdate { .. }
|
|
// T M10.10: BufferSnapshot is consumed by the BufferMirror
|
|
// layer on M10.10-aware frontends (gated by negotiated
|
|
// `crdt_replica`). The legacy TUI render path here doesn't
|
|
// maintain a BufferMirror, so the variant drops silently
|
|
// in this path. The M10.10 frontend wiring intercepts
|
|
// BufferSnapshot in the attach.rs message loop BEFORE it
|
|
// reaches apply_message.
|
|
| InstanceMessage::BufferSnapshot { .. }
|
|
// T M10.10: CursorByte is paired with Cursor for replica
|
|
// frontends. The cursor's grid position (consumed by the
|
|
// legacy render path above via Cursor) drives paint; the
|
|
// byte position (consumed by BufferMirror's cursor tracker
|
|
// in attach.rs) drives optimistic-apply. The legacy path
|
|
// here only needs grid; the byte variant drops silently.
|
|
| InstanceMessage::CursorByte { .. }
|
|
// T M11.1: the semantic-frontend projection family. This
|
|
// is the grid TUI — it advertises `semantic_render: false`,
|
|
// so a v3 daemon never sends these here (the per-session
|
|
// outgoing filter, M11.2, gates the family). If one
|
|
// arrives anyway it drops silently, same v0.1-ignored
|
|
// category as CrdtOp / PresenceUpdate. A semantic
|
|
// frontend (M11.5) consumes them via its own layout path,
|
|
// not this cell-grid path.
|
|
| InstanceMessage::StyleSpans { .. }
|
|
| InstanceMessage::Decorations { .. }
|
|
| InstanceMessage::InlineAdornments { .. }
|
|
| InstanceMessage::BlockAdornments { .. }
|
|
| InstanceMessage::FoldState { .. }
|
|
| InstanceMessage::FileStyleSummary { .. }
|
|
| InstanceMessage::StatusFacts { .. }
|
|
// Q#SR5 — SearchPrompt is a semantic-frontend status-band
|
|
// family member; the cell-grid TUI never negotiates it and
|
|
// drops it silently if one arrives.
|
|
| InstanceMessage::SearchPrompt { .. }
|
|
// Q#CM1 — MenuPrompt is the semantic-frontend menu surface;
|
|
// the TUI renders the menu via its cell overlay instead, so
|
|
// it drops this silently like the other semantic families.
|
|
| InstanceMessage::MenuPrompt { .. }
|
|
// Q#MB1 — MinibufferPrompt is the semantic-frontend minibuffer
|
|
// surface; the TUI paints the minibuffer via its own bottom
|
|
// row, so it drops this silently too.
|
|
| InstanceMessage::MinibufferPrompt { .. }
|
|
// UX gutter — LineNumbers is the semantic-frontend gutter
|
|
// toggle; the cell-grid TUI reads its window's mode directly,
|
|
// so it drops this silently like the other semantic families.
|
|
| InstanceMessage::LineNumbers { .. }
|
|
// Arc 1a Q#C5 — CompletionPopup is the semantic-frontend
|
|
// completion dropdown; the TUI paints the popup via its
|
|
// CompletionView cell overlay, so it drops this silently.
|
|
| InstanceMessage::CompletionPopup { .. }
|
|
// Themes Q#TH7 — ThemeFacts is the semantic-frontend face
|
|
// table; the cell-grid TUI receives its chrome pre-painted
|
|
// (the daemon resolves faces at paint time), so it drops
|
|
// this silently like the other semantic families.
|
|
| InstanceMessage::ThemeFacts { .. }
|
|
// Themes stage 2 Q#F4 — FontFacts is the GPU font
|
|
// preference; terminal fonts belong to the terminal, so
|
|
// the cell-grid TUI drops this silently too.
|
|
| InstanceMessage::FontFacts { .. }
|
|
// Q#SL7 — custom statusline segments are semantic-only;
|
|
// the grid TUI paints provider output directly from the
|
|
// registry and silently drops an unexpected wire copy.
|
|
| InstanceMessage::StatuslineSegments { .. }
|
|
// Vterm Stage 3 — TerminalFrame is the semantic frontend's
|
|
// terminal surface. A grid TUI composes terminal windows
|
|
// into its own CellDelta (Stage 2) and advertises no
|
|
// semantic render, so the daemon never sends it here; an
|
|
// unexpected copy drops silently like the rest of the
|
|
// family rather than being re-interpreted as cells.
|
|
| InstanceMessage::TerminalFrame(_)
|
|
// Q#GT4 — this pre-window semantic bootstrap result cannot
|
|
// legitimately reach the grid TUI.
|
|
| InstanceMessage::InitialTargetResult(_)
|
|
// Q#BP15 — the panel band is painted by the GPU frontend;
|
|
// the grid TUI renders its side windows through the cell
|
|
// grid and negotiates no panel capability, so this cannot
|
|
// legitimately reach here.
|
|
| InstanceMessage::PanelFrame(_)
|
|
| InstanceMessage::ResourceOffer { .. }
|
|
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
|
|
// optimistic-apply gate; if any reaches this render path
|
|
// (shouldn't, given the attach drain), drop silently.
|
|
| InstanceMessage::DispatchIdle { .. } => {
|
|
// v0.1 TUI ignores these; v0.3 GUI consumes them.
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Paint a one-row status banner across the bottom of the screen in
|
|
/// reverse video and hide the cursor.
|
|
///
|
|
/// Used during attach reconnect (T M5.8) to surface that the
|
|
/// session is disconnected and a reconnect is in flight. The
|
|
/// banner is sanitized (control chars replaced with spaces),
|
|
/// truncated to terminal width, and right-padded with spaces so
|
|
/// the entire bottom row is overwritten --- no leftover cells
|
|
/// from the previous frame or a prior overlay can bleed through.
|
|
/// Subsequent calls overwrite the previous overlay in place.
|
|
///
|
|
/// The cursor is hidden because input is suppressed during
|
|
/// reconnect: a visible cursor would lie about where the user's
|
|
/// keystrokes land. [`Frontend::clear_status_overlay`] does not
|
|
/// re-show it; the daemon's next [`InstanceMessage::Cursor`] in
|
|
/// the post-reattach resync restores cursor visibility.
|
|
///
|
|
/// Wrapped in DEC mode 2026 synchronized output so the banner
|
|
/// never appears half-painted on terminals that support the
|
|
/// brackets.
|
|
///
|
|
/// # Errors
|
|
/// Returns the underlying [`io::Error`] if writing to stdout
|
|
/// fails.
|
|
pub fn draw_status_overlay(&mut self, text: &str) -> io::Result<()> {
|
|
queue!(self.out, BeginSynchronizedUpdate)?;
|
|
emit_status_overlay(&mut self.out, self.size, text)?;
|
|
queue!(self.out, cursor::Hide, EndSynchronizedUpdate)?;
|
|
self.out.flush()
|
|
}
|
|
|
|
/// Repaint the bottom row in the default style to remove a status
|
|
/// overlay drawn by [`Frontend::draw_status_overlay`].
|
|
///
|
|
/// Does not restore cursor visibility; the daemon's next
|
|
/// [`InstanceMessage::Cursor`] in the resync will. Cells outside
|
|
/// the bottom row are untouched --- the post-reattach full-grid
|
|
/// resync repaints the bottom row content the daemon's view
|
|
/// places there.
|
|
///
|
|
/// # Errors
|
|
/// Returns the underlying [`io::Error`] if writing to stdout
|
|
/// fails.
|
|
pub fn clear_status_overlay(&mut self) -> io::Result<()> {
|
|
queue!(self.out, BeginSynchronizedUpdate)?;
|
|
emit_clear_status_overlay(&mut self.out, self.size)?;
|
|
queue!(self.out, EndSynchronizedUpdate)?;
|
|
self.out.flush()
|
|
}
|
|
|
|
/// Wait for the next input event, up to `timeout`.
|
|
///
|
|
/// Returns `Ok(None)` on timeout, `Ok(Some(event))` on event,
|
|
/// `Err(_)` on terminal error. A `Resize` event updates the
|
|
/// frontend's known size and reallocates the cell buffers.
|
|
pub fn poll_event(&mut self, timeout: Duration) -> io::Result<Option<Event>> {
|
|
if !crossterm::event::poll(timeout)? {
|
|
return Ok(None);
|
|
}
|
|
let event = crossterm::event::read()?;
|
|
if let Event::Resize(cols, rows) = event {
|
|
self.handle_resize(CellSize::new(u32::from(rows), u32::from(cols)));
|
|
}
|
|
Ok(Some(event))
|
|
}
|
|
|
|
/// Block until the next input event.
|
|
///
|
|
/// Returns the event, updating the frontend's size on `Resize`.
|
|
pub fn read_event(&mut self) -> io::Result<Event> {
|
|
let event = crossterm::event::read()?;
|
|
if let Event::Resize(cols, rows) = event {
|
|
self.handle_resize(CellSize::new(u32::from(rows), u32::from(cols)));
|
|
}
|
|
Ok(event)
|
|
}
|
|
|
|
fn handle_resize(&mut self, new_size: CellSize) {
|
|
self.size = new_size;
|
|
// Cell-buffer reallocation lives on
|
|
// [`crate::instance_render::RenderState`] now (T M5.2). The
|
|
// run loop is responsible for forwarding the new size there;
|
|
// the frontend just updates its own view of the terminal
|
|
// dimensions for input-event coordinates.
|
|
}
|
|
|
|
/// Tear down the terminal state. Idempotent. Called from [`Drop`] and
|
|
/// on init failure.
|
|
fn teardown(&mut self) {
|
|
if self.keyboard_enhancement {
|
|
// Always pop, even if the original push was a no-op for
|
|
// this terminal: a terminal that didn't enter the enhanced
|
|
// mode will silently ignore the pop.
|
|
let _ = queue!(self.out, PopKeyboardEnhancementFlags);
|
|
self.keyboard_enhancement = false;
|
|
}
|
|
if self.bracketed_paste {
|
|
let _ = queue!(self.out, DisableBracketedPaste);
|
|
self.bracketed_paste = false;
|
|
}
|
|
if self.mouse {
|
|
let _ = queue!(self.out, DisableMouseCapture);
|
|
self.mouse = false;
|
|
}
|
|
if self.alt_screen {
|
|
let _ = queue!(self.out, cursor::Show, ResetColor, LeaveAlternateScreen);
|
|
self.alt_screen = false;
|
|
}
|
|
let _ = self.out.flush();
|
|
if self.raw_mode {
|
|
let _ = disable_raw_mode();
|
|
self.raw_mode = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for Frontend {
|
|
fn drop(&mut self) {
|
|
self.teardown();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Span emission (pure; testable)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Emit one `CellDelta` — the resync blank, then every span.
|
|
///
|
|
/// **FG-INV.** A `CellDelta` with `full_grid: true` carries only the
|
|
/// **non-default** cells of the frame: the producer diffs against a
|
|
/// blank grid (`crate::instance_render::RenderState::render_frame`), so
|
|
/// a cell that should be blank produces no span at all. The resync is a
|
|
/// picture of the screen's *ink*, not of the screen. A consumer that
|
|
/// applies those spans to a surface it has not blanked keeps whatever
|
|
/// was underneath every blank cell.
|
|
///
|
|
/// That is not hypothetical. The startup path clears once
|
|
/// ([`Frontend::new`]), which made the sparse resync correct for
|
|
/// exactly one frame — the fresh-attach frame, where the screen
|
|
/// provably is blank — and wrong for every resize after it. Zooming a
|
|
/// terminal font left the reflowed previous frame showing through.
|
|
///
|
|
/// The clear is preceded by `ResetColor` + `SetAttribute(Reset)`
|
|
/// because `Clear` paints with the *current* background: a resync taken
|
|
/// while a styled span was last emitted would otherwise wash the screen
|
|
/// in that style.
|
|
///
|
|
/// **Empty spans still clear.** A resync whose frame is entirely blank
|
|
/// carries no spans at all, and that is precisely the frame that most
|
|
/// needs the surface blanked. Returning early on `spans.is_empty()`
|
|
/// would look like an optimization and reintroduce the whole defect.
|
|
///
|
|
/// Pure and tested below by capturing into a `Vec<u8>`, like its
|
|
/// neighbours.
|
|
fn emit_cell_delta<W: Write>(w: &mut W, spans: &[DiffSpan], full_grid: bool) -> io::Result<()> {
|
|
if full_grid {
|
|
queue!(
|
|
w,
|
|
ResetColor,
|
|
SetAttribute(Attribute::Reset),
|
|
Clear(ClearType::All)
|
|
)?;
|
|
}
|
|
for span in spans {
|
|
emit_span(w, span)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Emit a diff span as escape sequences to `w`.
|
|
///
|
|
/// Pure: the same span produces the same byte output, regardless of any
|
|
/// process state. Tested below by capturing into a `Vec<u8>`.
|
|
fn emit_span<W: Write>(w: &mut W, span: &DiffSpan) -> io::Result<()> {
|
|
if span.cells.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
queue!(w, MoveTo(span.start.col as u16, span.start.row as u16))?;
|
|
|
|
let mut last_style: Option<Style> = None;
|
|
for cell in &span.cells {
|
|
if last_style.as_ref() != Some(&cell.style) {
|
|
apply_style(w, &cell.style)?;
|
|
last_style = Some(cell.style);
|
|
}
|
|
match &cell.glyph {
|
|
Glyph::Char(c) => {
|
|
// Defense in depth: a control character in a cell would
|
|
// jump the cursor (`\n`, `\r`) or emit a CSI ESC
|
|
// sequence (`\x1b`), corrupting subsequent paint
|
|
// operations. Sanitize at the seam so a single bad
|
|
// status line cannot shred the frame.
|
|
let printable = if c.is_control() { ' ' } else { *c };
|
|
write!(w, "{printable}")?;
|
|
}
|
|
Glyph::Cluster(bytes) => w.write_all(bytes)?,
|
|
Glyph::Continuation => {
|
|
// Wide-char continuation: the previous cell's glyph occupies
|
|
// both columns. Skip; the terminal already drew over this
|
|
// cell when it rendered the wide glyph.
|
|
}
|
|
}
|
|
}
|
|
queue!(w, ResetColor, SetAttribute(Attribute::Reset))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Emit the bottom-row status banner as escape sequences to `w`.
|
|
///
|
|
/// Pure: same `(size, text)` produces the same bytes. The caller
|
|
/// supplies framing (synchronized-update brackets, cursor hide,
|
|
/// flush). A zero-area terminal is a no-op so the function is safe
|
|
/// to call before the first resize in unusual init paths.
|
|
///
|
|
/// Truncation is by `char` count, which equals column count for
|
|
/// the v0.1 banner texts (ASCII + em-dash). A terminal narrow
|
|
/// enough to truncate the message is already showing the user
|
|
/// "something is wrong"; precision wide-char width accounting
|
|
/// would be ceremony with no user-visible improvement at v0.1.
|
|
fn emit_status_overlay<W: Write>(w: &mut W, size: CellSize, text: &str) -> io::Result<()> {
|
|
let cols = size.cols as usize;
|
|
if cols == 0 || size.rows == 0 {
|
|
return Ok(());
|
|
}
|
|
let bottom_row = (size.rows - 1) as u16;
|
|
|
|
queue!(
|
|
w,
|
|
MoveTo(0, bottom_row),
|
|
SetAttribute(Attribute::Reset),
|
|
SetAttribute(Attribute::Reverse),
|
|
)?;
|
|
|
|
let mut painted: usize = 0;
|
|
for c in text.chars() {
|
|
if painted >= cols {
|
|
break;
|
|
}
|
|
// Sanitize controls so a stray newline / CR / ESC in the
|
|
// banner text cannot scroll the screen or inject SGR
|
|
// sequences. Mirrors the defense in `emit_span`.
|
|
let printable = if c.is_control() { ' ' } else { c };
|
|
write!(w, "{printable}")?;
|
|
painted += 1;
|
|
}
|
|
for _ in painted..cols {
|
|
write!(w, " ")?;
|
|
}
|
|
queue!(w, SetAttribute(Attribute::Reset))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Emit a bottom-row clear (default-style spaces) as escape sequences
|
|
/// to `w`.
|
|
///
|
|
/// Counterpart to [`emit_status_overlay`]. Same purity contract.
|
|
fn emit_clear_status_overlay<W: Write>(w: &mut W, size: CellSize) -> io::Result<()> {
|
|
let cols = size.cols as usize;
|
|
if cols == 0 || size.rows == 0 {
|
|
return Ok(());
|
|
}
|
|
let bottom_row = (size.rows - 1) as u16;
|
|
|
|
queue!(w, MoveTo(0, bottom_row), SetAttribute(Attribute::Reset))?;
|
|
for _ in 0..cols {
|
|
write!(w, " ")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_style<W: Write>(w: &mut W, style: &Style) -> io::Result<()> {
|
|
queue!(w, SetAttribute(Attribute::Reset))?;
|
|
queue!(w, SetForegroundColor(to_ct_color(style.fg)))?;
|
|
queue!(w, SetBackgroundColor(to_ct_color(style.bg)))?;
|
|
if style.bold {
|
|
queue!(w, SetAttribute(Attribute::Bold))?;
|
|
}
|
|
if style.italic {
|
|
queue!(w, SetAttribute(Attribute::Italic))?;
|
|
}
|
|
match style.underline {
|
|
crate::cell::UnderlineStyle::None => {}
|
|
crate::cell::UnderlineStyle::Single => {
|
|
queue!(w, SetAttribute(Attribute::Underlined))?;
|
|
}
|
|
// Kitty-style underline subparameters (CSI 4:N m). Terminals
|
|
// that predate the extension treat the whole sequence as
|
|
// SGR 4 (plain underline) or ignore the subparameter, so the
|
|
// fallback is a straight underline — same as the previous
|
|
// flatten-to-`Underlined` behavior (T M4.6).
|
|
crate::cell::UnderlineStyle::Double => write!(w, "\x1b[4:2m")?,
|
|
crate::cell::UnderlineStyle::Curly => write!(w, "\x1b[4:3m")?,
|
|
crate::cell::UnderlineStyle::Dotted => write!(w, "\x1b[4:4m")?,
|
|
crate::cell::UnderlineStyle::Dashed => write!(w, "\x1b[4:5m")?,
|
|
}
|
|
// Underline color (SGR 58, colon subparameter form). Emitted only
|
|
// when set and an underline is present: the SGR 0 at the top of
|
|
// this function already reset the underline color to
|
|
// follow-text-color (SGR 59 state), which is what
|
|
// `Color::Default` means.
|
|
if style.underline != crate::cell::UnderlineStyle::None {
|
|
match style.underline_color {
|
|
Color::Default => {}
|
|
Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m")?,
|
|
Color::Indexed(n) => write!(w, "\x1b[58:5:{n}m")?,
|
|
}
|
|
}
|
|
if style.reverse {
|
|
queue!(w, SetAttribute(Attribute::Reverse))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn to_ct_color(c: Color) -> CtColor {
|
|
match c {
|
|
Color::Default => CtColor::Reset,
|
|
Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
|
|
Color::Indexed(n) => CtColor::AnsiValue(n),
|
|
}
|
|
}
|
|
|
|
/// Standard-alphabet base64 (RFC 4648, with padding) for the OSC 52
|
|
/// clipboard payload (Q#CM6). Inlined rather than pulling a crate: the
|
|
/// only consumer is the clipboard escape, and the encoder is a dozen
|
|
/// lines with no edge cases beyond the 1-/2-byte tail.
|
|
fn osc52_base64(data: &[u8]) -> String {
|
|
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
|
for chunk in data.chunks(3) {
|
|
let b0 = chunk[0];
|
|
let b1 = chunk.get(1).copied().unwrap_or(0);
|
|
let b2 = chunk.get(2).copied().unwrap_or(0);
|
|
let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
|
|
out.push(ALPHABET[(n >> 18) as usize & 0x3f] as char);
|
|
out.push(ALPHABET[(n >> 12) as usize & 0x3f] as char);
|
|
out.push(if chunk.len() > 1 {
|
|
ALPHABET[(n >> 6) as usize & 0x3f] as char
|
|
} else {
|
|
'='
|
|
});
|
|
out.push(if chunk.len() > 2 {
|
|
ALPHABET[n as usize & 0x3f] as char
|
|
} else {
|
|
'='
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests (pure parts only --- the lifecycle machinery requires a TTY)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::cell::{Cell, CellCoord, Glyph, Style};
|
|
|
|
#[test]
|
|
fn osc52_base64_matches_known_vectors() {
|
|
// RFC 4648 §10 test vectors exercise both tail lengths.
|
|
assert_eq!(osc52_base64(b""), "");
|
|
assert_eq!(osc52_base64(b"f"), "Zg==");
|
|
assert_eq!(osc52_base64(b"fo"), "Zm8=");
|
|
assert_eq!(osc52_base64(b"foo"), "Zm9v");
|
|
assert_eq!(osc52_base64(b"foob"), "Zm9vYg==");
|
|
assert_eq!(osc52_base64(b"fooba"), "Zm9vYmE=");
|
|
assert_eq!(osc52_base64(b"foobar"), "Zm9vYmFy");
|
|
}
|
|
|
|
fn ch(c: char) -> Cell {
|
|
Cell {
|
|
glyph: Glyph::Char(c),
|
|
style: Style::default(),
|
|
attachment: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn theme_facts_drops_silently_on_the_grid_frontend() {
|
|
// Themes Q#TH7 / acceptance 18: the cell-grid TUI receives its
|
|
// chrome pre-painted (the daemon resolves faces at paint
|
|
// time), so a `ThemeFacts` reaching this client — which never
|
|
// negotiates it — must fall into the semantic-family silent
|
|
// drop, not error. Constructed directly (no terminal
|
|
// takeover); the drop arm writes nothing.
|
|
let mut fe = Frontend {
|
|
out: BufWriter::new(io::stdout()),
|
|
size: CellSize::new(24, 80),
|
|
raw_mode: false,
|
|
alt_screen: false,
|
|
bracketed_paste: false,
|
|
mouse: false,
|
|
keyboard_enhancement: false,
|
|
};
|
|
fe.apply_message(&InstanceMessage::ThemeFacts {
|
|
faces: vec![pmacs_protocol::ThemeFace {
|
|
name: "ui.modeline".into(),
|
|
style: Style::default(),
|
|
}],
|
|
})
|
|
.expect("the grid frontend must drop ThemeFacts silently");
|
|
}
|
|
|
|
#[test]
|
|
fn font_facts_drops_silently_on_the_grid_frontend() {
|
|
// Themes stage 2 Q#F4 / acceptance 8: terminal fonts belong
|
|
// to the terminal, so a `FontFacts` reaching the cell-grid
|
|
// TUI — which never negotiates it — must fall into the
|
|
// semantic-family silent drop, not error.
|
|
let mut fe = Frontend {
|
|
out: BufWriter::new(io::stdout()),
|
|
size: CellSize::new(24, 80),
|
|
raw_mode: false,
|
|
alt_screen: false,
|
|
bracketed_paste: false,
|
|
mouse: false,
|
|
keyboard_enhancement: false,
|
|
};
|
|
fe.apply_message(&InstanceMessage::FontFacts {
|
|
family: Some("Iosevka".into()),
|
|
size_centi_px: Some(1800),
|
|
})
|
|
.expect("the grid frontend must drop FontFacts silently");
|
|
}
|
|
|
|
#[test]
|
|
fn statusline_segments_drop_silently_on_the_grid_frontend() {
|
|
let mut fe = Frontend {
|
|
out: BufWriter::new(io::stdout()),
|
|
size: CellSize::new(24, 80),
|
|
raw_mode: false,
|
|
alt_screen: false,
|
|
bracketed_paste: false,
|
|
mouse: false,
|
|
keyboard_enhancement: false,
|
|
};
|
|
fe.apply_message(&InstanceMessage::StatuslineSegments {
|
|
buffer_id: crate::buffer::BufferId::from_raw(7),
|
|
left: vec![pmacs_protocol::StatuslineSegment {
|
|
text: "project".into(),
|
|
face: "ui.modeline.project".into(),
|
|
}],
|
|
right: Vec::new(),
|
|
})
|
|
.expect("the grid frontend must drop StatuslineSegments silently");
|
|
}
|
|
|
|
/// FG-INV, witness 1: a resync blanks the surface before it paints,
|
|
/// and the ORDER is the claim — a clear *after* a span erases the
|
|
/// frame it was meant to precede.
|
|
#[test]
|
|
fn a_resync_resets_style_and_clears_before_any_span() {
|
|
let span = DiffSpan {
|
|
start: CellCoord::new(0, 0),
|
|
cells: vec![ch('x')],
|
|
};
|
|
let mut out = Vec::new();
|
|
emit_cell_delta(&mut out, std::slice::from_ref(&span), true).unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
|
|
let clear = s.find("\x1b[2J").expect("resync must clear: {s:?}");
|
|
let reset_color = s.find("\x1b[0m").expect("resync must reset: {s:?}");
|
|
let glyph = s.find('x').expect("the span is still painted: {s:?}");
|
|
|
|
assert!(
|
|
reset_color < clear,
|
|
"reset must precede the clear — `Clear` paints with the CURRENT \
|
|
background, so a resync taken mid-style would wash the screen \
|
|
in it: {s:?}"
|
|
);
|
|
assert!(
|
|
clear < glyph,
|
|
"the clear must precede the paint, or it erases the frame it \
|
|
was meant to precede: {s:?}"
|
|
);
|
|
}
|
|
|
|
/// FG-INV, witness 2: the frame that most needs blanking carries no
|
|
/// spans at all.
|
|
///
|
|
/// A resync of an entirely blank frame produces zero spans, because
|
|
/// the producer diffs against a blank grid. `spans.is_empty()` looks
|
|
/// exactly like "nothing to do" — and an early return there
|
|
/// reintroduces the whole defect for the one frame whose entire
|
|
/// content IS the blanking.
|
|
#[test]
|
|
fn a_resync_with_no_spans_still_clears() {
|
|
let mut out = Vec::new();
|
|
emit_cell_delta(&mut out, &[], true).unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
assert!(
|
|
s.contains("\x1b[2J"),
|
|
"an empty resync is the stale-blank case, not a no-op: {s:?}"
|
|
);
|
|
}
|
|
|
|
/// FG-INV, witness 3: the obligation is the flag's, not the
|
|
/// message's. A differential frame that clears would erase
|
|
/// everything it does not repaint.
|
|
#[test]
|
|
fn a_differential_frame_never_clears() {
|
|
let span = DiffSpan {
|
|
start: CellCoord::new(1, 1),
|
|
cells: vec![ch('y')],
|
|
};
|
|
let mut out = Vec::new();
|
|
emit_cell_delta(&mut out, std::slice::from_ref(&span), false).unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
assert!(
|
|
!s.contains("\x1b[2J"),
|
|
"a differential frame must not clear: {s:?}"
|
|
);
|
|
assert!(s.contains('y'), "but it still paints: {s:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn emit_span_writes_cursor_move_then_chars() {
|
|
let span = DiffSpan {
|
|
start: CellCoord::new(2, 5),
|
|
cells: vec![ch('a'), ch('b'), ch('c')],
|
|
};
|
|
let mut out = Vec::new();
|
|
emit_span(&mut out, &span).unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
// Cursor move uses CSI <row+1> ; <col+1> H. Both 1-based.
|
|
assert!(s.contains("\x1b[3;6H"), "missing cursor move in {s:?}");
|
|
// Glyphs emitted in order.
|
|
assert!(s.contains("abc"), "missing glyphs in {s:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn emit_span_handles_continuation() {
|
|
// Wide char span: leading 中 + Continuation. Only the leading char
|
|
// should appear in the output bytes.
|
|
let span = DiffSpan {
|
|
start: CellCoord::new(0, 0),
|
|
cells: vec![
|
|
Cell {
|
|
glyph: Glyph::Char('中'),
|
|
style: Style::default(),
|
|
attachment: None,
|
|
},
|
|
Cell {
|
|
glyph: Glyph::Continuation,
|
|
style: Style::default(),
|
|
attachment: None,
|
|
},
|
|
],
|
|
};
|
|
let mut out = Vec::new();
|
|
emit_span(&mut out, &span).unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
// 中 appears, but no extra char follows (continuation contributes
|
|
// nothing of its own --- the wide glyph occupies both columns).
|
|
assert!(s.contains('中'));
|
|
assert!(!s.contains("中中"));
|
|
}
|
|
|
|
#[test]
|
|
fn emit_span_emits_cluster_bytes() {
|
|
let span = DiffSpan {
|
|
start: CellCoord::new(0, 0),
|
|
cells: vec![Cell {
|
|
glyph: Glyph::Cluster(b"\xC3\xA9".to_vec().into_boxed_slice()),
|
|
style: Style::default(),
|
|
attachment: None,
|
|
}],
|
|
};
|
|
let mut out = Vec::new();
|
|
emit_span(&mut out, &span).unwrap();
|
|
// Cluster is the UTF-8 bytes of é.
|
|
assert!(out.windows(2).any(|w| w == b"\xC3\xA9"));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_span_emits_nothing() {
|
|
let span = DiffSpan {
|
|
start: CellCoord::new(0, 0),
|
|
cells: vec![],
|
|
};
|
|
let mut out = Vec::new();
|
|
emit_span(&mut out, &span).unwrap();
|
|
assert!(out.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn style_transitions_are_emitted() {
|
|
// First cell has bold style; second cell drops it. The output must
|
|
// contain both the bold-on sequence and a reset between them.
|
|
let span = DiffSpan {
|
|
start: CellCoord::new(0, 0),
|
|
cells: vec![
|
|
Cell {
|
|
glyph: Glyph::Char('A'),
|
|
style: Style {
|
|
bold: true,
|
|
..Style::default()
|
|
},
|
|
attachment: None,
|
|
},
|
|
ch('b'),
|
|
],
|
|
};
|
|
let mut out = Vec::new();
|
|
emit_span(&mut out, &span).unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
// Bold uses SGR 1.
|
|
assert!(s.contains("\x1b[1m"), "missing bold-on in {s:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn curly_underline_emits_csi_4_3_and_underline_color() {
|
|
// A diagnostic-style cell: curly underline colored red via
|
|
// SGR 58 (T M4.6). The text color must NOT be touched.
|
|
let span = DiffSpan {
|
|
start: CellCoord::new(0, 0),
|
|
cells: vec![Cell {
|
|
glyph: Glyph::Char('x'),
|
|
style: Style {
|
|
underline: crate::cell::UnderlineStyle::Curly,
|
|
underline_color: crate::cell::Color::Indexed(1),
|
|
..Style::default()
|
|
},
|
|
attachment: None,
|
|
}],
|
|
};
|
|
let mut out = Vec::new();
|
|
emit_span(&mut out, &span).unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
assert!(s.contains("\x1b[4:3m"), "missing curly underline in {s:?}");
|
|
assert!(
|
|
s.contains("\x1b[58:5:1m"),
|
|
"missing underline color in {s:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_underline_color_emits_no_sgr_58() {
|
|
// A plain single underline with follow-text color: no SGR 58
|
|
// on the wire (the reset at the start of every style apply
|
|
// already put the terminal in SGR 59 state).
|
|
let span = DiffSpan {
|
|
start: CellCoord::new(0, 0),
|
|
cells: vec![Cell {
|
|
glyph: Glyph::Char('x'),
|
|
style: Style {
|
|
underline: crate::cell::UnderlineStyle::Single,
|
|
..Style::default()
|
|
},
|
|
attachment: None,
|
|
}],
|
|
};
|
|
let mut out = Vec::new();
|
|
emit_span(&mut out, &span).unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
assert!(!s.contains("\x1b[58"), "unexpected SGR 58 in {s:?}");
|
|
}
|
|
|
|
// ---- status overlay (T M5.8) -----------------------------------------
|
|
|
|
#[test]
|
|
fn status_overlay_moves_to_bottom_and_emits_reverse_attr_text_and_padding() {
|
|
// 10 rows, 20 cols → bottom row index 9, ANSI-1-based row 10.
|
|
let size = CellSize::new(10, 20);
|
|
let mut out = Vec::new();
|
|
emit_status_overlay(&mut out, size, "hi").unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
|
|
// CSI <row+1> ; <col+1> H — both 1-based.
|
|
assert!(s.contains("\x1b[10;1H"), "missing bottom-row move in {s:?}");
|
|
// SGR 7 — reverse video.
|
|
assert!(s.contains("\x1b[7m"), "missing reverse attr in {s:?}");
|
|
// Banner text painted.
|
|
assert!(s.contains("hi"), "missing banner text in {s:?}");
|
|
// Right-padded to full width: 2 chars of text + 18 spaces.
|
|
let space_count = s.matches(' ').count();
|
|
assert!(
|
|
space_count >= 18,
|
|
"expected at least 18 padding spaces, got {space_count} in {s:?}"
|
|
);
|
|
// Trailing reset so subsequent content isn't reverse-video.
|
|
assert!(s.ends_with("\x1b[0m"), "missing trailing reset in {s:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn status_overlay_truncates_text_to_terminal_width() {
|
|
// 4 cols → only the first 4 characters of the input survive.
|
|
let size = CellSize::new(5, 4);
|
|
let mut out = Vec::new();
|
|
emit_status_overlay(&mut out, size, "abcdefgh").unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
|
|
assert!(s.contains("abcd"), "missing prefix in {s:?}");
|
|
assert!(!s.contains("efgh"), "did not truncate: {s:?}");
|
|
// No padding when text fills the row exactly.
|
|
let space_count = s.matches(' ').count();
|
|
assert_eq!(space_count, 0, "unexpected padding in {s:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn status_overlay_sanitizes_control_chars() {
|
|
// Newline / CR / ESC inside the banner text would scroll the
|
|
// screen or inject SGR sequences. They must be replaced with
|
|
// spaces before reaching the terminal.
|
|
let size = CellSize::new(5, 20);
|
|
let mut out = Vec::new();
|
|
emit_status_overlay(&mut out, size, "a\nb\rc\x1bd").unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
|
|
// No raw newline / CR survives in the painted output.
|
|
assert!(!s.contains('\n'), "raw \\n leaked through: {s:?}");
|
|
assert!(!s.contains('\r'), "raw \\r leaked through: {s:?}");
|
|
// Each control char became a single space, so the painted text
|
|
// is "a b c d".
|
|
assert!(s.contains("a b c d"), "expected sanitized run in {s:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn status_overlay_zero_dimensions_is_noop() {
|
|
let mut out = Vec::new();
|
|
emit_status_overlay(&mut out, CellSize::new(0, 0), "x").unwrap();
|
|
assert!(out.is_empty());
|
|
|
|
let mut out = Vec::new();
|
|
emit_status_overlay(&mut out, CellSize::new(5, 0), "x").unwrap();
|
|
assert!(out.is_empty());
|
|
|
|
let mut out = Vec::new();
|
|
emit_status_overlay(&mut out, CellSize::new(0, 80), "x").unwrap();
|
|
assert!(out.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn status_overlay_empty_text_paints_full_row_of_spaces() {
|
|
let size = CellSize::new(3, 6);
|
|
let mut out = Vec::new();
|
|
emit_status_overlay(&mut out, size, "").unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
|
|
assert!(s.contains("\x1b[3;1H"));
|
|
assert_eq!(s.matches(' ').count(), 6, "expected 6 spaces in {s:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn clear_status_overlay_emits_move_then_default_style_spaces() {
|
|
let size = CellSize::new(10, 8);
|
|
let mut out = Vec::new();
|
|
emit_clear_status_overlay(&mut out, size).unwrap();
|
|
let s = String::from_utf8_lossy(&out);
|
|
|
|
// Move to bottom row.
|
|
assert!(s.contains("\x1b[10;1H"), "missing bottom-row move in {s:?}");
|
|
// Reset attribute before painting so we don't inherit reverse
|
|
// from a still-resident overlay.
|
|
assert!(s.contains("\x1b[0m"), "missing attr reset in {s:?}");
|
|
// Exactly `cols` spaces.
|
|
assert_eq!(s.matches(' ').count(), 8);
|
|
// No reverse attribute.
|
|
assert!(!s.contains("\x1b[7m"), "unexpected reverse attr in {s:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn clear_status_overlay_zero_dimensions_is_noop() {
|
|
let mut out = Vec::new();
|
|
emit_clear_status_overlay(&mut out, CellSize::new(0, 0)).unwrap();
|
|
assert!(out.is_empty());
|
|
|
|
let mut out = Vec::new();
|
|
emit_clear_status_overlay(&mut out, CellSize::new(5, 0)).unwrap();
|
|
assert!(out.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn color_conversion() {
|
|
assert!(matches!(to_ct_color(Color::Default), CtColor::Reset));
|
|
assert!(matches!(
|
|
to_ct_color(Color::Rgb(1, 2, 3)),
|
|
CtColor::Rgb { r: 1, g: 2, b: 3 }
|
|
));
|
|
assert!(matches!(
|
|
to_ct_color(Color::Indexed(42)),
|
|
CtColor::AnsiValue(42)
|
|
));
|
|
}
|
|
}
|