forked from NotAShelf/beer
beer: render text-sizing runs as scaled multicell blocks
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I599225a2532409a2e7a804cb748391246a6a6964
This commit is contained in:
parent
f818019ce1
commit
f42924c473
6 changed files with 503 additions and 4 deletions
|
|
@ -221,6 +221,21 @@ impl Fonts {
|
||||||
Ok(self.gcache.get(&key).expect("glyph was just inserted"))
|
Ok(self.gcache.get(&key).expect("glyph was just inserted"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rasterize `c` in `style` at `scale` times the base size, uncached.
|
||||||
|
///
|
||||||
|
/// Scaled glyphs come from the text-sizing protocol (`OSC 66`); they are
|
||||||
|
/// rare and transient, so they bypass the glyph cache. The scale is applied
|
||||||
|
/// as an outline transform, which leaves the face's configured pixel size -
|
||||||
|
/// and therefore the cell metrics every other glyph depends on - untouched.
|
||||||
|
/// Embedded-bitmap (colour) glyphs ignore the transform; the caller scales
|
||||||
|
/// those at blit time instead.
|
||||||
|
pub fn glyph_scaled(&mut self, c: char, style: Style, scale: f32) -> Result<Glyph, FontError> {
|
||||||
|
let idx = self.face_for(c, style)?;
|
||||||
|
let face = &self.faces[idx].face;
|
||||||
|
let (synth_bold, synth_italic) = synth_flags(face, style);
|
||||||
|
rasterize_scaled(face, c, scale.max(0.01), synth_bold, synth_italic)
|
||||||
|
}
|
||||||
|
|
||||||
/// Shape `base` plus its combining `marks` into positioned glyphs using
|
/// Shape `base` plus its combining `marks` into positioned glyphs using
|
||||||
/// HarfBuzz, so marks land where the font's GPOS table wants them rather
|
/// HarfBuzz, so marks land where the font's GPOS table wants them rather
|
||||||
/// than stacked at the origin. Returns `None` when shaping is unavailable or
|
/// than stacked at the origin. Returns `None` when shaping is unavailable or
|
||||||
|
|
@ -473,6 +488,58 @@ fn rasterize_index(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rasterize `c` with the outline scaled by `scale` (and sheared if italic is
|
||||||
|
/// synthesized). The transform is reset before returning so the face is left as
|
||||||
|
/// it was found.
|
||||||
|
fn rasterize_scaled(
|
||||||
|
face: &Face,
|
||||||
|
c: char,
|
||||||
|
scale: f32,
|
||||||
|
synth_bold: bool,
|
||||||
|
synth_italic: bool,
|
||||||
|
) -> Result<Glyph, FontError> {
|
||||||
|
// A scale matrix on the diagonal, in 16.16 fixed point; the off-diagonal
|
||||||
|
// `xy` term shears for a synthetic italic (~0.2 of the glyph height). `as _`
|
||||||
|
// takes the field's `FT_Fixed` type, as the identity/shear matrices do.
|
||||||
|
let mut matrix = Matrix {
|
||||||
|
xx: (scale * 65536.0).round() as _,
|
||||||
|
xy: if synth_italic {
|
||||||
|
(scale * 0.2 * 65536.0).round() as _
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
yx: 0,
|
||||||
|
yy: (scale * 65536.0).round() as _,
|
||||||
|
};
|
||||||
|
face.set_transform(&mut matrix, &mut Vector { x: 0, y: 0 });
|
||||||
|
let result = face.load_char(c as usize, LoadFlag::RENDER | LoadFlag::COLOR);
|
||||||
|
face.set_transform(&mut identity_matrix(), &mut Vector { x: 0, y: 0 });
|
||||||
|
result?;
|
||||||
|
|
||||||
|
let slot = face.glyph();
|
||||||
|
let bitmap = slot.bitmap();
|
||||||
|
let width = bitmap.width().max(0) as usize;
|
||||||
|
let height = bitmap.rows().max(0) as usize;
|
||||||
|
let pitch = bitmap.pitch();
|
||||||
|
let src = bitmap.buffer();
|
||||||
|
let mut data = match bitmap.pixel_mode()? {
|
||||||
|
PixelMode::Gray => GlyphData::Mask(pack_rows(src, width, pitch, height)),
|
||||||
|
PixelMode::Bgra => GlyphData::Color(pack_rows(src, width * 4, pitch, height)),
|
||||||
|
PixelMode::Mono => GlyphData::Mask(expand_mono(src, width, pitch, height)),
|
||||||
|
_ => GlyphData::Mask(vec![0; width * height]),
|
||||||
|
};
|
||||||
|
if synth_bold && let GlyphData::Mask(mask) = &mut data {
|
||||||
|
embolden(mask, width, height);
|
||||||
|
}
|
||||||
|
Ok(Glyph {
|
||||||
|
left: slot.bitmap_left(),
|
||||||
|
top: slot.bitmap_top(),
|
||||||
|
width: width as u32,
|
||||||
|
height: height as u32,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn rasterize_with(
|
fn rasterize_with(
|
||||||
face: &Face,
|
face: &Face,
|
||||||
synth_bold: bool,
|
synth_bold: bool,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const SCROLLBACK_CAP: usize = 10_000;
|
||||||
/// The protocol vocabulary an SGR/DECSET stream selects lives in
|
/// The protocol vocabulary an SGR/DECSET stream selects lives in
|
||||||
/// `beer-protocols` and is re-exported here so the grid and renderer keep
|
/// `beer-protocols` and is re-exported here so the grid and renderer keep
|
||||||
/// referring to it as `grid::Color`, `grid::Underline`, and so on.
|
/// referring to it as `grid::Color`, `grid::Underline`, and so on.
|
||||||
|
pub use beer_protocols::text_size::TextSize;
|
||||||
pub use beer_protocols::{Color, CursorShape, MouseEncoding, MouseProtocol, PromptKind, Underline};
|
pub use beer_protocols::{Color, CursorShape, MouseEncoding, MouseProtocol, PromptKind, Underline};
|
||||||
|
|
||||||
/// Per-cell style flags, packed into a `u16`.
|
/// Per-cell style flags, packed into a `u16`.
|
||||||
|
|
@ -36,6 +37,9 @@ impl Flags {
|
||||||
/// Trailing column of a double-width glyph; holds no character of its own.
|
/// Trailing column of a double-width glyph; holds no character of its own.
|
||||||
pub const WIDE_CONT: Self = Self(1 << 8);
|
pub const WIDE_CONT: Self = Self(1 << 8);
|
||||||
pub const OVERLINE: Self = Self(1 << 9);
|
pub const OVERLINE: Self = Self(1 << 9);
|
||||||
|
/// A cell of a text-sizing (`OSC 66`) block that is not the block's leading
|
||||||
|
/// cell; it holds no character of its own and is drawn by the leading cell.
|
||||||
|
pub const SIZED_CONT: Self = Self(1 << 10);
|
||||||
|
|
||||||
pub const fn empty() -> Self {
|
pub const fn empty() -> Self {
|
||||||
Self(0)
|
Self(0)
|
||||||
|
|
@ -58,6 +62,31 @@ impl Flags {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The text-sizing (`OSC 66`) descriptor a scaled cell carries. A run is drawn
|
||||||
|
/// as a block `cols` cells wide and `rows` high; this cell sits at `(dx, dy)`
|
||||||
|
/// within it. The leading cell (`dx == 0, dy == 0`) carries the run text and is
|
||||||
|
/// what the renderer draws; the rest are flagged [`Flags::SIZED_CONT`].
|
||||||
|
///
|
||||||
|
/// Boxed on the cell so the common, unscaled path keeps `Cell` lean: only the
|
||||||
|
/// rare scaled cell pays an allocation.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub struct Sized {
|
||||||
|
/// Scale, fractional scale, and alignment parsed from the metadata.
|
||||||
|
pub size: TextSize,
|
||||||
|
/// Block width in cells: `s * w`, or `s * grapheme_width` when `w == 0`.
|
||||||
|
pub cols: u8,
|
||||||
|
/// Block height in cells: `s`.
|
||||||
|
pub rows: u8,
|
||||||
|
/// This cell's column within the block.
|
||||||
|
pub dx: u8,
|
||||||
|
/// This cell's row within the block.
|
||||||
|
pub dy: u8,
|
||||||
|
/// The run text, on the leading cell only. For the per-grapheme (`w == 0`)
|
||||||
|
/// form this is `None` and the grapheme lives in the cell's `c`/`combining`;
|
||||||
|
/// for the packed (`w > 0`) form it holds the whole run.
|
||||||
|
pub run: Option<Box<str>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One grid cell: a character plus its rendering style.
|
/// One grid cell: a character plus its rendering style.
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
pub struct Cell {
|
pub struct Cell {
|
||||||
|
|
@ -74,6 +103,8 @@ pub struct Cell {
|
||||||
pub combining: Option<Box<str>>,
|
pub combining: Option<Box<str>>,
|
||||||
/// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`.
|
/// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`.
|
||||||
pub link: Option<NonZeroU16>,
|
pub link: Option<NonZeroU16>,
|
||||||
|
/// Text-sizing block membership (`OSC 66`), or `None` for ordinary cells.
|
||||||
|
pub sized: Option<Box<Sized>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Cell {
|
impl Default for Cell {
|
||||||
|
|
@ -87,6 +118,7 @@ impl Default for Cell {
|
||||||
underline_color: Color::Default,
|
underline_color: Color::Default,
|
||||||
combining: None,
|
combining: None,
|
||||||
link: None,
|
link: None,
|
||||||
|
sized: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -210,6 +242,24 @@ fn is_word(c: char, delims: &str) -> bool {
|
||||||
!c.is_whitespace() && !delims.contains(c)
|
!c.is_whitespace() && !delims.contains(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split text into grapheme-ish units for `OSC 66 w=0` layout: each base
|
||||||
|
/// character with its trailing zero-width combining marks and display width.
|
||||||
|
/// Leading combining marks with no base are dropped, as in normal printing.
|
||||||
|
fn graphemes(text: &str) -> Vec<(char, String, usize)> {
|
||||||
|
let mut out: Vec<(char, String, usize)> = Vec::new();
|
||||||
|
for c in text.chars() {
|
||||||
|
match c.width().unwrap_or(0) {
|
||||||
|
0 => {
|
||||||
|
if let Some(last) = out.last_mut() {
|
||||||
|
last.1.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w => out.push((c, String::new(), w)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
impl Grid {
|
impl Grid {
|
||||||
pub fn new(cols: usize, rows: usize) -> Self {
|
pub fn new(cols: usize, rows: usize) -> Self {
|
||||||
let cols = cols.max(1);
|
let cols = cols.max(1);
|
||||||
|
|
@ -314,6 +364,7 @@ impl Grid {
|
||||||
pub fn resize(&mut self, cols: usize, rows: usize) {
|
pub fn resize(&mut self, cols: usize, rows: usize) {
|
||||||
let cols = cols.max(1);
|
let cols = cols.max(1);
|
||||||
let rows = rows.max(1);
|
let rows = rows.max(1);
|
||||||
|
self.clear_sized_runs();
|
||||||
if self.alt_saved.is_some() {
|
if self.alt_saved.is_some() {
|
||||||
self.clip_resize(cols, rows);
|
self.clip_resize(cols, rows);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -560,9 +611,16 @@ impl Grid {
|
||||||
}
|
}
|
||||||
|
|
||||||
let (x, y) = (self.cursor.x, self.cursor.y);
|
let (x, y) = (self.cursor.x, self.cursor.y);
|
||||||
|
// Overwriting any cell of a text-sizing block dissolves the whole block,
|
||||||
|
// so no orphaned continuation cells are left for the renderer to draw.
|
||||||
|
self.clear_sized_at(x, y);
|
||||||
|
if width == 2 && x + 1 < self.cols {
|
||||||
|
self.clear_sized_at(x + 1, y);
|
||||||
|
}
|
||||||
let mut cell = self.pen.clone();
|
let mut cell = self.pen.clone();
|
||||||
cell.c = c;
|
cell.c = c;
|
||||||
cell.combining = None;
|
cell.combining = None;
|
||||||
|
cell.sized = None;
|
||||||
cell.flags.remove(Flags::WIDE_CONT);
|
cell.flags.remove(Flags::WIDE_CONT);
|
||||||
self.lines[y].cells[x] = cell;
|
self.lines[y].cells[x] = cell;
|
||||||
self.last_base = Some((x, y));
|
self.last_base = Some((x, y));
|
||||||
|
|
@ -570,6 +628,7 @@ impl Grid {
|
||||||
let mut cont = self.pen.clone();
|
let mut cont = self.pen.clone();
|
||||||
cont.c = ' ';
|
cont.c = ' ';
|
||||||
cont.combining = None;
|
cont.combining = None;
|
||||||
|
cont.sized = None;
|
||||||
cont.flags.insert(Flags::WIDE_CONT);
|
cont.flags.insert(Flags::WIDE_CONT);
|
||||||
self.lines[y].cells[x + 1] = cont;
|
self.lines[y].cells[x + 1] = cont;
|
||||||
}
|
}
|
||||||
|
|
@ -604,6 +663,137 @@ impl Grid {
|
||||||
cell.combining = Some(s.into_boxed_str());
|
cell.combining = Some(s.into_boxed_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lay out a text-sizing run (`OSC 66`) as scaled multicell blocks at the
|
||||||
|
/// cursor, advancing it on the same row by the total block width, as the
|
||||||
|
/// protocol requires. With `width == 0` each grapheme gets its own `s` by `s`
|
||||||
|
/// block (the font scaled by `s`); with `width > 0` the whole run is packed
|
||||||
|
/// into one block `s * width` cells wide and `s` high. A plain descriptor
|
||||||
|
/// (scale 1, no width, no fraction) falls back to ordinary printing.
|
||||||
|
pub fn print_sized(&mut self, text: &str, size: TextSize) {
|
||||||
|
if size.is_plain() {
|
||||||
|
for c in text.chars() {
|
||||||
|
self.print(c);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rows = size.cell_height().clamp(1, self.rows);
|
||||||
|
if size.width == 0 {
|
||||||
|
for (base, marks, w) in graphemes(text) {
|
||||||
|
let cols = (rows * w).clamp(1, self.cols);
|
||||||
|
self.place_block(base, marks, None, size, cols, rows);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let cols = (rows * size.width as usize).clamp(1, self.cols);
|
||||||
|
self.place_block(' ', String::new(), Some(text.into()), size, cols, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one scaled block of `cols` by `rows` cells at the cursor and
|
||||||
|
/// advance past it. The leading cell carries the text; the rest are flagged
|
||||||
|
/// [`Flags::SIZED_CONT`]. A block that will not fit wraps to the next line.
|
||||||
|
fn place_block(
|
||||||
|
&mut self,
|
||||||
|
lead: char,
|
||||||
|
marks: String,
|
||||||
|
run: Option<Box<str>>,
|
||||||
|
size: TextSize,
|
||||||
|
cols: usize,
|
||||||
|
rows: usize,
|
||||||
|
) {
|
||||||
|
if self.wrap_pending || (self.autowrap && self.cursor.x + cols > self.cols) {
|
||||||
|
self.cursor.x = 0;
|
||||||
|
self.lines[self.cursor.y].wrapped = true;
|
||||||
|
self.line_feed();
|
||||||
|
self.wrap_pending = false;
|
||||||
|
}
|
||||||
|
let (x0, y0) = (self.cursor.x, self.cursor.y);
|
||||||
|
let marks = (!marks.is_empty()).then(|| marks.into_boxed_str());
|
||||||
|
for dy in 0..rows {
|
||||||
|
let cy = y0 + dy;
|
||||||
|
if cy >= self.rows {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for dx in 0..cols {
|
||||||
|
let cx = x0 + dx;
|
||||||
|
if cx >= self.cols {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Dissolve any block already occupying this cell before reusing it.
|
||||||
|
self.clear_sized_at(cx, cy);
|
||||||
|
let lead_cell = dx == 0 && dy == 0;
|
||||||
|
let mut cell = self.pen.clone();
|
||||||
|
cell.c = if lead_cell { lead } else { ' ' };
|
||||||
|
cell.combining = if lead_cell { marks.clone() } else { None };
|
||||||
|
cell.flags.remove(Flags::WIDE_CONT);
|
||||||
|
if !lead_cell {
|
||||||
|
cell.flags.insert(Flags::SIZED_CONT);
|
||||||
|
}
|
||||||
|
cell.sized = Some(Box::new(Sized {
|
||||||
|
size,
|
||||||
|
cols: cols as u8,
|
||||||
|
rows: rows as u8,
|
||||||
|
dx: dx as u8,
|
||||||
|
dy: dy as u8,
|
||||||
|
run: if lead_cell { run.clone() } else { None },
|
||||||
|
}));
|
||||||
|
self.lines[cy].cells[cx] = cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_base = None;
|
||||||
|
if self.cursor.x + cols >= self.cols {
|
||||||
|
self.cursor.x = self.cols - 1;
|
||||||
|
self.wrap_pending = self.autowrap;
|
||||||
|
} else {
|
||||||
|
self.cursor.x += cols;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If `(x, y)` belongs to a text-sizing block, blank every cell of that
|
||||||
|
/// block so a write into it cannot orphan continuation cells.
|
||||||
|
fn clear_sized_at(&mut self, x: usize, y: usize) {
|
||||||
|
let Some(s) = self
|
||||||
|
.lines
|
||||||
|
.get(y)
|
||||||
|
.and_then(|l| l.cells.get(x))
|
||||||
|
.and_then(|c| c.sized.as_deref())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (dx, dy, cols, rows) = (
|
||||||
|
s.dx as usize,
|
||||||
|
s.dy as usize,
|
||||||
|
s.cols as usize,
|
||||||
|
s.rows as usize,
|
||||||
|
);
|
||||||
|
let x0 = x.saturating_sub(dx);
|
||||||
|
let y0 = y.saturating_sub(dy);
|
||||||
|
let blank = self.pen_blank();
|
||||||
|
let x_end = (x0 + cols).min(self.cols);
|
||||||
|
for by in y0..(y0 + rows).min(self.rows) {
|
||||||
|
for cell in &mut self.lines[by].cells[x0..x_end] {
|
||||||
|
*cell = blank.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop all text-sizing blocks: their cells become plain characters. Used
|
||||||
|
/// before a resize, since a scaled block must not be split across a rewrap;
|
||||||
|
/// applications using `OSC 66` repaint on resize regardless.
|
||||||
|
fn clear_sized_runs(&mut self) {
|
||||||
|
let strip = |line: &mut Line| {
|
||||||
|
for cell in &mut line.cells {
|
||||||
|
if cell.sized.take().is_some() {
|
||||||
|
cell.flags.remove(Flags::SIZED_CONT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.lines.iter_mut().for_each(strip);
|
||||||
|
self.scrollback.iter_mut().for_each(strip);
|
||||||
|
if let Some(alt) = self.alt_saved.as_mut() {
|
||||||
|
alt.iter_mut().for_each(strip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn shift_right(&mut self, n: usize) {
|
fn shift_right(&mut self, n: usize) {
|
||||||
let (x, y) = (self.cursor.x, self.cursor.y);
|
let (x, y) = (self.cursor.x, self.cursor.y);
|
||||||
let end = self.cols;
|
let end = self.cols;
|
||||||
|
|
@ -982,6 +1172,29 @@ impl Grid {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// For a viewport cell `(y, x)` that is the left edge of a text-sizing block
|
||||||
|
/// (its `dx == 0`), return the block's leading cell - which holds the text
|
||||||
|
/// and the full descriptor - together with this row's `dy` within the block.
|
||||||
|
/// `None` if `(y, x)` is not a left-edge sized cell, or the leading row is
|
||||||
|
/// scrolled above the viewport top (the block is then clipped, not drawn).
|
||||||
|
pub fn sized_lead(&self, y: usize, x: usize) -> Option<(&Cell, usize)> {
|
||||||
|
let dy = {
|
||||||
|
let s = self.view_row(y).get(x)?.sized.as_ref()?;
|
||||||
|
if s.dx != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
s.dy as usize
|
||||||
|
};
|
||||||
|
if dy > y {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lead = self.view_row(y - dy).get(x)?;
|
||||||
|
lead.sized
|
||||||
|
.as_ref()
|
||||||
|
.filter(|ls| ls.dx == 0 && ls.dy == 0)
|
||||||
|
.map(|_| (lead, dy))
|
||||||
|
}
|
||||||
|
|
||||||
// --- selection ---
|
// --- selection ---
|
||||||
|
|
||||||
/// The absolute row currently shown at viewport row `y`.
|
/// The absolute row currently shown at viewport row `y`.
|
||||||
|
|
@ -1130,7 +1343,7 @@ impl Grid {
|
||||||
self.lines[y]
|
self.lines[y]
|
||||||
.cells
|
.cells
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|c| !c.flags.contains(Flags::WIDE_CONT))
|
.filter(|c| !c.flags.contains(Flags::WIDE_CONT) && !c.flags.contains(Flags::SIZED_CONT))
|
||||||
.map(|c| c.c)
|
.map(|c| c.c)
|
||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
.trim_end()
|
.trim_end()
|
||||||
|
|
@ -1299,6 +1512,61 @@ mod tests {
|
||||||
assert_eq!(g.view_row(0)[0].c, '3');
|
assert_eq!(g.view_row(0)[0].c, '3');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sized_scale_lays_out_a_block_and_advances() {
|
||||||
|
// `s=2` with width 0: 'X' fills a 2x2 block, cursor advances 2 cells.
|
||||||
|
let mut g = Grid::new(8, 4);
|
||||||
|
g.print_sized("X", TextSize::parse_str("s=2"));
|
||||||
|
let lead = g.cell(0, 0);
|
||||||
|
assert_eq!(lead.c, 'X');
|
||||||
|
assert!(!lead.flags.contains(Flags::SIZED_CONT));
|
||||||
|
let s = lead.sized.as_ref().expect("leading cell is sized");
|
||||||
|
assert_eq!((s.cols, s.rows, s.dx, s.dy), (2, 2, 0, 0));
|
||||||
|
// The other three cells of the block are continuations.
|
||||||
|
for (x, y) in [(1, 0), (0, 1), (1, 1)] {
|
||||||
|
assert!(g.cell(x, y).flags.contains(Flags::SIZED_CONT));
|
||||||
|
}
|
||||||
|
assert_eq!(g.cursor(), (2, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sized_packed_run_round_trips_as_text() {
|
||||||
|
// `w=1` packs the whole run into one cell; selection yields it whole.
|
||||||
|
let mut g = Grid::new(8, 2);
|
||||||
|
g.print_sized("ab", TextSize::parse_str("n=1:d=2:w=1"));
|
||||||
|
assert!(g.cell(0, 0).sized.as_ref().unwrap().run.is_some());
|
||||||
|
g.start_selection(0, 0);
|
||||||
|
g.extend_selection(0, 0);
|
||||||
|
assert_eq!(g.selection_text().as_deref(), Some("ab"));
|
||||||
|
assert_eq!(g.cursor(), (1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overwriting_a_sized_block_dissolves_it() {
|
||||||
|
let mut g = Grid::new(8, 4);
|
||||||
|
g.print_sized("X", TextSize::parse_str("s=2"));
|
||||||
|
// Print a normal char into a continuation cell of the block.
|
||||||
|
g.move_to(1, 1);
|
||||||
|
g.print('z');
|
||||||
|
// The whole block is gone: no cell still claims to be sized.
|
||||||
|
for y in 0..2 {
|
||||||
|
for x in 0..2 {
|
||||||
|
assert!(g.cell(x, y).sized.is_none(), "({x},{y}) still sized");
|
||||||
|
assert!(!g.cell(x, y).flags.contains(Flags::SIZED_CONT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(g.cell(1, 1).c, 'z');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_dissolves_sized_runs() {
|
||||||
|
let mut g = Grid::new(8, 4);
|
||||||
|
g.print_sized("X", TextSize::parse_str("s=2"));
|
||||||
|
g.resize(6, 4);
|
||||||
|
assert!(g.cell(0, 0).sized.is_none());
|
||||||
|
assert!(!g.cell(1, 0).flags.contains(Flags::SIZED_CONT));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wide_char_occupies_two_columns() {
|
fn wide_char_occupies_two_columns() {
|
||||||
let mut g = Grid::new(6, 1);
|
let mut g = Grid::new(6, 1);
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,9 @@ impl Grid {
|
||||||
Some(out)
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The characters of an absolute row in `[from, to)`, skipping wide
|
/// The characters of an absolute row in `[from, to)`, skipping wide and
|
||||||
/// continuation cells.
|
/// text-sizing continuation cells. A leading text-sizing cell that holds a
|
||||||
|
/// packed run (`OSC 66 w>0`) contributes that whole run.
|
||||||
pub(super) fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {
|
pub(super) fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for cell in self
|
for cell in self
|
||||||
|
|
@ -178,8 +179,12 @@ impl Grid {
|
||||||
.get(from..to.min(self.abs_row(row).len()))
|
.get(from..to.min(self.abs_row(row).len()))
|
||||||
.unwrap_or(&[])
|
.unwrap_or(&[])
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|c| !c.flags.contains(Flags::WIDE_CONT))
|
.filter(|c| !c.flags.contains(Flags::WIDE_CONT) && !c.flags.contains(Flags::SIZED_CONT))
|
||||||
{
|
{
|
||||||
|
if let Some(run) = cell.sized.as_ref().and_then(|s| s.run.as_ref()) {
|
||||||
|
out.push_str(run);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
out.push(cell.c);
|
out.push(cell.c);
|
||||||
if let Some(marks) = &cell.combining {
|
if let Some(marks) = &cell.combining {
|
||||||
out.push_str(marks);
|
out.push_str(marks);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
|
|
||||||
use std::num::NonZeroU16;
|
use std::num::NonZeroU16;
|
||||||
|
|
||||||
|
use beer_protocols::text_size::{HAlign, VAlign};
|
||||||
|
|
||||||
use crate::font::{CellMetrics, Fonts, Glyph, GlyphData, Style};
|
use crate::font::{CellMetrics, Fonts, Glyph, GlyphData, Style};
|
||||||
use crate::grid::{Cell, CursorShape, Flags, Grid, Underline};
|
use crate::grid::{Cell, CursorShape, Flags, Grid, Underline};
|
||||||
use crate::theme::{Plane, Rgb, Theme};
|
use crate::theme::{Plane, Rgb, Theme};
|
||||||
|
|
@ -200,6 +202,18 @@ impl Renderer {
|
||||||
if cell.flags.contains(Flags::WIDE_CONT) {
|
if cell.flags.contains(Flags::WIDE_CONT) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Text-sizing (OSC 66) blocks are drawn from their per-row left edge,
|
||||||
|
// clipped to this row's band; every other block cell is skipped.
|
||||||
|
if let Some(sized) = &cell.sized {
|
||||||
|
if sized.dx == 0
|
||||||
|
&& let Some((lead, dy)) = grid.sized_lead(y, x)
|
||||||
|
{
|
||||||
|
let origin_x = pad_x + x as i32 * m.width as i32;
|
||||||
|
let (fg, _) = cell_colors(lead, theme);
|
||||||
|
self.draw_sized(&mut canvas, lead, dy, (origin_x, row_top), m, fg);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if cell.flags.contains(Flags::BLINK) && !blink_on {
|
if cell.flags.contains(Flags::BLINK) && !blink_on {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -444,6 +458,125 @@ impl Renderer {
|
||||||
};
|
};
|
||||||
blit_glyph(canvas, glyph, m, origin_x, cell_top, 0, fg);
|
blit_glyph(canvas, glyph, m, origin_x, cell_top, 0, fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Draw the slice of a text-sizing (`OSC 66`) block that falls in one row.
|
||||||
|
///
|
||||||
|
/// `lead` is the block's leading cell (holding the run text); `dy` is this
|
||||||
|
/// row's offset within the block; `pos` is this row's left edge and top in
|
||||||
|
/// pixels. The full block is `cols * width` by `rows * height` pixels,
|
||||||
|
/// starting `dy` rows above the row top; glyphs are rasterized at the block's
|
||||||
|
/// font scale and clipped to this row's band, so the block paints correctly
|
||||||
|
/// across its several per-row repaints.
|
||||||
|
fn draw_sized(
|
||||||
|
&mut self,
|
||||||
|
canvas: &mut Canvas,
|
||||||
|
lead: &Cell,
|
||||||
|
dy: usize,
|
||||||
|
pos: (i32, i32),
|
||||||
|
m: CellMetrics,
|
||||||
|
fg: Rgb,
|
||||||
|
) {
|
||||||
|
let (block_left_x, row_top) = pos;
|
||||||
|
let Some(s) = lead.sized.as_deref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let scale = s.size.font_scale();
|
||||||
|
let (cols, rows) = (s.cols as i32, s.rows as i32);
|
||||||
|
let block_top = row_top - dy as i32 * m.height as i32;
|
||||||
|
let (block_w, block_h) = (cols * m.width as i32, rows * m.height as i32);
|
||||||
|
let clip = (row_top, row_top + m.height as i32);
|
||||||
|
|
||||||
|
let advance = (m.width as f32 * scale).round().max(1.0) as i32;
|
||||||
|
let render_h = (m.height as f32 * scale).round().max(1.0) as i32;
|
||||||
|
|
||||||
|
// The run: a packed string (w>0) or the leading grapheme (w==0).
|
||||||
|
let run: Vec<char> = match s.run.as_deref() {
|
||||||
|
Some(text) => text.chars().collect(),
|
||||||
|
None => vec![lead.c],
|
||||||
|
};
|
||||||
|
let render_w = advance * run.len() as i32;
|
||||||
|
|
||||||
|
// A fractional scale renders into an area smaller than the block, placed
|
||||||
|
// by the v/h alignment; a whole scale fills the block (offsets zero).
|
||||||
|
let (ox, oy) = if s.size.has_fraction() {
|
||||||
|
let ox = match s.size.halign {
|
||||||
|
HAlign::Left => 0,
|
||||||
|
HAlign::Right => block_w - render_w,
|
||||||
|
HAlign::Center => (block_w - render_w) / 2,
|
||||||
|
};
|
||||||
|
let oy = match s.size.valign {
|
||||||
|
VAlign::Top => 0,
|
||||||
|
VAlign::Bottom => block_h - render_h,
|
||||||
|
VAlign::Middle => (block_h - render_h) / 2,
|
||||||
|
};
|
||||||
|
(ox.max(0), oy.max(0))
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let baseline = block_top + oy + (m.ascent as f32 * scale).round() as i32;
|
||||||
|
let style = cell_style(lead);
|
||||||
|
let mut pen_x = block_left_x + ox;
|
||||||
|
for c in run {
|
||||||
|
if c != ' '
|
||||||
|
&& let Ok(glyph) = self.fonts.glyph_scaled(c, style, scale)
|
||||||
|
{
|
||||||
|
blit_glyph_clipped(canvas, &glyph, pen_x, baseline, clip, fg, render_h);
|
||||||
|
}
|
||||||
|
pen_x += advance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composite a rasterized glyph, clipping to the vertical band `clip = (y0, y1)`
|
||||||
|
/// so a tall text-sizing glyph paints only the part belonging to the current
|
||||||
|
/// row. Mask glyphs are tinted with `fg`; colour glyphs are scaled to
|
||||||
|
/// `target_h` (the outline transform does not scale embedded bitmaps).
|
||||||
|
fn blit_glyph_clipped(
|
||||||
|
canvas: &mut Canvas,
|
||||||
|
glyph: &Glyph,
|
||||||
|
pen_x: i32,
|
||||||
|
baseline: i32,
|
||||||
|
clip: (i32, i32),
|
||||||
|
fg: Rgb,
|
||||||
|
target_h: i32,
|
||||||
|
) {
|
||||||
|
let (gw, gh) = (glyph.width as i32, glyph.height as i32);
|
||||||
|
match &glyph.data {
|
||||||
|
GlyphData::Mask(mask) => {
|
||||||
|
for gy in 0..gh {
|
||||||
|
let py = baseline - glyph.top + gy;
|
||||||
|
if py < clip.0 || py >= clip.1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for gx in 0..gw {
|
||||||
|
let a = mask[(gy * gw + gx) as usize];
|
||||||
|
if a != 0 {
|
||||||
|
canvas.blend(pen_x + glyph.left + gx, py, fg, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GlyphData::Color(bgra) if gh > 0 => {
|
||||||
|
let sc = target_h as f32 / gh as f32;
|
||||||
|
let tw = (gw as f32 * sc).round() as i32;
|
||||||
|
// Place the scaled bitmap so most of it sits above the baseline.
|
||||||
|
let top = baseline - target_h * 4 / 5;
|
||||||
|
for ty in 0..target_h {
|
||||||
|
let py = top + ty;
|
||||||
|
if py < clip.0 || py >= clip.1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sy = ((ty as f32 / sc) as i32).min(gh - 1);
|
||||||
|
for tx in 0..tw {
|
||||||
|
let sx = ((tx as f32 / sc) as i32).min(gw - 1);
|
||||||
|
let i = ((sy * gw + sx) * 4) as usize;
|
||||||
|
canvas.over(pen_x + tx, py, &bgra[i..i + 4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GlyphData::Color(_) => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Composite a rasterized glyph into the canvas. `origin_x`/`cell_top` are the
|
/// Composite a rasterized glyph into the canvas. `origin_x`/`cell_top` are the
|
||||||
|
|
|
||||||
|
|
@ -500,6 +500,19 @@ mod tests {
|
||||||
assert_eq!(t.grid().row_text(1), "two");
|
assert_eq!(t.grid().row_text(1), "two");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_sizing_osc66_lays_out_a_scaled_block() {
|
||||||
|
// `OSC 66 ; s=2 ; X BEL`: a 2x2 scaled block, cursor advances two cells.
|
||||||
|
let mut t = Term::new(20, 4);
|
||||||
|
feed(&mut t, b"\x1b]66;s=2;X\x07");
|
||||||
|
let g = t.grid();
|
||||||
|
assert_eq!(g.cell(0, 0).c, 'X');
|
||||||
|
let s = g.cell(0, 0).sized.as_ref().expect("leading cell is scaled");
|
||||||
|
assert_eq!((s.cols, s.rows), (2, 2));
|
||||||
|
assert!(g.cell(1, 1).flags.contains(crate::grid::Flags::SIZED_CONT));
|
||||||
|
assert_eq!(g.cursor(), (2, 0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn device_attributes_levels() {
|
fn device_attributes_levels() {
|
||||||
let mut t = Term::new(20, 4);
|
let mut t = Term::new(20, 4);
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,19 @@ impl Perform for Term {
|
||||||
self.clipboard_ops.push(ClipboardOp::Set { primary, text });
|
self.clipboard_ops.push(ClipboardOp::Set { primary, text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// OSC 66: kitty text-sizing protocol. `OSC 66 ; metadata ; text`,
|
||||||
|
// where metadata is a colon-separated key=value list and the text
|
||||||
|
// (which may itself contain ';') is rejoined and laid out scaled.
|
||||||
|
Some(&n) if n == b"66" => {
|
||||||
|
let size = beer_protocols::text_size::parse(params.get(1).copied().unwrap_or(b""));
|
||||||
|
let text = params
|
||||||
|
.get(2..)
|
||||||
|
.map(|parts| parts.join(&b';'))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Ok(text) = std::str::from_utf8(&text) {
|
||||||
|
self.grid.print_sized(text, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue