Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) |
Expand All @@ -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.

<details>
<summary><b>More on <code>toastty md</code> and <code>toastty image</code></b></summary>

Expand Down
169 changes: 147 additions & 22 deletions crates/toastty-render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -598,6 +600,13 @@ pub struct Renderer {
/// SDF pipeline for the close dialog's shapes. Lazily built
/// alongside the text pipeline.
ui_pipeline: Option<UiPipeline>,
/// Static session-only reference. Its presence never schedules frames.
reference_pin: Option<ReferencePin>,
reference_pin_hover: Option<PinButton>,
/// Separate instance buffer from the modal dialog.
reference_pin_pipeline: Option<UiPipeline>,
/// Uploaded text range retained for shader-only frames.
reference_pin_glyphs: std::ops::Range<usize>,
/// 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
Expand Down Expand Up @@ -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,
Expand All @@ -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::<usize>();
end += if grapheme_mode {
span.text
.graphemes(true)
.map(|g| usize::from(cluster_cell_width(g, true)))
.sum::<usize>()
} else {
span.text
.chars()
.map(|c| usize::from(char_cell_width(c, false)))
.sum::<usize>()
};
tones.push((end, span.tone));
}
if line.is_empty() {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ReferencePin>) {
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<reference_pin::PinLayout> {
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<PinButton> {
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<PinButton>) -> 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<dialog::DialogLayout> {
let prompt = self.close_prompt.as_ref()?;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -3345,6 +3438,7 @@ impl Renderer {
|tone| colors.text(tone),
cell_size,
content_origin,
false,
);
}
for button in layout.buttons() {
Expand All @@ -3362,6 +3456,7 @@ impl Renderer {
|tone| bc.text(tone),
cell_size,
content_origin,
false,
);
}
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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())),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
{
Expand Down
Loading
Loading