rustfmt.toml sets group_imports, imports_granularity and imports_layout, which are nightly-only; agents formatted with stable rustfmt, which ignored them. This is a formatting-only pass with `cargo +nightly fmt`. Test Plan: - cargo clippy --workspace --all-targets -- -D warnings - cargo test --workspace
472 lines
15 KiB
Rust
472 lines
15 KiB
Rust
//! The debug overlay that can be drawn on top of the terminal during the
|
|
//! rendering process (`renderer/Overlay.zig`).
|
|
//!
|
|
//! Zig draws with z2d on the CPU. Everything drawn here is an axis-aligned
|
|
//! rectangle (translucent fill plus a 1px border, no anti-aliasing), so the
|
|
//! Rust port rasterizes directly into a premultiplied RGBA8 buffer with
|
|
//! source-over compositing, which is what z2d does for these operations.
|
|
//!
|
|
//! Initialize the overlay, apply features with [`Overlay::apply_features`],
|
|
//! then get the resulting image with [`Overlay::pending_image`] to upload to
|
|
//! the GPU. This works in concert with [`crate::image::State`].
|
|
|
|
use ghostty_terminal::{
|
|
page::{SemanticContent, SemanticPrompt},
|
|
render::RenderState,
|
|
};
|
|
|
|
use crate::{
|
|
image::{Pending, PixelFormat},
|
|
size::{CellSize, Size},
|
|
};
|
|
|
|
/// The colors we use for overlays.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Color {
|
|
/// light blue
|
|
Hyperlink,
|
|
/// orange/gold
|
|
SemanticPrompt,
|
|
/// cyan
|
|
SemanticInput,
|
|
}
|
|
|
|
/// A premultiplied RGBA pixel.
|
|
type Pixel = [u8; 4];
|
|
|
|
impl Color {
|
|
pub fn rgb(self) -> [u8; 3] {
|
|
match self {
|
|
Color::Hyperlink => [180, 180, 255],
|
|
Color::SemanticPrompt => [255, 200, 64],
|
|
Color::SemanticInput => [64, 200, 255],
|
|
}
|
|
}
|
|
|
|
/// The fill color for rectangles.
|
|
pub fn rect_fill(self) -> Pixel {
|
|
self.alpha_pixel(96)
|
|
}
|
|
|
|
/// The border color for rectangles.
|
|
pub fn rect_border(self) -> Pixel {
|
|
self.alpha_pixel(200)
|
|
}
|
|
|
|
fn alpha_pixel(self, alpha: u8) -> Pixel {
|
|
let [r, g, b] = self.rgb();
|
|
let m = |c: u8| ((u32::from(c) * u32::from(alpha) + 127) / 255) as u8;
|
|
[m(r), m(g), m(b), alpha]
|
|
}
|
|
}
|
|
|
|
/// The set of available features and their configuration.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Feature {
|
|
HighlightHyperlinks,
|
|
SemanticPrompts,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum InitError {
|
|
/// The terminal dimensions are invalid to support an overlay.
|
|
/// Either too small or too big.
|
|
#[error("invalid overlay dimensions")]
|
|
InvalidDimensions,
|
|
}
|
|
|
|
/// The overlay surface plus the cell size so we can map grid coordinates to
|
|
/// pixels.
|
|
#[derive(Debug)]
|
|
pub struct Overlay {
|
|
width: u32,
|
|
height: u32,
|
|
/// Premultiplied RGBA, row-major.
|
|
buf: Vec<u8>,
|
|
|
|
/// Cell size information so we can map grid coordinates to pixels.
|
|
pub cell_size: CellSize,
|
|
}
|
|
|
|
impl Overlay {
|
|
/// Initialize a new, blank overlay.
|
|
pub fn new(sz: &Size) -> Result<Overlay, InitError> {
|
|
// Our surface does NOT need to take into account padding because
|
|
// we render the overlay using the image subsystem and shaders which
|
|
// already take that into account.
|
|
let term_size = sz.terminal();
|
|
if term_size.width == 0
|
|
|| term_size.height == 0
|
|
|| i32::try_from(term_size.width).is_err()
|
|
|| i32::try_from(term_size.height).is_err()
|
|
{
|
|
return Err(InitError::InvalidDimensions);
|
|
}
|
|
let len = (term_size.width as usize)
|
|
.checked_mul(term_size.height as usize)
|
|
.and_then(|n| n.checked_mul(4))
|
|
.ok_or(InitError::InvalidDimensions)?;
|
|
Ok(Overlay {
|
|
width: term_size.width,
|
|
height: term_size.height,
|
|
buf: vec![0; len],
|
|
cell_size: sz.cell,
|
|
})
|
|
}
|
|
|
|
pub fn width(&self) -> u32 {
|
|
self.width
|
|
}
|
|
|
|
pub fn height(&self) -> u32 {
|
|
self.height
|
|
}
|
|
|
|
/// The raw premultiplied RGBA pixels.
|
|
pub fn data(&self) -> &[u8] {
|
|
&self.buf
|
|
}
|
|
|
|
/// Returns a pending image that can be used to copy, convert, upload, etc.
|
|
pub fn pending_image(&self) -> Pending {
|
|
Pending {
|
|
width: self.width,
|
|
height: self.height,
|
|
pixel_format: PixelFormat::Rgba,
|
|
data: self.buf.clone(),
|
|
}
|
|
}
|
|
|
|
/// Clear the overlay.
|
|
pub fn reset(&mut self) {
|
|
self.buf.fill(0);
|
|
}
|
|
|
|
/// Apply the given features to this overlay. This will draw on top of
|
|
/// any pre-existing content in the overlay.
|
|
pub fn apply_features(&mut self, state: &RenderState, features: &[Feature]) {
|
|
for f in features {
|
|
match f {
|
|
Feature::HighlightHyperlinks => self.highlight_hyperlinks(state),
|
|
Feature::SemanticPrompts => self.highlight_semantic_prompts(state),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add rectangles around contiguous hyperlinks in the render state.
|
|
///
|
|
/// Note: this currently doesn't take into account unique hyperlink IDs
|
|
/// because the render state doesn't contain this. This will be added
|
|
/// later.
|
|
fn highlight_hyperlinks(&mut self, state: &RenderState) {
|
|
let border_color = Color::Hyperlink.rect_border();
|
|
let fill_color = Color::Hyperlink.rect_fill();
|
|
|
|
for (y, row) in state.row_data.iter().enumerate() {
|
|
if !row.raw.hyperlink() {
|
|
continue;
|
|
}
|
|
|
|
let raw_cells = &row.cells.raw;
|
|
let mut x = 0;
|
|
while x < raw_cells.len() {
|
|
// Skip cells without hyperlinks
|
|
if !raw_cells[x].hyperlink() {
|
|
x += 1;
|
|
continue;
|
|
}
|
|
|
|
// Found start of a hyperlink run
|
|
let start_x = x;
|
|
|
|
// Find end of contiguous hyperlink cells
|
|
while x < raw_cells.len() && raw_cells[x].hyperlink() {
|
|
x += 1;
|
|
}
|
|
let end_x = x;
|
|
|
|
if let Err(err) = self.highlight_grid_rect(
|
|
start_x,
|
|
y,
|
|
end_x - start_x,
|
|
1,
|
|
border_color,
|
|
fill_color,
|
|
) {
|
|
log::warn!("Error drawing hyperlink border: {err}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn highlight_semantic_prompts(&mut self, state: &RenderState) {
|
|
// Highlight the row-level semantic prompt bars. The prompts are easy
|
|
// because they're part of the row metadata.
|
|
{
|
|
let prompt_border = Color::SemanticPrompt.rect_border();
|
|
let prompt_fill = Color::SemanticPrompt.rect_fill();
|
|
|
|
let rows = &state.row_data;
|
|
let mut y = 0;
|
|
while y < rows.len() {
|
|
// If its not a semantic prompt row, skip it.
|
|
if rows[y].raw.semantic_prompt() == SemanticPrompt::None {
|
|
y += 1;
|
|
continue;
|
|
}
|
|
|
|
// Find the full length of the semantic prompt row by
|
|
// connecting all continuations.
|
|
let start_y = y;
|
|
y += 1;
|
|
while y < rows.len()
|
|
&& rows[y].raw.semantic_prompt() == SemanticPrompt::PromptContinuation
|
|
{
|
|
y += 1;
|
|
}
|
|
let end_y = y; // Exclusive
|
|
|
|
let bar_width = 5.min(self.cell_size.width as usize);
|
|
if let Err(err) = self.highlight_pixel_rect(
|
|
0,
|
|
start_y,
|
|
bar_width,
|
|
end_y - start_y,
|
|
prompt_border,
|
|
prompt_fill,
|
|
) {
|
|
log::warn!("Error drawing semantic prompt bar: {err}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Highlight contiguous semantic cells within rows.
|
|
for (y, row) in state.row_data.iter().enumerate() {
|
|
let raw_cells = &row.cells.raw;
|
|
|
|
let mut x = 0;
|
|
while x < raw_cells.len() {
|
|
let content = raw_cells[x].semantic_content();
|
|
let start_x = x;
|
|
|
|
// We skip output because its just the rest of the non-prompt
|
|
// parts and it makes the overlay too noisy.
|
|
if content == SemanticContent::Output {
|
|
x += 1;
|
|
continue;
|
|
}
|
|
|
|
// Find the end of this content.
|
|
x += 1;
|
|
while x < raw_cells.len() && raw_cells[x].semantic_content() == content {
|
|
x += 1;
|
|
}
|
|
|
|
let color = match content {
|
|
SemanticContent::Prompt => Color::SemanticPrompt,
|
|
SemanticContent::Input => Color::SemanticInput,
|
|
SemanticContent::Output => unreachable!(),
|
|
};
|
|
|
|
if let Err(err) = self.highlight_grid_rect(
|
|
start_x,
|
|
y,
|
|
x - start_x,
|
|
1,
|
|
color.rect_border(),
|
|
color.rect_fill(),
|
|
) {
|
|
log::warn!("Error drawing semantic content highlight: {err}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Creates a rectangle for highlighting a grid region. x/y/width/height
|
|
/// are all in grid cells.
|
|
fn highlight_grid_rect(
|
|
&mut self,
|
|
x: usize,
|
|
y: usize,
|
|
width: usize,
|
|
height: usize,
|
|
border_color: Pixel,
|
|
fill_color: Pixel,
|
|
) -> Result<(), Overflow> {
|
|
// All math below uses checked arithmetic to avoid overflows. The
|
|
// inputs aren't trusted and the path this is in isn't hot enough
|
|
// to warrant unsafe optimizations.
|
|
let cw = self.cell_size.width as usize;
|
|
let px_width = checked_i32(width.checked_mul(cw).ok_or(Overflow)?)?;
|
|
let px_height = checked_i32(
|
|
height
|
|
.checked_mul(self.cell_size.height as usize)
|
|
.ok_or(Overflow)?,
|
|
)?;
|
|
let start_x = checked_i32(x.checked_mul(cw).ok_or(Overflow)?)?;
|
|
let start_y = checked_i32(
|
|
y.checked_mul(self.cell_size.height as usize)
|
|
.ok_or(Overflow)?,
|
|
)?;
|
|
self.rect(
|
|
start_x,
|
|
start_y,
|
|
start_x.checked_add(px_width).ok_or(Overflow)?,
|
|
start_y.checked_add(px_height).ok_or(Overflow)?,
|
|
border_color,
|
|
fill_color,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Creates a rectangle for highlighting a region. x/y are grid cells and
|
|
/// width/height are pixels (width) and grid cells (height).
|
|
fn highlight_pixel_rect(
|
|
&mut self,
|
|
x: usize,
|
|
y: usize,
|
|
width_px: usize,
|
|
height: usize,
|
|
border_color: Pixel,
|
|
fill_color: Pixel,
|
|
) -> Result<(), Overflow> {
|
|
let px_width = checked_i32(width_px)?;
|
|
let px_height = checked_i32(
|
|
height
|
|
.checked_mul(self.cell_size.height as usize)
|
|
.ok_or(Overflow)?,
|
|
)?;
|
|
let start_x = checked_i32(
|
|
x.checked_mul(self.cell_size.width as usize)
|
|
.ok_or(Overflow)?,
|
|
)?;
|
|
let start_y = checked_i32(
|
|
y.checked_mul(self.cell_size.height as usize)
|
|
.ok_or(Overflow)?,
|
|
)?;
|
|
self.rect(
|
|
start_x,
|
|
start_y,
|
|
start_x.checked_add(px_width).ok_or(Overflow)?,
|
|
start_y.checked_add(px_height).ok_or(Overflow)?,
|
|
border_color,
|
|
fill_color,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Fill the half-open rectangle `[x0, x1) x [y0, y1)` and then stroke a
|
|
/// one pixel hairline along its outline (the closed path through the
|
|
/// four corners, so the right/bottom edge lies on `x1`/`y1`).
|
|
fn rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, border: Pixel, fill: Pixel) {
|
|
for y in y0..y1 {
|
|
for x in x0..x1 {
|
|
self.blend(x, y, fill);
|
|
}
|
|
}
|
|
for x in x0..=x1 {
|
|
self.blend(x, y0, border);
|
|
self.blend(x, y1, border);
|
|
}
|
|
for y in y0..=y1 {
|
|
self.blend(x0, y, border);
|
|
self.blend(x1, y, border);
|
|
}
|
|
}
|
|
|
|
/// Source-over blend of a premultiplied pixel, clipped to the surface.
|
|
fn blend(&mut self, x: i32, y: i32, src: Pixel) {
|
|
if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height {
|
|
return;
|
|
}
|
|
let i = (y as usize * self.width as usize + x as usize) * 4;
|
|
let inv = 255 - u32::from(src[3]);
|
|
for (d, s) in self.buf[i..i + 4].iter_mut().zip(src) {
|
|
let out = u32::from(s) + (u32::from(*d) * inv + 127) / 255;
|
|
*d = out.min(255) as u8;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
#[error("overflow")]
|
|
struct Overflow;
|
|
|
|
fn checked_i32(v: usize) -> Result<i32, Overflow> {
|
|
i32::try_from(v).map_err(|_| Overflow)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use ghostty_terminal::terminal::{Options, Terminal};
|
|
|
|
use super::*;
|
|
use crate::size::{Padding, ScreenSize};
|
|
|
|
fn size(w: u32, h: u32) -> Size {
|
|
Size {
|
|
screen: ScreenSize {
|
|
width: w,
|
|
height: h,
|
|
},
|
|
cell: CellSize {
|
|
width: 10,
|
|
height: 20,
|
|
},
|
|
padding: Padding::default(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_dimensions() {
|
|
assert!(Overlay::new(&size(0, 10)).is_err());
|
|
assert!(Overlay::new(&size(10, 0)).is_err());
|
|
assert!(Overlay::new(&size(40, 40)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn colors_are_premultiplied() {
|
|
assert_eq!(Color::Hyperlink.rect_fill()[3], 96);
|
|
assert_eq!(Color::Hyperlink.rect_border()[3], 200);
|
|
let [r, _, _, a] = Color::SemanticPrompt.rect_fill();
|
|
assert_eq!(a, 96);
|
|
assert_eq!(r, ((255u32 * 96 + 127) / 255) as u8);
|
|
}
|
|
|
|
#[test]
|
|
fn grid_rect_fill_and_reset() {
|
|
let mut o = Overlay::new(&size(100, 100)).unwrap();
|
|
o.highlight_grid_rect(1, 1, 2, 1, [0, 0, 100, 200], [0, 0, 50, 96])
|
|
.unwrap();
|
|
// Interior pixel is the fill.
|
|
let i = ((30 * 100) + 15) * 4;
|
|
assert_eq!(&o.data()[i..i + 4], &[0, 0, 50, 96]);
|
|
// The top-left corner pixel is the border (blended over fill).
|
|
let i = ((20 * 100) + 10) * 4;
|
|
assert!(o.data()[i + 3] >= 200);
|
|
// Outside is untouched.
|
|
assert_eq!(&o.data()[0..4], &[0, 0, 0, 0]);
|
|
o.reset();
|
|
assert!(o.data().iter().all(|&b| b == 0));
|
|
}
|
|
|
|
#[test]
|
|
fn highlights_semantic_prompt_rows() {
|
|
let mut t = Terminal::new(Options::new(10, 3)).unwrap();
|
|
{
|
|
let mut s = t.vt_stream();
|
|
// OSC 133 A: start of prompt.
|
|
s.next_slice(b"\x1b]133;A\x07$ ").unwrap();
|
|
}
|
|
let mut state = RenderState::new();
|
|
state.update(&mut t).unwrap();
|
|
let mut o = Overlay::new(&size(100, 60)).unwrap();
|
|
o.apply_features(&state, &[Feature::SemanticPrompts]);
|
|
assert!(o.data().iter().any(|&b| b != 0));
|
|
// And the hyperlink feature on a terminal without links draws nothing.
|
|
let mut o = Overlay::new(&size(100, 60)).unwrap();
|
|
o.apply_features(&state, &[Feature::HighlightHyperlinks]);
|
|
assert!(o.data().iter().all(|&b| b == 0));
|
|
}
|
|
}
|