diff --git a/README.md b/README.md index 4cb2cd46..65c4320f 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,10 @@ The full matrix is in [docs/protocols.md](docs/protocols.md), and ### πŸ›‹οΈ Small comforts +- **Reference Pin**: select an error, identifier or command recipe and press + Ctrl+Shift+P (Cmd+Shift+P on macOS) to keep a copy visible while you keep + working. The card stays put as output scrolls; Copy puts its text on the + clipboard and Dismiss removes it. - **Smooth scrolling** through 10,000 lines of scrollback (configurable), for wheel and touchpad. - A **scroll-to-bottom button** appears while you're reading history. @@ -281,6 +285,7 @@ OSC 52 and Markdown. | Copy selection | Ctrl+Shift+C | Cmd+C | | Paste clipboard | Ctrl+Shift+V | Cmd+V | | Paste PRIMARY selection | Shift+Insert | Shift+Insert (clipboard) | +| Pin or replace selected text; dismiss pin when nothing is selected | Ctrl+Shift+P | Cmd+Shift+P | | Open hyperlink | Ctrl+click | Cmd+click | | Copy a Markdown block's source | click the copy chip shown at the block's top right on hover | same | | Zoom in | Ctrl+= (also Ctrl++, Ctrl+Shift+=, Ctrl+keypad +) | Cmd+= (same variants) | @@ -290,6 +295,20 @@ OSC 52 and Markdown. Mouse: double-click selects a word, triple-click a line, and middle-click pastes PRIMARY. See [docs/selection.md](docs/selection.md). +Reference Pin keeps one plain-text snapshot per window, only in memory. It +survives scrolling, clearing the screen and entering a full-screen app, but +is not saved when the window closes. A new selection replaces the old pin. +The card previews up to six wrapped lines; Copy includes the retained text, +not just the preview. Snapshots are limited to 16 KiB at a grapheme boundary, +with a visible notice when capped. On very small windows the card hides until +there is room again; the keyboard shortcut can still dismiss it. The pin +shortcut never runs a command or writes the clipboard; normal select-to-copy +still applies, and Copy on the card copies the retained snapshot. Escape +remains available to your shell or app. + +For a hands-on demo with synthetic output, run `bash demos/reference-pin.sh` +inside toastty and follow the on-screen instructions. +
More on toastty md and toastty image diff --git a/crates/toastty-render/src/lib.rs b/crates/toastty-render/src/lib.rs index 8dd478df..f5afee73 100644 --- a/crates/toastty-render/src/lib.rs +++ b/crates/toastty-render/src/lib.rs @@ -28,6 +28,7 @@ pub mod color; pub mod dialog; pub mod image; pub mod post; +pub mod reference_pin; #[cfg(test)] mod render_tests; pub mod rgp; @@ -59,6 +60,7 @@ use crate::image::instance::{ use crate::image::pipeline::ImagePipeline; pub use crate::post::{PostAnimation, PostShaderSource, ShaderError}; use crate::post::{PostChain, PostState}; +use crate::reference_pin::{PinButton, ReferencePin}; use crate::rgp::{GpuAssetCache, Rgp3dPipeline}; use crate::text::glyph_rasterizer::{DEFAULT_LINE_HEIGHT_RATIO, GlyphRasterizer}; use crate::text::instance::{CellInstance, CellRenderOpts, Theme}; @@ -598,6 +600,13 @@ pub struct Renderer { /// SDF pipeline for the close dialog's shapes. Lazily built /// alongside the text pipeline. ui_pipeline: Option, + /// Static session-only reference. Its presence never schedules frames. + reference_pin: Option, + reference_pin_hover: Option, + /// Separate instance buffer from the modal dialog. + reference_pin_pipeline: Option, + /// Uploaded text range retained for shader-only frames. + reference_pin_glyphs: std::ops::Range, /// Optional IME preedit (in-progress composition) string drawn inline /// at the terminal cursor every frame. Fed by the platform IME /// (e.g. fcitx5) while the user composes Hangul/CJK; it is NOT part of @@ -1224,12 +1233,12 @@ impl std::fmt::Debug for TextState { } } -/// Append glyph instances for one line of close-dialog text. `run`'s +/// Append glyph instances for one line of UI text. `run`'s /// origin is absolute (physical px); instances are content-relative, so /// `content_origin` (which the vertex shader adds back) is subtracted. /// Columns come from the shaper, so wide characters land where it put /// them; each glyph takes the colour of the span covering its column. -#[allow(clippy::cast_precision_loss)] +#[allow(clippy::cast_precision_loss, clippy::too_many_arguments)] fn push_dialog_text( queue: &Queue, text: &mut TextState, @@ -1238,20 +1247,28 @@ fn push_dialog_text( color: impl Fn(dialog::Tone) -> [f32; 4], cell: (f32, f32), content_origin: [f32; 2], + grapheme_mode: bool, ) { use crate::text::instance::{FLAG_COLOR_GLYPH, FLAG_OVERLAY}; use toastty_protocols::unicode_core::char_cell_width; + use unicode_segmentation::UnicodeSegmentation; let mut line = String::new(); // (end column, tone) per span, for colouring by column. let mut tones: Vec<(usize, dialog::Tone)> = Vec::with_capacity(run.spans.len()); let mut end = 0; for span in &run.spans { line.push_str(&span.text); - end += span - .text - .chars() - .map(|c| usize::from(char_cell_width(c, false))) - .sum::(); + end += if grapheme_mode { + span.text + .graphemes(true) + .map(|g| usize::from(cluster_cell_width(g, true))) + .sum::() + } else { + span.text + .chars() + .map(|c| usize::from(char_cell_width(c, false))) + .sum::() + }; tones.push((end, span.tone)); } if line.is_empty() { @@ -1264,7 +1281,7 @@ fn push_dialog_text( .or(tones.last()) .map_or(dialog::Tone::Strong, |t| t.1) }; - let lg = text.rasterizer.shape_line(queue, &line, false); + let lg = text.rasterizer.shape_line(queue, &line, grapheme_mode); let x0 = run.origin[0] - content_origin[0]; let y0 = run.origin[1] - content_origin[1]; let primary = lg @@ -1454,6 +1471,10 @@ impl Renderer { close_prompt: None, close_dialog_hover: None, ui_pipeline: None, + reference_pin: None, + reference_pin_hover: None, + reference_pin_pipeline: None, + reference_pin_glyphs: 0..0, preedit: None, trace_render: std::env::var_os("TOASTTY_TRACE_RENDER").is_some(), // The very first frame is always a full clear β†’ goes @@ -1888,6 +1909,56 @@ impl Renderer { self.close_prompt.is_some() } + /// Replace the in-memory reference without changing terminal content. + pub fn set_reference_pin(&mut self, pin: Option) { + if pin != self.reference_pin { + self.needs_full_clear = true; + self.reference_pin_hover = None; + self.reference_pin = pin; + } + } + + fn reference_pin_layout(&self) -> Option { + if self.close_prompt.is_some() { + return None; + } + let pin = self.reference_pin.as_ref()?; + let cell = self.cell_size(); + reference_pin::pin_layout( + pin, + &reference_pin::PinMetrics { + origin: self.content_origin(), + content: self.content_dims(cell), + cell, + scale: self.ui_scale, + }, + ) + } + + /// Hit-test the visible card in physical framebuffer coordinates. + #[must_use] + pub fn reference_pin_contains(&self, x: f32, y: f32) -> bool { + self.reference_pin_layout() + .is_some_and(|l| l.contains(x, y)) + } + + /// Hit-test the visible controls in physical framebuffer coordinates. + #[must_use] + pub fn reference_pin_button_at(&self, x: f32, y: f32) -> Option { + self.reference_pin_layout()?.button_at(x, y) + } + + /// Update hover and return whether a redraw is needed. + pub fn set_reference_pin_hover(&mut self, hover: Option) -> bool { + let hover = hover.filter(|_| self.reference_pin.is_some() && self.close_prompt.is_none()); + if hover == self.reference_pin_hover { + return false; + } + self.reference_pin_hover = hover; + self.needs_full_clear = true; + true + } + /// Where the close dialog goes this frame, if shown. fn close_dialog_layout(&self) -> Option { let prompt = self.close_prompt.as_ref()?; @@ -2926,10 +2997,12 @@ impl Renderer { } self.chip_drawn_last = chip_alpha > 0.0; - // The close dialog's scrim is translucent and covers the whole - // window: repaint everything under it each frame it's up (its - // removal already forces a full clear in `set_close_prompt`). - if self.close_prompt.is_some() && self.post.is_none() { + // Repaint beneath viewport UI on active frames, so panel pixels + // cannot stack or survive partial draws. This is after the idle + // skip gate; merely having a pin does not cause any work. + if (self.close_prompt.is_some() || self.reference_pin_layout().is_some()) + && self.post.is_none() + { self.needs_full_clear = true; term.mark_all_dirty(); } @@ -3141,6 +3214,10 @@ impl Renderer { let colors = dialog::dialog_colors(theme.bg, theme.fg, self.close_dialog_hover); (layout, colors) }); + let pin_layout = self.reference_pin_layout(); + if pin_layout.is_some() && self.reference_pin_pipeline.is_none() { + self.reference_pin_pipeline = Some(UiPipeline::new(&self.device, self.config.format)); + } // Build instances using the cached row glyphs. Reuse the // scratch vec across frames. We have to temporarily extract // the scratch out of TextState because the builders need to @@ -3331,9 +3408,25 @@ impl Renderer { ); } - // Close dialog text, last so it can be drawn on its own after the - // dialog panel (see the render pass). Glyph-only instances, so the - // bg pass skips them. + // Card and modal text have separate ranges, each drawn after its + // own panel. Glyph-only instances are excluded from the cell pass. + let pin_glyphs_start = instances.len(); + if let Some(layout) = pin_layout.as_ref() { + let colors = dialog::dialog_colors(theme.bg, theme.fg, None); + for run in &layout.lines { + push_dialog_text( + &self.queue, + text, + &mut instances, + run, + |tone| colors.text(tone), + cell_size, + content_origin, + true, + ); + } + } + self.reference_pin_glyphs = pin_glyphs_start..instances.len(); let dialog_glyphs_start = instances.len(); if let Some((layout, colors)) = close_dialog.as_ref() { for run in &layout.lines { @@ -3345,6 +3438,7 @@ impl Renderer { |tone| colors.text(tone), cell_size, content_origin, + false, ); } for button in layout.buttons() { @@ -3362,6 +3456,7 @@ impl Renderer { |tone| bc.text(tone), cell_size, content_origin, + false, ); } } @@ -3680,11 +3775,7 @@ impl Renderer { // The close dialog's glyphs (if any) sit at the end and are // drawn after its panel, below. With post shaders, the UI // instances (from `ui_start`) are drawn in the UI pass instead. - let inst_count = if post_on { - ui_start - } else { - dialog_glyphs_start - }; + let inst_count = if post_on { ui_start } else { pin_glyphs_start }; text_ref .pipeline .render_bg(&mut rp, &text_ref.bind_group, inst_count); @@ -3770,6 +3861,20 @@ impl Renderer { chip_pipe.render(&self.queue, &mut rp, &uniform); } + if !post_on + && let Some(layout) = pin_layout.as_ref() + && let Some(pipe) = self.reference_pin_pipeline.as_mut() + { + let rects = + reference_pin::pin_rects(layout, theme.bg, theme.fg, self.reference_pin_hover); + pipe.render(&self.device, &self.queue, &mut rp, &rects, viewport); + text_ref.pipeline.render_glyph_range( + &mut rp, + &text_ref.bind_group, + self.reference_pin_glyphs.clone(), + ); + } + // 8. close dialog, topmost: scrim, shadow, panel, buttons and // icon, then its text on the panel. if !post_on @@ -3794,7 +3899,7 @@ impl Renderer { frame.texture(), term, chip_alpha, - ui_start..dialog_glyphs_start, + ui_start..pin_glyphs_start, close_dialog .as_ref() .map(|d| (d, dialog_glyphs_start..instances.len())), @@ -3867,7 +3972,8 @@ impl Renderer { /// A frame where the content hasn't changed but the post chain wants /// one (an animation tick or an input event it reads): re-run the post /// passes over the scratch texture as it is, then the UI pass. Only the - /// chips can be up here; every other overlay forces a full frame. + /// chips and the static reference pin can be up here; other overlays + /// force a full frame. Pin glyphs reuse the last uploaded text range. fn render_post_only( &mut self, term: &Term, @@ -3964,6 +4070,7 @@ impl Renderer { } else { None }; + let pin_layout = self.reference_pin_layout(); let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("toastty-render UI pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -4006,6 +4113,24 @@ impl Renderer { .with_icon(chip::pipeline::ICON_COPY); chip_pipe.render(&self.queue, &mut rp, &uniform); } + if let Some(layout) = pin_layout.as_ref() + && let Some(pipe) = self.reference_pin_pipeline.as_mut() + { + let rects = reference_pin::pin_rects( + layout, + self.theme.bg, + self.theme.fg, + self.reference_pin_hover, + ); + pipe.render(&self.device, &self.queue, &mut rp, &rects, viewport); + if let Some(text) = self.text.as_ref() { + text.pipeline.render_glyph_range( + &mut rp, + &text.bind_group, + self.reference_pin_glyphs.clone(), + ); + } + } if let Some(((layout, colors), glyphs)) = dialog && let Some(ui_pipe) = self.ui_pipeline.as_mut() { diff --git a/crates/toastty-render/src/reference_pin.rs b/crates/toastty-render/src/reference_pin.rs new file mode 100644 index 00000000..3d1733ef --- /dev/null +++ b/crates/toastty-render/src/reference_pin.rs @@ -0,0 +1,350 @@ +//! An ephemeral, bounded text reference anchored to the viewport. + +use toastty_protocols::unicode_core::cluster_cell_width; +use unicode_segmentation::UnicodeSegmentation; + +use crate::dialog::pipeline::UiRect; +use crate::dialog::{Span, TextRun, Tone, dialog_colors}; + +const MAX_BYTES: usize = 16 * 1024; + +/// A snapshot, independent of the selection and terminal scrollback. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReferencePin { + text: String, + truncated: bool, +} + +impl ReferencePin { + /// Retain at most 16 KiB, cutting only at a grapheme boundary. + /// Whitespace is preserved; wholly blank selections are not pinned. + #[must_use] + pub fn new(text: &str) -> Option { + if text.trim().is_empty() { + return None; + } + let end = text + .grapheme_indices(true) + .take_while(|(i, g)| i + g.len() <= MAX_BYTES) + .last() + .map_or(0, |(i, g)| i + g.len()); + if end == 0 { + return None; + } + Some(Self { + text: text[..end].to_owned(), + truncated: end < text.len(), + }) + } + + #[must_use] + pub fn text(&self) -> &str { + &self.text + } + + /// True when the retained snapshot, not merely its preview, was cut. + #[must_use] + pub fn truncated(&self) -> bool { + self.truncated + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PinButton { + Copy, + Dismiss, +} + +pub(crate) struct PinMetrics { + pub origin: [f32; 2], + pub content: (f32, f32), + pub cell: (f32, f32), + pub scale: f32, +} + +pub(crate) struct PinLayout { + pub panel: [f32; 4], + pub lines: Vec, + pub copy: [f32; 4], + pub dismiss: [f32; 4], + pub scale: f32, +} + +fn inside(r: [f32; 4], x: f32, y: f32) -> bool { + x >= r[0] && x < r[2] && y >= r[1] && y < r[3] +} + +impl PinLayout { + pub(crate) fn contains(&self, x: f32, y: f32) -> bool { + inside(self.panel, x, y) + } + pub(crate) fn button_at(&self, x: f32, y: f32) -> Option { + if inside(self.copy, x, y) { + Some(PinButton::Copy) + } else if inside(self.dismiss, x, y) { + Some(PinButton::Dismiss) + } else { + None + } + } +} + +/// Printable preview only. Copy always uses the unmodified snapshot. +fn preview(text: &str, cols: usize, rows: usize) -> (Vec, bool) { + let mut lines = vec![String::new()]; + let mut used = 0; + for g in text.graphemes(true) { + if g == "\n" || g == "\r\n" { + if lines.len() == rows { + return (lines, true); + } + lines.push(String::new()); + used = 0; + continue; + } + let printable = if g == "\t" { " " } else { g }; + if printable.chars().any(char::is_control) { + continue; + } + let width = if g == "\t" { + 4 + } else { + usize::from(cluster_cell_width(g, true)) + }; + if used + width > cols { + if lines.len() == rows { + return (lines, true); + } + lines.push(String::new()); + used = 0; + } + if width > cols { + return (lines, true); + } + lines + .last_mut() + .expect("one preview line") + .push_str(printable); + used += width; + } + (lines, false) +} + +/// Compact top-right card, never wider than 60% or taller than 50%. +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss +)] +pub(crate) fn pin_layout(pin: &ReferencePin, m: &PinMetrics) -> Option { + if ![m.content.0, m.content.1, m.cell.0, m.cell.1, m.scale] + .into_iter() + .all(|v| v.is_finite() && v > 0.0) + || !m.origin.into_iter().all(f32::is_finite) + { + return None; + } + let pad = 12.0 * m.scale; + let margin = 12.0 * m.scale; + let width = (52.0 * m.cell.0 + 2.0 * pad).min(m.content.0 * 0.6); + let cols = ((width - 2.0 * pad) / m.cell.0).floor().max(0.0) as usize; + let button_h = (28.0 * m.scale).max(m.cell.1 + 8.0 * m.scale); + let fixed_h = 3.0 * pad + 2.0 * m.cell.1 + button_h; + let rows = ((m.content.1 * 0.5 - fixed_h) / m.cell.1) + .floor() + .clamp(0.0, 6.0) as usize; + if cols < 24 || rows == 0 { + return None; + } + let (body, clipped) = preview(pin.text(), cols, rows); + let height = fixed_h + body.len() as f32 * m.cell.1; + let x = (m.origin[0] + m.content.0 - margin - width).floor(); + let y = (m.origin[1] + margin).floor(); + let mut lines = Vec::new(); + let mut line = |text: String, origin: [f32; 2], tone| { + lines.push(TextRun { + origin, + spans: vec![Span { text, tone }], + }); + }; + line("REFERENCE PIN".into(), [x + pad, y + pad], Tone::Muted); + for (i, text) in body.into_iter().enumerate() { + line( + text, + [x + pad, y + pad + (i + 1) as f32 * m.cell.1], + Tone::Strong, + ); + } + let status_y = y + height - 2.0 * pad - button_h - m.cell.1; + let status = if pin.truncated() { + "Capped at 16 KiB" + } else if clipped { + "Preview clipped; Copy" + } else { + "Snapshot; not live" + }; + line(status.into(), [x + pad, status_y], Tone::Muted); + let button_y = y + height - pad - button_h; + let copy_w = 6.0 * m.cell.0; + let dismiss_w = 9.0 * m.cell.0; + let dismiss = [ + x + width - pad - dismiss_w, + button_y, + x + width - pad, + button_y + button_h, + ]; + let copy = [ + dismiss[0] - pad - copy_w, + button_y, + dismiss[0] - pad, + button_y + button_h, + ]; + let label_y = button_y + (button_h - m.cell.1) * 0.5; + line("Copy".into(), [copy[0] + m.cell.0, label_y], Tone::Strong); + line( + "Dismiss".into(), + [dismiss[0] + m.cell.0, label_y], + Tone::Strong, + ); + Some(PinLayout { + panel: [x, y, x + width, y + height], + lines, + copy, + dismiss, + scale: m.scale, + }) +} + +pub(crate) fn pin_rects( + l: &PinLayout, + bg: [f32; 4], + fg: [f32; 4], + hover: Option, +) -> Vec { + let colors = dialog_colors(bg, fg, None); + let mut rects = vec![UiRect::rect( + l.panel, + colors.panel_fill, + colors.text_muted, + 10.0 * l.scale, + l.scale, + )]; + for (button, rect) in [(PinButton::Copy, l.copy), (PinButton::Dismiss, l.dismiss)] { + let c = dialog_colors( + bg, + fg, + (hover == Some(button)).then_some(crate::DialogButton::Cancel), + ); + rects.push(UiRect::rect( + rect, + c.cancel.fill, + c.cancel.ring, + 5.0 * l.scale, + l.scale, + )); + } + rects +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metrics() -> PinMetrics { + PinMetrics { + origin: [10.0, 20.0], + content: (1000.0, 700.0), + cell: (10.0, 20.0), + scale: 1.0, + } + } + + #[test] + fn snapshot_preserves_whitespace_and_cuts_at_graphemes() { + assert!(ReferencePin::new(" \n\t").is_none()); + assert_eq!(ReferencePin::new(" x\n").unwrap().text(), " x\n"); + let input = format!("{}πŸ‘©β€πŸ’»tail", "a".repeat(MAX_BYTES - 1)); + let pin = ReferencePin::new(&input).unwrap(); + assert_eq!(pin.text(), "a".repeat(MAX_BYTES - 1)); + assert!(pin.truncated()); + } + + #[test] + fn preview_wraps_wide_clusters_and_preserves_copy() { + let pin = ReferencePin::new("δ½ ε₯½e\u{301}πŸ‘©β€πŸ’»\nnext\tline").unwrap(); + let (lines, cut) = preview(pin.text(), 4, 3); + assert_eq!(lines, ["δ½ ε₯½", "e\u{301}πŸ‘©β€πŸ’»", "next"]); + assert!(cut); + assert_eq!(pin.text(), "δ½ ε₯½e\u{301}πŸ‘©β€πŸ’»\nnext\tline"); + assert!(!pin.truncated()); + } + + #[test] + fn all_text_fits_the_narrowest_card_at_multiple_scales() { + let pin = ReferencePin::new(&"x".repeat(500)).unwrap(); + for scale in [1.0, 2.0] { + let m = PinMetrics { + content: (440.0 * scale, 400.0 * scale), + cell: (10.0 * scale, 19.0 * scale), + scale, + ..metrics() + }; + let l = pin_layout(&pin, &m).unwrap(); + for run in &l.lines { + let cells: u16 = run + .spans + .iter() + .flat_map(|span| span.text.graphemes(true)) + .map(|g| u16::from(cluster_cell_width(g, true))) + .sum(); + let right = run.origin[0] + f32::from(cells) * m.cell.0; + assert!( + right <= l.panel[2] - 12.0 * scale + 0.01, + "text exceeds the card's content area at scale {scale}" + ); + } + } + } + + #[test] + fn card_stays_bounded_and_buttons_match_drawn_rects() { + let pin = ReferencePin::new(&"row\n".repeat(30)).unwrap(); + let m = metrics(); + let l = pin_layout(&pin, &m).unwrap(); + assert!(l.panel[2] - l.panel[0] <= m.content.0 * 0.6); + assert!(l.panel[3] - l.panel[1] <= m.content.1 * 0.5); + assert!( + l.lines + .iter() + .any(|r| r.spans[0].text == "Preview clipped; Copy") + ); + assert_eq!( + l.button_at(l.copy[0] + 1.0, l.copy[1] + 1.0), + Some(PinButton::Copy) + ); + assert_eq!( + l.button_at(l.dismiss[0] + 1.0, l.dismiss[1] + 1.0), + Some(PinButton::Dismiss) + ); + assert!(l.contains(l.panel[0] + 1.0, l.panel[1] + 1.0)); + assert_eq!(l.button_at(l.panel[0], l.panel[1]), None); + assert!(!l.contains(l.panel[2], l.panel[3])); + } + + #[test] + fn tiny_or_invalid_windows_hide_card() { + let pin = ReferencePin::new("hello").unwrap(); + for content in [(100.0, 700.0), (1000.0, 100.0), (f32::NAN, 700.0)] { + assert!( + pin_layout( + &pin, + &PinMetrics { + content, + ..metrics() + } + ) + .is_none() + ); + } + } +} diff --git a/crates/toastty-render/src/render_tests.rs b/crates/toastty-render/src/render_tests.rs index 09abd4c7..3d2bf819 100644 --- a/crates/toastty-render/src/render_tests.rs +++ b/crates/toastty-render/src/render_tests.rs @@ -78,6 +78,72 @@ fn px(img: &[u8], x: u32, y: u32) -> [u8; 4] { [img[i], img[i + 1], img[i + 2], img[i + 3]] } +#[test] +fn reference_pin_survives_post_only_frames_and_dismissal_restores_content() { + use crate::reference_pin::ReferencePin; + let Some(mut r) = Renderer::headless(900, 600, true) else { + return; + }; + r.with_font(None, DEFAULT_FONT_SIZE_PX); + let mut t = Term::new(30, 90, 100); + feed(&mut t, b"\x1b[2 qunderlying terminal\r\n"); + let baseline = frame(&mut r, &mut t).1; + r.set_reference_pin(ReferencePin::new( + "keep this nearby\ncargo test --workspace", + )); + let pinned = frame(&mut r, &mut t).1; + assert!(pinned != baseline, "the card must be visible"); + assert_eq!(frame(&mut r, &mut t).0, RenderOutcome::Skipped); + assert_eq!( + r.next_redraw_deadline(&t), + None, + "a pin is not an animation" + ); + + let l = r.reference_pin_layout().expect("large viewport fits card"); + assert!(r.reference_pin_contains(l.copy[0] + 1.0, l.copy[1] + 1.0)); + r.set_close_prompt(Some(crate::ClosePrompt::default())); + assert!(!r.reference_pin_contains(l.copy[0] + 1.0, l.copy[1] + 1.0)); + let _ = frame(&mut r, &mut t); + r.set_close_prompt(None); + assert!( + frame(&mut r, &mut t).1 == pinned, + "pin returns after modal dismissal" + ); + + r.set_post_shaders(&shader("pin-mouse.glsl", MOUSE)) + .expect("compiles"); + let post_pinned = frame(&mut r, &mut t).1; + assert!( + post_pinned == pinned, + "pass-through post keeps pin unchanged" + ); + assert_eq!(frame(&mut r, &mut t).0, RenderOutcome::Skipped); + r.set_pointer((1.0, 1.0), true); + let (outcome, post_only) = frame(&mut r, &mut t); + assert_eq!(outcome, RenderOutcome::Rendered); + // Every interior card pixel (including its text) is unshaded and + // survives the cached-scratch post-only path. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + for y in (l.panel[1] as usize + 12)..(l.panel[3] as usize - 12) { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + for x in (l.panel[0] as usize + 12)..(l.panel[2] as usize - 12) { + let i = (y * 900 + x) * 4; + assert_eq!(&post_only[i..i + 4], &pinned[i..i + 4], "pin pixel {x},{y}"); + } + } + assert!( + post_only != pinned, + "shader actually changed content behind the pin" + ); + r.set_post_shaders(&[]).expect("disabled"); + r.set_reference_pin(None); + assert!( + frame(&mut r, &mut t).1 == baseline, + "dismissal leaves no stale panel or glyphs" + ); +} + #[test] fn pass_through_chain_matches_the_plain_frame_path() { for view_formats in [true, false] { diff --git a/crates/toastty/src/lib.rs b/crates/toastty/src/lib.rs index 7b5dac7d..995fcafe 100644 --- a/crates/toastty/src/lib.rs +++ b/crates/toastty/src/lib.rs @@ -28,6 +28,7 @@ pub mod paste; pub mod perf_stats; pub mod preload; pub mod pty_log; +pub mod reference_pin; pub mod shader_files; pub mod shader_watcher; pub mod shell; diff --git a/crates/toastty/src/main.rs b/crates/toastty/src/main.rs index af34a37c..e17ffa3f 100644 --- a/crates/toastty/src/main.rs +++ b/crates/toastty/src/main.rs @@ -22,6 +22,7 @@ use toastty_parser::Parser; use toastty_protocols::resize_inband::encode_resize_report; use toastty_protocols::synchronized::{BSU_TIMEOUT, should_force_flush}; use toastty_pty::{Pty, PtySpec, WinSize}; +use toastty_render::reference_pin::{PinButton, ReferencePin}; use toastty_render::{ ClosePrompt, DialogButton, PostAnimation, PostShaderSource, RenderOutcome, Renderer, }; @@ -54,6 +55,7 @@ use toastty::paste::wrap_for_paste; use toastty::perf_stats::PerfStats; use toastty::preload::Preload; use toastty::pty_log::{Direction, PtyLogger}; +use toastty::reference_pin::{ReferenceHit, ReferencePointer, is_pin_binding}; use toastty::shader_files; use toastty::shader_watcher::ShaderWatcher; use toastty::shell::resolve_command; @@ -356,7 +358,14 @@ struct Toastty { /// keeps a connection to the OS clipboard server; constructing it /// failed on the user's box is recoverable β€” we just log + skip. clipboard: Option, - /// Keys whose *press* toastty consumed as a copy/paste shortcut. + /// Explicit, window-local reference snapshot. Never written to disk or + /// sent to the shell; the renderer owns an identical bounded copy. + reference_pin: Option, + reference_pointer: ReferencePointer, + /// Pin-owned physical key until release, including repeats after + /// the shortcut modifiers have been released. + pin_key_down: Option, + /// Keys whose *press* toastty consumed as a local shortcut. /// Under the kitty keyboard protocol (flag 2, report events) the /// matching release would otherwise reach the app as an orphan /// (`ESC[118;1:3u` after Ctrl+Shift+V), so we swallow it too. Keyed @@ -557,6 +566,9 @@ impl Toastty { link_pointer: false, last_reported_cell: None, clipboard: None, + reference_pin: None, + reference_pointer: ReferencePointer::default(), + pin_key_down: None, swallowed_releases: Vec::new(), pressed_keys: Vec::new(), held_modifier_keys: Vec::new(), @@ -1647,6 +1659,7 @@ impl Toastty { /// swallowed whenever it comes. fn open_close_prompt(&mut self, prompt: ClosePrompt) { self.close_pending = true; + self.reference_pointer.cancel_actions(); self.close_prompt_press = None; let local_drag = self.selecting.is_some(); self.finish_selection_drag(); @@ -1686,6 +1699,7 @@ impl Toastty { self.refresh_scroll_chip_hover(); self.refresh_rich_chip(); self.refresh_link_hover(); + self.refresh_reference_hover(); } } @@ -1892,6 +1906,14 @@ impl Toastty { repeat: bool, is_synthetic: bool, ) -> ControlSignal { + let pin_owned = + self.pin_key_down.as_deref() == Some(key_identity(logical, physical).as_str()); + if pin_owned { + if state == KeyState::Pressed && repeat { + return ControlSignal::Continue; + } + self.pin_key_down = None; + } // While the close-confirmation prompt is up the window is modal: // keystrokes drive the prompt and are never forwarded to the PTY. // Releases / synthetic focus-loss events are swallowed silently. @@ -1922,6 +1944,18 @@ impl Toastty { self.swallowed_releases.swap_remove(i); return ControlSignal::Continue; } + // Pin a selection without touching the clipboard or sending a + // command. With no selection the same chord dismisses an existing + // pin; with neither, leave the shortcut available to the app. + if state == KeyState::Pressed + && !is_synthetic + && is_pin_binding(logical, modifiers, cfg!(target_os = "macos")) + && self.pin_selection_or_dismiss() + { + self.pin_key_down = Some(key_id.clone()); + self.swallow_release(key_id); + return ControlSignal::RedrawIn(Duration::ZERO); + } // Intercept paste bindings *before* the regular encoder. // Shift+Insert pastes PRIMARY on X11/Wayland (xterm, Alacritty), // the clipboard elsewhere. @@ -2112,8 +2146,21 @@ impl Toastty { self.mouse_pos = position; self.pointer_inside = true; self.link_modifiers = modifiers; - let hover_changed = - self.refresh_scroll_chip_hover() | self.refresh_link_hover() | self.refresh_rich_chip(); + let hit = self.reference_hit(position); + self.reference_pointer.motion(hit); + let hover_changed = self.refresh_scroll_chip_hover() + | self.refresh_link_hover() + | self.refresh_rich_chip() + | self.refresh_reference_hover(); + if hit != ReferenceHit::Outside || self.reference_pointer.is_captured() { + self.selection_autoscroll = 0; + self.last_reported_cell = None; + return if hover_changed { + ControlSignal::RedrawIn(Duration::ZERO) + } else { + ControlSignal::Continue + }; + } let signal = self.handle_mouse_motion_inner(position, modifiers); if hover_changed && signal == ControlSignal::Continue { ControlSignal::RedrawIn(Duration::ZERO) @@ -2126,14 +2173,15 @@ impl Toastty { /// visible scroll-to-bottom chip and push that to the renderer. /// Returns `true` when the hover state changed. fn refresh_scroll_chip_hover(&mut self) -> bool { + let covered = self.reference_hit(self.mouse_pos) != ReferenceHit::Outside; let Some(r) = self.renderer.as_mut() else { return false; }; #[allow(clippy::cast_possible_truncation)] let (x, y) = (self.mouse_pos.0 as f32, self.mouse_pos.1 as f32); - let hovered = r - .scroll_button_rect(&self.term) - .is_some_and(|rect| x >= rect[0] && x < rect[2] && y >= rect[1] && y < rect[3]); + let hovered = !covered + && r.scroll_button_rect(&self.term) + .is_some_and(|rect| x >= rect[0] && x < rect[2] && y >= rect[1] && y < rect[3]); r.set_scroll_button_hover(hovered) } @@ -2149,7 +2197,10 @@ impl Toastty { return false; }; let on_chip = r.rich_chip_rect(&self.term).is_some_and(inside); - let block = if !self.pointer_inside || self.close_pending { + let block = if !self.pointer_inside + || self.close_pending + || self.reference_hit(self.mouse_pos) != ReferenceHit::Outside + { None } else if on_chip && self.rich_chip_block.is_some() { self.rich_chip_block @@ -2251,6 +2302,21 @@ impl Toastty { self.modal_buttons_down.swap_remove(i); return ControlSignal::Continue; } + let hit = self.reference_hit(position); + let outcome = self.reference_pointer.mouse(button, state, hit); + if outcome.consume { + match outcome.activate { + Some(PinButton::Copy) => { + if let Some(pin) = &self.reference_pin { + self.write_clipboard(pin.text().to_owned()); + } + } + Some(PinButton::Dismiss) => self.set_reference_pin(None), + None => {} + } + self.refresh_reference_hover(); + return ControlSignal::RedrawIn(Duration::ZERO); + } match state { KeyState::Pressed => self.mouse_held = Some(button), KeyState::Released => { @@ -2611,6 +2677,86 @@ impl Toastty { } } + /// Returns whether the pin shortcut belongs to toastty this time. + fn pin_selection_or_dismiss(&mut self) -> bool { + let selected_text = self.term.extract_selection_text(); + let selected = selected_text.as_deref().and_then(ReferencePin::new); + if selected_text.is_some() && selected.is_none() { + // A blank or non-pinnable selection is not a request to + // dismiss an existing reference. + return true; + } + if selected.is_none() && self.reference_pin.is_none() { + return false; + } + if self.selecting.is_some() { + // Capture the unfinished local drag's release before clearing + // its selection: a tracking app must not see an orphan release. + self.selecting = None; + if self.buttons_down.contains(&MouseButton::Left) { + let _ = self.reference_pointer.mouse( + MouseButton::Left, + KeyState::Pressed, + ReferenceHit::Body, + ); + } + if self.mouse_held == Some(MouseButton::Left) { + self.mouse_held = None; + } + } + self.set_reference_pin(selected); + self.term.clear_selection(); + self.selection_autoscroll = 0; + true + } + + fn set_reference_pin(&mut self, pin: Option) { + self.reference_pointer.cancel_actions(); + if let Some(r) = self.renderer.as_mut() { + r.set_reference_pin(pin.clone()); + } + self.reference_pin = pin; + self.clear_link_hover(); + self.refresh_rich_chip(); + self.refresh_reference_hover(); + } + + fn reference_hit(&self, position: (f64, f64)) -> ReferenceHit { + if self.close_pending { + return ReferenceHit::Outside; + } + let Some(r) = &self.renderer else { + return ReferenceHit::Outside; + }; + #[allow(clippy::cast_possible_truncation)] + let (x, y) = (position.0 as f32, position.1 as f32); + if let Some(button) = r.reference_pin_button_at(x, y) { + ReferenceHit::Button(button) + } else if r.reference_pin_contains(x, y) { + ReferenceHit::Body + } else { + ReferenceHit::Outside + } + } + + fn refresh_reference_hover(&mut self) -> bool { + let hit = if self.pointer_inside { + self.reference_hit(self.mouse_pos) + } else { + ReferenceHit::Outside + }; + let button = match hit { + ReferenceHit::Button(button) => Some(button), + ReferenceHit::Outside | ReferenceHit::Body => None, + }; + if let Some(w) = &self.window { + w.set_pointer_cursor(button.is_some() || self.link_pointer); + } + self.renderer + .as_mut() + .is_some_and(|r| r.set_reference_pin_hover(button)) + } + /// Stable `(line_id, col)` of the cell under the pointer, or `None` /// when the pointer is outside the cell grid (padding, the leftover /// sliver). Honors the scrollback offset like selection does. @@ -2648,7 +2794,8 @@ impl Toastty { // Nothing behind the close prompt reacts to the pointer. let want = toastty::links::is_open_link_modifiers(self.link_modifiers) && self.selecting.is_none() - && !self.close_pending; + && !self.close_pending + && self.reference_hit(self.mouse_pos) == ReferenceHit::Outside; if !want && self.term.link_hover().is_none() && !self.link_pointer { return false; } @@ -3063,6 +3210,9 @@ impl App for Toastty { { return ControlSignal::RedrawIn(Duration::ZERO); } + if relayout && self.refresh_reference_hover() && signal == ControlSignal::Continue { + return ControlSignal::RedrawIn(Duration::ZERO); + } signal } } @@ -3350,6 +3500,9 @@ impl Toastty { ControlSignal::RedrawIn(Duration::ZERO) } Event::Focus(focused) => { + if !focused { + self.reference_pointer.cancel_actions(); + } if let Some(bytes) = encode_focus(focused, self.term.report_focus()) { self.write_pty(bytes); } @@ -3385,8 +3538,9 @@ impl Toastty { } Event::PointerLeft => { self.pointer_inside = false; + self.reference_pointer.cancel_actions(); let chip = self.refresh_rich_chip(); - if self.clear_link_hover() | chip { + if self.clear_link_hover() | chip | self.refresh_reference_hover() { ControlSignal::RedrawIn(Duration::ZERO) } else { ControlSignal::Continue @@ -3416,6 +3570,13 @@ impl Toastty { modifiers, } => self.handle_mouse_motion(position, modifiers), Event::Scroll { .. } if self.close_pending => ControlSignal::Continue, + Event::Scroll { .. } + if self.reference_pointer.is_captured() + || (self.pointer_inside + && self.reference_hit(self.mouse_pos) != ReferenceHit::Outside) => + { + ControlSignal::Continue + } Event::Scroll { kind, phase, @@ -3678,6 +3839,193 @@ mod tests { ) } + fn pin_key(state: KeyState, repeat: bool, is_synthetic: bool) -> Event { + Event::Key { + logical: ch("P"), + unmodified: ch("p"), + physical: PhysicalKey::Code("KeyP".to_owned()), + text: Some("\u{10}".to_owned()), + modifiers: Modifiers::SHIFT + | if cfg!(target_os = "macos") { + Modifiers::SUPER + } else { + Modifiers::CONTROL + }, + state, + repeat, + is_synthetic, + } + } + + fn select_first_row(app: &mut Toastty) { + let top = app.term.bottom_id() - u64::from(app.term.size().0 - 1); + app.term.set_selection(Selection { + anchor: Pos::new(top, 0), + active: Pos::new(top, app.term.size().1 - 1), + mode: SelectionMode::Line, + }); + app.term.commit_selection(); + } + + #[test] + fn reference_pin_shortcut_captures_replaces_and_dismisses_without_pty_input() { + let mut app = headless_app(); + app.parser.advance(&mut app.term, b"request-id: abc-123"); + select_first_row(&mut app); + app.event(pin_key(KeyState::Pressed, false, false)); + assert_eq!( + app.reference_pin.as_ref().unwrap().text(), + "request-id: abc-123" + ); + assert!(app.term.selection().is_none()); + // Repeat must not dismiss the pin now that capture cleared selection. + app.event(pin_key(KeyState::Pressed, true, false)); + let mut repeat_without_modifiers = pin_key(KeyState::Pressed, true, false); + if let Event::Key { + modifiers, logical, .. + } = &mut repeat_without_modifiers + { + *modifiers = Modifiers::empty(); + *logical = ch("p"); + } + app.event(repeat_without_modifiers); + app.event(pin_key(KeyState::Released, false, false)); + assert!(app.reference_pin.is_some()); + assert_eq!(app.swallowed_releases, Vec::::new()); + + app.parser.advance(&mut app.term, b"\r\x1b[2Kreplacement"); + select_first_row(&mut app); + app.event(pin_key(KeyState::Pressed, false, false)); + app.event(pin_key(KeyState::Released, false, false)); + assert_eq!(app.reference_pin.as_ref().unwrap().text(), "replacement"); + app.event(pin_key(KeyState::Pressed, false, false)); + app.event(pin_key(KeyState::Released, false, false)); + assert!(app.reference_pin.is_none()); + assert_eq!(app.pty_sent, b""); + assert!( + app.clipboard.is_none(), + "pinning never reads or writes the clipboard" + ); + } + + #[test] + fn reference_pin_survives_eviction_resize_and_screen_switches() { + let mut app = headless_app(); + app.term = Term::new(3, 30, 2); + app.parser.advance(&mut app.term, b"keep this reference"); + select_first_row(&mut app); + app.event(pin_key(KeyState::Pressed, false, false)); + app.event(pin_key(KeyState::Released, false, false)); + for _ in 0..20 { + app.parser.advance(&mut app.term, b"\r\nnew output"); + } + app.term.resize(4, 12); + app.parser + .advance(&mut app.term, b"\x1b[?1049hfull-screen app\x1b[?1049l"); + assert_eq!( + app.reference_pin.as_ref().unwrap().text(), + "keep this reference" + ); + assert_eq!(app.pty_sent, b""); + } + + #[test] + fn reference_pin_keeps_previous_snapshot_when_selection_is_blank() { + let mut app = headless_app(); + app.set_reference_pin(ReferencePin::new("keep me")); + select_first_row(&mut app); + app.event(pin_key(KeyState::Pressed, false, false)); + app.event(pin_key(KeyState::Released, false, false)); + assert_eq!(app.reference_pin.as_ref().unwrap().text(), "keep me"); + assert_eq!(app.pty_sent, b""); + assert!(app.term.selection().is_some()); + } + + #[test] + fn reference_pin_does_not_steal_unused_shortcut_or_ordinary_keys() { + let mut app = headless_app(); + // Request kitty key events so both press and release are observable. + app.parser.advance(&mut app.term, b"\x1b[>3u"); + app.event(pin_key(KeyState::Pressed, false, false)); + app.event(pin_key(KeyState::Released, false, false)); + assert!(!app.pty_sent.is_empty(), "unused chord belongs to the app"); + app.pty_sent.clear(); + + app.set_reference_pin(ReferencePin::new("reference")); + app.event(Event::Key { + logical: ch("x"), + unmodified: ch("x"), + physical: PhysicalKey::Code("KeyX".to_owned()), + text: Some("x".to_owned()), + modifiers: Modifiers::empty(), + state: KeyState::Pressed, + repeat: false, + is_synthetic: false, + }); + assert!(!app.pty_sent.is_empty(), "typing still reaches the app"); + assert_eq!(app.reference_pin.as_ref().unwrap().text(), "reference"); + app.pty_sent.clear(); + app.event(pin_key(KeyState::Pressed, true, true)); + assert!( + app.reference_pin.is_some(), + "synthetic focus events do not dismiss" + ); + assert_eq!(app.pty_sent, b""); + } + + #[test] + fn reference_pin_capture_keeps_mouse_release_owned_across_close_prompt() { + let mut app = headless_app(); + app.parser.advance(&mut app.term, b"\x1b[?1000;1006h"); + // Headless equivalent of a card-owned press, followed by the + // real modal dispatch and eventual release path. + let outcome = app.reference_pointer.mouse( + MouseButton::Left, + KeyState::Pressed, + ReferenceHit::Button(PinButton::Dismiss), + ); + assert!(outcome.consume); + app.track_button_down(MouseButton::Left, KeyState::Pressed); + app.open_close_prompt(ClosePrompt::default()); + app.event(mouse(MouseButton::Left, KeyState::Released, (5.0, 5.0))); + assert!(!app.reference_pointer.is_captured()); + assert!(app.pty_sent.is_empty(), "no orphan mouse release"); + assert!( + app.close_pending, + "release does not activate the modal dialog" + ); + } + + #[test] + fn reference_pin_during_local_drag_does_not_leak_release_to_tracking_app() { + let mut app = headless_app(); + app.parser + .advance(&mut app.term, b"\x1b[?1000;1006hreference"); + app.event(Event::Mouse { + button: MouseButton::Left, + state: KeyState::Pressed, + position: (5.0, 5.0), + modifiers: Modifiers::SHIFT, + }); + assert!(app.selecting.is_some()); + select_first_row(&mut app); + app.event(pin_key(KeyState::Pressed, false, false)); + assert!(app.reference_pin.is_some()); + assert!(app.selecting.is_none()); + app.event(Event::MouseMotion { + position: (200.0, 200.0), + modifiers: Modifiers::empty(), + }); + app.event(mouse(MouseButton::Left, KeyState::Released, (200.0, 200.0))); + app.event(pin_key(KeyState::Released, false, false)); + assert!(!app.reference_pointer.is_captured()); + assert_eq!(app.pty_sent, b""); + assert!( + app.clipboard.is_none(), + "pin action does not copy the selection" + ); + } + fn mouse(button: MouseButton, state: KeyState, position: (f64, f64)) -> Event { Event::Mouse { button, diff --git a/crates/toastty/src/reference_pin.rs b/crates/toastty/src/reference_pin.rs new file mode 100644 index 00000000..1cc99185 --- /dev/null +++ b/crates/toastty/src/reference_pin.rs @@ -0,0 +1,382 @@ +//! Pure input routing for the nonmodal reference pin. +//! +//! The app owns snapshot, clipboard and redraw side effects. This module +//! classifies the shortcut and pairs pointer presses with their releases +//! so the terminal never receives half of an overlay click. + +use toastty_render::reference_pin::PinButton; +use toastty_window::{KeyState, LogicalKey, Modifiers, MouseButton}; + +/// Match Ctrl+Shift+P, or Cmd+Shift+P on macOS. +/// +/// Caps/Num Lock do not change the binding. Other modifiers must match +/// exactly: notably, plain Ctrl+P remains the terminal application's key. +/// The caller handles synthetic events, repeats and release suppression. +#[must_use] +pub fn is_pin_binding(logical: &LogicalKey, modifiers: Modifiers, macos: bool) -> bool { + let primary = if macos { + Modifiers::SUPER + } else { + Modifiers::CONTROL + }; + let relevant = modifiers - (Modifiers::CAPS_LOCK | Modifiers::NUM_LOCK); + relevant == (primary | Modifiers::SHIFT) + && matches!(logical, LogicalKey::Character(text) if text.eq_ignore_ascii_case("p")) +} + +/// The current pointer position relative to the card. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReferenceHit { + Outside, + Body, + Button(PinButton), +} + +/// What the app should do with a mouse-button event. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PointerOutcome { + /// Do not forward this event to selection, links or the PTY. + pub consume: bool, + /// Perform this action after a complete left click on its button. + pub activate: Option, +} + +#[derive(Debug)] +struct Capture { + button: MouseButton, + action: Option, +} + +/// Tracks buttons whose presses belonged to the reference card. +/// +/// Every button pressed over the card is consumed, including presses on +/// its noninteractive body. Only a left click can activate an action. +/// Each matching release is consumed wherever it lands, even after the +/// card disappears. Releases for uncaptured buttons always pass through, +/// including when a terminal drag ends over the card. +/// +/// Keep this state alive when hiding or replacing the pin. Use +/// [`Self::cancel_actions`] to disarm clicks without losing their paired +/// releases; dropping captures while buttons are held would leak those +/// releases to the terminal. +#[derive(Debug, Default)] +pub struct ReferencePointer { + captures: Vec, +} + +impl ReferencePointer { + /// Whether a card-owned press is still awaiting its release. + /// + /// The app can suppress pointer motion and wheel events while this + /// is true, including outside the card during a captured drag. + #[must_use] + pub fn is_captured(&self) -> bool { + !self.captures.is_empty() + } + + /// Route one button event using the same hit test as the renderer. + /// + /// Calling this while the card is hidden, with `Outside`, still + /// consumes any releases of presses that began on the card. + #[must_use] + pub fn mouse( + &mut self, + button: MouseButton, + state: KeyState, + hit: ReferenceHit, + ) -> PointerOutcome { + let captured = self.captures.iter().position(|c| c.button == button); + match state { + KeyState::Pressed => { + // A duplicate press cannot change ownership or rearm a + // cancelled action before the matching release. + if captured.is_none() { + if hit == ReferenceHit::Outside { + return PointerOutcome::default(); + } + let action = match (button, hit) { + (MouseButton::Left, ReferenceHit::Button(action)) => Some(action), + _ => None, + }; + self.captures.push(Capture { button, action }); + } + PointerOutcome { + consume: true, + activate: None, + } + } + KeyState::Released => { + let Some(index) = captured else { + return PointerOutcome::default(); + }; + let capture = self.captures.swap_remove(index); + PointerOutcome { + consume: true, + activate: capture + .action + .filter(|action| hit == ReferenceHit::Button(*action)), + } + } + } + } + + /// Cancel an action when a drag leaves the button it started on. + /// + /// Returning to the button does not rearm it. Ownership of the + /// eventual release is retained, regardless of the new position. + pub fn motion(&mut self, hit: ReferenceHit) { + for capture in &mut self.captures { + if capture + .action + .is_some_and(|action| hit != ReferenceHit::Button(action)) + { + capture.action = None; + } + } + } + + /// Disarm pending actions without giving their releases to the PTY. + /// + /// Useful when the pointer leaves the window, the pin is dismissed + /// by a shortcut, or a modal dialog takes priority over the card. + pub fn cancel_actions(&mut self) { + for capture in &mut self.captures { + capture.action = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use toastty_window::NamedKey; + + const CONSUMED: PointerOutcome = PointerOutcome { + consume: true, + activate: None, + }; + + #[test] + fn pin_shortcut_requires_the_exact_platform_chord() { + for (macos, primary, other) in [ + (false, Modifiers::CONTROL, Modifiers::SUPER), + (true, Modifiers::SUPER, Modifiers::CONTROL), + ] { + let chord = primary | Modifiers::SHIFT; + for text in ["p", "P"] { + let key = LogicalKey::Character(text.into()); + for locks in [ + Modifiers::empty(), + Modifiers::CAPS_LOCK, + Modifiers::NUM_LOCK, + Modifiers::CAPS_LOCK | Modifiers::NUM_LOCK, + ] { + assert!(is_pin_binding(&key, chord | locks, macos)); + } + for wrong in [ + Modifiers::empty(), + primary, + Modifiers::SHIFT, + other | Modifiers::SHIFT, + chord | other, + chord | Modifiers::ALT, + ] { + assert!(!is_pin_binding(&key, wrong, macos)); + } + } + for key in [ + LogicalKey::Character("pp".into()), + LogicalKey::Character("q".into()), + LogicalKey::Character("\u{10}".into()), + LogicalKey::Named(NamedKey::Escape), + LogicalKey::Unidentified, + ] { + assert!(!is_pin_binding(&key, chord, macos)); + } + } + } + + #[test] + fn matching_left_click_activates_only_on_release() { + for action in [PinButton::Copy, PinButton::Dismiss] { + let mut pointer = ReferencePointer::default(); + let hit = ReferenceHit::Button(action); + assert_eq!( + pointer.mouse(MouseButton::Left, KeyState::Pressed, hit), + CONSUMED + ); + pointer.motion(hit); + assert_eq!( + pointer.mouse(MouseButton::Left, KeyState::Released, hit), + PointerOutcome { + consume: true, + activate: Some(action), + } + ); + assert!(!pointer.is_captured()); + assert_eq!( + pointer.mouse(MouseButton::Left, KeyState::Released, hit), + PointerOutcome::default() + ); + } + } + + #[test] + fn terminal_press_keeps_its_release_even_over_card() { + for hit in [ + ReferenceHit::Body, + ReferenceHit::Button(PinButton::Copy), + ReferenceHit::Button(PinButton::Dismiss), + ] { + let mut pointer = ReferencePointer::default(); + assert_eq!( + pointer.mouse(MouseButton::Left, KeyState::Pressed, ReferenceHit::Outside), + PointerOutcome::default() + ); + pointer.motion(hit); + assert_eq!( + pointer.mouse(MouseButton::Left, KeyState::Released, hit), + PointerOutcome::default() + ); + assert!(!pointer.is_captured()); + } + } + + #[test] + fn card_body_captures_every_button_without_activating() { + for button in [ + MouseButton::Left, + MouseButton::Middle, + MouseButton::Right, + MouseButton::Back, + MouseButton::Forward, + MouseButton::Other(17), + MouseButton::Other(u16::MAX), + ] { + let mut pointer = ReferencePointer::default(); + assert_eq!( + pointer.mouse(button, KeyState::Pressed, ReferenceHit::Body), + CONSUMED + ); + assert!(pointer.is_captured()); + assert_eq!( + pointer.mouse( + button, + KeyState::Released, + ReferenceHit::Button(PinButton::Dismiss), + ), + CONSUMED + ); + assert!(!pointer.is_captured()); + } + } + + #[test] + fn non_left_clicks_on_actions_are_consumed_not_activated() { + for button in [ + MouseButton::Right, + MouseButton::Middle, + MouseButton::Other(8), + ] { + let mut pointer = ReferencePointer::default(); + let hit = ReferenceHit::Button(PinButton::Copy); + assert_eq!(pointer.mouse(button, KeyState::Pressed, hit), CONSUMED); + assert_eq!(pointer.mouse(button, KeyState::Released, hit), CONSUMED); + } + } + + #[test] + fn releasing_off_original_button_cancels_but_still_consumes() { + for hit in [ + ReferenceHit::Outside, + ReferenceHit::Body, + ReferenceHit::Button(PinButton::Dismiss), + ] { + let mut pointer = ReferencePointer::default(); + let _ = pointer.mouse( + MouseButton::Left, + KeyState::Pressed, + ReferenceHit::Button(PinButton::Copy), + ); + assert_eq!( + pointer.mouse(MouseButton::Left, KeyState::Released, hit), + CONSUMED + ); + assert!(!pointer.is_captured()); + } + } + + #[test] + fn dragging_away_cannot_rearm_the_action_by_returning() { + let mut pointer = ReferencePointer::default(); + let copy = ReferenceHit::Button(PinButton::Copy); + let _ = pointer.mouse(MouseButton::Left, KeyState::Pressed, copy); + pointer.motion(ReferenceHit::Outside); + assert!(pointer.is_captured()); + pointer.motion(copy); + // Even a duplicate press cannot turn the cancelled drag into + // a fresh click or leave an extra capture behind. + assert_eq!( + pointer.mouse(MouseButton::Left, KeyState::Pressed, copy), + CONSUMED + ); + assert_eq!( + pointer.mouse(MouseButton::Left, KeyState::Released, copy), + CONSUMED + ); + assert!(!pointer.is_captured()); + } + + #[test] + fn simultaneous_buttons_keep_independent_release_ownership() { + let mut pointer = ReferencePointer::default(); + let copy = ReferenceHit::Button(PinButton::Copy); + let _ = pointer.mouse(MouseButton::Left, KeyState::Pressed, copy); + let _ = pointer.mouse(MouseButton::Right, KeyState::Pressed, ReferenceHit::Body); + assert_eq!( + pointer.mouse(MouseButton::Middle, KeyState::Released, copy), + PointerOutcome::default() + ); + assert_eq!( + pointer.mouse( + MouseButton::Right, + KeyState::Released, + ReferenceHit::Outside + ), + CONSUMED + ); + assert!(pointer.is_captured()); + assert_eq!( + pointer.mouse(MouseButton::Left, KeyState::Released, copy), + PointerOutcome { + consume: true, + activate: Some(PinButton::Copy), + } + ); + assert!(!pointer.is_captured()); + } + + #[test] + fn cancelling_actions_keeps_captures_until_release() { + let mut pointer = ReferencePointer::default(); + let dismiss = ReferenceHit::Button(PinButton::Dismiss); + let _ = pointer.mouse(MouseButton::Left, KeyState::Pressed, dismiss); + let _ = pointer.mouse(MouseButton::Other(7), KeyState::Pressed, ReferenceHit::Body); + pointer.cancel_actions(); + assert!(pointer.is_captured()); + assert_eq!( + pointer.mouse(MouseButton::Left, KeyState::Released, dismiss), + CONSUMED + ); + assert!(pointer.is_captured()); + assert_eq!( + pointer.mouse( + MouseButton::Other(7), + KeyState::Released, + ReferenceHit::Outside, + ), + CONSUMED + ); + assert!(!pointer.is_captured()); + } +} diff --git a/demos/reference-pin.sh b/demos/reference-pin.sh new file mode 100644 index 00000000..471e691a --- /dev/null +++ b/demos/reference-pin.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Interactive fixture for Reference Pin. Run inside toastty; no network, +# persistent files, environment changes, or real deployment commands. +set -eu + +printf '\033[2J\033[H' +printf '\033[1;36mReference Pin\033[0m | synthetic deployment log\n\n' +printf 'Pin these three lines as your working reference:\n\n' +printf ' deployment: api-staging\n' +printf ' request-id: req-8cf2a90\n' +printf ' expected: HTTP 200\n\n' +printf 'Select the three lines, then press Ctrl+Shift+P (Cmd+Shift+P on macOS).\n' +printf 'Press Enter here to stream new output. The reference stays visible.\n' +read -r _ + +for step in $(seq 1 45); do + printf '\033[90m[%02d/45]\033[0m Checking synthetic worker %02d ... \033[32mready\033[0m\n' "$step" "$step" + sleep 0.06 +done +printf '\n\033[1;32mChecks complete.\033[0m The original reference has scrolled away.\n' +printf 'Use Copy on the card, or keep typing while the reference stays in view.\n' +printf 'Dismiss clears it; Escape still belongs to your shell.\n'