use std::borrow::Cow;
use felis_grid::{
Cell, CursorStyle, Grapheme, HAlign, ScreenBuffer, Sizing, UnderlineStyle, VAlign,
char_cell_width,
};
use felis_shaping::{CellMetrics, SizingKey};
pub use crate::appearance::{CursorPaint, PreeditSpan};
use crate::{
SelectionRange,
appearance::{
CellAppearance, CursorMarkerKind, Disposition, FrameState, RowCells, RowFrame, resolve_cell,
},
display_list::{AtlasKind, DecoKind, Decoration, GlyphDraw, Rect, RowPaint},
glyphs::{
AtlasView, ClusterGlyph, GlyphSlot, ShapeFrame, ShapedCell, cluster_base_char,
cluster_block_cols, font_style_of, sizing_key_from,
},
overlay::{ConfirmOverlay, LinkPreviewOverlay, PreeditOverlay, SearchOverlay},
palette::{
CONFIRM_BAR_BG, PREEDIT_BG, ResolvedTheme, SCROLLBAR_THUMB, SCROLLBAR_TRACK, SEARCH_BAR_BG,
SEARCH_CURRENT_BG, SEARCH_MATCH_BG,
},
};
pub const CURSOR_MARKER_PX: f32 = 2.0;
const LINK_UNDERLINE_PX: f32 = 1.0;
const PREEDIT_UNDERLINE_PX: f32 = 2.0;
const PREEDIT_ACTIVE_UNDERLINE_PX: f32 = 4.0;
const SCROLLBAR_LANE_PX: f32 = 6.0;
#[must_use]
fn sizing_scale_for_layout(sizing: Sizing) -> f32 {
f32::from(sizing.scale().max(1))
}
#[must_use]
fn sizing_glyph_scale(sizing: Sizing) -> f32 {
sizing_key_from(sizing).effective_scale()
}
#[must_use]
pub fn cursor_rect_px(
metrics: CellMetrics,
origin_px: [f32; 2],
row: u16,
(first, last): (u16, u16),
style: CursorStyle,
) -> [f32; 4] {
let (cw, ch) = (metrics.width as f32, metrics.height as f32);
let x = f32::from(first).mul_add(cw, origin_px[0]);
let y = f32::from(row).mul_add(ch, origin_px[1]);
let w = f32::from(last - first + 1) * cw;
match style {
CursorStyle::Block => [x, y, w, ch],
CursorStyle::Underline => [x, y + ch - CURSOR_MARKER_PX, w, CURSOR_MARKER_PX],
CursorStyle::Bar => [x, y, CURSOR_MARKER_PX, ch],
}
}
struct CellRef<'a> {
row: u16,
col: u16,
cell: &'a Cell,
attrs: &'a felis_grid::Attributes,
cells: &'a RowCells<'a>,
frame: &'a RowFrame,
}
pub struct CellPainter<'a, A: AtlasView> {
screen: &'a ScreenBuffer,
theme: &'a ResolvedTheme,
atlas: &'a A,
shape_frame: &'a ShapeFrame,
metrics: CellMetrics,
cw: f32,
ch: f32,
ascent: f32,
cursor_visible: bool,
viewport: u32,
viewport_max: u32,
frame: FrameState,
}
impl<'a, A: AtlasView> CellPainter<'a, A> {
#[must_use]
pub fn new(
screen: &'a ScreenBuffer,
metrics: CellMetrics,
theme: &'a ResolvedTheme,
atlas: &'a A,
shape_frame: &'a ShapeFrame,
) -> Self {
Self {
screen,
theme,
atlas,
shape_frame,
metrics,
cw: metrics.width as f32,
ch: metrics.height as f32,
ascent: f32::from(u16::try_from(metrics.ascent).unwrap_or(u16::MAX)),
cursor_visible: false,
viewport: 0,
viewport_max: 0,
frame: FrameState {
cursor: CursorPaint::default(),
selection: None,
screen_reverse: false,
preedit: None,
reserved_bottom_rows: 0,
rows: screen.rows(),
},
}
}
#[must_use]
pub const fn with_reverse_video(mut self, on: bool) -> Self {
self.frame.screen_reverse = on;
self
}
#[must_use]
pub const fn with_cursor_visible(mut self, visible: bool) -> Self {
self.cursor_visible = visible;
self
}
#[must_use]
pub const fn with_selection(mut self, selection: Option<SelectionRange>) -> Self {
self.frame.selection = selection;
self
}
#[must_use]
pub const fn with_viewport(mut self, viewport: u32, viewport_max: u32) -> Self {
self.viewport = viewport;
self.viewport_max = viewport_max;
self
}
#[must_use]
pub const fn with_reserved_bottom_rows(mut self, rows: u16) -> Self {
self.frame.reserved_bottom_rows = rows;
self
}
#[must_use]
pub const fn with_preedit(mut self, span: Option<PreeditSpan>) -> Self {
self.frame.preedit = span;
self
}
pub fn extend_screen(self, out: &mut RowPaint) {
let painter = self.resolved();
let cells = usize::from(painter.screen.rows()) * usize::from(painter.screen.cols());
out.rects.reserve(cells);
out.glyphs.reserve(cells);
for r in 0..painter.screen.rows() {
painter.extend_row(out, r);
}
painter.push_scrollbar_lane(out);
}
#[must_use]
pub fn resolved(mut self) -> Self {
self.frame = FrameState::for_screen(
self.screen,
self.cursor_visible,
self.viewport,
self.frame.selection,
self.frame.screen_reverse,
self.frame.preedit,
self.frame.reserved_bottom_rows,
);
self
}
#[must_use]
pub const fn cursor(&self) -> CursorPaint {
self.frame.cursor
}
pub fn extend_row(&self, out: &mut RowPaint, r: u16) {
let Some(cells) = RowCells::of_screen(self.screen, r) else {
return;
};
let frame = self.frame.row(r, &cells);
if !frame.painted {
return;
}
for c in 0..cells.cols {
let look = resolve_cell(&cells, &frame, self.theme, c);
if look.disposition == Disposition::Skip {
continue;
}
let cell = cells.cell(c);
let at = CellRef {
row: r,
col: c,
cell,
attrs: cells.styles.resolve(cell.style),
cells: &cells,
frame: &frame,
};
self.push_cell_chrome(out, &at, &look);
if !look.glyph_visible {
continue;
}
let shaped = self.shape_frame.cell_at(r, c);
if matches!(shaped, ShapedCell::Trailing) {
continue;
}
if let ShapedCell::Cluster { start, len, fit_px } = shaped {
self.push_cluster_cell(out, &at, look.fg, (start, len), fit_px);
continue;
}
self.push_char_cell(out, &at, look.fg, shaped);
}
}
fn origin(&self, at: &CellRef<'_>) -> [f32; 2] {
[f32::from(at.col) * self.cw, f32::from(at.row) * self.ch]
}
fn push_cell_chrome(&self, out: &mut RowPaint, at: &CellRef<'_>, look: &CellAppearance) {
let origin = self.origin(at);
out.rects.push(Rect {
origin_px: origin,
size_px: [self.cw, self.ch],
color: look.bg,
});
if let Some(color) = look.link_underline {
out.rects.push(Rect {
origin_px: [origin[0], origin[1] + self.ch - LINK_UNDERLINE_PX],
size_px: [self.cw, LINK_UNDERLINE_PX],
color,
});
}
match look.cursor_marker {
Some((CursorMarkerKind::Underline, color)) => out.rects.push(Rect {
origin_px: [origin[0], origin[1] + self.ch - CURSOR_MARKER_PX],
size_px: [self.cw, CURSOR_MARKER_PX],
color,
}),
Some((CursorMarkerKind::Bar, color)) => out.rects.push(Rect {
origin_px: origin,
size_px: [CURSOR_MARKER_PX, self.ch],
color,
}),
None => {}
}
self.push_cell_decorations(&mut out.decos, at, look);
}
fn push_cluster_cell(
&self,
out: &mut RowPaint,
at: &CellRef<'_>,
fg_color: [f32; 4],
(start, len): (u32, u16),
fit_px: u16,
) {
let sizing = self
.screen
.cell_sizing(at.row, at.col)
.copied()
.unwrap_or_default();
let base_sizing = sizing_key_from(sizing).with_style(font_style_of(at.attrs.flags));
let glyph_scale = sizing_glyph_scale(sizing);
let scaled_ascent = self.ascent * glyph_scale;
let pen = ClusterPen {
glyphs: self.shape_frame.cluster_slice(start, len),
base_sizing,
fit_px,
glyph_scale,
origin: [0.0, scaled_ascent],
color: fg_color,
};
let block_shift = self.cluster_block_shift(at, pen, sizing);
let origin = self.origin(at);
push_cluster_glyphs(
self.atlas,
self.metrics.height,
ClusterPen {
origin: [
origin[0] + block_shift[0],
origin[1] + block_shift[1] + scaled_ascent,
],
..pen
},
&mut out.glyphs,
);
}
fn cluster_block_shift(
&self,
at: &CellRef<'_>,
pen: ClusterPen<'_>,
sizing: Sizing,
) -> [f32; 2] {
let fitted = pen.fit_px > 0;
if !fitted
&& matches!(sizing.halign(), HAlign::Left)
&& matches!(sizing.valign(), VAlign::Top)
{
return [0.0, 0.0];
}
let Some([ink_x0, ink_y0, ink_x1, ink_y1]) =
cluster_ink_box(self.atlas, self.metrics.height, pen)
else {
return [0.0, 0.0];
};
let ink_w = ink_x1 - ink_x0;
let ink_h = ink_y1 - ink_y0;
let layout_scale = sizing_scale_for_layout(sizing);
let block_w =
self.cw * f32::from(cluster_block_cols(self.screen, at.row, at.col)) * layout_scale;
let block_h = self.ch * layout_scale;
let centre_x = (block_w - ink_w) / 2.0 - ink_x0;
let centre_y = (block_h - ink_h) / 2.0 - ink_y0;
let shift_x = match sizing.halign() {
HAlign::Left if fitted => centre_x,
HAlign::Left => 0.0,
HAlign::Right => block_w - ink_x1,
HAlign::Center => centre_x,
};
let shift_y = match sizing.valign() {
VAlign::Top if fitted => centre_y,
VAlign::Top => 0.0,
VAlign::Bottom => block_h - ink_y1,
VAlign::Center => centre_y,
};
[shift_x, shift_y]
}
fn push_char_cell(
&self,
out: &mut RowPaint,
at: &CellRef<'_>,
fg_color: [f32; 4],
shaped: ShapedCell,
) {
let shape_primary = match shaped {
ShapedCell::Primary {
glyph_id,
span_cols,
} => Some((glyph_id, span_cols)),
_ => None,
};
let glyph = match &at.cell.grapheme {
Grapheme::Empty | Grapheme::Spacer | Grapheme::SizedSpacer => return,
Grapheme::Char(c) if *c == felis_protocol::kitty_graphics::placeholder::PLACEHOLDER => {
return;
}
Grapheme::Ascii(b) => *b as char,
Grapheme::Char(c) => *c,
Grapheme::Cluster(id) => match cluster_base_char(self.screen, *id) {
Some(c) if c == felis_protocol::kitty_graphics::placeholder::PLACEHOLDER => {
return;
}
Some(c) => c,
None => return,
},
};
let sizing = self
.screen
.cell_sizing(at.row, at.col)
.copied()
.unwrap_or_default();
let sizing_key = sizing_key_from(sizing).with_style(font_style_of(at.attrs.flags));
let slot_lookup = match shape_primary {
Some((glyph_id, _span_cols)) => {
self.atlas
.glyph_id_slot(glyph_id, self.metrics.height, sizing_key)
}
None => self.atlas.slot(glyph, self.metrics.height, sizing_key),
};
let Some(slot) = slot_lookup else {
return;
};
let layout_scale = sizing_scale_for_layout(sizing);
let glyph_scale = sizing_glyph_scale(sizing);
let scaled_ascent = self.ascent * glyph_scale;
let bitmap_w = slot.size_px[0] as f32;
let bitmap_h = slot.size_px[1] as f32;
let override_w = if sizing.cell_width() > 0 {
f32::from(sizing.cell_width())
} else {
f32::from(char_cell_width(glyph).max(1))
};
let block_w = self.cw * override_w * layout_scale;
let block_h = self.ch * layout_scale;
let h_offset = match sizing.halign() {
HAlign::Left => slot.offset_px[0] as f32,
HAlign::Right => block_w - bitmap_w,
HAlign::Center => (block_w - bitmap_w) / 2.0,
};
let v_offset = match sizing.valign() {
VAlign::Top => scaled_ascent - slot.offset_px[1] as f32,
VAlign::Bottom => block_h - bitmap_h,
VAlign::Center => (block_h - bitmap_h) / 2.0,
};
let origin = self.origin(at);
let draw_origin = [origin[0] + h_offset, origin[1] + v_offset];
let quad = glyph_draw(slot, draw_origin, fg_color);
if shape_primary.is_some() && !slot.is_color {
self.push_sliced_by_cell(out, quad, at);
} else {
out.glyphs.push(quad);
}
}
pub fn push_scrollbar_lane(&self, out: &mut RowPaint) {
let rows_u32 = u32::from(self.screen.rows());
if self.viewport > 0 && self.viewport_max > rows_u32 {
let max_scroll = (self.viewport_max - rows_u32) as f32;
let total_w = f32::from(self.screen.cols()) * self.cw;
let total_h = f32::from(self.screen.rows()) * self.ch;
let lane_x = total_w - SCROLLBAR_LANE_PX;
out.rects.push(Rect {
origin_px: [lane_x, 0.0],
size_px: [SCROLLBAR_LANE_PX, total_h],
color: SCROLLBAR_TRACK,
});
let thumb_h_raw = total_h * (rows_u32 as f32) / (self.viewport_max as f32);
let thumb_h = thumb_h_raw.max(SCROLLBAR_LANE_PX).min(total_h);
let thumb_top_band = total_h - thumb_h;
let thumb_top = thumb_top_band * (max_scroll - self.viewport as f32) / max_scroll;
out.rects.push(Rect {
origin_px: [lane_x, thumb_top],
size_px: [SCROLLBAR_LANE_PX, thumb_h],
color: SCROLLBAR_THUMB,
});
}
}
fn push_cell_decorations(
&self,
out: &mut Vec<Decoration>,
at: &CellRef<'_>,
look: &CellAppearance,
) {
let thickness = decoration_thickness(self.ch);
let origin = self.origin(at);
let ox = origin[0];
let oy = origin[1];
let cw = self.cw;
let solid = |out: &mut Vec<Decoration>, top: f32, color: [f32; 4]| {
out.push(Decoration {
origin_px: [ox, top.clamp(oy, oy + self.ch - thickness)],
size_px: [cw, thickness],
color,
kind: DecoKind::Solid,
thickness_px: thickness,
period_px: 0.0,
});
};
if let Some((style, color)) = look.underline {
let descent_band = (self.ch - self.ascent).max(thickness);
let underline_top = (oy + self.ascent + (descent_band * 0.3).round().max(1.0))
.min(oy + self.ch - thickness)
.max(oy);
match style {
UnderlineStyle::Single => solid(out, underline_top, color),
UnderlineStyle::Double => {
solid(out, underline_top, color);
solid(out, thickness.mul_add(-2.0, underline_top), color);
}
UnderlineStyle::Dotted => out.push(Decoration {
origin_px: [ox, underline_top],
size_px: [cw, thickness],
color,
kind: DecoKind::Dotted,
thickness_px: thickness,
period_px: (thickness * 3.0).max(3.0),
}),
UnderlineStyle::Dashed => out.push(Decoration {
origin_px: [ox, underline_top],
size_px: [cw, thickness],
color,
kind: DecoKind::Dashed,
thickness_px: thickness,
period_px: (cw * 0.75).max(6.0),
}),
UnderlineStyle::Curly => {
let amplitude = thickness;
let band_h = amplitude.mul_add(2.0, thickness);
let center = thickness.mul_add(0.5, underline_top);
let band_top = band_h
.mul_add(-0.5, center)
.clamp(oy, oy + self.ch - band_h);
out.push(Decoration {
origin_px: [ox, band_top],
size_px: [cw, band_h],
color,
kind: DecoKind::Curly,
thickness_px: thickness,
period_px: cw.max(thickness * 4.0),
});
}
}
}
if let Some(color) = look.strikethrough {
let strike_top = thickness.mul_add(-0.5, oy + (self.ascent * 0.6).round());
solid(out, strike_top, color);
}
if let Some(color) = look.overline {
solid(out, oy, color);
}
}
fn push_sliced_by_cell(&self, out: &mut RowPaint, quad: GlyphDraw, at: &CellRef<'_>) {
let x0 = quad.origin_px[0];
let x1 = x0 + quad.size_px[0];
if quad.size_px[0] <= 0.0 {
out.glyphs.push(quad);
return;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let col_first_raw = (x0 / self.cw).floor().max(0.0) as u16;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let col_last_raw = ((x1 / self.cw).ceil() - 1.0).max(0.0) as u16;
let col_last = col_last_raw.min(self.screen.cols().saturating_sub(1));
let col_first = col_first_raw.min(col_last);
if col_first == col_last {
out.glyphs.push(quad);
return;
}
let fg_at = |col: u16| -> [f32; 4] {
if col == at.col {
return quad.color;
}
resolve_cell(at.cells, at.frame, self.theme, col).fg
};
#[allow(clippy::float_cmp)]
let uniform = (col_first..=col_last).all(|col| fg_at(col) == quad.color);
if uniform {
out.glyphs.push(quad);
return;
}
let w = x1 - x0;
for col in col_first..=col_last {
let sx0 = if col == col_first {
x0
} else {
f32::from(col) * self.cw
};
let sx1 = if col == col_last {
x1
} else {
f32::from(col + 1) * self.cw
};
if sx1 <= sx0 {
continue;
}
let t0 = (sx0 - x0) / w;
let t1 = (sx1 - x0) / w;
let mut strip = GlyphDraw {
origin_px: [sx0, quad.origin_px[1]],
size_px: [sx1 - sx0, quad.size_px[1]],
color: fg_at(col),
..quad
};
strip.crop_src_x(t0, t1);
out.glyphs.push(strip);
}
}
}
pub fn preedit_covered_cols(overlay: &PreeditOverlay, grid_cols: u16) -> Option<PreeditSpan> {
let (row, start) = overlay.anchor;
let col = overlay_cells(&overlay.text, start, grid_cols)
.last()?
.col_end();
Some(PreeditSpan {
row,
col_start: start,
col_end: col,
})
}
pub fn extend_preedit<A: AtlasView>(
overlay: &PreeditOverlay,
metrics: CellMetrics,
grid_rows: u16,
grid_cols: u16,
atlas: &A,
out: &mut RowPaint,
) {
let (row, start) = overlay.anchor;
if row >= grid_rows {
return;
}
let cw = metrics.width as f32;
let ch = metrics.height as f32;
let active_range = overlay.cursor.filter(|(s, e)| e > s);
let first_glyph = out.glyphs.len();
for cell in overlay_cells(&overlay.text, start, grid_cols) {
let origin = [f32::from(cell.col) * cw, f32::from(row) * ch];
let span_px = [cw * f32::from(cell.width), ch];
let underline_px =
if active_range.is_some_and(|(s, e)| cell.bytes.start < e && cell.bytes.end > s) {
PREEDIT_ACTIVE_UNDERLINE_PX
} else {
PREEDIT_UNDERLINE_PX
};
out.rects.push(Rect {
origin_px: origin,
size_px: span_px,
color: PREEDIT_BG,
});
out.rects.push(Rect {
origin_px: [origin[0], origin[1] + ch - underline_px],
size_px: [span_px[0], underline_px],
color: theme_default_fg(),
});
push_overlay_glyphs(
atlas,
metrics,
&overlay.text[cell.bytes],
origin,
&mut out.glyphs,
);
}
clip_fg_right_of(&mut out.glyphs, first_glyph, f32::from(grid_cols) * cw);
}
struct OverlayCell {
bytes: std::ops::Range<usize>,
col: u16,
width: u8,
clipped: bool,
}
impl OverlayCell {
fn col_end(&self) -> u16 {
self.col + u16::from(self.width)
}
}
fn overlay_cells(text: &str, start: u16, grid_cols: u16) -> impl Iterator<Item = OverlayCell> {
let mut col = start;
felis_grid::text_cells(text).map_while(move |(bytes, natural)| {
let left = grid_cols.checked_sub(col).filter(|&left| left > 0)?;
let width = u8::try_from(left).map_or(natural, |left| natural.min(left));
let cell = OverlayCell {
bytes,
col,
width,
clipped: width < natural,
};
col = if cell.clipped {
grid_cols
} else {
cell.col_end()
};
Some(cell)
})
}
fn push_overlay_glyphs<A: AtlasView>(
atlas: &A,
metrics: CellMetrics,
cluster: &str,
origin: [f32; 2],
out: &mut Vec<GlyphDraw>,
) {
let ascent = f32::from(u16::try_from(metrics.ascent).unwrap_or(u16::MAX));
let mut chars = cluster.chars();
let Some(base) = chars.next() else {
return;
};
if chars.next().is_some()
&& let Some(glyphs) = atlas.overlay_cluster(cluster).filter(|g| !g.is_empty())
{
push_cluster_glyphs(
atlas,
metrics.height,
ClusterPen {
glyphs,
base_sizing: SizingKey::default(),
fit_px: 0,
glyph_scale: 1.0,
origin: [origin[0], origin[1] + ascent],
color: theme_default_fg(),
},
out,
);
return;
}
if let Some(slot) = atlas.slot(base, metrics.height, SizingKey::default()) {
out.push(glyph_draw(
slot,
[
origin[0] + slot.offset_px[0] as f32,
origin[1] + ascent - slot.offset_px[1] as f32,
],
theme_default_fg(),
));
}
}
#[derive(Clone, Copy)]
struct ClusterPen<'g> {
glyphs: &'g [ClusterGlyph],
base_sizing: SizingKey,
fit_px: u16,
glyph_scale: f32,
origin: [f32; 2],
color: [f32; 4],
}
fn cluster_glyph_quads<'g, A: AtlasView>(
atlas: &'g A,
cell_height: u32,
pen: ClusterPen<'g>,
) -> impl Iterator<Item = (GlyphSlot, [f32; 2])> + 'g {
pen.glyphs
.iter()
.scan(0.0f32, move |x, cg| {
let key = pen.base_sizing.with_font_id(usize::from(cg.font_id));
let slot = match pen.fit_px {
0 => atlas.glyph_id_slot(cg.glyph_id, cell_height, key),
px => atlas.fitted_glyph_id_slot(cg.glyph_id, px, cell_height, key),
};
let quad = slot.map(|slot| {
let x_base = pen.origin[0] + *x;
let gx = cg.x_offset_px.mul_add(pen.glyph_scale, x_base) + slot.offset_px[0] as f32;
let y_base = pen.origin[1] - slot.offset_px[1] as f32;
let gy = cg.y_offset_px.mul_add(-pen.glyph_scale, y_base);
(slot, [gx, gy])
});
*x = cg.advance_px.mul_add(pen.glyph_scale, *x);
Some(quad)
})
.flatten()
}
fn cluster_ink_box<A: AtlasView>(
atlas: &A,
cell_height: u32,
pen: ClusterPen<'_>,
) -> Option<[f32; 4]> {
cluster_glyph_quads(atlas, cell_height, pen)
.map(|(slot, [x0, y0])| {
[
x0,
y0,
x0 + slot.size_px[0] as f32,
y0 + slot.size_px[1] as f32,
]
})
.reduce(|[a, b, c, d], [x0, y0, x1, y1]| [a.min(x0), b.min(y0), c.max(x1), d.max(y1)])
}
const fn glyph_draw(slot: GlyphSlot, origin_px: [f32; 2], color: [f32; 4]) -> GlyphDraw {
let [x, y] = slot.atlas_px;
let [w, h] = slot.size_px;
GlyphDraw {
origin_px,
size_px: [w as f32, h as f32],
atlas: if slot.is_color {
AtlasKind::Color
} else {
AtlasKind::Mono
},
src_min_px: [x as f32, y as f32],
src_max_px: [(x + w) as f32, (y + h) as f32],
color,
}
}
fn push_cluster_glyphs<A: AtlasView>(
atlas: &A,
cell_height: u32,
pen: ClusterPen<'_>,
out: &mut Vec<GlyphDraw>,
) {
out.extend(
cluster_glyph_quads(atlas, cell_height, pen)
.map(|(slot, origin_px)| glyph_draw(slot, origin_px, pen.color)),
);
}
const fn theme_default_fg() -> [f32; 4] {
[1.0, 1.0, 1.0, 1.0]
}
pub fn extend_search<A: AtlasView>(
overlay: &SearchOverlay,
metrics: CellMetrics,
grid_rows: u16,
grid_cols: u16,
draw_bar: bool,
atlas: &A,
out: &mut RowPaint,
) {
if grid_rows == 0 || grid_cols == 0 {
return;
}
let cw = metrics.width as f32;
let ch = metrics.height as f32;
for hit in &overlay.visible_hits {
if hit.row >= grid_rows {
continue;
}
let col_start = hit.col_start.min(grid_cols);
let col_end = hit.col_end.min(grid_cols);
if col_end <= col_start {
continue;
}
let color = if hit.is_current {
SEARCH_CURRENT_BG
} else {
SEARCH_MATCH_BG
};
out.rects.push(Rect {
origin_px: [f32::from(col_start) * cw, f32::from(hit.row) * ch],
size_px: [f32::from(col_end - col_start) * cw, ch],
color,
});
}
if !draw_bar || overlay.label.is_empty() {
return;
}
push_bottom_bar(
&overlay.label,
SEARCH_BAR_BG,
metrics,
grid_rows,
grid_cols,
atlas,
out,
);
}
pub fn extend_confirm<A: AtlasView>(
overlay: &ConfirmOverlay,
metrics: CellMetrics,
grid_rows: u16,
grid_cols: u16,
atlas: &A,
out: &mut RowPaint,
) {
if grid_rows == 0 || grid_cols == 0 || overlay.label.is_empty() {
return;
}
push_bottom_bar(
&overlay.label,
CONFIRM_BAR_BG,
metrics,
grid_rows,
grid_cols,
atlas,
out,
);
}
pub fn extend_link_preview<A: AtlasView>(
overlay: &LinkPreviewOverlay,
metrics: CellMetrics,
grid_rows: u16,
grid_cols: u16,
atlas: &A,
out: &mut RowPaint,
) {
if grid_rows == 0 || grid_cols == 0 || overlay.text.is_empty() {
return;
}
push_bottom_bar(
&fit_bar_text_to_cells(&overlay.text, grid_cols),
SEARCH_BAR_BG,
metrics,
grid_rows,
grid_cols,
atlas,
out,
);
}
pub fn clip_cells_above(out: &mut RowPaint, max_y: f32) {
out.rects.retain_mut(|quad| {
clip_extent_at(quad.origin_px[1], &mut quad.size_px[1], max_y).is_some()
});
out.glyphs.retain_mut(|quad| {
let Some(kept) = clip_extent_at(quad.origin_px[1], &mut quad.size_px[1], max_y) else {
return false;
};
quad.crop_src_y(0.0, kept);
true
});
out.decos.retain_mut(|quad| {
clip_extent_at(quad.origin_px[1], &mut quad.size_px[1], max_y).is_some()
});
}
fn clip_fg_right_of(quads: &mut Vec<GlyphDraw>, from: usize, max_x: f32) {
let mut i = from;
while i < quads.len() {
let quad = &mut quads[i];
let Some(kept) = clip_extent_at(quad.origin_px[0], &mut quad.size_px[0], max_x) else {
quads.remove(i);
continue;
};
quad.crop_src_x(0.0, kept);
i += 1;
}
}
#[must_use]
pub fn clip_extent_at(origin: f32, extent: &mut f32, max: f32) -> Option<f32> {
if *extent <= 0.0 {
return None;
}
if origin + *extent <= max {
return Some(1.0);
}
let visible = max - origin;
if visible <= 0.0 {
return None;
}
let kept = visible / *extent;
*extent = visible;
Some(kept)
}
pub const BAR_ELLIPSIS: char = '…';
fn fit_bar_text_to_cells(text: &str, cols: u16) -> Cow<'_, str> {
let width: u32 = felis_grid::text_cells(text)
.map(|(_, w)| u32::from(w))
.sum();
if width <= u32::from(cols) {
return Cow::Borrowed(text);
}
let head_end = overlay_cells(text, 0, cols.saturating_sub(1))
.take_while(|cell| !cell.clipped)
.last()
.map_or(0, |cell| cell.bytes.end);
let mut head = text[..head_end].to_owned();
head.push(BAR_ELLIPSIS);
Cow::Owned(head)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BottomBarClaim {
Free,
Chrome,
LinkPreview,
}
impl BottomBarClaim {
#[must_use]
pub const fn reserved_rows(self) -> u16 {
match self {
Self::Free => 0,
Self::Chrome | Self::LinkPreview => 1,
}
}
#[must_use]
pub const fn draw_link_preview(self) -> bool {
matches!(self, Self::LinkPreview)
}
}
#[must_use]
pub const fn bottom_bar_claim(
chrome: ChromeBar,
preedit_active: bool,
link_preview_active: bool,
) -> BottomBarClaim {
if !matches!(chrome, ChromeBar::None) {
return BottomBarClaim::Chrome;
}
if link_preview_active && !preedit_active {
return BottomBarClaim::LinkPreview;
}
BottomBarClaim::Free
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChromeBar {
None,
Search,
Confirm,
}
impl ChromeBar {
#[must_use]
pub const fn from_flags(search_active: bool, confirm_active: bool) -> Self {
if confirm_active {
Self::Confirm
} else if search_active {
Self::Search
} else {
Self::None
}
}
}
fn push_bottom_bar<A: AtlasView>(
label: &str,
bar_bg: [f32; 4],
metrics: CellMetrics,
grid_rows: u16,
grid_cols: u16,
atlas: &A,
out: &mut RowPaint,
) {
let cw = metrics.width as f32;
let ch = metrics.height as f32;
let bar_row = grid_rows - 1;
let bar_origin_y = f32::from(bar_row) * ch;
out.rects.push(Rect {
origin_px: [0.0, bar_origin_y],
size_px: [f32::from(grid_cols) * cw, ch],
color: bar_bg,
});
let first_glyph = out.glyphs.len();
for cell in overlay_cells(label, 0, grid_cols) {
let origin = [f32::from(cell.col) * cw, bar_origin_y];
push_overlay_glyphs(atlas, metrics, &label[cell.bytes], origin, &mut out.glyphs);
}
clip_fg_right_of(&mut out.glyphs, first_glyph, f32::from(grid_cols) * cw);
}
fn decoration_thickness(ch: f32) -> f32 {
(ch / 16.0).round().max(1.0)
}
#[cfg(test)]
mod tests {
#![allow(clippy::float_cmp)]
use std::collections::HashMap;
#[cfg(not(windows))]
use std::num::NonZeroU32;
#[cfg(not(windows))]
use crate::glyphs::{GlyphIndex, GridWalker};
use crate::{
appearance::{cell_contains_bidi_override, dim_toward_bg},
overlay::SearchHitSpan,
palette::{BIDI_MARKER_BG, BIDI_MARKER_FG, SELECTION_BG, Theme, resolve_fg},
};
use felis_grid::{AttrFlags, Color, Grid};
#[cfg(not(windows))]
use felis_shaping::{FontStack, FontStyle, Shaper};
use felis_vt::Parser;
use super::*;
struct MockAtlas {
map: HashMap<char, GlyphSlot>,
gid_map: HashMap<felis_shaping::GlyphId, GlyphSlot>,
fitted_map: HashMap<(felis_shaping::GlyphId, u16), GlyphSlot>,
clusters: HashMap<String, Vec<ClusterGlyph>>,
}
impl MockAtlas {
fn full(slot: GlyphSlot) -> Self {
let mut map = HashMap::new();
for b in 0x21u8..=0x7Eu8 {
map.insert(b as char, slot);
}
Self {
map,
gid_map: HashMap::new(),
fitted_map: HashMap::new(),
clusters: HashMap::new(),
}
}
}
impl AtlasView for MockAtlas {
fn slot(&self, glyph: char, _height: u32, _sizing: SizingKey) -> Option<GlyphSlot> {
self.map.get(&glyph).copied()
}
fn glyph_id_slot(
&self,
glyph_id: felis_shaping::GlyphId,
_cell_height_px: u32,
_sizing: SizingKey,
) -> Option<GlyphSlot> {
self.gid_map.get(&glyph_id).copied()
}
fn fitted_glyph_id_slot(
&self,
glyph_id: felis_shaping::GlyphId,
px: u16,
_cell_height_px: u32,
_sizing: SizingKey,
) -> Option<GlyphSlot> {
self.fitted_map.get(&(glyph_id, px)).copied()
}
fn overlay_cluster(&self, text: &str) -> Option<&[ClusterGlyph]> {
self.clusters.get(text).map(Vec::as_slice)
}
fn sheet_side(&self, _sheet: AtlasKind) -> u32 {
80
}
}
fn metrics() -> CellMetrics {
CellMetrics {
width: 8,
height: 16,
ascent: 12,
}
}
fn slot() -> GlyphSlot {
GlyphSlot {
atlas_px: [0, 0],
size_px: [6, 10],
offset_px: [1, 10],
is_color: false,
}
}
fn drive(rows: u16, cols: u16, bytes: &[u8]) -> Grid {
let mut g = Grid::new(rows, cols);
let mut p = Parser::new();
p.advance(&mut g, bytes);
g
}
fn build_instances<A: AtlasView>(
screen: &ScreenBuffer,
metrics: CellMetrics,
theme: &Theme,
atlas: &A,
) -> RowPaint {
build_instances_with_reverse(screen, metrics, theme, atlas, false)
}
fn build_instances_with_reverse<A: AtlasView>(
screen: &ScreenBuffer,
metrics: CellMetrics,
theme: &Theme,
atlas: &A,
screen_reverse: bool,
) -> RowPaint {
let mut buffers = RowPaint::default();
CellPainter::new(
screen,
metrics,
&ResolvedTheme::new(theme),
atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_viewport(0, u32::from(screen.rows()))
.with_reverse_video(screen_reverse)
.extend_screen(&mut buffers);
buffers
}
#[test]
fn cursor_rect_spans_both_columns_of_a_wide_character() {
let metrics = CellMetrics {
width: 10,
height: 20,
ascent: 16,
};
let span = (3, 4);
assert_eq!(
cursor_rect_px(metrics, [5.0, 0.0], 1, span, CursorStyle::Block),
[35.0, 20.0, 20.0, 20.0]
);
assert_eq!(
cursor_rect_px(metrics, [5.0, 0.0], 1, span, CursorStyle::Underline),
[35.0, 40.0 - CURSOR_MARKER_PX, 20.0, CURSOR_MARKER_PX]
);
assert_eq!(
cursor_rect_px(metrics, [5.0, 0.0], 1, span, CursorStyle::Bar),
[35.0, 20.0, CURSOR_MARKER_PX, 20.0]
);
}
#[test]
fn osc_66_valign_bottom_drops_bitmap_to_block_bottom() {
let g = drive(1, 4, b"\x1b]66;v=1;A\x07");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(buffers.glyphs.len(), 1);
assert_eq!(buffers.glyphs[0].origin_px, [1.0, 6.0]);
}
#[test]
fn osc_66_halign_right_with_w_override_pushes_bitmap_to_block_right_edge() {
let g = drive(1, 4, b"\x1b]66;w=3:h=1;A\x07");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(buffers.glyphs.len(), 1);
assert_eq!(buffers.glyphs[0].origin_px, [18.0, 2.0]);
}
#[test]
fn osc_66_center_alignment_centers_the_bitmap_in_the_block() {
let g = drive(1, 4, b"\x1b]66;w=3:h=2:v=2;A\x07");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(buffers.glyphs.len(), 1);
assert_eq!(buffers.glyphs[0].origin_px, [9.0, 3.0]);
}
#[test]
fn osc_66_fractional_scale_shrinks_the_glyph_without_shrinking_the_block() {
let atlas = MockAtlas::full(slot());
let integer = drive(2, 4, b"\x1b]66;s=2;A\x07");
let integer = build_instances(integer.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(integer.glyphs.len(), 1);
assert_eq!(integer.glyphs[0].origin_px, [1.0, 14.0]);
let fractional = drive(2, 4, b"\x1b]66;s=2:n=1:d=2;A\x07");
let fractional = build_instances(fractional.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(fractional.glyphs.len(), 1);
assert_eq!(fractional.glyphs[0].origin_px, [1.0, 2.0]);
let bottom = drive(2, 4, b"\x1b]66;s=2:n=1:d=2:v=1;A\x07");
let bottom = build_instances(bottom.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(bottom.glyphs.len(), 1);
assert_eq!(bottom.glyphs[0].origin_px, [1.0, 22.0]);
}
#[test]
fn osc_66_sized_spacer_cells_emit_bg_but_not_fg() {
let g = drive(2, 4, b"\x1b]66;s=2;X\x07");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(buffers.rects.len(), 8);
assert_eq!(buffers.glyphs.len(), 1);
let inst = &buffers.glyphs[0];
assert_eq!(inst.origin_px, [1.0, 14.0]);
assert_eq!(inst.size_px, [6.0, 10.0]);
}
#[track_caller]
fn assert_px_close(got: f32, want: f32) {
assert!(
(got - want).abs() < 1e-5,
"source px mismatch: got {got}, want {want}"
);
}
fn ligature_slot(left: i32) -> GlyphSlot {
GlyphSlot {
atlas_px: [0, 0],
size_px: [24, 10],
offset_px: [left, 10],
is_color: false,
}
}
fn build_shaped(screen: &ScreenBuffer, atlas: &MockAtlas, frame: &ShapeFrame) -> RowPaint {
let mut buffers = RowPaint::default();
CellPainter::new(
screen,
metrics(),
&ResolvedTheme::new(&Theme::default()),
atlas,
frame,
)
.with_cursor_visible(false)
.with_viewport(0, u32::from(screen.rows()))
.extend_screen(&mut buffers);
buffers
}
#[test]
fn shaped_quad_slices_per_cell_when_colors_differ() {
let g = drive(1, 3, b"<-\x1b[31m>");
let mut atlas = MockAtlas::full(slot());
atlas.gid_map.insert(42, ligature_slot(0));
let frame = ShapeFrame::from_cells(
vec![
ShapedCell::Primary {
glyph_id: 42,
span_cols: 3,
},
ShapedCell::Trailing,
ShapedCell::Trailing,
],
3,
);
let buffers = build_shaped(g.screen(), &atlas, &frame);
assert_eq!(buffers.glyphs.len(), 3, "one strip per covered cell");
for (i, inst) in buffers.glyphs.iter().enumerate() {
let i_f = i as f32;
assert_eq!(inst.origin_px, [8.0 * i_f, 2.0]);
assert_eq!(inst.size_px, [8.0, 10.0]);
assert_px_close(inst.src_min_px[0], 8.0 * i_f);
assert_px_close(inst.src_max_px[0], 8.0 * (i_f + 1.0));
assert_eq!(inst.src_min_px[1], 0.0);
assert_eq!(inst.src_max_px[1], 10.0);
}
let red = resolve_fg(Color::Indexed(1), &ResolvedTheme::new(&Theme::default()));
assert_eq!(buffers.glyphs[0].color, buffers.glyphs[1].color);
assert_eq!(buffers.glyphs[2].color, red);
assert_ne!(buffers.glyphs[0].color, red);
}
#[test]
fn shaped_quad_stays_unsliced_when_colors_match() {
let g = drive(1, 3, b"<->");
let mut atlas = MockAtlas::full(slot());
atlas.gid_map.insert(42, ligature_slot(0));
let frame = ShapeFrame::from_cells(
vec![
ShapedCell::Primary {
glyph_id: 42,
span_cols: 3,
},
ShapedCell::Trailing,
ShapedCell::Trailing,
],
3,
);
let buffers = build_shaped(g.screen(), &atlas, &frame);
assert_eq!(buffers.glyphs.len(), 1, "uniform colors must not slice");
let inst = &buffers.glyphs[0];
assert_eq!(inst.origin_px, [0.0, 2.0]);
assert_eq!(inst.size_px, [24.0, 10.0]);
assert_eq!(inst.src_min_px, [0.0, 0.0]);
assert_eq!(inst.src_max_px, [24.0, 10.0]);
}
#[test]
fn shaped_quad_with_negative_bearing_slices_back_across_cells() {
let g = drive(1, 3, b"\x1b[31m<\x1b[39m->");
let mut atlas = MockAtlas::full(slot());
atlas.gid_map.insert(43, ligature_slot(-16));
let frame = ShapeFrame::from_cells(
vec![
ShapedCell::Primary {
glyph_id: 41,
span_cols: 1,
},
ShapedCell::Primary {
glyph_id: 41,
span_cols: 1,
},
ShapedCell::Primary {
glyph_id: 43,
span_cols: 1,
},
],
3,
);
let buffers = build_shaped(g.screen(), &atlas, &frame);
assert_eq!(buffers.glyphs.len(), 3, "one strip per reached-back cell");
let red = resolve_fg(Color::Indexed(1), &ResolvedTheme::new(&Theme::default()));
assert_eq!(buffers.glyphs[0].origin_px, [0.0, 2.0]);
assert_eq!(
buffers.glyphs[0].color, red,
"reached-back cell keeps its own red"
);
assert_eq!(
buffers.glyphs[1].color, buffers.glyphs[2].color,
"default-fg cells match"
);
assert_ne!(buffers.glyphs[1].color, red);
}
#[test]
fn cluster_cell_emits_one_instance_per_glyph_stacked_by_gpos() {
let g = drive(1, 4, b"x");
let mut atlas = MockAtlas::full(slot());
atlas.gid_map.insert(100, slot());
atlas.gid_map.insert(200, slot());
let frame = ShapeFrame::from_cluster_cells(
vec![
ShapedCell::Cluster {
start: 0,
len: 2,
fit_px: 0,
},
ShapedCell::None,
ShapedCell::None,
ShapedCell::None,
],
4,
vec![
ClusterGlyph {
glyph_id: 100,
font_id: 0,
advance_px: 8.0,
x_offset_px: 0.0,
y_offset_px: 0.0,
},
ClusterGlyph {
glyph_id: 200,
font_id: 0,
advance_px: 0.0,
x_offset_px: -8.0,
y_offset_px: 4.0,
},
],
);
let buffers = build_shaped(g.screen(), &atlas, &frame);
assert_eq!(buffers.glyphs.len(), 2, "one instance per cluster glyph");
assert_eq!(buffers.glyphs[0].origin_px, [1.0, 2.0]);
assert_eq!(buffers.glyphs[1].origin_px, [1.0, -2.0]);
assert_eq!(
buffers.glyphs[0].origin_px[0], buffers.glyphs[1].origin_px[0],
"mark shares the base's x column",
);
assert!(
buffers.glyphs[1].origin_px[1] < buffers.glyphs[0].origin_px[1],
"mark sits above its base (smaller screen y)",
);
}
#[test]
fn sized_cluster_scales_pen_and_gpos_by_glyph_scale() {
let g = drive(2, 4, b"\x1b]66;s=2;X\x07");
let mut atlas = MockAtlas::full(slot());
atlas.gid_map.insert(100, slot());
atlas.gid_map.insert(200, slot());
let glyphs = vec![
ClusterGlyph {
glyph_id: 100,
font_id: 0,
advance_px: 8.0,
x_offset_px: 0.0,
y_offset_px: 0.0,
},
ClusterGlyph {
glyph_id: 200,
font_id: 0,
advance_px: 0.0,
x_offset_px: -8.0,
y_offset_px: 4.0,
},
];
let mut cells = vec![ShapedCell::None; 8];
cells[0] = ShapedCell::Cluster {
start: 0,
len: 2,
fit_px: 0,
};
let frame = ShapeFrame::from_cluster_cells(cells, 4, glyphs);
let buffers = build_shaped(g.screen(), &atlas, &frame);
assert_eq!(buffers.glyphs.len(), 2, "one instance per cluster glyph");
assert_eq!(buffers.glyphs[0].origin_px, [1.0, 14.0]);
assert_eq!(buffers.glyphs[1].origin_px, [1.0, 6.0]);
let rise = buffers.glyphs[0].origin_px[1] - buffers.glyphs[1].origin_px[1];
assert!(
(rise - 8.0).abs() < f32::EPSILON,
"mark rises 2× the unscaled 4 px"
);
assert_eq!(
buffers.glyphs[0].origin_px[0], buffers.glyphs[1].origin_px[0],
"mark still shares the base's x column at scale",
);
}
#[test]
fn sized_cluster_alignment_uses_the_union_ink_box() {
let g = drive(1, 4, b"\x1b]66;w=2:h=1:v=1;X\x07");
let mut atlas = MockAtlas::full(slot());
atlas.gid_map.insert(100, slot());
atlas.gid_map.insert(
200,
GlyphSlot {
atlas_px: [0, 0],
size_px: [10, 12],
offset_px: [0, 11],
is_color: false,
},
);
let glyphs = vec![
ClusterGlyph {
glyph_id: 100,
font_id: 0,
advance_px: 8.0,
x_offset_px: 0.0,
y_offset_px: 0.0,
},
ClusterGlyph {
glyph_id: 200,
font_id: 0,
advance_px: 0.0,
x_offset_px: -8.0,
y_offset_px: 0.0,
},
];
let mut cells = vec![ShapedCell::None; 4];
cells[0] = ShapedCell::Cluster {
start: 0,
len: 2,
fit_px: 0,
};
let frame = ShapeFrame::from_cluster_cells(cells, 4, glyphs);
let buffers = build_shaped(g.screen(), &atlas, &frame);
assert_eq!(buffers.glyphs.len(), 2, "one instance per cluster glyph");
assert_eq!(
buffers.glyphs[0].origin_px,
[7.0, 5.0],
"base rides the shift"
);
assert_eq!(
buffers.glyphs[1].origin_px,
[6.0, 4.0],
"the mark's ink ends flush with the block's right/bottom edges",
);
}
#[test]
fn a_cluster_block_spans_the_cells_the_grid_gives_it() {
let cols = |bytes: &[u8], col| {
let g = drive(2, 8, bytes);
assert!(!matches!(
g.screen().cell(0, col).map(|c| c.grapheme),
Some(Grapheme::Empty)
));
cluster_block_cols(g.screen(), 0, col)
};
assert_eq!(cols("\u{2764}\u{FE0F}".as_bytes(), 0), 2, "widened");
assert_eq!(cols("\x1b[8G\u{2764}\u{FE0F}".as_bytes(), 7), 1, "refused");
assert_eq!(
cols("\x1b]66;w=3;e\u{301}\x07".as_bytes(), 0),
3,
"OSC 66 w"
);
assert_eq!(
cols("\x1b]66;s=2;\u{2764}\u{FE0F}\x07".as_bytes(), 0),
2,
"sized, VS16 in the run"
);
assert_eq!(
cols("\x1b]66;s=2;\u{2764}\x07\u{FE0F}".as_bytes(), 0),
1,
"sized, VS16 printed after"
);
assert_eq!(
cols("\x1b]66;s=2;\u{5B57}\u{301}\x07".as_bytes(), 0),
2,
"sized, wide base"
);
}
#[test]
fn a_centred_cluster_centres_in_its_grid_span() {
let g = drive(1, 4, "\x1b]66;h=2;\u{2764}\u{FE0F}\x07".as_bytes());
assert_eq!(g.screen().char_span(0, 0), (0, 1), "the grid widened it");
let mut atlas = MockAtlas::full(slot());
atlas.gid_map.insert(100, slot());
let glyph = ClusterGlyph {
glyph_id: 100,
font_id: 0,
advance_px: 8.0,
x_offset_px: 0.0,
y_offset_px: 0.0,
};
let mut cells = vec![ShapedCell::None; 4];
cells[0] = ShapedCell::Cluster {
start: 0,
len: 1,
fit_px: 0,
};
let frame = ShapeFrame::from_cluster_cells(cells, 4, vec![glyph]);
let buffers = build_shaped(g.screen(), &atlas, &frame);
assert_eq!(buffers.glyphs[0].origin_px[0], 5.0);
}
#[test]
fn a_fitted_cluster_draws_its_fitted_slots_centred() {
let g = drive(1, 4, b"x");
let mut atlas = MockAtlas::full(slot());
atlas.gid_map.insert(100, slot());
atlas.fitted_map.insert(
(100, 9),
GlyphSlot {
size_px: [4, 6],
offset_px: [3, 9],
..slot()
},
);
let glyph = ClusterGlyph {
glyph_id: 100,
font_id: 0,
advance_px: 8.0,
x_offset_px: 0.0,
y_offset_px: 0.0,
};
let mut cells = vec![ShapedCell::None; 4];
cells[0] = ShapedCell::Cluster {
start: 0,
len: 1,
fit_px: 9,
};
let frame = ShapeFrame::from_cluster_cells(cells, 4, vec![glyph]);
let buffers = build_shaped(g.screen(), &atlas, &frame);
assert_eq!(buffers.glyphs.len(), 1);
assert_eq!(buffers.glyphs[0].size_px, [4.0, 6.0], "the fitted slot");
assert_eq!(buffers.glyphs[0].origin_px, [2.0, 5.0]);
}
#[test]
fn single_glyph_cluster_matches_char_placement() {
let char_grid = drive(1, 4, b"A");
let atlas = MockAtlas::full(slot());
let char_buffers =
build_instances(char_grid.screen(), metrics(), &Theme::default(), &atlas);
let char_inst = char_buffers.glyphs[0];
let g = drive(1, 4, b"x");
let mut cluster_atlas = MockAtlas::full(slot());
cluster_atlas.gid_map.insert(100, slot());
let frame = ShapeFrame::from_cluster_cells(
vec![
ShapedCell::Cluster {
start: 0,
len: 1,
fit_px: 0,
},
ShapedCell::None,
ShapedCell::None,
ShapedCell::None,
],
4,
vec![ClusterGlyph {
glyph_id: 100,
font_id: 0,
advance_px: 8.0,
x_offset_px: 0.0,
y_offset_px: 0.0,
}],
);
let buffers = build_shaped(g.screen(), &cluster_atlas, &frame);
assert_eq!(
buffers.glyphs.len(),
1,
"single-glyph cluster emits one instance"
);
assert_eq!(buffers.glyphs[0].origin_px, char_inst.origin_px);
assert_eq!(buffers.glyphs[0].size_px, char_inst.size_px);
assert_eq!(buffers.glyphs[0].src_min_px, char_inst.src_min_px);
assert_eq!(buffers.glyphs[0].src_max_px, char_inst.src_max_px);
}
#[test]
fn empty_grid_emits_one_bg_per_cell_and_no_fg() {
let g = drive(2, 3, b"");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(buffers.rects.len(), 6);
assert_eq!(buffers.glyphs, Vec::<GlyphDraw>::new());
let last = buffers.rects.last().unwrap();
assert_eq!(last.origin_px, [16.0, 16.0]);
assert_eq!(last.size_px, [8.0, 16.0]);
}
#[test]
fn ascii_glyph_emits_a_fg_instance_at_baseline_offset() {
let g = drive(1, 3, b"A");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(buffers.rects.len(), 3);
assert_eq!(buffers.glyphs.len(), 1);
let inst = &buffers.glyphs[0];
assert_eq!(inst.origin_px, [1.0, 2.0]);
assert_eq!(inst.size_px, [6.0, 10.0]);
assert_eq!(inst.src_min_px, [0.0, 0.0]);
assert_eq!(inst.src_max_px, [6.0, 10.0]);
}
#[test]
fn reverse_video_swaps_fg_and_bg_colors() {
let g = drive(1, 2, b"\x1b[?25lA\x1b[7mB");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let theme = Theme::default();
assert_eq!(buffers.glyphs[0].color, theme.fg);
assert_eq!(buffers.rects[0].color, theme.bg);
assert_eq!(buffers.glyphs[1].color, theme.bg);
assert_eq!(buffers.rects[1].color, theme.fg);
}
#[test]
fn decscnm_xors_with_sgr_reverse_over_the_resolved_pair() {
let g = drive(1, 4, b"\x1b[?25lA\x1b[7mB\x1b[0;38;5;1;48;5;4mC\x1b[7mD");
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let resolved = ResolvedTheme::new(&theme);
let pal = |i: u8| resolve_fg(Color::Indexed(i), &resolved);
let (dfg, dbg) = (theme.fg, theme.bg);
let (efg, ebg) = (pal(1), pal(4));
let plain = build_instances_with_reverse(g.screen(), metrics(), &theme, &atlas, false);
let pairs: Vec<_> = (0..4)
.map(|i| (plain.glyphs[i].color, plain.rects[i].color))
.collect();
assert_eq!(
pairs,
vec![(dfg, dbg), (dbg, dfg), (efg, ebg), (ebg, efg)],
"without DECSCNM only SGR 7 swaps",
);
let reversed = build_instances_with_reverse(g.screen(), metrics(), &theme, &atlas, true);
let pairs: Vec<_> = (0..4)
.map(|i| (reversed.glyphs[i].color, reversed.rects[i].color))
.collect();
assert_eq!(
pairs,
vec![(dbg, dfg), (dfg, dbg), (ebg, efg), (efg, ebg)],
"DECSCNM flips every cell, SGR 7 cells back to normal",
);
}
#[test]
fn atlas_miss_drops_only_the_fg_instance() {
let mut atlas_map = HashMap::new();
atlas_map.insert('A', slot());
let atlas = MockAtlas {
map: atlas_map,
gid_map: HashMap::new(),
fitted_map: HashMap::new(),
clusters: HashMap::new(),
};
let g = drive(1, 2, b"AB");
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(buffers.rects.len(), 2, "every cell still has a bg");
assert_eq!(buffers.glyphs.len(), 1, "B produced no glyph instance");
}
#[test]
fn sgr_indexed_color_is_resolved_via_palette() {
let g = drive(1, 1, b"\x1b[?25l\x1b[31mZ");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let expected = resolve_fg(Color::Indexed(1), &ResolvedTheme::new(&Theme::default()));
assert_eq!(buffers.glyphs[0].color, expected);
}
#[test]
fn cursor_cell_renders_as_reverse_video_block() {
let g = drive(1, 3, b"A");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let theme = Theme::default();
assert_eq!(g.cursor().row, 0);
assert_eq!(g.cursor().col, 1);
assert_eq!(
buffers.rects[1].color, theme.fg,
"cursor cell bg must be the theme fg (reverse video)"
);
assert_eq!(buffers.rects[0].color, theme.bg);
assert_eq!(buffers.glyphs[0].color, theme.fg);
}
#[test]
fn hidden_cursor_skips_reverse_video_swap() {
let g = drive(1, 3, b"\x1b[?25lA");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let theme = Theme::default();
assert!(!g.cursor().visible);
assert_eq!(buffers.rects[1].color, theme.bg);
}
#[test]
fn bidi_override_cell_paints_with_warning_marker() {
let mut g = Grid::new(1, 4);
let mut p = Parser::new();
p.advance(&mut g, "\x1b[?25lA\u{202E}B".as_bytes());
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let mut marked = false;
for r in 0..g.rows() {
for c in 0..g.cols() {
let Some(cell) = g.cell(r, c) else { continue };
if cell_contains_bidi_override(cell.grapheme, g.screen().cluster_table()) {
let idx = usize::from(r) * usize::from(g.cols()) + usize::from(c);
assert_eq!(
buffers.rects[idx].color, BIDI_MARKER_BG,
"bidi-override cell at ({r},{c}) must paint with marker bg"
);
marked = true;
}
}
}
assert!(
marked,
"parser should have placed the RLO in some cell — neither Char nor Cluster found"
);
}
#[test]
fn bidi_override_at_column_zero_marks_the_character_after_it() {
let g = drive(1, 4, "\x1b[?25l\u{202E}AB".as_bytes());
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let buffers = build_instances(g.screen(), metrics(), &theme, &atlas);
assert_eq!(buffers.rects[0].color, BIDI_MARKER_BG);
assert_eq!(buffers.rects[1].color, theme.bg);
}
#[test]
fn ordinary_text_does_not_trigger_bidi_marker() {
let mut g = Grid::new(1, 8);
let mut p = Parser::new();
p.advance(&mut g, b"\x1b[?25lhello");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let theme = Theme::default();
for inst in &buffers.rects {
assert_ne!(
inst.color, BIDI_MARKER_BG,
"non-bidi text must not paint the warning marker"
);
}
assert_eq!(buffers.rects[0].color, theme.bg);
}
#[test]
fn bidi_marker_overrides_cursor_swap() {
let g = drive(1, 4, "A\u{202E}\x1b[1;1H".as_bytes());
assert!(cell_contains_bidi_override(
g.cell(0, 0).unwrap().grapheme,
g.screen().cluster_table()
));
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(buffers.rects[0].color, BIDI_MARKER_BG);
assert!(
!buffers.glyphs.is_empty(),
"the `A` under the cursor draws a glyph"
);
assert!(
buffers.glyphs.iter().all(|f| f.color == BIDI_MARKER_FG),
"the glyph on the cursor cell keeps the marker fg"
);
}
#[test]
fn underline_cursor_appends_a_thin_bar_at_the_bottom_of_the_cell() {
let g = drive(1, 3, b"\x1b[4 qA");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let theme = Theme::default();
assert_eq!(g.cursor().col, 1);
assert_eq!(buffers.rects[0].color, theme.bg, "cell 0 normal");
assert_eq!(buffers.rects[1].color, theme.bg, "cursor cell bg unchanged");
let marker = buffers
.rects
.iter()
.find(|b| b.size_px[1] == CURSOR_MARKER_PX)
.expect("underline marker present");
assert_eq!(marker.size_px, [metrics().width as f32, CURSOR_MARKER_PX]);
let expected_y = metrics().height as f32 - CURSOR_MARKER_PX;
assert_eq!(marker.origin_px[1], expected_y);
}
#[test]
fn bar_cursor_appends_a_thin_vertical_strip_at_the_left_of_the_cell() {
let g = drive(1, 3, b"\x1b[6 qA");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let marker = buffers
.rects
.iter()
.find(|b| b.size_px[0] == CURSOR_MARKER_PX)
.expect("bar marker present");
assert_eq!(marker.size_px, [CURSOR_MARKER_PX, metrics().height as f32]);
let cell_origin = [metrics().width as f32 * 1.0, 0.0];
assert_eq!(marker.origin_px, cell_origin);
}
#[test]
fn block_cursor_emits_no_marker_quad() {
let g = drive(1, 3, b"AB"); let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(buffers.rects.len(), 3);
}
#[test]
fn underline_cursor_uses_theme_cursor_color_when_configured() {
let theme = Theme {
cursor: Some([0.5, 0.7, 0.9, 1.0]),
..Theme::default()
};
let g = drive(1, 3, b"\x1b[4 qA");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &theme, &atlas);
let marker = buffers
.rects
.iter()
.find(|b| b.size_px[1] == CURSOR_MARKER_PX)
.expect("underline marker present");
assert_eq!(marker.color, [0.5, 0.7, 0.9, 1.0]);
}
#[test]
fn bar_cursor_uses_theme_cursor_color_when_configured() {
let theme = Theme {
cursor: Some([0.5, 0.7, 0.9, 1.0]),
..Theme::default()
};
let g = drive(1, 3, b"\x1b[6 qA");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &theme, &atlas);
let marker = buffers
.rects
.iter()
.find(|b| b.size_px[0] == CURSOR_MARKER_PX)
.expect("bar marker present");
assert_eq!(marker.color, [0.5, 0.7, 0.9, 1.0]);
}
#[test]
fn bidi_marker_overrides_underline_cursor_marker() {
let mut g = Grid::new(1, 4);
let mut p = Parser::new();
p.advance(&mut g, "\x1b[4 qA\u{202E}\x1b[1;1H".as_bytes());
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let marker = buffers
.rects
.iter()
.find(|b| b.size_px[1] == CURSOR_MARKER_PX);
assert!(
marker.is_none(),
"bidi marker must suppress the underline marker"
);
}
#[test]
fn bidi_marker_overrides_bar_cursor_marker() {
let mut g = Grid::new(1, 4);
let mut p = Parser::new();
p.advance(&mut g, "\x1b[6 qA\u{202E}\x1b[1;1H".as_bytes());
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let marker = buffers
.rects
.iter()
.find(|b| b.size_px[0] == CURSOR_MARKER_PX);
assert!(
marker.is_none(),
"bidi marker must suppress the bar marker too"
);
}
#[test]
fn unfocused_window_suppresses_the_cursor_block() {
let g = drive(1, 3, b"AB");
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(false)
.with_viewport(0, u32::from(g.rows()))
.extend_screen(&mut buffers);
assert_eq!(buffers.rects.len(), 3);
for inst in &buffers.rects {
assert_eq!(inst.color, theme.bg, "no cell painted with cursor swap");
}
}
#[test]
fn reserved_bottom_rows_keeps_bar_row_free_of_cells() {
let g = drive(2, 3, b"AB\r\nCD");
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_viewport(0, u32::from(g.rows()))
.with_reserved_bottom_rows(1)
.extend_screen(&mut buffers);
assert_eq!(
buffers.rects.len(),
3,
"bottom row's bg cells must be suppressed"
);
let bar_y = f32::from(g.rows() - 1) * metrics().height as f32;
for inst in &buffers.rects {
assert!(
inst.origin_px[1] < bar_y,
"bg cell leaked onto the reserved bar row"
);
}
for inst in &buffers.glyphs {
assert!(
inst.origin_px[1] < bar_y,
"fg glyph leaked onto the reserved bar row"
);
}
}
#[test]
fn preedit_overlay_span_is_reserved_from_the_cell_walk() {
let g = drive(1, 5, b"ABCDE");
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let mut buffers = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (0, 1),
text: "あ".to_string(),
cursor: None,
};
let span = preedit_covered_cols(&overlay, g.cols());
assert_eq!(
span,
Some(PreeditSpan {
row: 0,
col_start: 1,
col_end: 3,
}),
"wide char covers cols 1 and 2",
);
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_viewport(0, u32::from(g.rows()))
.with_preedit(span)
.extend_screen(&mut buffers);
let cw = metrics().width as f32;
let leaked = |x: f32| {
let col = (x / cw).round() as u16;
(col == 1 || col == 2).then_some(col)
};
for inst in &buffers.rects {
assert!(
leaked(inst.origin_px[0]).is_none(),
"cell bg leaked under the pre-edit overlay at col {:?}",
leaked(inst.origin_px[0]),
);
}
for inst in &buffers.glyphs {
assert!(
leaked(inst.origin_px[0]).is_none(),
"glyph leaked under the pre-edit overlay at col {:?}",
leaked(inst.origin_px[0]),
);
}
}
#[test]
fn preedit_span_matches_painted_extent() {
let atlas = MockAtlas::full(slot());
let overlay = PreeditOverlay {
anchor: (0, 1),
text: "あxい".to_string(), cursor: None,
};
let cols = 6; let mut buffers = RowPaint::default();
extend_preedit(&overlay, metrics(), 1, cols, &atlas, &mut buffers);
let cw = metrics().width as f32;
let painted_end = buffers
.rects
.iter()
.map(|b| b.origin_px[0] + b.size_px[0])
.fold(0.0_f32, f32::max);
let span_end = preedit_covered_cols(&overlay, cols)
.expect("non-empty overlay")
.col_end;
assert_eq!(
f32::from(span_end) * cw,
painted_end,
"reserved span must end where the painted overlay ends",
);
}
#[test]
fn unfocused_window_suppresses_underline_marker_too() {
let g = drive(1, 3, b"\x1b[4 qA");
let atlas = MockAtlas::full(slot());
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&Theme::default()),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(false)
.with_viewport(0, u32::from(g.rows()))
.extend_screen(&mut buffers);
let marker = buffers
.rects
.iter()
.find(|b| b.size_px[1] == CURSOR_MARKER_PX);
assert!(
marker.is_none(),
"underline marker must drop when unfocused"
);
}
#[test]
fn unfocused_window_suppresses_bar_marker_too() {
let g = drive(1, 3, b"\x1b[6 qA");
let atlas = MockAtlas::full(slot());
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&Theme::default()),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(false)
.with_viewport(0, u32::from(g.rows()))
.extend_screen(&mut buffers);
let marker = buffers
.rects
.iter()
.find(|b| b.size_px[0] == CURSOR_MARKER_PX);
assert!(marker.is_none(), "bar marker must drop when unfocused");
}
#[test]
fn selection_highlight_paints_cells_inside_the_range() {
let g = drive(1, 5, b"\x1b[?25l"); let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_selection(Some(SelectionRange {
start: (0, 1),
end: (0, 3),
rectangle: false,
}))
.with_viewport(0, u32::from(g.rows()))
.extend_screen(&mut buffers);
assert_eq!(buffers.rects.len(), 5);
assert_eq!(buffers.rects[0].color, theme.bg, "cell 0 outside selection");
for (i, buffer) in buffers.rects.iter().enumerate().skip(1).take(3) {
assert_eq!(buffer.color, SELECTION_BG, "cell {i} should be highlighted");
}
assert_eq!(buffers.rects[4].color, theme.bg, "cell 4 outside selection");
}
#[test]
fn selection_highlight_clips_to_boundary_columns_across_rows() {
let g = drive(3, 4, b"\x1b[?25l");
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_selection(Some(SelectionRange {
start: (0, 2),
end: (2, 1),
rectangle: false,
}))
.with_viewport(0, u32::from(g.rows()))
.extend_screen(&mut buffers);
let inside = |idx: usize| matches!(idx, 2..=9);
for (idx, inst) in buffers.rects.iter().enumerate() {
if inside(idx) {
assert_eq!(inst.color, SELECTION_BG, "idx {idx} should be highlighted");
} else {
assert_eq!(inst.color, theme.bg, "idx {idx} outside selection");
}
}
}
#[test]
fn no_selection_leaves_every_cell_on_theme_bg() {
let g = drive(1, 5, b"\x1b[?25l");
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_viewport(0, u32::from(g.rows()))
.extend_screen(&mut buffers);
assert_eq!(buffers.rects.len(), 5);
for (i, inst) in buffers.rects.iter().enumerate() {
assert_eq!(inst.color, theme.bg, "cell {i}");
}
}
#[test]
fn bidi_marker_wins_over_selection_highlight() {
let mut g = Grid::new(1, 4);
let mut p = Parser::new();
p.advance(&mut g, "\x1b[?25lA\u{202E}B".as_bytes());
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_selection(Some(SelectionRange {
start: (0, 0),
end: (0, 3),
rectangle: false,
}))
.with_viewport(0, u32::from(g.rows()))
.extend_screen(&mut buffers);
for r in 0..g.rows() {
for c in 0..g.cols() {
let Some(cell) = g.cell(r, c) else { continue };
if cell_contains_bidi_override(cell.grapheme, g.screen().cluster_table()) {
let idx = usize::from(r) * usize::from(g.cols()) + usize::from(c);
assert_eq!(
buffers.rects[idx].color, BIDI_MARKER_BG,
"bidi cell at ({r},{c}) must keep marker bg",
);
}
}
}
}
#[test]
fn cursor_uses_theme_cursor_color_when_configured() {
let theme = Theme {
cursor: Some([0.5, 0.7, 0.9, 1.0]),
..Theme::default()
};
let g = drive(1, 3, b"AB");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &theme, &atlas);
assert_eq!(g.cursor().col, 2);
assert_eq!(buffers.rects[2].color, [0.5, 0.7, 0.9, 1.0]);
}
#[test]
fn cursor_glyph_renders_in_reversed_fg_color() {
let g = drive(1, 3, b"AB\x1b[1;1H");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let theme = Theme::default();
assert_eq!(g.cursor().row, 0);
assert_eq!(g.cursor().col, 0);
assert_eq!(buffers.rects[0].color, theme.fg);
assert_eq!(
buffers.glyphs[0].color, theme.bg,
"cursor cell glyph must paint in reversed fg color"
);
}
#[test]
fn extend_preedit_empty_text_is_noop() {
let mut out = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (0, 0),
text: String::new(),
cursor: None,
};
let atlas = MockAtlas::full(slot());
extend_preedit(&overlay, metrics(), 5, 80, &atlas, &mut out);
assert_eq!(out.rects, Vec::<Rect>::new());
assert_eq!(out.glyphs, Vec::<GlyphDraw>::new());
}
#[test]
fn extend_preedit_emits_bg_underline_glyph_per_ascii_char() {
let mut out = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (1, 2),
text: "ab".to_owned(),
cursor: None,
};
let atlas = MockAtlas::full(slot());
extend_preedit(&overlay, metrics(), 5, 80, &atlas, &mut out);
assert_eq!(out.rects.len(), 4);
assert_eq!(out.glyphs.len(), 2);
assert_eq!(out.rects[0].origin_px, [16.0, 16.0]);
assert_eq!(out.rects[0].size_px, [8.0, 16.0]);
assert_eq!(out.rects[0].color, PREEDIT_BG);
assert_eq!(out.rects[1].origin_px, [16.0, 30.0]);
assert_eq!(out.rects[1].size_px[1], PREEDIT_UNDERLINE_PX);
assert_eq!(out.rects[2].origin_px, [24.0, 16.0]);
}
#[test]
fn extend_preedit_advances_two_cells_for_wide_glyph() {
let mut out = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (0, 0),
text: "あい".to_owned(),
cursor: None,
};
let atlas = MockAtlas::full(slot());
extend_preedit(&overlay, metrics(), 5, 80, &atlas, &mut out);
assert_eq!(out.rects[0].origin_px, [0.0, 0.0]);
assert_eq!(out.rects[0].size_px, [16.0, 16.0]);
assert_eq!(out.rects[2].origin_px, [16.0, 0.0]);
assert_eq!(out.rects[2].size_px, [16.0, 16.0]);
}
#[test]
fn extend_preedit_anchor_out_of_range_is_noop() {
let mut out = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (10, 0),
text: "x".to_owned(),
cursor: None,
};
let atlas = MockAtlas::full(slot());
extend_preedit(&overlay, metrics(), 5, 80, &atlas, &mut out);
assert_eq!(out.rects, Vec::<Rect>::new());
}
#[test]
fn extend_preedit_col_at_or_past_grid_cols_is_noop() {
let mut out = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (0, 80),
text: "abc".to_owned(),
cursor: None,
};
let atlas = MockAtlas::full(slot());
extend_preedit(&overlay, metrics(), 5, 80, &atlas, &mut out);
assert!(out.rects.is_empty(), "no bg pushed when col >= grid_cols");
assert!(
out.glyphs.is_empty(),
"no glyph pushed when col >= grid_cols"
);
}
#[test]
fn extend_preedit_active_segment_paints_thicker_underline() {
let mut out = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (0, 0),
text: "abcde".to_owned(),
cursor: Some((1, 3)),
};
let atlas = MockAtlas::full(slot());
extend_preedit(&overlay, metrics(), 1, 80, &atlas, &mut out);
assert!(
(out.rects[1].size_px[1] - PREEDIT_UNDERLINE_PX).abs() < f32::EPSILON,
"char 0 'a' outside active range: default underline",
);
assert!(
(out.rects[3].size_px[1] - PREEDIT_ACTIVE_UNDERLINE_PX).abs() < f32::EPSILON,
"char 1 'b' inside active range: thicker underline",
);
assert!(
(out.rects[5].size_px[1] - PREEDIT_ACTIVE_UNDERLINE_PX).abs() < f32::EPSILON,
"char 2 'c' inside active range: thicker underline",
);
assert!(
(out.rects[7].size_px[1] - PREEDIT_UNDERLINE_PX).abs() < f32::EPSILON,
"char 3 'd' outside active range: default underline",
);
}
#[test]
fn extend_preedit_empty_active_range_falls_back_to_default_underline() {
let mut out = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (0, 0),
text: "ab".to_owned(),
cursor: Some((1, 1)),
};
let atlas = MockAtlas::full(slot());
extend_preedit(&overlay, metrics(), 1, 80, &atlas, &mut out);
for i in [1, 3] {
assert!(
(out.rects[i].size_px[1] - PREEDIT_UNDERLINE_PX).abs() < f32::EPSILON,
"no thicker underline when cursor is a single point",
);
}
}
#[test]
fn extend_preedit_active_segment_handles_multibyte_chars() {
let mut out = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (0, 0),
text: "あいう".to_owned(),
cursor: Some((3, 6)),
};
let atlas = MockAtlas::full(slot());
extend_preedit(&overlay, metrics(), 1, 80, &atlas, &mut out);
assert!(
(out.rects[1].size_px[1] - PREEDIT_UNDERLINE_PX).abs() < f32::EPSILON,
"あ outside active range: default underline",
);
assert!(
(out.rects[3].size_px[1] - PREEDIT_ACTIVE_UNDERLINE_PX).abs() < f32::EPSILON,
"い inside active range: thicker underline",
);
assert!(
(out.rects[5].size_px[1] - PREEDIT_UNDERLINE_PX).abs() < f32::EPSILON,
"う outside active range: default underline",
);
}
#[test]
fn extend_preedit_clips_at_right_edge_of_row() {
let mut out = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (0, 4),
text: "abc".to_owned(),
cursor: None,
};
let atlas = MockAtlas::full(slot());
extend_preedit(&overlay, metrics(), 1, 5, &atlas, &mut out);
assert_eq!(out.rects.len(), 2);
assert_eq!(out.glyphs.len(), 1);
}
#[test]
fn extend_preedit_clips_wide_glyph_at_right_edge_to_one_cell() {
let mut out = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (0, 4),
text: "あ".to_owned(),
cursor: None,
};
let atlas = MockAtlas::full(slot());
extend_preedit(&overlay, metrics(), 1, 5, &atlas, &mut out);
assert_eq!(out.rects.len(), 2, "bg quad + underline only");
assert_eq!(out.rects[0].size_px, [8.0, 16.0], "bg clipped to 1 cell");
assert_eq!(out.rects[1].size_px[0], 8.0, "underline clipped to 1 cell");
}
#[test]
fn link_cells_emit_an_extra_underline_instance() {
use crate::palette::LINK_UNDERLINE;
let g = drive(
1,
4,
b"\x1b[?25l\x1b]8;;https://example.com\x07ab\x1b]8;;\x07cd",
);
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(
buffers.rects.len(),
6,
"two link cells each add an underline"
);
assert_eq!(buffers.rects[1].color, LINK_UNDERLINE);
assert_eq!(buffers.rects[1].size_px[1], LINK_UNDERLINE_PX);
assert_eq!(buffers.rects[3].color, LINK_UNDERLINE);
assert_eq!(buffers.rects[4].color, Theme::default().bg);
assert_eq!(buffers.rects[5].color, Theme::default().bg);
}
#[test]
fn disallowed_scheme_emits_no_link_underline_instance() {
use crate::palette::LINK_UNDERLINE;
let g = drive(
1,
6,
b"\x1b[?25l\x1b]8;;javascript:alert(1)\x07evil\x1b]8;;\x07ok",
);
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert!(
buffers.rects.iter().all(|b| b.color != LINK_UNDERLINE),
"javascript: scheme leaked past dispatch_osc_8 and produced a link underline",
);
}
#[test]
fn disallowed_scheme_after_safe_open_clears_underline_run() {
use crate::palette::LINK_UNDERLINE;
let g = drive(
1,
8,
b"\x1b[?25l\x1b]8;;https://good\x07safe\x1b]8;;javascript:bad\x07evil\x1b]8;;\x07",
);
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let underline_count = buffers
.rects
.iter()
.filter(|b| b.color == LINK_UNDERLINE)
.count();
assert_eq!(
underline_count, 4,
"underline run leaked past the javascript: pen-clear into `evil`",
);
}
#[test]
fn link_underline_skipped_on_bidi_marker_cells() {
use crate::palette::{BIDI_MARKER_BG, LINK_UNDERLINE};
let g = drive(
1,
3,
"\x1b[?25l\x1b]8;;https://example.test/\u{0007}A\u{202E}\x1b]8;;\u{0007}".as_bytes(),
);
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
assert_eq!(buffers.rects[0].color, BIDI_MARKER_BG);
assert!(
buffers.rects.iter().all(|b| b.color != LINK_UNDERLINE),
"link underline must not paint over bidi marker",
);
}
#[test]
fn rectangle_selection_clips_every_row_to_column_range() {
let g = drive(3, 4, b"\x1b[?25l");
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_selection(Some(SelectionRange {
start: (0, 1),
end: (2, 2),
rectangle: true,
}))
.with_viewport(0, u32::from(g.rows()))
.extend_screen(&mut buffers);
let inside = |r: u16, c: u16| (1..=2).contains(&c) && (0..=2).contains(&r);
for r in 0..3u16 {
for c in 0..4u16 {
let idx = (usize::from(r) * 4) + usize::from(c);
let want_highlight = inside(r, c);
let got = buffers.rects[idx].color;
if want_highlight {
assert_eq!(got, SELECTION_BG, "({r},{c}) should be highlighted");
} else {
assert_eq!(got, theme.bg, "({r},{c}) should NOT be highlighted");
}
}
}
}
#[test]
fn extend_preedit_atlas_miss_still_paints_bg_and_underline() {
let mut out = RowPaint::default();
let overlay = PreeditOverlay {
anchor: (0, 0),
text: "?".to_owned(),
cursor: None,
};
let atlas = MockAtlas {
map: HashMap::new(),
gid_map: HashMap::new(),
fitted_map: HashMap::new(),
clusters: HashMap::new(),
};
extend_preedit(&overlay, metrics(), 1, 5, &atlas, &mut out);
assert_eq!(out.rects.len(), 2, "bg + underline survive an atlas miss");
assert!(out.glyphs.is_empty(), "fg pass skipped on atlas miss");
}
#[test]
fn extend_search_empty_overlay_is_noop() {
let mut out = RowPaint::default();
let overlay = SearchOverlay::default();
let atlas = MockAtlas::full(slot());
extend_search(&overlay, metrics(), 5, 80, true, &atlas, &mut out);
assert_eq!(out.rects, Vec::<Rect>::new());
assert_eq!(out.glyphs, Vec::<GlyphDraw>::new());
}
#[test]
fn extend_search_keeps_its_highlights_when_it_loses_the_bar_row() {
let mut out = RowPaint::default();
let overlay = SearchOverlay {
label: "search: ab".to_owned(),
visible_hits: vec![SearchHitSpan {
row: 2,
col_start: 3,
col_end: 7,
is_current: true,
}],
};
let atlas = MockAtlas::full(slot());
extend_search(&overlay, metrics(), 5, 80, false, &atlas, &mut out);
assert_eq!(out.rects.len(), 1, "the highlight, and no bar background");
assert_eq!(out.rects[0].color, SEARCH_CURRENT_BG);
assert!(out.glyphs.is_empty(), "no label glyphs without the bar");
}
#[test]
fn extend_search_paints_match_bg_for_visible_hit() {
let mut out = RowPaint::default();
let overlay = SearchOverlay {
label: String::new(),
visible_hits: vec![SearchHitSpan {
row: 2,
col_start: 3,
col_end: 7,
is_current: false,
}],
};
let atlas = MockAtlas::full(slot());
extend_search(&overlay, metrics(), 5, 80, true, &atlas, &mut out);
assert_eq!(out.rects.len(), 1);
assert_eq!(out.rects[0].origin_px, [24.0, 32.0]);
assert_eq!(out.rects[0].size_px, [32.0, 16.0]);
assert_eq!(out.rects[0].color, SEARCH_MATCH_BG);
}
#[test]
fn extend_search_current_hit_paints_in_vivid_color() {
let mut out = RowPaint::default();
let overlay = SearchOverlay {
label: String::new(),
visible_hits: vec![SearchHitSpan {
row: 0,
col_start: 0,
col_end: 2,
is_current: true,
}],
};
let atlas = MockAtlas::full(slot());
extend_search(&overlay, metrics(), 5, 80, true, &atlas, &mut out);
assert_eq!(out.rects.len(), 1);
assert_eq!(out.rects[0].color, SEARCH_CURRENT_BG);
}
#[test]
fn extend_search_clips_hit_past_grid_edges() {
let mut out = RowPaint::default();
let overlay = SearchOverlay {
label: String::new(),
visible_hits: vec![
SearchHitSpan {
row: 99,
col_start: 0,
col_end: 4,
is_current: false,
},
SearchHitSpan {
row: 1,
col_start: 6,
col_end: 999,
is_current: false,
},
],
};
let atlas = MockAtlas::full(slot());
extend_search(&overlay, metrics(), 5, 8, true, &atlas, &mut out);
assert_eq!(out.rects.len(), 1);
assert_eq!(out.rects[0].size_px, [16.0, 16.0]);
}
#[test]
fn extend_search_paints_bar_at_bottom_row_with_label_glyphs() {
let mut out = RowPaint::default();
let overlay = SearchOverlay {
label: "ab".to_owned(),
visible_hits: vec![],
};
let atlas = MockAtlas::full(slot());
extend_search(&overlay, metrics(), 3, 10, true, &atlas, &mut out);
assert_eq!(out.rects.len(), 1, "1 bar bg quad");
assert_eq!(out.rects[0].origin_px, [0.0, 32.0]);
assert_eq!(out.rects[0].size_px, [80.0, 16.0]);
assert_eq!(out.rects[0].color, SEARCH_BAR_BG);
assert_eq!(out.glyphs.len(), 2, "1 fg quad per label char");
}
#[test]
fn extend_search_label_clips_at_right_edge() {
let mut out = RowPaint::default();
let overlay = SearchOverlay {
label: "abcdef".to_owned(),
visible_hits: vec![],
};
let atlas = MockAtlas::full(slot());
extend_search(&overlay, metrics(), 2, 3, true, &atlas, &mut out);
assert_eq!(out.glyphs.len(), 3);
}
#[test]
fn extend_search_zero_grid_is_noop() {
let mut out = RowPaint::default();
let overlay = SearchOverlay {
label: "find: x".to_owned(),
visible_hits: vec![SearchHitSpan {
row: 0,
col_start: 0,
col_end: 1,
is_current: false,
}],
};
let atlas = MockAtlas::full(slot());
extend_search(&overlay, metrics(), 0, 80, true, &atlas, &mut out);
assert_eq!(out.rects, Vec::<Rect>::new());
assert_eq!(out.glyphs, Vec::<GlyphDraw>::new());
let mut out2 = RowPaint::default();
extend_search(&overlay, metrics(), 5, 0, true, &atlas, &mut out2);
assert_eq!(out2.rects, Vec::<Rect>::new());
assert_eq!(out2.glyphs, Vec::<GlyphDraw>::new());
}
#[test]
fn extend_link_preview_paints_bar_at_bottom_row_with_search_bar_color() {
let mut out = RowPaint::default();
let overlay = LinkPreviewOverlay {
text: "https://e".to_owned(),
};
let atlas = MockAtlas::full(slot());
extend_link_preview(&overlay, metrics(), 3, 10, &atlas, &mut out);
assert_eq!(out.rects.len(), 1, "1 bar bg quad");
assert_eq!(out.rects[0].origin_px, [0.0, 32.0]);
assert_eq!(out.rects[0].color, SEARCH_BAR_BG);
assert_eq!(out.glyphs.len(), overlay.text.chars().count());
}
#[test]
fn a_glyph_reaching_down_into_the_chrome_row_is_cut_at_its_top_edge() {
let mut out = RowPaint::default();
out.glyphs.push(GlyphDraw {
origin_px: [0.0, 16.0],
size_px: [8.0, 32.0],
atlas: AtlasKind::Mono,
src_min_px: [0.0, 0.0],
src_max_px: [8.0, 16.0],
color: [1.0; 4],
});
out.glyphs.push(GlyphDraw {
origin_px: [0.0, 34.0],
size_px: [8.0, 10.0],
atlas: AtlasKind::Mono,
src_min_px: [0.0, 0.0],
src_max_px: [8.0, 16.0],
color: [1.0; 4],
});
clip_cells_above(&mut out, 32.0);
assert_eq!(
out.glyphs.len(),
1,
"the quad starting inside the row is gone"
);
assert_eq!(out.glyphs[0].size_px[1], 16.0);
assert!((out.glyphs[0].src_max_px[1] - 8.0).abs() < f32::EPSILON);
}
#[test]
fn link_preview_wider_than_the_grid_ends_in_an_ellipsis() {
let mut out = RowPaint::default();
let overlay = LinkPreviewOverlay {
text: "https://example.com/very/long/path".to_owned(),
};
let mut atlas = MockAtlas::full(slot());
atlas.map.insert(BAR_ELLIPSIS, slot());
extend_link_preview(&overlay, metrics(), 3, 10, &atlas, &mut out);
assert_eq!(fit_bar_text_to_cells(&overlay.text, 10), "https://e…");
assert_eq!(out.glyphs.len(), 10, "one quad per column");
let cw = metrics().width as f32;
let offset_x = slot().offset_px[0] as f32;
assert_eq!(out.glyphs[9].origin_px[0], 9.0f32.mul_add(cw, offset_x));
}
#[test]
fn link_preview_within_the_grid_is_left_verbatim() {
assert_eq!(
fit_bar_text_to_cells("https://e", 10),
Cow::Borrowed("https://e")
);
assert_eq!(
fit_bar_text_to_cells("https://ex", 10),
Cow::Borrowed("https://ex")
);
}
#[test]
fn link_preview_never_puts_half_a_wide_glyph_in_the_last_cell() {
assert_eq!(fit_bar_text_to_cells("ab漢字", 5), "ab漢…");
assert_eq!(fit_bar_text_to_cells("漢字漢", 5), "漢字…");
assert_eq!(fit_bar_text_to_cells("漢字", 1), "…");
}
#[test]
fn bottom_bar_claim_gives_the_row_to_search_and_confirm_over_the_preview() {
for (search, confirm) in [(true, false), (false, true), (true, true)] {
assert_eq!(
bottom_bar_claim(ChromeBar::from_flags(search, confirm), false, true),
BottomBarClaim::Chrome
);
}
}
#[test]
fn bottom_bar_claim_gives_the_row_to_an_ime_composition_over_the_preview() {
assert_eq!(
bottom_bar_claim(ChromeBar::None, true, true),
BottomBarClaim::Free
);
}
#[test]
fn bottom_bar_claim_draws_the_preview_when_no_other_bar_is_open() {
assert_eq!(
bottom_bar_claim(ChromeBar::None, false, true),
BottomBarClaim::LinkPreview
);
}
#[test]
fn bottom_bar_claim_reserves_nothing_with_every_bar_closed() {
assert_eq!(
bottom_bar_claim(ChromeBar::None, false, false),
BottomBarClaim::Free
);
}
#[test]
fn extend_link_preview_empty_text_is_noop() {
let mut out = RowPaint::default();
let overlay = LinkPreviewOverlay::default();
let atlas = MockAtlas::full(slot());
extend_link_preview(&overlay, metrics(), 3, 10, &atlas, &mut out);
assert_eq!(out.rects, Vec::<Rect>::new());
assert_eq!(out.glyphs, Vec::<GlyphDraw>::new());
}
#[test]
fn extend_link_preview_zero_grid_is_noop() {
let mut out = RowPaint::default();
let overlay = LinkPreviewOverlay {
text: "https://example.com".to_owned(),
};
let atlas = MockAtlas::full(slot());
extend_link_preview(&overlay, metrics(), 0, 80, &atlas, &mut out);
assert_eq!(out.rects, Vec::<Rect>::new());
assert_eq!(out.glyphs, Vec::<GlyphDraw>::new());
}
#[test]
fn extend_screen_viewport_zero_paints_same_bg_as_live_path() {
let g = drive(2, 4, b"\x1b[?25lAB\r\nCD");
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_viewport(0, u32::from(g.rows()))
.extend_screen(&mut buffers);
assert_eq!(
buffers.rects.len(),
8,
"viewport=0 must paint cells only — no scrollbar lane",
);
for inst in &buffers.rects {
assert_ne!(inst.color, SCROLLBAR_TRACK);
assert_ne!(inst.color, SCROLLBAR_THUMB);
}
}
#[test]
fn extend_screen_paints_scrollbar_lane_while_browsing() {
let mut g = Grid::new(2, 4);
let mut p = Parser::new();
p.advance(&mut g, b"\x1b[?25l");
p.advance(&mut g, b"row1\r\nrow2\r\nrow3");
assert!(!g.scrollback().is_empty());
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_viewport(1, 3)
.extend_screen(&mut buffers);
assert_eq!(
buffers.rects.len(),
8 + 2,
"browse mode must add a track and a thumb to the cell quads",
);
let last_two = &buffers.rects[buffers.rects.len() - 2..];
assert_eq!(last_two[0].color, SCROLLBAR_TRACK, "track painted first");
assert_eq!(last_two[1].color, SCROLLBAR_THUMB, "thumb wins on top");
let lane_x = (g.cols() as f32).mul_add(metrics().width as f32, -SCROLLBAR_LANE_PX);
assert_eq!(last_two[0].origin_px[0], lane_x);
assert_eq!(last_two[1].origin_px[0], lane_x);
}
#[test]
fn extend_screen_hides_cursor_while_viewport_above_zero() {
let mut g = Grid::new(2, 4);
let mut p = Parser::new();
p.advance(&mut g, b"row1\r\nrow2\r\nrow3");
let atlas = MockAtlas::full(slot());
let theme = Theme::default();
let mut buffers_live = RowPaint::default();
let mut buffers_browse = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_viewport(0, 3)
.extend_screen(&mut buffers_live);
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_viewport(1, 3)
.extend_screen(&mut buffers_browse);
let cursor = g.cursor();
let idx = usize::from(cursor.row) * usize::from(g.cols()) + usize::from(cursor.col);
assert_eq!(
buffers_live.rects[idx].color, theme.fg,
"live cursor paints with the swap (cursor bg = theme fg)",
);
assert_ne!(
buffers_browse.rects[idx].color, theme.fg,
"cursor must hide in browse mode — no swap on the cursor cell",
);
}
fn deco_for(sgr: &[u8]) -> Vec<Decoration> {
let mut bytes = sgr.to_vec();
bytes.push(b'A');
let g = drive(1, 4, &bytes);
let atlas = MockAtlas::full(slot());
build_instances(g.screen(), metrics(), &Theme::default(), &atlas).decos
}
#[test]
fn single_underline_emits_one_solid_line_in_the_descender_band() {
let deco = deco_for(b"\x1b[4m");
assert_eq!(deco.len(), 1);
let d = deco[0];
assert_eq!(d.kind, DecoKind::Solid);
assert_eq!(d.size_px[0], metrics().width as f32, "spans the cell width");
assert_eq!(
d.color,
Theme::default().fg,
"default underline color is the cell fg"
);
let ascent = metrics().ascent as f32;
let ch = metrics().height as f32;
assert!(
d.origin_px[1] >= ascent && d.origin_px[1] <= ch - d.thickness_px,
"underline sits in the descender band, got y={}",
d.origin_px[1],
);
}
#[test]
fn double_underline_emits_two_stacked_lines() {
let deco = deco_for(b"\x1b[21m");
assert_eq!(deco.len(), 2);
assert!(deco.iter().all(|d| d.kind == DecoKind::Solid));
assert_ne!(
deco[0].origin_px[1], deco[1].origin_px[1],
"two distinct rows"
);
}
#[test]
fn curly_dotted_dashed_underlines_pick_the_procedural_kinds() {
assert_eq!(deco_for(b"\x1b[4:3m")[0].kind, DecoKind::Curly);
assert_eq!(deco_for(b"\x1b[4:4m")[0].kind, DecoKind::Dotted);
assert_eq!(deco_for(b"\x1b[4:5m")[0].kind, DecoKind::Dashed);
let curly = deco_for(b"\x1b[4:3m")[0];
assert!(
curly.size_px[1] > curly.thickness_px,
"curly band must exceed the thickness so the wave fits",
);
let dotted = deco_for(b"\x1b[4:4m")[0];
assert_eq!(
dotted.size_px[1], dotted.thickness_px,
"dotted stays one line tall"
);
assert!(
dotted.period_px > 0.0,
"a dotted pattern needs a non-zero period"
);
}
#[test]
fn underline_color_uses_sgr_58_when_set() {
let deco = deco_for(b"\x1b[4;58:2::255:0:0m");
assert_eq!(deco.len(), 1);
let want = crate::palette::srgb_to_linear_rgba([255, 0, 0], 1.0);
assert_eq!(
deco[0].color, want,
"SGR 58 underline color must be honored"
);
assert_ne!(deco[0].color, Theme::default().fg);
}
#[test]
fn strikethrough_emits_a_mid_cell_line_in_the_fg_color() {
let deco = deco_for(b"\x1b[9m");
assert_eq!(deco.len(), 1);
let d = deco[0];
assert_eq!(d.kind, DecoKind::Solid);
assert_eq!(d.color, Theme::default().fg);
let ascent = metrics().ascent as f32;
assert!(
d.origin_px[1] < ascent,
"strike crosses the glyph body, above the baseline"
);
}
#[test]
fn overline_emits_a_line_at_the_cell_top() {
let deco = deco_for(b"\x1b[53m");
assert_eq!(deco.len(), 1);
assert_eq!(deco[0].kind, DecoKind::Solid);
assert_eq!(deco[0].origin_px[1], 0.0, "overline seats at the cell top");
assert_eq!(deco[0].color, Theme::default().fg);
}
#[test]
fn faint_dims_the_glyph_toward_the_background() {
let theme = Theme::default();
let atlas = MockAtlas::full(slot());
let plain = build_instances(drive(1, 4, b"A").screen(), metrics(), &theme, &atlas);
let faint = build_instances(drive(1, 4, b"\x1b[2mA").screen(), metrics(), &theme, &atlas);
assert_eq!(plain.glyphs.len(), 1);
assert_eq!(faint.glyphs.len(), 1);
assert_eq!(
plain.glyphs[0].color, theme.fg,
"baseline glyph paints the theme fg"
);
assert_eq!(
faint.glyphs[0].color,
dim_toward_bg(theme.fg, theme.bg),
"faint glyph mixes the fg toward the bg",
);
assert_ne!(
faint.glyphs[0].color, theme.fg,
"faint must actually change the color"
);
}
#[test]
fn conceal_hides_the_glyph_but_keeps_the_background() {
let theme = Theme::default();
let atlas = MockAtlas::full(slot());
let buffers = build_instances(drive(1, 4, b"\x1b[8mA").screen(), metrics(), &theme, &atlas);
assert!(
buffers.glyphs.is_empty(),
"concealed glyph must leave no ink"
);
assert!(
!buffers.rects.is_empty(),
"the cell background must still paint"
);
}
#[test]
fn conceal_hides_the_glyph_yet_keeps_the_underline() {
let deco = deco_for(b"\x1b[4;8m");
let atlas = MockAtlas::full(slot());
let buffers = build_instances(
drive(1, 4, b"\x1b[4;8mA").screen(),
metrics(),
&Theme::default(),
&atlas,
);
assert!(buffers.glyphs.is_empty(), "conceal still hides the glyph");
assert_eq!(deco.len(), 1, "the underline survives conceal");
}
#[test]
fn a_link_cell_yields_the_underline_slot_but_keeps_strikethrough() {
use std::num::NonZeroU16;
let mut g = Grid::new(1, 4);
let style = g.style_table_mut().intern(felis_grid::Attributes {
flags: AttrFlags::UNDERLINE | AttrFlags::STRIKETHROUGH,
..felis_grid::Attributes::default()
});
g.set_cell(
0,
0,
Cell {
grapheme: Grapheme::Ascii(b'A'),
style,
link: NonZeroU16::new(1),
sizing: None,
},
);
let atlas = MockAtlas::full(slot());
let deco = build_instances(g.screen(), metrics(), &Theme::default(), &atlas).decos;
assert_eq!(
deco.len(),
1,
"link suppresses the SGR underline but not the strike"
);
assert!(
deco[0].origin_px[1] < metrics().ascent as f32,
"the surviving decoration is the strikethrough",
);
}
#[test]
fn a_bidi_override_cell_suppresses_every_decoration() {
let mut g = Grid::new(1, 4);
let style = g.style_table_mut().intern(felis_grid::Attributes {
flags: AttrFlags::UNDERLINE | AttrFlags::STRIKETHROUGH | AttrFlags::OVERLINE,
..felis_grid::Attributes::default()
});
g.set_cell(
0,
0,
Cell {
grapheme: Grapheme::Char('\u{202e}'), style,
link: None,
sizing: None,
},
);
let atlas = MockAtlas::full(slot());
let deco = build_instances(g.screen(), metrics(), &Theme::default(), &atlas).decos;
assert!(
deco.is_empty(),
"a bidi-override cell drops every decoration"
);
}
proptest::proptest! {
#[test]
fn fitted_bar_text_never_overflows_its_budget(
text in "\\PC{0,40}",
cols in 1u16..40,
) {
let fitted = fit_bar_text_to_cells(&text, cols);
let width: u32 = felis_grid::text_cells(&fitted)
.map(|(_, w)| u32::from(w))
.sum();
proptest::prop_assert!(
width <= u32::from(cols),
"{fitted:?} occupies {width} cells, past {cols}"
);
if fitted.as_ref() != text {
let head = fitted
.strip_suffix(BAR_ELLIPSIS)
.expect("truncated text ends in the truncation mark");
proptest::prop_assert!(text.starts_with(head));
}
}
}
fn cell_bgs(buffers: &RowPaint) -> Vec<[f32; 4]> {
let m = metrics();
buffers
.rects
.iter()
.filter(|b| b.size_px == [m.width as f32, m.height as f32])
.map(|b| b.color)
.collect()
}
fn fg_in_col(buffers: &RowPaint, col: u16) -> Option<&GlyphDraw> {
let cw = metrics().width as f32;
let left = f32::from(col) * cw;
buffers
.glyphs
.iter()
.find(|q| q.origin_px[0] >= left && q.origin_px[0] < left + cw)
}
fn build_with_selection(screen: &ScreenBuffer, range: SelectionRange) -> RowPaint {
let theme = Theme::default();
let atlas = MockAtlas::full(slot());
let mut buffers = RowPaint::default();
CellPainter::new(
screen,
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_selection(Some(range))
.with_viewport(0, u32::from(screen.rows()))
.extend_screen(&mut buffers);
buffers
}
#[test]
fn block_cursor_on_a_wide_character_covers_both_halves() {
let theme = Theme::default();
let mut atlas = MockAtlas::full(slot());
atlas.map.insert('字', slot());
for to_col in [b"\x1b[2G", b"\x1b[3G"] {
let g = drive(1, 6, &["a字b".as_bytes(), to_col].concat());
let buffers = build_instances(g.screen(), metrics(), &theme, &atlas);
let bgs = cell_bgs(&buffers);
assert_eq!(bgs[0], theme.bg);
assert_eq!(bgs[1], theme.fg, "lead under the cursor");
assert_eq!(bgs[2], theme.fg, "Spacer under the cursor");
assert_eq!(bgs[3], theme.bg);
let glyph = fg_in_col(&buffers, 1).expect("字 draws");
assert_eq!(glyph.color, theme.bg, "the glyph takes the swapped fg");
}
}
#[test]
fn underline_cursor_on_a_wide_character_spans_both_halves() {
let g = drive(1, 6, "\x1b[4 qa字b\x1b[3G".as_bytes());
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let cw = metrics().width as f32;
let xs: Vec<f32> = buffers
.rects
.iter()
.filter(|b| b.size_px == [cw, CURSOR_MARKER_PX])
.map(|b| b.origin_px[0])
.collect();
assert_eq!(xs, [cw, 2.0 * cw]);
}
#[test]
fn bar_cursor_on_a_wide_character_sits_at_its_left_edge() {
let g = drive(1, 6, "\x1b[6 qa字b\x1b[3G".as_bytes());
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let xs: Vec<f32> = buffers
.rects
.iter()
.filter(|b| b.size_px[0] == CURSOR_MARKER_PX)
.map(|b| b.origin_px[0])
.collect();
assert_eq!(xs, [metrics().width as f32]);
}
#[test]
fn a_selection_touching_either_half_highlights_the_whole_character() {
let g = drive(1, 6, "\x1b[?25la字b".as_bytes());
let theme = Theme::default();
let ends_on_lead = build_with_selection(
g.screen(),
SelectionRange {
start: (0, 0),
end: (0, 1),
rectangle: false,
},
);
assert_eq!(
cell_bgs(&ends_on_lead)[..4],
[SELECTION_BG, SELECTION_BG, SELECTION_BG, theme.bg]
);
let starts_on_spacer = build_with_selection(
g.screen(),
SelectionRange {
start: (0, 2),
end: (0, 3),
rectangle: false,
},
);
assert_eq!(
cell_bgs(&starts_on_spacer)[..5],
[theme.bg, SELECTION_BG, SELECTION_BG, SELECTION_BG, theme.bg]
);
}
#[test]
fn a_rectangle_selection_widens_per_row_to_the_characters_it_touches() {
let g = drive(2, 4, "\x1b[?25la字b\r\n字cd".as_bytes());
let theme = Theme::default();
let buffers = build_with_selection(
g.screen(),
SelectionRange {
start: (0, 1),
end: (1, 1),
rectangle: true,
},
);
let bgs = cell_bgs(&buffers);
assert_eq!(bgs[..4], [theme.bg, SELECTION_BG, SELECTION_BG, theme.bg]);
assert_eq!(bgs[4..], [SELECTION_BG, SELECTION_BG, theme.bg, theme.bg]);
}
#[test]
fn a_wide_cluster_carrying_a_bidi_override_is_marked_on_both_halves() {
let g = drive(1, 6, "\x1b[?25la字\u{202E}b".as_bytes());
let atlas = MockAtlas::full(slot());
let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
let bgs = cell_bgs(&buffers);
assert_eq!(bgs[1], BIDI_MARKER_BG);
assert_eq!(bgs[2], BIDI_MARKER_BG);
assert_ne!(bgs[3], BIDI_MARKER_BG);
}
#[test]
fn a_preedit_over_the_spacer_hides_the_lead_glyph_but_keeps_its_background() {
let g = drive(1, 6, "\x1b[?25la\x1b[41m字\x1b[mb".as_bytes());
let theme = Theme::default();
let atlas = MockAtlas::full(slot());
let mut buffers = RowPaint::default();
CellPainter::new(
g.screen(),
metrics(),
&ResolvedTheme::new(&theme),
&atlas,
&ShapeFrame::empty(),
)
.with_cursor_visible(true)
.with_viewport(0, u32::from(g.rows()))
.with_preedit(Some(PreeditSpan {
row: 0,
col_start: 2,
col_end: 4,
}))
.extend_screen(&mut buffers);
assert!(fg_in_col(&buffers, 0).is_some(), "a draws");
assert!(
fg_in_col(&buffers, 1).is_none(),
"字 would ink under the overlay"
);
let cw = metrics().width as f32;
assert!(
buffers
.rects
.iter()
.any(|b| b.origin_px[0] == cw && b.size_px[0] == cw && b.color != theme.bg),
"the lead keeps its red background"
);
}
fn preedit(text: &str, col: u16) -> PreeditOverlay {
PreeditOverlay {
anchor: (0, col),
text: text.to_owned(),
cursor: None,
}
}
#[test]
fn preedit_gives_a_cluster_the_cells_the_grid_gives_it() {
for (text, cells) in [
("e\u{301}x", 2),
("❤\u{FE0F}", 2),
("👍🏻", 2),
("🇯🇵", 2),
("a字", 3),
] {
let span = preedit_covered_cols(&preedit(text, 1), 20).expect("non-empty");
assert_eq!(span.col_end - span.col_start, cells, "{text:?}");
}
}
#[test]
fn preedit_draws_a_cluster_with_its_shaped_glyphs() {
let mut atlas = MockAtlas::full(slot());
for (id, flag) in [(1, "🇯🇵"), (2, "🇯🇲")] {
atlas.clusters.insert(
flag.to_owned(),
vec![ClusterGlyph {
glyph_id: id,
font_id: 0,
advance_px: 16.0,
x_offset_px: 0.0,
y_offset_px: 0.0,
}],
);
atlas.gid_map.insert(
id,
GlyphSlot {
atlas_px: [u32::from(id), 0],
..slot()
},
);
}
let src_of = |text: &str| {
let mut out = RowPaint::default();
extend_preedit(&preedit(text, 0), metrics(), 1, 10, &atlas, &mut out);
assert_eq!(out.glyphs.len(), 1, "{text:?} draws one shaped glyph");
out.glyphs[0].src_min_px
};
assert_ne!(src_of("🇯🇵"), src_of("🇯🇲"));
}
#[test]
fn overlay_glyphs_never_draw_past_the_right_edge() {
let mut atlas = MockAtlas::full(slot());
let wide = GlyphSlot {
size_px: [14, 10],
..slot()
};
atlas.map.insert('字', wide);
let cols = 5;
let edge = f32::from(cols) * metrics().width as f32;
let mut out = RowPaint::default();
extend_preedit(&preedit("ab字", 2), metrics(), 1, cols, &atlas, &mut out);
extend_search(
&SearchOverlay {
label: "abcd字".to_owned(),
visible_hits: Vec::new(),
},
metrics(),
2,
cols,
true,
&atlas,
&mut out,
);
assert_eq!(
out.glyphs.len(),
3 + 5,
"each clipped 字 keeps its visible half"
);
for q in &out.glyphs {
assert!(q.origin_px[0] + q.size_px[0] <= edge, "{q:?}");
}
}
#[cfg(not(windows))]
struct Painted {
index: GlyphIndex,
frame: ShapeFrame,
glyphs: Vec<GlyphDraw>,
}
#[cfg(not(windows))]
fn paint_pinned(bytes: &[u8], cols: u16) -> Painted {
let stack = FontStack::pinned_test_stack();
let mut grid = Grid::new(1, cols);
Parser::new().advance(&mut grid, bytes);
let mut index = GlyphIndex::new(stack, 18.0, NonZeroU32::new(1024).unwrap());
let mut frame = ShapeFrame::empty();
GridWalker::default().walk(
&mut index,
grid.screen(),
&mut Shaper::new(),
&[],
&mut frame,
);
let theme = Theme::default();
let mut out = RowPaint::default();
CellPainter::new(
grid.screen(),
index.cell_metrics(),
&ResolvedTheme::new(&theme),
&index,
&frame,
)
.with_viewport(0, 1)
.extend_screen(&mut out);
Painted {
index,
frame,
glyphs: out.glyphs,
}
}
#[cfg(not(windows))]
fn cluster_fit_px_at(p: &Painted, col: u16) -> u16 {
match p.frame.cell_at(0, col) {
ShapedCell::Cluster { fit_px, .. } => fit_px,
other => panic!("col {col} is not a cluster: {other:?}"),
}
}
#[cfg(not(windows))]
fn ink(fg: &[GlyphDraw]) -> [f32; 4] {
fg.iter()
.map(|q| {
let [x, y] = q.origin_px;
[x, y, x + q.size_px[0], y + q.size_px[1]]
})
.reduce(|[a, b, c, d], [x0, y0, x1, y1]| [a.min(x0), b.min(y0), c.max(x1), d.max(y1)])
.expect("the cluster painted")
}
#[cfg(not(windows))]
#[test]
fn a_cluster_wider_than_its_cells_fills_and_centres_in_them() {
let repros: [(&str, &[u8], u16, u16); 3] = [
("non-RGI ZWJ", "\u{1F408}\u{200D}\u{1F408}".as_bytes(), 0, 2),
("modifier", "\u{1F600}\u{1F3FB}".as_bytes(), 0, 2),
("refused widen", "\x1b[4G\u{2764}\u{FE0F}".as_bytes(), 3, 1),
];
for (name, bytes, col, cells) in repros {
let p = paint_pinned(bytes, 4);
let fit_px = f32::from(cluster_fit_px_at(&p, col));
assert_ne!(fit_px, 0.0, "{name}: fitted");
let filled = |ink: f32, block: f32| ink >= block * fit_px / (fit_px + 1.0) - 1.0;
let m = p.index.cell_metrics();
let (cw, ch) = (m.width as f32, m.height as f32);
let (bx0, bx1) = (f32::from(col) * cw, f32::from(col + cells) * cw);
let [x0, y0, x1, y1] = ink(&p.glyphs);
assert!(
x0 >= bx0 - 1.0 && x1 <= bx1 + 1.0 && y0 >= -1.0 && y1 <= ch + 1.0,
"{name}: ink {x0}..{x1} x {y0}..{y1} inside {bx0}..{bx1} x 0..{ch}"
);
assert!(
((x0 - bx0) - (bx1 - x1)).abs() <= 1.0 && (y0 - (ch - y1)).abs() <= 1.0,
"{name}: ink {x0}..{x1} x {y0}..{y1} centred in {bx0}..{bx1} x 0..{ch}"
);
assert!(
filled(x1 - x0, bx1 - bx0) || filled(y1 - y0, ch),
"{name}: ink {x0}..{x1} x {y0}..{y1} fills an axis"
);
}
}
#[cfg(not(windows))]
#[test]
fn a_one_glyph_cluster_fits_as_its_bare_char_does() {
let cluster = paint_pinned("\x1b[4G\u{263A}\u{FE0F}".as_bytes(), 4);
let bare = paint_pinned("\x1b[4G\u{263A}".as_bytes(), 4);
let [a0, b0, a1, b1] = ink(&cluster.glyphs);
let [c0, d0, c1, d1] = ink(&bare.glyphs);
assert!(
((a1 - a0) - (c1 - c0)).abs() <= 1.0 && ((b1 - b0) - (d1 - d0)).abs() <= 1.0,
"cluster {}x{} vs bare {}x{}",
a1 - a0,
b1 - b0,
c1 - c0,
d1 - d0,
);
}
#[cfg(not(windows))]
#[test]
fn a_fitted_cluster_advances_its_pen_at_the_fitted_size() {
let cats = "\u{1F408}\u{200D}\u{1F408}";
let stack = FontStack::pinned_test_stack();
let mut grid = Grid::new(2, 8);
Parser::new().advance(
&mut grid,
format!("\x1b]66;w=1:s=2:n=3:d=4;{cats}\x07").as_bytes(),
);
let mut index = GlyphIndex::new(stack, 18.0, NonZeroU32::new(1024).unwrap());
let mut frame = ShapeFrame::empty();
GridWalker::default().walk(
&mut index,
grid.screen(),
&mut Shaper::new(),
&[],
&mut frame,
);
let ShapedCell::Cluster { start, len, fit_px } = frame.cell_at(0, 0) else {
panic!("not a cluster: {:?}", frame.cell_at(0, 0));
};
assert_ne!(fit_px, 0, "fitted");
let native = Shaper::new().shape_cluster(index.stack(), 18.0, FontStyle::REGULAR, cats);
let pool = frame.cluster_slice(start, len);
let expected = native.glyphs[0].advance_px * f32::from(fit_px) / (18.0 * 1.5);
assert!(
(pool[0].advance_px - expected).abs() < 1e-3,
"pool advance {} vs {expected}",
pool[0].advance_px
);
let theme = Theme::default();
let mut out = RowPaint::default();
CellPainter::new(
grid.screen(),
index.cell_metrics(),
&ResolvedTheme::new(&theme),
&index,
&frame,
)
.with_viewport(0, 2)
.extend_screen(&mut out);
let [first, second] = out.glyphs.as_slice() else {
panic!("two cats: {} quads", out.glyphs.len());
};
let step = second.origin_px[0] - first.origin_px[0];
let fitted_advance = native.glyphs[0].advance_px * f32::from(fit_px) / 18.0;
assert!(
(step - fitted_advance).abs() < 1e-3,
"step {step} vs the fitted advance {fitted_advance}"
);
}
}