mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
* fix(terminal): detect links across wrapped and hard-newline rows A URL that spans terminal rows was truncated at the first row edge, so Cmd-hover underline and Cmd-click only saw the first line. Resolve links over the joined logical line instead: soft-wrapped rows were already stitched, and a new hard-wrap bridge joins rows a program split with a literal newline when the row is full to the edge with a link char that continues into the next row. Double-click smart-select keeps its word boundaries and never bridges. HoveredLink now spans grid points across rows and the underline paints every covered cell. * fix(terminal): never bridge a hard newline into a URL authority The hard-wrap bridge joins two rows when the first is filled to the right edge with a link char and the next opens with one. A hard newline carries no signal about whether the producer split a URL, so a *complete* URL ending exactly at the right edge is bridged onto the next row's first token (`.../a` + `README.md` resolves as `.../aREADME.md`). That false positive is accepted: the head of a genuinely split URL is itself a valid URL, so there is no reliable test to tell the two apart, and the address bar shows the mistake. The same accident promoting the *second* row to the authority is not acceptable. `https://good.com` + `@evil.com/x` parses as userinfo per RFC 3986, so the real host becomes `evil.com` while the hover underline still reads `good.com` — a phishing hop wearing a trusted label. Refuse to bridge into a `@`. Soft wraps are unaffected: there the terminal folded one logical line, so the continuation is certain and a userinfo URL must still resolve whole. Also drop a dead `c != ' '` guard (`is_url_char(' ')` is already false) and apply rustfmt, which CI enforces as a required check. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2810 lines
110 KiB
Rust
2810 lines
110 KiB
Rust
//! GPUI element that paints an `alacritty_terminal` grid as a fixed character
|
||
//! matrix: background quads, shaped glyph runs, and a cursor overlay.
|
||
|
||
use std::cell::RefCell;
|
||
use std::collections::HashMap;
|
||
|
||
use alacritty_terminal::grid::Dimensions as _;
|
||
use alacritty_terminal::index::{Column as AlacColumn, Line as AlacLine, Point as AlacPoint};
|
||
use alacritty_terminal::selection::SelectionRange;
|
||
use alacritty_terminal::term::cell::{Cell, Flags};
|
||
use alacritty_terminal::vte::ansi::{Color as AnsiColor, CursorShape, NamedColor, Rgb};
|
||
use gpui::{
|
||
App, BorderStyle, Bounds, ContentMask, CursorStyle, Element, ElementId, Font, FontStyle,
|
||
FontWeight, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, Hsla, IntoElement, LayoutId,
|
||
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Rgba, SharedString,
|
||
Style, TextAlign, TextRun, Window, fill, outline, point, px, relative, size,
|
||
};
|
||
use gpui_component::ActiveTheme as _;
|
||
|
||
use super::view::TerminalView;
|
||
// NOTE: `gpui::CursorStyle` (mouse pointer) is already in scope above, so the
|
||
// config cursor-shape enum is always referred to fully-qualified as
|
||
// `crate::core::config::CursorStyle` to avoid the name clash.
|
||
use crate::core::config::Config;
|
||
|
||
/// Which underline variant the emulator asked for. The alacritty `Flags` bits
|
||
/// are independent, so we collapse them into one ordered style rather than a
|
||
/// bare bool — that lets the painter map curly → wavy and (best effort) vary
|
||
/// the rest instead of drawing every SGR 4:x the same.
|
||
#[derive(Clone, Copy, PartialEq, Default, Debug)]
|
||
enum UnderlineKind {
|
||
#[default]
|
||
None,
|
||
Single,
|
||
Double,
|
||
Curly,
|
||
Dotted,
|
||
Dashed,
|
||
}
|
||
|
||
/// Per-cell render data resolved from the emulator grid.
|
||
#[derive(Clone)]
|
||
struct RenderCell {
|
||
c: char,
|
||
/// Combining marks the emulator stacked on this cell: accents, variation
|
||
/// selectors, ZWJ-sequence tails. They carry no column of their own, but
|
||
/// dropping them changes what the shaper sees — `❤` and `❤\u{FE0F}` pick
|
||
/// different faces — so they ride along and get shaped with their base.
|
||
marks: Option<Box<[char]>>,
|
||
fg: Hsla,
|
||
bg: Hsla,
|
||
draw_bg: bool,
|
||
bold: bool,
|
||
italic: bool,
|
||
underline: UnderlineKind,
|
||
/// SGR 58 underline color (OSC-less, per-cell). `None` means "reuse the
|
||
/// glyph foreground", matching xterm's default.
|
||
underline_color: Option<Hsla>,
|
||
spacer: bool,
|
||
selected: bool,
|
||
/// Covered by a (non-current) search match.
|
||
match_hit: bool,
|
||
/// Covered by the current (focused) search match.
|
||
match_current: bool,
|
||
/// Part of the URL currently under the mouse; painted underlined so the link
|
||
/// reads as clickable.
|
||
link_hover: bool,
|
||
}
|
||
|
||
impl Default for RenderCell {
|
||
fn default() -> Self {
|
||
Self {
|
||
c: ' ',
|
||
marks: None,
|
||
fg: Hsla::default(),
|
||
bg: Hsla::default(),
|
||
draw_bg: false,
|
||
bold: false,
|
||
italic: false,
|
||
underline: UnderlineKind::None,
|
||
underline_color: None,
|
||
spacer: false,
|
||
selected: false,
|
||
match_hit: false,
|
||
match_current: false,
|
||
link_hover: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
pub struct TerminalElement {
|
||
view: gpui::Entity<TerminalView>,
|
||
}
|
||
|
||
impl TerminalElement {
|
||
pub fn new(view: gpui::Entity<TerminalView>) -> Self {
|
||
Self { view }
|
||
}
|
||
}
|
||
|
||
pub struct TermLayout {
|
||
cell_width: Pixels,
|
||
line_height: Pixels,
|
||
cols: usize,
|
||
rows: usize,
|
||
/// Hitbox over the grid, inserted in prepaint so paint can flip the cursor to
|
||
/// a pointing hand while a link is hovered.
|
||
hitbox: Hitbox,
|
||
}
|
||
|
||
fn to_hsla(c: Rgb) -> Hsla {
|
||
Rgba {
|
||
r: c.r as f32 / 255.,
|
||
g: c.g as f32 / 255.,
|
||
b: c.b as f32 / 255.,
|
||
a: 1.,
|
||
}
|
||
.into()
|
||
}
|
||
|
||
/// Resolve an alacritty color slot to RGB, honoring OSC overrides then falling
|
||
/// back to the static xterm palette / theme defaults.
|
||
fn resolve(
|
||
color: AnsiColor,
|
||
palette: &[Rgb; 256],
|
||
default_fg: Rgb,
|
||
default_bg: Rgb,
|
||
) -> (Rgb, bool) {
|
||
match color {
|
||
AnsiColor::Spec(rgb) => (rgb, false),
|
||
AnsiColor::Indexed(i) => (palette[i as usize], false),
|
||
AnsiColor::Named(named) => match named {
|
||
NamedColor::Foreground => (default_fg, true),
|
||
NamedColor::Background => (default_bg, true),
|
||
other => {
|
||
let idx = other as usize;
|
||
if idx < 256 {
|
||
(palette[idx], false)
|
||
} else {
|
||
(default_fg, true)
|
||
}
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
fn build_font(base: &Font, bold: bool, italic: bool) -> Font {
|
||
let mut f = base.clone();
|
||
f.weight = if bold {
|
||
FontWeight::BOLD
|
||
} else {
|
||
FontWeight::NORMAL
|
||
};
|
||
f.style = if italic {
|
||
FontStyle::Italic
|
||
} else {
|
||
FontStyle::Normal
|
||
};
|
||
// Batched runs shape several chars in one line, where a programming font's
|
||
// contextual ligatures (`calt`, e.g. Fira Code's "->") can fuse cells into
|
||
// one glyph and stress `force_width`'s one-glyph-per-column snapping. Keep
|
||
// the terminal-safe default unless the user explicitly configured OpenType
|
||
// features on the base font.
|
||
if f.features.tag_value_list().is_empty() {
|
||
f.features = gpui::FontFeatures::disable_ligatures();
|
||
}
|
||
f
|
||
}
|
||
|
||
/// Resolve one emulator cell into a `RenderCell`: colors (inverse/hidden
|
||
/// handling included), emphasis, underline style/color, and the selection
|
||
/// flag. `point` is the cell's grid-space position, used only for the
|
||
/// selection test. Wide-char spacers come back with just `spacer` set — the
|
||
/// leading cell paints them.
|
||
fn snapshot_cell(
|
||
cell: &Cell,
|
||
point: AlacPoint,
|
||
palette: &[Rgb; 256],
|
||
colors: &PaintColors,
|
||
selection: Option<&SelectionRange>,
|
||
) -> RenderCell {
|
||
let flags = cell.flags;
|
||
if flags.contains(Flags::WIDE_CHAR_SPACER) || flags.contains(Flags::LEADING_WIDE_CHAR_SPACER) {
|
||
return RenderCell {
|
||
spacer: true,
|
||
..RenderCell::default()
|
||
};
|
||
}
|
||
|
||
let inverse = flags.contains(Flags::INVERSE);
|
||
let (mut fgc, _) = resolve(cell.fg, palette, colors.fg_rgb, colors.bg_rgb);
|
||
let (bgc, bg_default) = resolve(cell.bg, palette, colors.fg_rgb, colors.bg_rgb);
|
||
let (fgc, bgc, draw_bg) = if inverse {
|
||
(bgc, fgc, true)
|
||
} else {
|
||
if flags.contains(Flags::HIDDEN) {
|
||
fgc = bgc;
|
||
}
|
||
(fgc, bgc, !bg_default)
|
||
};
|
||
|
||
let mut rc = RenderCell {
|
||
c: cell.c,
|
||
// `Cell::zerowidth` answers out of the same lazily-boxed `extra` that
|
||
// holds SGR 58 and OSC 8, so a cell carrying only an underline color or
|
||
// a hyperlink reports `Some(&[])`. Drop the empties: an empty mark list
|
||
// is still `Some`, which would pull every linked or SGR-58 cell off the
|
||
// batched run path onto its own `Cluster` — a `shape_line` per cell for
|
||
// a whole `ls --hyperlink` listing, and no powerline fast path either.
|
||
marks: cell
|
||
.zerowidth()
|
||
.filter(|marks| !marks.is_empty())
|
||
.map(Box::from),
|
||
fg: to_hsla(fgc),
|
||
bg: to_hsla(bgc),
|
||
draw_bg,
|
||
bold: flags.contains(Flags::BOLD) || flags.contains(Flags::BOLD_ITALIC),
|
||
italic: flags.contains(Flags::ITALIC) || flags.contains(Flags::BOLD_ITALIC),
|
||
// Map the specific underline bit → style. The variants are mutually
|
||
// exclusive in practice, but check the more specific bits first so a
|
||
// plain UNDERLINE never shadows them.
|
||
underline: if flags.contains(Flags::DOUBLE_UNDERLINE) {
|
||
UnderlineKind::Double
|
||
} else if flags.contains(Flags::UNDERCURL) {
|
||
UnderlineKind::Curly
|
||
} else if flags.contains(Flags::DOTTED_UNDERLINE) {
|
||
UnderlineKind::Dotted
|
||
} else if flags.contains(Flags::DASHED_UNDERLINE) {
|
||
UnderlineKind::Dashed
|
||
} else if flags.contains(Flags::UNDERLINE) {
|
||
UnderlineKind::Single
|
||
} else {
|
||
UnderlineKind::None
|
||
},
|
||
// SGR 58 underline color (falls back to fg at paint when absent).
|
||
underline_color: cell
|
||
.underline_color()
|
||
.map(|c| to_hsla(resolve(c, palette, colors.fg_rgb, colors.bg_rgb).0)),
|
||
..RenderCell::default()
|
||
};
|
||
|
||
// Selection only *flags* the cell: the paint pass lays a translucent wash
|
||
// over it and the cell keeps its own foreground and background — so colors
|
||
// whose information lives in the background (fastfetch swatches, colored
|
||
// diff blocks, TUI status bars) stay visible while selected, matching the
|
||
// inline editor's translucent selection.
|
||
if selection.is_some_and(|s| s.contains(point)) {
|
||
rc.selected = true;
|
||
}
|
||
rc
|
||
}
|
||
|
||
/// The active preset's terminal selection background, read from the
|
||
/// `ActivePalette` global. Falls back to a neutral dark tone if the global
|
||
/// isn't published yet (only possible before the first paint).
|
||
fn active_selection_bg(cx: &gpui::App) -> Rgb {
|
||
match cx.try_global::<crate::terminal::palette::ActivePalette>() {
|
||
Some(a) => a.sel_bg,
|
||
None => Rgb {
|
||
r: 0x4a,
|
||
g: 0x43,
|
||
b: 0x39,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Theme, selection, and search-highlight colors resolved once per paint pass,
|
||
/// so the grid builder and the painters share one consistent set instead of
|
||
/// each re-deriving them.
|
||
struct PaintColors {
|
||
default_fg: Hsla,
|
||
default_bg: Hsla,
|
||
caret: Hsla,
|
||
selection_bg: Hsla,
|
||
match_bg: Hsla,
|
||
current_match_bg: Hsla,
|
||
current_match_border: Hsla,
|
||
/// RGB defaults handed to the ANSI palette resolver.
|
||
fg_rgb: Rgb,
|
||
bg_rgb: Rgb,
|
||
}
|
||
|
||
impl PaintColors {
|
||
fn resolve(theme: &gpui_component::Theme, cx: &gpui::App) -> Self {
|
||
let default_fg = theme.foreground;
|
||
let default_bg = theme.background;
|
||
let caret = theme.caret;
|
||
// The selection paints as a *translucent* wash over the cells' own
|
||
// colors — like the inline editor's selection and VS Code — so
|
||
// selected text keeps its syntax colors and background-only cells
|
||
// (fastfetch swatches, colored diff blocks) stay visible instead of
|
||
// vanishing under an opaque fill. Foreground at 0.24 alpha mirrors the
|
||
// preset's opaque selection surface (`mix(bg, fg, 0.24)` — see
|
||
// `presets::active_palette`): on default-background cells it composites
|
||
// to exactly the tone the opaque fill used to have.
|
||
let selection_bg = {
|
||
let mut c = default_fg;
|
||
c.a = 0.24;
|
||
c
|
||
};
|
||
// Search highlights derive from the preset's selection surface
|
||
// (published as the `ActivePalette` global by `apply_theme`, tuned to
|
||
// keep text legible on both themes): non-current matches get a subtle
|
||
// wash, the current match a stronger fill plus an accent outline.
|
||
let base_match = to_hsla(active_selection_bg(cx));
|
||
let match_bg = {
|
||
let mut c = base_match;
|
||
c.a = 0.32;
|
||
c
|
||
};
|
||
let current_match_bg = {
|
||
let mut c = base_match;
|
||
c.a = 0.85;
|
||
c
|
||
};
|
||
Self {
|
||
default_fg,
|
||
default_bg,
|
||
caret,
|
||
selection_bg,
|
||
match_bg,
|
||
current_match_bg,
|
||
current_match_border: caret,
|
||
fg_rgb: super::palette::hsla_to_rgb(default_fg),
|
||
bg_rgb: super::palette::hsla_to_rgb(default_bg),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Paint per-cell background quads, merging each horizontal run of equal color
|
||
/// into a single quad. Background color varies per cell, so this can't share
|
||
/// the fixed-color `paint_cell_runs` helper.
|
||
fn paint_backgrounds(window: &mut Window, geom: &CellGeom, buf: &[RenderCell]) {
|
||
for row in 0..geom.rows {
|
||
let mut col = 0;
|
||
while col < geom.cols {
|
||
let cell = &buf[row * geom.cols + col];
|
||
if !cell.draw_bg {
|
||
col += 1;
|
||
continue;
|
||
}
|
||
let bg = cell.bg;
|
||
let start = col;
|
||
while col < geom.cols {
|
||
let c = &buf[row * geom.cols + col];
|
||
// Spacer cells (trailing half of a wide char) inherit the
|
||
// preceding cell's background — include them in the run.
|
||
if c.spacer || (c.draw_bg && c.bg == bg) {
|
||
col += 1;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
window.paint_quad(fill(geom.cell_rect(row, start, col - start), bg));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Paint a single fixed `color` over every horizontal run of cells matching
|
||
/// `covered`, merging contiguous cells into one quad. An optional `border`
|
||
/// draws an accent outline around each run (used by the current search match).
|
||
///
|
||
/// This collapses the selection, search-wash, and current-match overlays — all
|
||
/// previously copy-pasted run-merge loops — into one place.
|
||
fn paint_cell_runs(
|
||
window: &mut Window,
|
||
geom: &CellGeom,
|
||
buf: &[RenderCell],
|
||
color: Hsla,
|
||
border: Option<Hsla>,
|
||
mut covered: impl FnMut(&RenderCell) -> bool,
|
||
) {
|
||
for row in 0..geom.rows {
|
||
let mut col = 0;
|
||
while col < geom.cols {
|
||
if !covered(&buf[row * geom.cols + col]) {
|
||
col += 1;
|
||
continue;
|
||
}
|
||
let start = col;
|
||
while col < geom.cols {
|
||
let cell = &buf[row * geom.cols + col];
|
||
// Spacer cells are the trailing half of a wide (CJK) char.
|
||
// They inherit the preceding cell's highlight state, so include
|
||
// them in the run to paint the full 2-column width.
|
||
if covered(cell) || cell.spacer {
|
||
col += 1;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
let rect = geom.cell_rect(row, start, col - start);
|
||
window.paint_quad(fill(rect, color));
|
||
if let Some(b) = border {
|
||
window.paint_quad(outline(rect, b, BorderStyle::Solid));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The style facets that must agree for two cells to share one shaped run.
|
||
/// Everything here feeds the `TextRun` (face, color, underline); backgrounds,
|
||
/// selection and search washes live in separate paint layers and don't split
|
||
/// glyph runs.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
struct GlyphStyle {
|
||
fg: Hsla,
|
||
bold: bool,
|
||
italic: bool,
|
||
underline: UnderlineKind,
|
||
underline_color: Option<Hsla>,
|
||
link_hover: bool,
|
||
}
|
||
|
||
impl GlyphStyle {
|
||
fn of(cell: &RenderCell) -> Self {
|
||
Self {
|
||
fg: cell.fg,
|
||
bold: cell.bold,
|
||
italic: cell.italic,
|
||
underline: cell.underline,
|
||
underline_color: cell.underline_color,
|
||
link_hover: cell.link_hover,
|
||
}
|
||
}
|
||
|
||
/// Whether this style paints ink even on blank cells (an underline does),
|
||
/// which forbids batching across space gaps: a lone space was never
|
||
/// underlined by the per-cell painter, and batching must not change that.
|
||
fn draws_on_blanks(&self) -> bool {
|
||
self.underline != UnderlineKind::None || self.link_hover
|
||
}
|
||
|
||
/// Underline for either an emulator-styled underline or a hovered link (a
|
||
/// hovered link with no underline of its own reads as a plain single line
|
||
/// so it looks clickable).
|
||
fn underline_style(&self) -> Option<gpui::UnderlineStyle> {
|
||
self.draws_on_blanks().then(|| {
|
||
// gpui's `UnderlineStyle` only exposes `thickness` / `color` /
|
||
// `wavy`, so curly maps to `wavy` and double gets a thicker
|
||
// line. TODO: gpui has no dotted/dashed primitive, so those
|
||
// fall back to a straight single line for now.
|
||
let wavy = self.underline == UnderlineKind::Curly;
|
||
let thickness = if self.underline == UnderlineKind::Double {
|
||
px(2.)
|
||
} else {
|
||
px(1.)
|
||
};
|
||
gpui::UnderlineStyle {
|
||
thickness,
|
||
// SGR 58 color when set, else the glyph's own foreground so
|
||
// the line reads as part of the text it sits on.
|
||
color: Some(self.underline_color.unwrap_or(self.fg)),
|
||
wavy,
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/// A blank cell paints no glyph (and today, no underline either — see
|
||
/// `GlyphStyle::draws_on_blanks`). A blank carrying combining marks is not
|
||
/// blank: a mark that opens a line lands on the space the grid starts with, and
|
||
/// it still has to be drawn.
|
||
fn is_blank(cell: &RenderCell) -> bool {
|
||
(cell.c == '\0' || cell.c == ' ') && cell.marks.is_none()
|
||
}
|
||
|
||
/// One paintable piece of a row produced by [`segment_row`].
|
||
#[derive(Debug, PartialEq)]
|
||
enum RowSeg {
|
||
/// Style-identical ASCII cells (with interior blank gaps rendered as
|
||
/// spaces), shaped as a single line.
|
||
Run {
|
||
start: usize,
|
||
/// Columns covered, gaps included — the clip width in cells.
|
||
cells: usize,
|
||
text: String,
|
||
},
|
||
/// One wide (two-column) glyph — CJK text, wide emoji — shaped on its own
|
||
/// and pinned to `2 × cell_width`.
|
||
///
|
||
/// These used to batch into multi-glyph runs, which was wrong. gpui's
|
||
/// `apply_force_width_to_layout` distinguishes a base glyph from a
|
||
/// zero-advance combining mark by asking whether the shaped x advanced by
|
||
/// more than *half* the forced width — and fullwidth punctuation fails
|
||
/// that test: `(` (U+FF08) advances 0.472 em in the common CJK faces,
|
||
/// while half a two-cell slot is 0.6 em against a 0.6 em primary. The
|
||
/// glyph *after* such a character was therefore treated as a mark, painted
|
||
/// at the punctuation's own advance instead of the next column, and the
|
||
/// two overlapped (every following glyph in the batch then sat one column
|
||
/// early until the run ended).
|
||
///
|
||
/// Shaping one glyph per line sidesteps the heuristic entirely: the first
|
||
/// glyph of a line is unconditionally a base. The cost is one `shape_line`
|
||
/// per wide glyph instead of per run, which the layout cache absorbs —
|
||
/// it is keyed on text + font + size + force_width, so a per-character key
|
||
/// recurs far more often than a per-phrase one.
|
||
Wide {
|
||
start: usize,
|
||
/// Columns covered — always 2 (the glyph plus its spacer).
|
||
cells: usize,
|
||
/// Interned via [`char_string`], so re-painting a screen full of CJK
|
||
/// allocates nothing: the same `SharedString` is handed to `shape_line`
|
||
/// every frame, which is also what keys its layout cache.
|
||
text: SharedString,
|
||
},
|
||
/// A cell painted on its own: any single-width non-ASCII glyph (box
|
||
/// drawing, accented Latin, …) that may route to a fallback face whose
|
||
/// advance isn't the cell width.
|
||
Solo { col: usize },
|
||
/// A base with everything that has to shape alongside it — the combining
|
||
/// marks stacked on it, and a following SARA AM — as one string, so the
|
||
/// shaper sees the whole cluster. Never batched with neighbours: marks add
|
||
/// characters without adding columns, which is exactly the correspondence
|
||
/// `force_width` relies on in a [`RowSeg::Run`] or [`RowSeg::Wide`].
|
||
///
|
||
/// An absorbed SARA AM takes the base's style rather than its own. Unlike
|
||
/// a [`RowSeg::Run`], the cluster can't break on a style change: split off,
|
||
/// SARA AM has no base to reorder its nikhahit onto and renders as a dotted
|
||
/// circle. A recoloured vowel beats a broken one.
|
||
Cluster {
|
||
col: usize,
|
||
/// Columns the whole cluster occupies — 2 for a wide base, or for a
|
||
/// narrow base that absorbed a following SARA AM.
|
||
cells: usize,
|
||
text: String,
|
||
/// Whether `cells == 2` because the *base* is wide, rather than because
|
||
/// a spacing character joined it. The two need opposite pinning: a wide
|
||
/// base is one glyph across two columns, an absorbed SARA AM is two
|
||
/// glyphs of one column each.
|
||
wide_base: bool,
|
||
},
|
||
}
|
||
|
||
/// Append a cell's character followed by any combining marks riding on it.
|
||
fn push_cell(text: &mut String, cell: &RenderCell) {
|
||
text.push(cell.c);
|
||
text.extend(cell.marks.iter().flat_map(|marks| marks.iter()));
|
||
}
|
||
|
||
/// SARA AM (Thai U+0E33, Lao U+0EB3) is `Lo` and owns a column, but it is not
|
||
/// atomic to the shaper: the Thai shaper decomposes it into NIKHAHIT + SARA AA
|
||
/// and moves the nikhahit backwards over any above-base marks onto the base
|
||
/// consonant. Shaped in a run of its own it has no base to reorder onto, and
|
||
/// comes out as a dotted circle.
|
||
fn is_sara_am(c: char) -> bool {
|
||
matches!(c, '\u{0E33}' | '\u{0EB3}')
|
||
}
|
||
|
||
/// Does `col` hold a SARA AM that should join the preceding cell's cluster?
|
||
fn sara_am_at(row: &[RenderCell], col: usize) -> Option<&RenderCell> {
|
||
row.get(col)
|
||
.filter(|cell| !cell.spacer && is_sara_am(cell.c))
|
||
}
|
||
|
||
/// Split one grid row into paintable segments.
|
||
///
|
||
/// ASCII-graphic cells batch into [`RowSeg::Run`]s: they always come from the
|
||
/// primary monospace face, so their advances match the cell width (and
|
||
/// `force_width` pins them exactly at paint). Blank cells end an underlined
|
||
/// run, silently join a plain one, and never lead or trail a run.
|
||
///
|
||
/// Wide glyphs (a cell followed by its spacer — the grid's authoritative
|
||
/// two-column marker) each become their own [`RowSeg::Wide`], shaped alone and
|
||
/// pinned by `force_width` at `2 × cell_width`. They are deliberately *not*
|
||
/// batched: a full-width advance is not always > half the forced width (CJK
|
||
/// fullwidth punctuation is ~0.47 em), which breaks `apply_force_width_to_layout`'s
|
||
/// base-vs-combining-mark test — see [`RowSeg::Wide`].
|
||
///
|
||
/// Single-width non-ASCII glyphs still paint solo, preserving the per-cell
|
||
/// behavior for glyphs with unpredictable advances (box drawing must fill its
|
||
/// cell exactly, so per-cell clipping is deliberate there).
|
||
fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> {
|
||
let mut segs = Vec::new();
|
||
let mut col = 0;
|
||
while col < row.len() {
|
||
let cell = &row[col];
|
||
if cell.spacer || is_blank(cell) {
|
||
col += 1;
|
||
continue;
|
||
}
|
||
// Combining marks come first: they can sit on an ASCII base too, and
|
||
// either way the whole cluster has to reach the shaper in one string.
|
||
if let Some(marks) = &cell.marks {
|
||
let wide_base = col + 1 < row.len() && row[col + 1].spacer;
|
||
let mut cells = if wide_base { 2 } else { 1 };
|
||
let mut text = String::with_capacity(1 + marks.len());
|
||
push_cell(&mut text, cell);
|
||
// A wide base already owns both columns, so only a narrow one has a
|
||
// column spare for SARA AM to join it in. A SARA AM is not itself a
|
||
// base to absorb onto — two in a row stay separate.
|
||
if !wide_base
|
||
&& !is_sara_am(cell.c)
|
||
&& let Some(am) = sara_am_at(row, col + 1)
|
||
{
|
||
push_cell(&mut text, am);
|
||
cells = 2;
|
||
}
|
||
segs.push(RowSeg::Cluster {
|
||
col,
|
||
cells,
|
||
text,
|
||
wide_base,
|
||
});
|
||
col += cells;
|
||
continue;
|
||
}
|
||
if !cell.c.is_ascii_graphic() {
|
||
// Wide (two-column) glyph? The trailing spacer is the grid's own
|
||
// width marker, so no Unicode width guessing is needed.
|
||
if col + 1 < row.len() && row[col + 1].spacer {
|
||
// One segment per glyph, deliberately unbatched — see the
|
||
// `RowSeg::Wide` docs for why batching can't be made safe.
|
||
segs.push(RowSeg::Wide {
|
||
start: col,
|
||
cells: 2,
|
||
text: char_string(cell.c),
|
||
});
|
||
col += 2;
|
||
} else if !is_sara_am(cell.c)
|
||
&& let Some(am) = sara_am_at(row, col + 1)
|
||
{
|
||
// An unmarked base still has to shape with its SARA AM. A
|
||
// baseless SARA AM is not a base for the next one: absorbing
|
||
// there would pin the second one's glyphs outside the cluster's
|
||
// clip, so two in a row stay separate and both stay visible.
|
||
let mut text = String::with_capacity(2);
|
||
push_cell(&mut text, cell);
|
||
push_cell(&mut text, am);
|
||
segs.push(RowSeg::Cluster {
|
||
col,
|
||
cells: 2,
|
||
text,
|
||
wide_base: false,
|
||
});
|
||
col += 2;
|
||
} else {
|
||
segs.push(RowSeg::Solo { col });
|
||
col += 1;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let style = GlyphStyle::of(cell);
|
||
let start = col;
|
||
let mut text = String::new();
|
||
text.push(cell.c);
|
||
let mut cells = 1;
|
||
col += 1;
|
||
// Blanks between words may extend the run (they paint nothing), but are
|
||
// committed only once another matching glyph follows, so a run never
|
||
// carries trailing blanks.
|
||
let mut gap = 0;
|
||
while col < row.len() {
|
||
let c = &row[col];
|
||
if is_blank(c) && !c.spacer {
|
||
if style.draws_on_blanks() {
|
||
break;
|
||
}
|
||
gap += 1;
|
||
col += 1;
|
||
continue;
|
||
}
|
||
if c.spacer
|
||
|| c.marks.is_some()
|
||
|| !c.c.is_ascii_graphic()
|
||
|| GlyphStyle::of(c) != style
|
||
{
|
||
break;
|
||
}
|
||
for _ in 0..gap {
|
||
text.push(' ');
|
||
}
|
||
cells += gap;
|
||
gap = 0;
|
||
text.push(c.c);
|
||
cells += 1;
|
||
col += 1;
|
||
}
|
||
segs.push(RowSeg::Run { start, cells, text });
|
||
}
|
||
segs
|
||
}
|
||
|
||
thread_local! {
|
||
/// Single-char `SharedString`s memoized across frames and panes (the UI
|
||
/// thread paints everything). Solo glyphs used to allocate a fresh String
|
||
/// per cell per frame — thousands of allocations per paint on a CJK-dense
|
||
/// screen. Entries are font-independent so they never go stale; the map is
|
||
/// cleared wholesale only if a pathological stream floods it with unique
|
||
/// codepoints.
|
||
static CHAR_STRINGS: RefCell<HashMap<char, SharedString>> = RefCell::new(HashMap::new());
|
||
|
||
/// The grid snapshot buffer, reused across frames and panes (the UI thread
|
||
/// paints everything sequentially). A full-screen grid is tens of
|
||
/// thousands of `RenderCell`s (~2 MB); remaking that Vec every paint was
|
||
/// pure allocator churn. Taken (`mem::take`) for the duration of one
|
||
/// element's paint and put back after, so panes share one allocation.
|
||
static GRID_BUF: RefCell<Vec<RenderCell>> = const { RefCell::new(Vec::new()) };
|
||
}
|
||
|
||
fn char_string(c: char) -> SharedString {
|
||
CHAR_STRINGS.with(|m| {
|
||
let mut m = m.borrow_mut();
|
||
if m.len() > 32_768 {
|
||
m.clear();
|
||
}
|
||
m.entry(c)
|
||
.or_insert_with(|| SharedString::from(c.to_string()))
|
||
.clone()
|
||
})
|
||
}
|
||
|
||
/// The Nerd Font powerline separators tty7 draws natively — as gpui paths
|
||
/// sized to the exact cell — instead of rasterizing a font glyph.
|
||
///
|
||
/// These glyphs are pure geometry that only reads right when it fills the cell
|
||
/// edge-to-edge: prompt themes (powerlevel10k, oh-my-posh, starship) butt them
|
||
/// against colored segment backgrounds, so any gap or overshoot shows as a
|
||
/// seam. Font rasterization can't guarantee that fit — the primary font rarely
|
||
/// covers these codepoints, and a fallback face renders them at *its own*
|
||
/// advance, narrower or wider than our cell (issue #17: separators at
|
||
/// two-thirds width from a mismatched fallback). Building the shape from the
|
||
/// cell rect makes it exact for every font/size combination — the approach
|
||
/// kitty settled on with its programmatic glyphs.
|
||
/// The thin/outline variants (U+E0B1, U+E0B3, …) stay on the font path: they
|
||
/// are hairline strokes, not fills, and the bundled Hack covers the common
|
||
/// ones.
|
||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||
enum PowerlineShape {
|
||
/// U+E0B0 — solid right-pointing triangle (the classic left separator).
|
||
TriangleRight,
|
||
/// U+E0B2 — solid left-pointing triangle (right-prompt separator).
|
||
TriangleLeft,
|
||
/// U+E0B4 — solid half-circle bulging right (rounded cap/separator).
|
||
HalfCircleRight,
|
||
/// U+E0B6 — solid half-circle bulging left.
|
||
HalfCircleLeft,
|
||
/// U+E0B8 — solid slant triangle filling the lower-left half.
|
||
SlantLowerLeft,
|
||
/// U+E0BA — solid slant triangle filling the lower-right half.
|
||
SlantLowerRight,
|
||
/// U+E0BC — solid slant triangle filling the upper-left half.
|
||
SlantUpperLeft,
|
||
/// U+E0BE — solid slant triangle filling the upper-right half.
|
||
SlantUpperRight,
|
||
}
|
||
|
||
impl PowerlineShape {
|
||
fn of(c: char) -> Option<Self> {
|
||
Some(match c {
|
||
'\u{e0b0}' => Self::TriangleRight,
|
||
'\u{e0b2}' => Self::TriangleLeft,
|
||
'\u{e0b4}' => Self::HalfCircleRight,
|
||
'\u{e0b6}' => Self::HalfCircleLeft,
|
||
'\u{e0b8}' => Self::SlantLowerLeft,
|
||
'\u{e0ba}' => Self::SlantLowerRight,
|
||
'\u{e0bc}' => Self::SlantUpperLeft,
|
||
'\u{e0be}' => Self::SlantUpperRight,
|
||
_ => return None,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Build the fill path for one powerline shape spanning exactly `bounds`
|
||
/// (one cell). Pure geometry, split from the painting so tests can check it.
|
||
///
|
||
/// The half-circles approximate each quarter-ellipse with a single quadratic
|
||
/// Bézier through the corner control point — within ~7% of a true ellipse,
|
||
/// indistinguishable at cell sizes and the same trade kitty makes.
|
||
fn powerline_path(bounds: Bounds<Pixels>, shape: PowerlineShape) -> gpui::Path<Pixels> {
|
||
let (x0, y0) = (bounds.origin.x, bounds.origin.y);
|
||
let (x1, y1) = (x0 + bounds.size.width, y0 + bounds.size.height);
|
||
let ymid = y0 + bounds.size.height / 2.;
|
||
|
||
let tri = |a: Point<Pixels>, b: Point<Pixels>, c: Point<Pixels>| {
|
||
let mut p = gpui::Path::new(a);
|
||
p.line_to(b);
|
||
p.line_to(c);
|
||
p
|
||
};
|
||
match shape {
|
||
PowerlineShape::TriangleRight => tri(point(x0, y0), point(x1, ymid), point(x0, y1)),
|
||
PowerlineShape::TriangleLeft => tri(point(x1, y0), point(x0, ymid), point(x1, y1)),
|
||
PowerlineShape::SlantLowerLeft => tri(point(x0, y0), point(x1, y1), point(x0, y1)),
|
||
PowerlineShape::SlantLowerRight => tri(point(x1, y0), point(x1, y1), point(x0, y1)),
|
||
PowerlineShape::SlantUpperLeft => tri(point(x0, y0), point(x1, y0), point(x0, y1)),
|
||
PowerlineShape::SlantUpperRight => tri(point(x0, y0), point(x1, y0), point(x1, y1)),
|
||
PowerlineShape::HalfCircleRight => {
|
||
// Flat edge on the left; the fan fill from the start point closes
|
||
// it implicitly (start → last point is that straight edge).
|
||
let mut p = gpui::Path::new(point(x0, y0));
|
||
p.curve_to(point(x1, ymid), point(x1, y0));
|
||
p.curve_to(point(x0, y1), point(x1, y1));
|
||
p
|
||
}
|
||
PowerlineShape::HalfCircleLeft => {
|
||
let mut p = gpui::Path::new(point(x1, y0));
|
||
p.curve_to(point(x0, ymid), point(x0, y0));
|
||
p.curve_to(point(x1, y1), point(x0, y1));
|
||
p
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What a natively-drawn cell — a Powerline separator or a box-drawing/block
|
||
/// character, both painted as geometry rather than as a font glyph — still has
|
||
/// to send through the text pipeline after its ink is on screen.
|
||
///
|
||
/// `None` for the common case: the geometry *is* the whole cell, so the shaping
|
||
/// and painting below can be skipped entirely.
|
||
///
|
||
/// `Some(' ')` when the style draws on blanks, i.e. it carries an underline (or
|
||
/// is part of a hovered link). Underlines are not painted per-cell — they ride
|
||
/// on the [`TextRun`] that `paint_glyphs` builds, so a cell that returns early
|
||
/// silently loses its underline, leaving a one-column hole in an `ESC[4m` span
|
||
/// or a hovered URL. Shaping a *space* in the cell's own style closes the hole:
|
||
/// a space puts no glyph ink over the geometry already painted, and gpui draws
|
||
/// the line from the same [`gpui::UnderlineStyle`] (curly and double included)
|
||
/// it uses for every other cell, so weight, offset and colour match exactly.
|
||
/// The space comes from the primary monospace face, whose advance *is* the cell
|
||
/// width, so it needs no `force_width` to cover its column.
|
||
fn native_cell_residue(style: &GlyphStyle) -> Option<char> {
|
||
style.draws_on_blanks().then_some(' ')
|
||
}
|
||
|
||
/// The width `paint_glyphs` clips a segment's paint to.
|
||
///
|
||
/// A batched `Run`/`Wide` segment clips to its exact column span (`cells`
|
||
/// columns): its glyphs come from faces whose advance matches the cell, so
|
||
/// nothing should spill past that span. A lone `solo` glyph is different — it
|
||
/// can be a symbol whose face paints ink well past the single cell the grid
|
||
/// reserved for it: a non-Mono Nerd Font sets a *one-cell advance* on its icons
|
||
/// yet draws up to ~1.9 cells of ink (measured across Hasklug / Meslo /
|
||
/// JetBrainsMono NF), and the OS cascade serves a proportional `➜`/`❯` the same
|
||
/// way. Clipping that to one cell severs the glyph mid-ink — the incomplete
|
||
/// icons and the cut-off arrow in issue #17.
|
||
///
|
||
/// Advance is no signal there (it reads one cell for exactly those overflowing
|
||
/// icons), so a solo glyph gets a two-cell window instead. A glyph that already
|
||
/// fits is untouched — it has no ink to spill — while a symbol that overflows
|
||
/// renders whole, bleeding into a trailing blank the way iTerm2 and Terminal.app
|
||
/// do with non-Mono faces. The two-cell bound keeps a pathological face from
|
||
/// smearing a lone glyph across the row.
|
||
fn seg_clip_width(solo: bool, cells: usize, cell_width: Pixels) -> Pixels {
|
||
if solo {
|
||
cell_width * 2.
|
||
} else {
|
||
cell_width * cells as f32
|
||
}
|
||
}
|
||
|
||
/// Paint glyphs as per-row batched runs where safe, single cells otherwise.
|
||
///
|
||
/// Merging cells into multi-char `shape_line` runs causes drift whenever a
|
||
/// glyph's font advance ≠ cell_width. gpui's `force_width` (added upstream for
|
||
/// Zed's terminal) pins every glyph in a shaped line to its own column, which
|
||
/// makes batching safe when all glyphs in the line occupy the same number of
|
||
/// columns *and* every one of them clears its base-glyph test — true for
|
||
/// style-identical ASCII runs at `cell_width` per glyph, false for wide glyphs
|
||
/// (see [`RowSeg::Wide`]), which shape one per line at `2 × cell_width`.
|
||
/// Single-width glyphs that may come from a fallback face (box drawing, …)
|
||
/// still paint cell-by-cell: their advances are unpredictable and mixing
|
||
/// widths inside one batch would break `force_width`'s uniform-column
|
||
/// assumption. Powerline separators skip fonts entirely — see
|
||
/// [`PowerlineShape`].
|
||
fn paint_glyphs(
|
||
window: &mut Window,
|
||
cx: &mut App,
|
||
geom: &CellGeom,
|
||
buf: &[RenderCell],
|
||
font_size: Pixels,
|
||
base_font: &Font,
|
||
bold_font: Option<&Font>,
|
||
italic_font: Option<&Font>,
|
||
) {
|
||
// The four style faces, resolved once per paint instead of once per cell.
|
||
// A distinct bold/italic family applies when configured (bold wins for
|
||
// bold+italic cells); `build_font` synthesizes the emphasis otherwise.
|
||
let faces = [
|
||
build_font(base_font, false, false),
|
||
build_font(bold_font.unwrap_or(base_font), true, false),
|
||
build_font(italic_font.unwrap_or(base_font), false, true),
|
||
build_font(bold_font.unwrap_or(base_font), true, true),
|
||
];
|
||
let face = |bold: bool, italic: bool| &faces[(bold as usize) | ((italic as usize) << 1)];
|
||
|
||
let run_buf = &mut [TextRun {
|
||
len: 0,
|
||
font: base_font.clone(),
|
||
color: Hsla::default(),
|
||
background_color: None,
|
||
underline: None,
|
||
strikethrough: None,
|
||
}];
|
||
|
||
for row in 0..geom.rows {
|
||
let row_base = row * geom.cols;
|
||
let y = geom.origin.y + geom.line_height * (row as f32);
|
||
|
||
for seg in segment_row(&buf[row_base..row_base + geom.cols]) {
|
||
let (start, cells, text, force_width, solo) = match seg {
|
||
RowSeg::Run { start, cells, text } => (
|
||
start,
|
||
cells,
|
||
SharedString::from(text),
|
||
Some(geom.cell_width),
|
||
false,
|
||
),
|
||
// Each wide glyph is pinned to its own two-column slot; the
|
||
// clip stops an oversized fallback glyph bleeding past the run.
|
||
RowSeg::Wide { start, cells, text } => {
|
||
(start, cells, text, Some(geom.cell_width * 2.), false)
|
||
}
|
||
// Always exactly one column now — anything with a trailing
|
||
// spacer became a Wide run in `segment_row`. No `force_width`
|
||
// for a single glyph — it paints at the run origin regardless.
|
||
RowSeg::Solo { col } => {
|
||
let cell = &buf[row_base + col];
|
||
let cell_bounds = Bounds::new(
|
||
point(geom.origin.x + geom.cell_width * (col as f32), y),
|
||
size(geom.cell_width, geom.line_height),
|
||
);
|
||
// Two families paint as native geometry rather than as a
|
||
// font glyph: Powerline separators, and the box-drawing /
|
||
// block characters (`boxdraw`) — a font glyph only covers
|
||
// the font's own line height, which broke every vertical
|
||
// run of `│`/`╭`/`╰` into dashes at line_height > 1.0.
|
||
// Either way the cell may still owe an underline, so this
|
||
// records whether the ink is already down rather than
|
||
// returning outright.
|
||
let native = if let Some(shape) = PowerlineShape::of(cell.c) {
|
||
let path = powerline_path(cell_bounds, shape);
|
||
window.paint_path(path, GlyphStyle::of(cell).fg);
|
||
true
|
||
} else if let Some(ink) =
|
||
super::boxdraw::glyph(cell.c, cell_bounds, window.scale_factor())
|
||
{
|
||
let fg = GlyphStyle::of(cell).fg;
|
||
for piece in ink {
|
||
match piece {
|
||
super::boxdraw::Ink::Rect(r) => window.paint_quad(fill(r, fg)),
|
||
super::boxdraw::Ink::Shade(r, alpha) => {
|
||
let mut c = fg;
|
||
c.a *= alpha;
|
||
window.paint_quad(fill(r, c));
|
||
}
|
||
super::boxdraw::Ink::Path(p) => window.paint_path(p, fg),
|
||
}
|
||
}
|
||
true
|
||
} else {
|
||
false
|
||
};
|
||
if !native {
|
||
(col, 1, char_string(cell.c), None, true)
|
||
} else {
|
||
match native_cell_residue(&GlyphStyle::of(cell)) {
|
||
None => continue,
|
||
// `solo: false` clips the space to its own single
|
||
// column so the underline can't spill sideways.
|
||
Some(c) => (col, 1, char_string(c), None, false),
|
||
}
|
||
}
|
||
}
|
||
// Same pinning as the batched runs, just for one base: two
|
||
// columns get `force_width` so a fallback emoji face can't
|
||
// drift, one column paints at the origin like `Solo`.
|
||
// Two columns pin per *base glyph*, and which that is depends
|
||
// on why the cluster is two cells wide: a wide base is one
|
||
// glyph spanning both, an absorbed SARA AM is two glyphs of one
|
||
// column each. `force_width` classifies by advance, so the
|
||
// marks ride their base under either. One column paints at the
|
||
// origin like `Solo`.
|
||
RowSeg::Cluster {
|
||
col,
|
||
cells,
|
||
text,
|
||
wide_base,
|
||
} => (
|
||
col,
|
||
cells,
|
||
SharedString::from(text),
|
||
(cells == 2).then(|| geom.cell_width * if wide_base { 2. } else { 1. }),
|
||
cells == 1,
|
||
),
|
||
};
|
||
|
||
let style = GlyphStyle::of(&buf[row_base + start]);
|
||
run_buf[0] = TextRun {
|
||
len: text.len(),
|
||
font: face(style.bold, style.italic).clone(),
|
||
color: style.fg,
|
||
background_color: None,
|
||
underline: style.underline_style(),
|
||
strikethrough: None,
|
||
};
|
||
|
||
let shaped = window
|
||
.text_system()
|
||
.shape_line(text, font_size, run_buf, force_width);
|
||
|
||
let x = geom.origin.x + geom.cell_width * (start as f32);
|
||
// Batched runs clip to their exact column span; a solo glyph gets a
|
||
// two-cell window so a symbol/icon face whose ink overflows its cell
|
||
// isn't severed (see `seg_clip_width` — issue #17).
|
||
let clip_width = seg_clip_width(solo, cells, geom.cell_width);
|
||
let clip = Bounds::new(point(x, y), size(clip_width, geom.line_height));
|
||
window.with_content_mask(Some(ContentMask { bounds: clip }), |window| {
|
||
_ = shaped.paint(
|
||
point(x, y),
|
||
geom.line_height,
|
||
TextAlign::Left,
|
||
None,
|
||
window,
|
||
cx,
|
||
);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Where the emulator's cursor sits, plus whether the app has hidden it
|
||
/// (DECTCEM off / `CursorShape::Hidden`). The position is tracked even while
|
||
/// hidden so the IME candidate window can anchor to it.
|
||
#[derive(Clone, Copy)]
|
||
struct GridCursor {
|
||
row: usize,
|
||
col: usize,
|
||
hidden: bool,
|
||
/// The shape to draw after resolving terminal DECSCUSR/default state.
|
||
style: crate::core::config::CursorStyle,
|
||
}
|
||
|
||
/// Paint the cursor overlay in the configured shape: a filled block, a thin
|
||
/// vertical bar, or an underline in the blink "on" phase when focused (nothing in
|
||
/// the "off" phase, so it blinks). When unfocused every shape falls back to a
|
||
/// static hollow block outline — the conventional "not the active pane" cue,
|
||
/// independent of the shape choice.
|
||
fn paint_cursor(
|
||
window: &mut Window,
|
||
geom: &CellGeom,
|
||
cursor: Option<(usize, usize, crate::core::config::CursorStyle)>,
|
||
focused: bool,
|
||
cursor_visible: bool,
|
||
caret: Hsla,
|
||
) {
|
||
use crate::core::config::CursorStyle;
|
||
let Some((row, col, style)) = cursor else {
|
||
return;
|
||
};
|
||
let rect = geom.cell_rect(row, col, 1);
|
||
if !focused {
|
||
let mut c = caret;
|
||
c.a = 0.5;
|
||
window.paint_quad(outline(rect, c, BorderStyle::Solid));
|
||
return;
|
||
}
|
||
if !cursor_visible {
|
||
return; // blink "off" phase
|
||
}
|
||
let mut c = caret;
|
||
c.a = 0.55;
|
||
match style {
|
||
CursorStyle::Block => window.paint_quad(fill(rect, c)),
|
||
CursorStyle::Bar => {
|
||
// A 2px vertical bar hugging the cell's left edge, scaled up a touch
|
||
// on very large fonts so it stays visible.
|
||
let w = (geom.cell_width * 0.15).max(px(1.)).min(px(3.));
|
||
let bar = Bounds::new(rect.origin, size(w, rect.size.height));
|
||
window.paint_quad(fill(bar, c));
|
||
}
|
||
CursorStyle::Underline => {
|
||
// A thin line along the cell's baseline, same thickness logic.
|
||
let h = (geom.line_height * 0.12).max(px(1.)).min(px(3.));
|
||
let y = rect.origin.y + rect.size.height - h;
|
||
let line = Bounds::new(point(rect.origin.x, y), size(rect.size.width, h));
|
||
window.paint_quad(fill(line, c));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn cursor_style_from_shape(shape: CursorShape) -> crate::core::config::CursorStyle {
|
||
match shape {
|
||
CursorShape::Beam => crate::core::config::CursorStyle::Bar,
|
||
CursorShape::Underline => crate::core::config::CursorStyle::Underline,
|
||
_ => crate::core::config::CursorStyle::Block,
|
||
}
|
||
}
|
||
|
||
/// Paint IME pre-edit (composing) text over the cursor cell, underlined so it
|
||
/// reads as provisional.
|
||
fn paint_marked(
|
||
window: &mut Window,
|
||
cx: &mut App,
|
||
geom: &CellGeom,
|
||
cursor: Option<(usize, usize)>,
|
||
marked: &str,
|
||
font_size: Pixels,
|
||
base_font: &Font,
|
||
default_fg: Hsla,
|
||
default_bg: Hsla,
|
||
) {
|
||
if marked.is_empty() {
|
||
return;
|
||
}
|
||
let Some((row, col)) = cursor else {
|
||
return;
|
||
};
|
||
let x = geom.origin.x + geom.cell_width * (col as f32);
|
||
let y = geom.origin.y + geom.line_height * (row as f32);
|
||
let run = TextRun {
|
||
len: marked.len(),
|
||
font: base_font.clone(),
|
||
color: default_fg,
|
||
background_color: None,
|
||
underline: Some(gpui::UnderlineStyle {
|
||
thickness: px(1.),
|
||
color: Some(default_fg),
|
||
wavy: false,
|
||
}),
|
||
strikethrough: None,
|
||
};
|
||
let shaped = window.text_system().shape_line(
|
||
SharedString::from(marked.to_owned()),
|
||
font_size,
|
||
&[run],
|
||
None,
|
||
);
|
||
let bg_rect = Bounds::new(point(x, y), size(shaped.width, geom.line_height));
|
||
window.paint_quad(fill(bg_rect, default_bg));
|
||
_ = shaped.paint(
|
||
point(x, y),
|
||
geom.line_height,
|
||
TextAlign::Left,
|
||
None,
|
||
window,
|
||
cx,
|
||
);
|
||
}
|
||
|
||
/// What [`TerminalElement::build_grid`] found alongside the cell buffer: the
|
||
/// cursor cell, the optional sub-line-scroll sliver row, and whether any cell
|
||
/// carries a selection / search-highlight flag — so the paint pass can skip
|
||
/// the corresponding overlay scans entirely in the common no-selection,
|
||
/// no-search frame.
|
||
struct GridSnapshot {
|
||
cursor: Option<GridCursor>,
|
||
sliver: Option<Vec<RenderCell>>,
|
||
any_selected: bool,
|
||
any_match: bool,
|
||
any_current: bool,
|
||
}
|
||
|
||
impl TerminalElement {
|
||
/// Snapshot the emulator grid into `buf` (releasing the term lock before
|
||
/// returning) and locate the cursor cell. Search-match highlighting is
|
||
/// layered on afterwards. `buf` is caller-provided so the (rows × cols)
|
||
/// allocation is reused across frames instead of remade per paint.
|
||
///
|
||
/// With `want_sliver`, also snapshots the row just above the viewport
|
||
/// (grid line `-(display_offset + 1)`), which becomes visible when a
|
||
/// sub-line scroll fraction shifts the whole grid down at paint.
|
||
fn build_grid(
|
||
&self,
|
||
colors: &PaintColors,
|
||
buf: &mut Vec<RenderCell>,
|
||
rows: usize,
|
||
cols: usize,
|
||
want_sliver: bool,
|
||
cx: &App,
|
||
) -> GridSnapshot {
|
||
buf.clear();
|
||
buf.resize(rows * cols, RenderCell::default());
|
||
let mut cursor: Option<GridCursor> = None;
|
||
let mut sliver: Option<Vec<RenderCell>> = None;
|
||
let mut any_selected = false;
|
||
let display_offset;
|
||
{
|
||
let mut palette = self.view.read(cx).terminal.palette;
|
||
// Overwrite the ANSI 16 (slots 0-15) with the active preset's set
|
||
// for the current mode, published as the `ActivePalette` global by
|
||
// `apply_theme`. The 256-color cube and grayscale ramp (slots 16+)
|
||
// stay as built. Falls back to the stored palette if unset.
|
||
if let Some(active) = cx.try_global::<crate::terminal::palette::ActivePalette>() {
|
||
palette[..16].copy_from_slice(&active.ansi16);
|
||
}
|
||
let term = self.view.read(cx).terminal.term.clone();
|
||
let term = term.lock();
|
||
let content = term.renderable_content();
|
||
display_offset = content.display_offset as i32;
|
||
let selection = content.selection;
|
||
|
||
for cell in content.display_iter {
|
||
let row = cell.point.line.0 + display_offset;
|
||
let col = cell.point.column.0;
|
||
if row < 0 || row as usize >= rows || col >= cols {
|
||
continue;
|
||
}
|
||
let rc = snapshot_cell(cell.cell, cell.point, &palette, colors, selection.as_ref());
|
||
any_selected |= rc.selected;
|
||
buf[row as usize * cols + col] = rc;
|
||
}
|
||
|
||
// The extra top row for sub-line scrolling. Grid lines index into
|
||
// history below 0, so the line above the viewport exists whenever
|
||
// we're not already at the top of the scrollback. Search-match
|
||
// washes are skipped here: matches are flagged in viewport
|
||
// coordinates, and a strip at most one line tall lighting up a
|
||
// frame early isn't worth widening that mapping.
|
||
if want_sliver && (display_offset as usize) < term.grid().history_size() {
|
||
let line = AlacLine(-display_offset - 1);
|
||
let mut row_buf = vec![RenderCell::default(); cols];
|
||
for (col, rc) in row_buf
|
||
.iter_mut()
|
||
.enumerate()
|
||
.take(term.columns().min(cols))
|
||
{
|
||
let point = AlacPoint::new(line, AlacColumn(col));
|
||
*rc = snapshot_cell(
|
||
&term.grid()[line][AlacColumn(col)],
|
||
point,
|
||
&palette,
|
||
colors,
|
||
selection.as_ref(),
|
||
);
|
||
any_selected |= rc.selected;
|
||
}
|
||
sliver = Some(row_buf);
|
||
}
|
||
|
||
// Cursor cell. We record the position even when the app has hidden the
|
||
// cursor (`CursorShape::Hidden`, e.g. a full-screen TUI like Claude Code
|
||
// that draws its own): the rendered block honours `hidden`, but the IME
|
||
// candidate window still needs an anchor at the input cell — otherwise it
|
||
// falls back to a window corner and can't follow the caret.
|
||
let cur = content.cursor;
|
||
let row = cur.point.line.0 + display_offset;
|
||
let col = cur.point.column.0;
|
||
if row >= 0 && (row as usize) < rows && col < cols {
|
||
cursor = Some(GridCursor {
|
||
row: row as usize,
|
||
col,
|
||
hidden: matches!(cur.shape, CursorShape::Hidden),
|
||
style: cursor_style_from_shape(cur.shape),
|
||
});
|
||
}
|
||
}
|
||
|
||
let (any_match, any_current) =
|
||
self.flag_search_matches(buf, rows, cols, display_offset, cx);
|
||
self.flag_hovered_link(buf, rows, cols, display_offset, cx);
|
||
GridSnapshot {
|
||
cursor,
|
||
sliver,
|
||
any_selected,
|
||
any_match,
|
||
any_current,
|
||
}
|
||
}
|
||
|
||
/// Flag the cells covered by the hovered link (if it's currently on screen) so
|
||
/// they paint underlined. The link is stored in scroll-stable grid coordinates,
|
||
/// so we shift it back into a screen row by the current display offset.
|
||
fn flag_hovered_link(
|
||
&self,
|
||
buf: &mut [RenderCell],
|
||
rows: usize,
|
||
cols: usize,
|
||
display_offset: i32,
|
||
cx: &App,
|
||
) {
|
||
let Some(link) = self.view.read(cx).hovered_link.as_ref() else {
|
||
return;
|
||
};
|
||
// The link may span several rows — a soft wrap, or a URL a program
|
||
// split with a hard newline. Paint every covered cell: full columns on
|
||
// the interior rows, clamped to `start`/`end` on the first and last.
|
||
let (start, end) = (link.start, link.end);
|
||
let mut line = start.line.0;
|
||
while line <= end.line.0 {
|
||
let grid_row = line + display_offset;
|
||
if grid_row >= 0 && (grid_row as usize) < rows {
|
||
let grid_row = grid_row as usize;
|
||
let col_start = if line == start.line.0 {
|
||
start.column.0
|
||
} else {
|
||
0
|
||
};
|
||
let col_end = if line == end.line.0 {
|
||
end.column.0
|
||
} else {
|
||
cols.saturating_sub(1)
|
||
};
|
||
let mut col = col_start;
|
||
while col <= col_end && col < cols {
|
||
buf[grid_row * cols + col].link_hover = true;
|
||
col += 1;
|
||
}
|
||
}
|
||
line += 1;
|
||
}
|
||
}
|
||
|
||
/// Flag cells covered by search matches. Driven entirely by the SearchState
|
||
/// match list (computed only when the query changes), so this is the single
|
||
/// source of truth for highlighting. Cheap per frame: the list is ordered
|
||
/// top→bottom and non-overlapping (see `recompute_matches`), so a binary
|
||
/// search finds the first match that can touch the viewport and iteration
|
||
/// stops at the first one past it — instead of scanning all (up to 10k)
|
||
/// matches every frame. Returns whether any (non-current, current) cells
|
||
/// were flagged, so paint can skip the highlight passes entirely.
|
||
fn flag_search_matches(
|
||
&self,
|
||
buf: &mut [RenderCell],
|
||
rows: usize,
|
||
cols: usize,
|
||
display_offset: i32,
|
||
cx: &App,
|
||
) -> (bool, bool) {
|
||
let Some(search) = self.view.read(cx).search.as_ref() else {
|
||
return (false, false);
|
||
};
|
||
let (mut any_hit, mut any_current) = (false, false);
|
||
// First match whose end reaches the viewport's top row.
|
||
let first = search
|
||
.matches
|
||
.partition_point(|m| m.end().line.0 + display_offset < 0);
|
||
for (i, m) in search.matches.iter().enumerate().skip(first) {
|
||
let is_current = search.current_index == Some(i);
|
||
let start = *m.start();
|
||
let end = *m.end();
|
||
if start.line.0 + display_offset >= rows as i32 {
|
||
break; // ordered: everything after starts below the viewport too
|
||
}
|
||
if is_current {
|
||
any_current = true;
|
||
} else {
|
||
any_hit = true;
|
||
}
|
||
let mut line = start.line.0;
|
||
while line <= end.line.0 {
|
||
let row = line + display_offset;
|
||
if row >= 0 && (row as usize) < rows {
|
||
let col_start = if line == start.line.0 {
|
||
start.column.0
|
||
} else {
|
||
0
|
||
};
|
||
let col_end = if line == end.line.0 {
|
||
end.column.0
|
||
} else {
|
||
cols.saturating_sub(1)
|
||
};
|
||
let mut col = col_start;
|
||
while col <= col_end && col < cols {
|
||
let rc = &mut buf[row as usize * cols + col];
|
||
if is_current {
|
||
rc.match_current = true;
|
||
} else {
|
||
rc.match_hit = true;
|
||
}
|
||
col += 1;
|
||
}
|
||
}
|
||
line += 1;
|
||
}
|
||
}
|
||
(any_hit, any_current)
|
||
}
|
||
|
||
/// Register the per-frame mouse listeners (press / drag / release) over our
|
||
/// bounds, translating pixel positions to grid cells and routing to the view
|
||
/// (selection, link opening, or mouse-tracking reports).
|
||
fn register_mouse_handlers(
|
||
&self,
|
||
geom: CellGeom,
|
||
bounds: Bounds<Pixels>,
|
||
hitbox: HitboxId,
|
||
window: &mut Window,
|
||
) {
|
||
let view = self.view.clone();
|
||
window.on_mouse_event(move |ev: &MouseDownEvent, phase, window, cx| {
|
||
// `is_hovered` (not `bounds.contains`) so a click on an overlay that
|
||
// sits above the terminal — the Cmd+F search bar, which `.occlude()`s
|
||
// its area — doesn't fall through and start a terminal selection.
|
||
if !phase.bubble() || !hitbox.is_hovered(window) {
|
||
return;
|
||
}
|
||
let (col, row, left) = geom.pos_to_cell(ev.position);
|
||
let raw_row = geom.pos_to_row_raw(ev.position);
|
||
let mods = ev.modifiers;
|
||
let button = ev.button;
|
||
let clicks = ev.click_count;
|
||
view.update(cx, |v, cx| {
|
||
// Secondary+click (⌘ on macOS, Ctrl on Windows/Linux) opens a
|
||
// URL under the cursor. Not the raw platform key: that's Win/
|
||
// Super off macOS, which the OS mostly swallows — and every
|
||
// other terminal there opens links on Ctrl+click.
|
||
let link_modifier = mods.secondary() || v.link_modifier_down();
|
||
if link_modifier
|
||
&& button == MouseButton::Left
|
||
&& v.open_link_at(col, row, window, cx)
|
||
{
|
||
return;
|
||
}
|
||
// Report to the app when in mouse-tracking mode (Shift forces
|
||
// local selection instead).
|
||
if v.mouse_mode() && !mods.shift {
|
||
v.mouse_press(button, col, row, &mods);
|
||
return;
|
||
}
|
||
// A left click/double-click/triple-click on the command-editor
|
||
// line drives its caret/selection instead of a (meaningless)
|
||
// terminal selection over it.
|
||
if button == MouseButton::Left
|
||
&& v.editor_click(col, raw_row, clicks, mods.shift, cx)
|
||
{
|
||
return;
|
||
}
|
||
if button == MouseButton::Left {
|
||
v.on_select_start(col, row, left, clicks, mods.shift, cx);
|
||
}
|
||
});
|
||
});
|
||
|
||
let view = self.view.clone();
|
||
window.on_mouse_event(move |ev: &MouseMoveEvent, _phase, window, cx| {
|
||
let (col, row, left) = geom.pos_to_cell(ev.position);
|
||
let raw_row = geom.pos_to_row_raw(ev.position);
|
||
let mods = ev.modifiers;
|
||
let Some(button) = ev.pressed_button else {
|
||
// No button down: detect a link under the cursor so it underlines.
|
||
// This runs in mouse-tracking mode too — hover detection is purely
|
||
// local (button-less motion is never forwarded to the app), and
|
||
// ⌘-click opens links inside mouse-mode TUIs as well, so the
|
||
// underline affordance must match. Skipped only when the pointer
|
||
// is outside our bounds (or under the search-bar overlay).
|
||
let inside = hitbox.is_hovered(window);
|
||
// Focus-follows-mouse: hovering an unfocused pane focuses it, no
|
||
// click needed. Guarded on `inside` and the config flag.
|
||
if inside && cx.global::<Config>().focus_follows_mouse {
|
||
let handle = view.read(cx).focus_handle.clone();
|
||
if !handle.is_focused(window) {
|
||
window.focus(&handle, cx);
|
||
}
|
||
}
|
||
view.update(cx, |v, cx| {
|
||
if inside {
|
||
// Any-event mouse tracking (mode 1003): apps that asked
|
||
// for all motion get button-less moves too; `mouse_motion`
|
||
// no-ops unless the mode is set. Shift keeps the mouse
|
||
// local, matching the click/drag/scroll routing.
|
||
if !mods.shift {
|
||
v.mouse_motion(col, row, &mods);
|
||
}
|
||
let include_files = mods.secondary() || v.link_modifier_down();
|
||
v.hover_link_at(col, row, include_files, cx);
|
||
} else {
|
||
v.clear_hovered_link(cx);
|
||
}
|
||
});
|
||
return;
|
||
};
|
||
view.update(cx, |v, cx| {
|
||
if v.mouse_mode() && !mods.shift {
|
||
v.mouse_drag(button, col, row, &mods);
|
||
return;
|
||
}
|
||
// A drag that began on the command-editor line extends its
|
||
// selection rather than the terminal's.
|
||
if button == MouseButton::Left && v.editor_drag(col, raw_row, cx) {
|
||
return;
|
||
}
|
||
if button == MouseButton::Left {
|
||
v.on_select_update(col, row, left, cx);
|
||
// Past the top/bottom edge, keep the selection growing by
|
||
// auto-scrolling the scrollback (`pos_to_cell` clamps the
|
||
// row, so the position alone stops at the edge).
|
||
let overshoot = drag_overshoot(ev.position.y, bounds, geom.line_height);
|
||
v.select_autoscroll(overshoot, col, left, cx);
|
||
}
|
||
});
|
||
});
|
||
|
||
let view = self.view.clone();
|
||
window.on_mouse_event(move |ev: &MouseUpEvent, phase, _window, cx| {
|
||
if !phase.bubble() {
|
||
return;
|
||
}
|
||
let (col, row, _left) = geom.pos_to_cell(ev.position);
|
||
let mods = ev.modifiers;
|
||
let button = ev.button;
|
||
view.update(cx, |v, cx| {
|
||
if v.mouse_mode() && !mods.shift {
|
||
v.mouse_release(button, col, row, &mods);
|
||
return;
|
||
}
|
||
v.on_select_end(cx);
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
impl IntoElement for TerminalElement {
|
||
type Element = Self;
|
||
fn into_element(self) -> Self::Element {
|
||
self
|
||
}
|
||
}
|
||
|
||
impl Element for TerminalElement {
|
||
type RequestLayoutState = ();
|
||
type PrepaintState = TermLayout;
|
||
|
||
fn id(&self) -> Option<ElementId> {
|
||
None
|
||
}
|
||
|
||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||
None
|
||
}
|
||
|
||
fn request_layout(
|
||
&mut self,
|
||
_id: Option<&GlobalElementId>,
|
||
_: Option<&gpui::InspectorElementId>,
|
||
window: &mut Window,
|
||
cx: &mut App,
|
||
) -> (LayoutId, Self::RequestLayoutState) {
|
||
let mut style = Style::default();
|
||
style.size.width = relative(1.).into();
|
||
style.size.height = relative(1.).into();
|
||
style.flex_grow = 1.0;
|
||
(window.request_layout(style, [], cx), ())
|
||
}
|
||
|
||
fn prepaint(
|
||
&mut self,
|
||
_id: Option<&GlobalElementId>,
|
||
_: Option<&gpui::InspectorElementId>,
|
||
bounds: Bounds<Pixels>,
|
||
_request_layout: &mut Self::RequestLayoutState,
|
||
window: &mut Window,
|
||
cx: &mut App,
|
||
) -> Self::PrepaintState {
|
||
let font_size = self.view.read(cx).font_size;
|
||
let base_font = self.view.read(cx).font.clone();
|
||
|
||
// Measure the monospace advance from a single glyph.
|
||
let sample = window.text_system().shape_line(
|
||
SharedString::new_static("M"),
|
||
font_size,
|
||
&[TextRun {
|
||
len: 1,
|
||
font: base_font.clone(),
|
||
color: Hsla::default(),
|
||
background_color: None,
|
||
underline: None,
|
||
strikethrough: None,
|
||
}],
|
||
None,
|
||
);
|
||
let cell_width = sample.width.max(px(1.));
|
||
let line_height_mul = self.view.read(cx).line_height_mul;
|
||
// Clamp to >= 1px like `cell_width`: a degenerate config (font_size 0 or a
|
||
// tiny line-height multiple) can round to 0, and dividing `bounds.height`
|
||
// by 0 yields `inf`, which casts to `usize::MAX` rows → `rows * cols`
|
||
// capacity overflow and an allocation panic on the first paint.
|
||
let line_height = px((font_size.as_f32() * line_height_mul).round()).max(px(1.));
|
||
|
||
let cols = (bounds.size.width.as_f32() / cell_width.as_f32())
|
||
.floor()
|
||
.max(1.0) as usize;
|
||
let rows = (bounds.size.height.as_f32() / line_height.as_f32())
|
||
.floor()
|
||
.max(1.0) as usize;
|
||
|
||
self.view.update(cx, |view, _cx| {
|
||
view.set_grid_size(cols, rows, cell_width, line_height);
|
||
});
|
||
|
||
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||
|
||
TermLayout {
|
||
cell_width,
|
||
line_height,
|
||
cols,
|
||
rows,
|
||
hitbox,
|
||
}
|
||
}
|
||
|
||
fn paint(
|
||
&mut self,
|
||
_id: Option<&GlobalElementId>,
|
||
_: Option<&gpui::InspectorElementId>,
|
||
bounds: Bounds<Pixels>,
|
||
_request_layout: &mut Self::RequestLayoutState,
|
||
prepaint: &mut Self::PrepaintState,
|
||
window: &mut Window,
|
||
cx: &mut App,
|
||
) {
|
||
// Optional frame timing (TTY7_FPS=1). Times the whole paint body below.
|
||
let fps_start = super::fps::enabled().then(std::time::Instant::now);
|
||
|
||
// Sub-line scroll fraction: shift the whole grid down by this many
|
||
// pixels so trackpad scrolling moves continuously instead of snapping
|
||
// line by line. The strip that opens above the top row is filled with
|
||
// the next older row (the "sliver"). Mouse mapping stays consistent
|
||
// automatically: `pos_to_cell` measures from this shifted origin.
|
||
let frac = self.view.read(cx).scroll_frac.clamp(0., 1.);
|
||
// While the command editor's wrapped input would spill past the bottom
|
||
// row, the whole grid shifts up by that many lines — the top rows are
|
||
// clipped by the content mask and the overlay's wrapped tail lands in
|
||
// the vacated strip, emulating the scroll an echoing shell would do.
|
||
// Mouse mapping follows automatically via the shifted origin.
|
||
let input_shift = self.view.read(cx).input_scroll_rows();
|
||
let geom = CellGeom {
|
||
origin: point(
|
||
bounds.origin.x,
|
||
bounds.origin.y + prepaint.line_height * (frac - input_shift as f32),
|
||
),
|
||
cell_width: prepaint.cell_width,
|
||
line_height: prepaint.line_height,
|
||
cols: prepaint.cols,
|
||
rows: prepaint.rows,
|
||
};
|
||
let colors = PaintColors::resolve(cx.theme(), cx);
|
||
|
||
let font_size = self.view.read(cx).font_size;
|
||
let base_font = self.view.read(cx).font.clone();
|
||
let bold_font = self.view.read(cx).font_bold.clone();
|
||
let italic_font = self.view.read(cx).font_italic.clone();
|
||
let focused = self.view.read(cx).focus_handle.is_focused(window);
|
||
// Blink phase (only meaningful while focused) and the transient bell flash.
|
||
let cursor_visible = self.view.read(cx).cursor_visible;
|
||
let bell_flash = self.view.read(cx).bell_flash;
|
||
// While the inline line editor is live it owns the keyboard and draws its
|
||
// own caret at the prompt; suppress the terminal's block cursor so the two
|
||
// don't stack (the editor isn't focused on `focus_handle`, so otherwise the
|
||
// grid would paint a stale hollow box behind the field).
|
||
let editor_active = self.view.read(cx).input_active();
|
||
|
||
// Snapshot the emulator grid into the reused buffer (lock released
|
||
// inside). The buffer is returned to `GRID_BUF` at the end of paint.
|
||
let mut buf = GRID_BUF.with(|b| std::mem::take(&mut *b.borrow_mut()));
|
||
let snap = self.build_grid(&colors, &mut buf, geom.rows, geom.cols, frac > 0., cx);
|
||
let cursor = snap.cursor;
|
||
let sliver = snap.sliver.as_ref();
|
||
|
||
// Cell the IME candidate window anchors to. Use the cursor position even
|
||
// when the app has hidden the hardware cursor (full-screen TUIs draw their
|
||
// own) so composition still tracks the input cell instead of dropping to a
|
||
// window corner.
|
||
let cursor_cell = cursor.map(|c| (c.row, c.col));
|
||
// The rendered cursor, by contrast, honours `hidden`; it carries its shape
|
||
// so `paint_cursor` can draw a block / bar / underline.
|
||
let render_cursor = cursor
|
||
.filter(|c| !c.hidden)
|
||
.map(|c| (c.row, c.col, c.style));
|
||
|
||
// Register the IME / text input handler so CJK (and dead-key) input
|
||
// composes and commits to the PTY. Positioned at the cursor cell so the
|
||
// candidate window appears in the right place.
|
||
let cursor_bounds = cursor_cell.map(|(row, col)| geom.cell_rect(row, col, 1));
|
||
let focus_handle = self.view.read(cx).focus_handle.clone();
|
||
window.handle_input(
|
||
&focus_handle,
|
||
super::input::TerminalInputHandler::new(self.view.clone(), cursor_bounds),
|
||
cx,
|
||
);
|
||
let marked = self.view.read(cx).marked_text.clone();
|
||
|
||
window.with_content_mask(Some(ContentMask { bounds }), |window| {
|
||
// Background quads, then the selection / search overlays, then
|
||
// glyphs. The overlay passes each rescan the whole buffer, so they
|
||
// only run when the snapshot actually flagged something — the
|
||
// common no-selection, no-search frame skips all three.
|
||
paint_backgrounds(window, &geom, &buf);
|
||
if snap.any_selected {
|
||
paint_cell_runs(window, &geom, &buf, colors.selection_bg, None, |c| {
|
||
c.selected
|
||
});
|
||
}
|
||
if snap.any_match {
|
||
paint_cell_runs(window, &geom, &buf, colors.match_bg, None, |c| {
|
||
c.match_hit && !c.match_current
|
||
});
|
||
}
|
||
if snap.any_current {
|
||
paint_cell_runs(
|
||
window,
|
||
&geom,
|
||
&buf,
|
||
colors.current_match_bg,
|
||
Some(colors.current_match_border),
|
||
|c| c.match_current,
|
||
);
|
||
}
|
||
paint_glyphs(
|
||
window,
|
||
cx,
|
||
&geom,
|
||
&buf,
|
||
font_size,
|
||
&base_font,
|
||
bold_font.as_ref(),
|
||
italic_font.as_ref(),
|
||
);
|
||
// The sliver row above the viewport, exposed by the sub-line
|
||
// scroll shift: same paint layers on a one-row geometry sitting
|
||
// one line above the (already shifted) grid origin, clipped by
|
||
// the surrounding content mask.
|
||
if let Some(row) = sliver {
|
||
let sg = CellGeom {
|
||
origin: point(geom.origin.x, geom.origin.y - geom.line_height),
|
||
rows: 1,
|
||
..geom
|
||
};
|
||
paint_backgrounds(window, &sg, row);
|
||
if snap.any_selected {
|
||
paint_cell_runs(window, &sg, row, colors.selection_bg, None, |c| c.selected);
|
||
}
|
||
paint_glyphs(
|
||
window,
|
||
cx,
|
||
&sg,
|
||
row,
|
||
font_size,
|
||
&base_font,
|
||
bold_font.as_ref(),
|
||
italic_font.as_ref(),
|
||
);
|
||
}
|
||
// While the command editor is live, it draws its own caret and IME
|
||
// pre-edit in the overlay; suppress the grid's versions so they don't
|
||
// double up.
|
||
if !editor_active {
|
||
paint_cursor(
|
||
window,
|
||
&geom,
|
||
render_cursor,
|
||
focused,
|
||
cursor_visible,
|
||
colors.caret,
|
||
);
|
||
paint_marked(
|
||
window,
|
||
cx,
|
||
&geom,
|
||
cursor_cell,
|
||
&marked,
|
||
font_size,
|
||
&base_font,
|
||
colors.default_fg,
|
||
colors.default_bg,
|
||
);
|
||
}
|
||
|
||
// Visual bell: a brief, low-alpha wash over the whole surface as a
|
||
// restrained, non-intrusive alternative to an audible beep. Cleared
|
||
// automatically ~150ms after the bell by the view's timer.
|
||
if bell_flash {
|
||
let mut c = colors.default_fg;
|
||
c.a = 0.12;
|
||
window.paint_quad(fill(bounds, c));
|
||
}
|
||
});
|
||
|
||
// Hand the snapshot buffer back for the next paint (any pane).
|
||
GRID_BUF.with(|b| *b.borrow_mut() = buf);
|
||
|
||
self.register_mouse_handlers(geom, bounds, prepaint.hitbox.id, window);
|
||
|
||
// Mouse pointer over the surface: a pointing hand over a hovered link
|
||
// (Cmd+click opens it); otherwise an I-beam over the selectable text,
|
||
// like every other terminal. Once a program takes over mouse reporting
|
||
// the pointer stays the default arrow, signalling "the app owns this"
|
||
// (matching Terminal.app / iTerm).
|
||
let view = self.view.read(cx);
|
||
if view.hovered_link.is_some() {
|
||
window.set_cursor_style(CursorStyle::PointingHand, &prepaint.hitbox);
|
||
} else if !view.mouse_mode() {
|
||
window.set_cursor_style(CursorStyle::IBeam, &prepaint.hitbox);
|
||
}
|
||
|
||
if let Some(start) = fps_start {
|
||
super::fps::record(start.elapsed());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Geometry helper for mapping pixel positions to grid cells.
|
||
#[derive(Clone, Copy)]
|
||
struct CellGeom {
|
||
origin: Point<Pixels>,
|
||
cell_width: Pixels,
|
||
line_height: Pixels,
|
||
cols: usize,
|
||
rows: usize,
|
||
}
|
||
|
||
impl CellGeom {
|
||
/// The pixel rectangle covering `span` cells starting at (`row`, `col`).
|
||
fn cell_rect(&self, row: usize, col: usize, span: usize) -> Bounds<Pixels> {
|
||
let x = self.origin.x + self.cell_width * (col as f32);
|
||
let y = self.origin.y + self.line_height * (row as f32);
|
||
Bounds::new(
|
||
point(x, y),
|
||
size(self.cell_width * (span as f32), self.line_height),
|
||
)
|
||
}
|
||
|
||
/// Returns (column, row, is_left_half).
|
||
fn pos_to_cell(&self, pos: Point<Pixels>) -> (usize, usize, bool) {
|
||
let lx = (pos.x - self.origin.x).as_f32().max(0.);
|
||
let ly = (pos.y - self.origin.y).as_f32().max(0.);
|
||
let colf = lx / self.cell_width.as_f32();
|
||
let col = (colf.floor() as usize).min(self.cols.saturating_sub(1));
|
||
let row =
|
||
((ly / self.line_height.as_f32()).floor() as usize).min(self.rows.saturating_sub(1));
|
||
let left = (colf - colf.floor()) <= 0.5;
|
||
(col, row, left)
|
||
}
|
||
|
||
/// The row under `pos` without `pos_to_cell`'s bottom clamp. While the
|
||
/// input overlay shifts the grid up, its wrapped rows extend past
|
||
/// `rows - 1` into the vacated strip; the command editor needs those raw
|
||
/// rows so clicks and drags land on the right wrapped line. (Terminal
|
||
/// consumers — selection, mouse reports — keep the clamped row.)
|
||
fn pos_to_row_raw(&self, pos: Point<Pixels>) -> usize {
|
||
let ly = (pos.y - self.origin.y).as_f32().max(0.);
|
||
(ly / self.line_height.as_f32()).floor() as usize
|
||
}
|
||
}
|
||
|
||
/// Vertical overshoot of a selection drag past the pane bounds, in lines:
|
||
/// positive above the top edge (auto-scroll up into history), negative below
|
||
/// the bottom, zero while inside. Feeds `TerminalView::select_autoscroll`.
|
||
fn drag_overshoot(y: Pixels, bounds: Bounds<Pixels>, line_height: Pixels) -> f32 {
|
||
let lh = line_height.as_f32().max(1.);
|
||
if y < bounds.top() {
|
||
(bounds.top() - y).as_f32() / lh
|
||
} else if y > bounds.bottom() {
|
||
-((y - bounds.bottom()).as_f32() / lh)
|
||
} else {
|
||
0.
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn to_hsla_normalizes_channels_and_alpha() {
|
||
let black = to_hsla(Rgb { r: 0, g: 0, b: 0 });
|
||
assert_eq!(black.a, 1.0);
|
||
assert!(black.l.abs() < 1e-6, "black has zero lightness");
|
||
|
||
let white = to_hsla(Rgb {
|
||
r: 255,
|
||
g: 255,
|
||
b: 255,
|
||
});
|
||
assert!((white.l - 1.0).abs() < 1e-6, "white has full lightness");
|
||
assert!(white.s.abs() < 1e-6, "white is desaturated");
|
||
|
||
// A pure primary round-trips back through Rgba.
|
||
let back = Rgba::from(to_hsla(Rgb { r: 255, g: 0, b: 0 }));
|
||
assert!((back.r - 1.0).abs() < 1e-3);
|
||
assert!(back.g.abs() < 1e-3 && back.b.abs() < 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_covers_every_color_slot() {
|
||
// A palette whose red channel encodes its own index, for easy assertions.
|
||
let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
||
for (i, slot) in palette.iter_mut().enumerate() {
|
||
slot.r = i as u8;
|
||
}
|
||
let fg = Rgb {
|
||
r: 200,
|
||
g: 201,
|
||
b: 202,
|
||
};
|
||
let bg = Rgb {
|
||
r: 10,
|
||
g: 11,
|
||
b: 12,
|
||
};
|
||
|
||
// A direct RGB spec passes through and is not a "default".
|
||
let spec = Rgb { r: 1, g: 2, b: 3 };
|
||
assert_eq!(
|
||
resolve(AnsiColor::Spec(spec), &palette, fg, bg),
|
||
(spec, false)
|
||
);
|
||
|
||
// Indexed reads the palette slot.
|
||
let (rgb, is_def) = resolve(AnsiColor::Indexed(5), &palette, fg, bg);
|
||
assert_eq!(rgb.r, 5);
|
||
assert!(!is_def);
|
||
|
||
// Named Foreground/Background fall back to the theme defaults (is_default=true).
|
||
assert_eq!(
|
||
resolve(AnsiColor::Named(NamedColor::Foreground), &palette, fg, bg),
|
||
(fg, true)
|
||
);
|
||
assert_eq!(
|
||
resolve(AnsiColor::Named(NamedColor::Background), &palette, fg, bg),
|
||
(bg, true)
|
||
);
|
||
|
||
// A concrete named ANSI color reads the palette, not a default.
|
||
let (rgb, is_def) = resolve(AnsiColor::Named(NamedColor::Red), &palette, fg, bg);
|
||
assert_eq!(rgb.r, NamedColor::Red as u8);
|
||
assert!(!is_def);
|
||
}
|
||
|
||
#[test]
|
||
fn build_font_sets_weight_and_style() {
|
||
let base = gpui::font("Courier");
|
||
let plain = build_font(&base, false, false);
|
||
assert_eq!(plain.weight, FontWeight::NORMAL);
|
||
assert_eq!(plain.style, FontStyle::Normal);
|
||
|
||
let bold_italic = build_font(&base, true, true);
|
||
assert_eq!(bold_italic.weight, FontWeight::BOLD);
|
||
assert_eq!(bold_italic.style, FontStyle::Italic);
|
||
|
||
// Family is preserved across the tweak.
|
||
assert_eq!(bold_italic.family, base.family);
|
||
}
|
||
|
||
#[test]
|
||
fn cell_rect_maps_grid_to_pixels() {
|
||
let geom = CellGeom {
|
||
origin: point(px(10.), px(20.)),
|
||
cell_width: px(8.),
|
||
line_height: px(16.),
|
||
cols: 80,
|
||
rows: 24,
|
||
};
|
||
let r = geom.cell_rect(2, 3, 1);
|
||
assert_eq!(r.origin.x, px(10. + 8. * 3.));
|
||
assert_eq!(r.origin.y, px(20. + 16. * 2.));
|
||
assert_eq!(r.size.width, px(8.));
|
||
assert_eq!(r.size.height, px(16.));
|
||
// A multi-cell span widens the rect by that many cells.
|
||
assert_eq!(geom.cell_rect(0, 0, 4).size.width, px(32.));
|
||
}
|
||
|
||
#[test]
|
||
fn pos_to_cell_clamps_and_detects_halves() {
|
||
let geom = CellGeom {
|
||
origin: point(px(0.), px(0.)),
|
||
cell_width: px(10.),
|
||
line_height: px(20.),
|
||
cols: 5,
|
||
rows: 3,
|
||
};
|
||
// Left of the cell midpoint → left half.
|
||
let (c, r, left) = geom.pos_to_cell(point(px(2.), px(5.)));
|
||
assert_eq!((c, r), (0, 0));
|
||
assert!(left);
|
||
// Right of the midpoint → right half.
|
||
let (_, _, left) = geom.pos_to_cell(point(px(8.), px(5.)));
|
||
assert!(!left);
|
||
// Negative offsets clamp to the first cell.
|
||
let (c, r, _) = geom.pos_to_cell(point(px(-100.), px(-100.)));
|
||
assert_eq!((c, r), (0, 0));
|
||
// Far beyond the grid clamps to (cols-1, rows-1).
|
||
let (c, r, _) = geom.pos_to_cell(point(px(9999.), px(9999.)));
|
||
assert_eq!((c, r), (4, 2));
|
||
}
|
||
|
||
/// Drag auto-scroll only engages past the vertical edges, scaled to lines:
|
||
/// above the top is positive (into history), below the bottom negative.
|
||
#[test]
|
||
fn drag_overshoot_signed_by_edge_and_zero_inside() {
|
||
let bounds = Bounds::new(point(px(0.), px(100.)), size(px(200.), px(100.)));
|
||
// Anywhere inside (including the exact edges) → no auto-scroll.
|
||
assert_eq!(drag_overshoot(px(150.), bounds, px(10.)), 0.);
|
||
assert_eq!(drag_overshoot(px(100.), bounds, px(10.)), 0.);
|
||
assert_eq!(drag_overshoot(px(200.), bounds, px(10.)), 0.);
|
||
// 20px above the top at 10px lines → 2 lines up.
|
||
assert_eq!(drag_overshoot(px(80.), bounds, px(10.)), 2.);
|
||
// 30px below the bottom → 3 lines down.
|
||
assert_eq!(drag_overshoot(px(230.), bounds, px(10.)), -3.);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_cursor_shape_maps_to_painted_cursor_style() {
|
||
use crate::core::config::CursorStyle;
|
||
|
||
assert_eq!(cursor_style_from_shape(CursorShape::Beam), CursorStyle::Bar);
|
||
assert_eq!(
|
||
cursor_style_from_shape(CursorShape::Underline),
|
||
CursorStyle::Underline
|
||
);
|
||
assert_eq!(
|
||
cursor_style_from_shape(CursorShape::Block),
|
||
CursorStyle::Block
|
||
);
|
||
assert_eq!(
|
||
cursor_style_from_shape(CursorShape::HollowBlock),
|
||
CursorStyle::Block
|
||
);
|
||
}
|
||
|
||
// ---- segment_row ----
|
||
|
||
fn cell(c: char) -> RenderCell {
|
||
RenderCell {
|
||
c,
|
||
..RenderCell::default()
|
||
}
|
||
}
|
||
|
||
fn run(start: usize, cells: usize, text: &str) -> RowSeg {
|
||
RowSeg::Run {
|
||
start,
|
||
cells,
|
||
text: text.to_string(),
|
||
}
|
||
}
|
||
|
||
fn wide(start: usize, cells: usize, text: &str) -> RowSeg {
|
||
RowSeg::Wide {
|
||
start,
|
||
cells,
|
||
text: text.into(),
|
||
}
|
||
}
|
||
|
||
/// A row of wide glyphs as the grid stores them: each char followed by its
|
||
/// trailing spacer cell.
|
||
fn wide_cells(chars: &str) -> Vec<RenderCell> {
|
||
let mut row = Vec::new();
|
||
for c in chars.chars() {
|
||
row.push(cell(c));
|
||
let mut sp = cell(' ');
|
||
sp.spacer = true;
|
||
row.push(sp);
|
||
}
|
||
row
|
||
}
|
||
|
||
#[test]
|
||
fn segment_row_batches_uniform_ascii() {
|
||
let row: Vec<_> = "hello".chars().map(cell).collect();
|
||
assert_eq!(segment_row(&row), [run(0, 5, "hello")]);
|
||
}
|
||
|
||
#[test]
|
||
fn segment_row_joins_plain_runs_across_gaps_but_trims_edges() {
|
||
// " ab cd " → one run: interior blanks join, leading/trailing don't.
|
||
let row: Vec<_> = " ab cd ".chars().map(cell).collect();
|
||
assert_eq!(segment_row(&row), [run(1, 6, "ab cd")]);
|
||
// NUL cells (never-written grid slots) count as blanks too.
|
||
let mut row: Vec<_> = "ab cd".chars().map(cell).collect();
|
||
row[2].c = '\0';
|
||
assert_eq!(segment_row(&row), [run(0, 5, "ab cd")]);
|
||
}
|
||
|
||
#[test]
|
||
fn segment_row_ends_underlined_runs_at_blanks() {
|
||
// The per-cell painter never underlined a blank cell; a batched run
|
||
// must not start doing so, thus the gap splits the run.
|
||
let mut row: Vec<_> = "ab cd".chars().map(cell).collect();
|
||
for c in &mut row {
|
||
c.underline = UnderlineKind::Single;
|
||
}
|
||
assert_eq!(segment_row(&row), [run(0, 2, "ab"), run(3, 2, "cd")]);
|
||
|
||
// Same for a hovered link.
|
||
let mut row: Vec<_> = "ab cd".chars().map(cell).collect();
|
||
for c in &mut row {
|
||
c.link_hover = true;
|
||
}
|
||
assert_eq!(segment_row(&row), [run(0, 2, "ab"), run(3, 2, "cd")]);
|
||
}
|
||
|
||
#[test]
|
||
fn segment_row_splits_on_style_changes() {
|
||
// Foreground color change mid-word.
|
||
let mut row: Vec<_> = "abcd".chars().map(cell).collect();
|
||
row[2].fg = gpui::red();
|
||
row[3].fg = gpui::red();
|
||
assert_eq!(segment_row(&row), [run(0, 2, "ab"), run(2, 2, "cd")]);
|
||
|
||
// Bold toggling.
|
||
let mut row: Vec<_> = "abcd".chars().map(cell).collect();
|
||
row[0].bold = true;
|
||
assert_eq!(segment_row(&row), [run(0, 1, "a"), run(1, 3, "bcd")]);
|
||
}
|
||
|
||
#[test]
|
||
fn segment_row_isolates_non_ascii() {
|
||
// A wide CJK char (cell + spacer) between ASCII words: the wide cell
|
||
// becomes a one-glyph Wide run (spanning its spacer), and the ASCII
|
||
// resumes batching after it.
|
||
let mut row: Vec<_> = "ok?字 no".chars().map(cell).collect();
|
||
row.insert(4, {
|
||
let mut sp = cell(' ');
|
||
sp.spacer = true;
|
||
sp
|
||
});
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[run(0, 3, "ok?"), wide(3, 2, "字"), run(6, 2, "no")]
|
||
);
|
||
|
||
// Single-width non-ASCII (box drawing) paints solo — it may come
|
||
// from a fallback face with a non-cell advance.
|
||
let row: Vec<_> = "a─b".chars().map(cell).collect();
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[run(0, 1, "a"), RowSeg::Solo { col: 1 }, run(2, 1, "b")]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn powerline_shape_maps_only_the_solid_separators() {
|
||
// The eight solid separators are drawn natively.
|
||
for (c, shape) in [
|
||
('\u{e0b0}', PowerlineShape::TriangleRight),
|
||
('\u{e0b2}', PowerlineShape::TriangleLeft),
|
||
('\u{e0b4}', PowerlineShape::HalfCircleRight),
|
||
('\u{e0b6}', PowerlineShape::HalfCircleLeft),
|
||
('\u{e0b8}', PowerlineShape::SlantLowerLeft),
|
||
('\u{e0ba}', PowerlineShape::SlantLowerRight),
|
||
('\u{e0bc}', PowerlineShape::SlantUpperLeft),
|
||
('\u{e0be}', PowerlineShape::SlantUpperRight),
|
||
] {
|
||
assert_eq!(PowerlineShape::of(c), Some(shape), "U+{:04X}", c as u32);
|
||
}
|
||
// The thin/outline variants are hairline strokes — a filled gpui path
|
||
// can't draw those, so they stay on the font path — as do neighboring
|
||
// codepoints and ordinary prompt symbols.
|
||
for c in [
|
||
'\u{e0b1}', '\u{e0b3}', '\u{e0b5}', '\u{e0b7}', '\u{e0b9}', '\u{e0bb}', '\u{e0bd}',
|
||
'\u{e0bf}', '\u{e0a0}', '\u{e0c0}', '\u{2500}', '❯', '➜',
|
||
] {
|
||
assert_eq!(PowerlineShape::of(c), None, "U+{:04X}", c as u32);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn powerline_path_fills_exactly_one_cell() {
|
||
// Native drawing exists to guarantee edge-to-edge fit: every vertex of
|
||
// every shape must stay inside the cell it was given (no overshoot into
|
||
// a neighbor), and the fill must actually reach both horizontal edges
|
||
// (no two-thirds-width separators — the issue #17 symptom).
|
||
let (x0, y0, w, h) = (px(10.), px(20.), px(9.), px(21.));
|
||
let bounds = Bounds::new(point(x0, y0), size(w, h));
|
||
for shape in [
|
||
PowerlineShape::TriangleRight,
|
||
PowerlineShape::TriangleLeft,
|
||
PowerlineShape::HalfCircleRight,
|
||
PowerlineShape::HalfCircleLeft,
|
||
PowerlineShape::SlantLowerLeft,
|
||
PowerlineShape::SlantLowerRight,
|
||
PowerlineShape::SlantUpperLeft,
|
||
PowerlineShape::SlantUpperRight,
|
||
] {
|
||
let path = powerline_path(bounds, shape);
|
||
assert!(!path.vertices.is_empty(), "{shape:?} produced no geometry");
|
||
let (mut min_x, mut max_x) = (px(f32::MAX), px(f32::MIN));
|
||
for v in &path.vertices {
|
||
let p = v.xy_position;
|
||
assert!(
|
||
p.x >= x0 && p.x <= x0 + w && p.y >= y0 && p.y <= y0 + h,
|
||
"{shape:?} vertex at {p:?} escapes the cell"
|
||
);
|
||
min_x = min_x.min(p.x);
|
||
max_x = max_x.max(p.x);
|
||
}
|
||
assert_eq!(min_x, x0, "{shape:?} does not reach the left cell edge");
|
||
assert_eq!(
|
||
max_x,
|
||
x0 + w,
|
||
"{shape:?} does not reach the right cell edge"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn seg_clip_width_frees_solo_symbols_but_pins_batched_runs() {
|
||
let cell = px(10.);
|
||
// Batched Run/Wide segments clip to their exact column span, so an
|
||
// oversized fallback glyph can't bleed past the run.
|
||
assert_eq!(seg_clip_width(false, 1, cell), px(10.));
|
||
assert_eq!(seg_clip_width(false, 5, cell), px(50.)); // an ASCII run
|
||
assert_eq!(seg_clip_width(false, 2, cell), px(20.)); // a wide (CJK) glyph
|
||
// A solo glyph gets a two-cell window instead, so a non-Mono Nerd Font
|
||
// icon (one-cell advance, ~1.9-cell ink) or a proportional arrow renders
|
||
// whole instead of severed at the cell edge (issue #17) — the bound keeps
|
||
// a pathological face from smearing across the row.
|
||
assert_eq!(seg_clip_width(true, 1, cell), px(20.));
|
||
}
|
||
|
||
#[test]
|
||
fn build_font_disables_ligatures_unless_features_are_configured() {
|
||
let font = build_font(&gpui::font("Test"), false, false);
|
||
assert_eq!(font.features.is_calt_enabled(), Some(false));
|
||
|
||
let mut configured = gpui::font("Test");
|
||
configured.features = serde_json::from_str(r#"{"calt":true,"liga":1}"#).unwrap();
|
||
let font = build_font(&configured, false, false);
|
||
assert_eq!(font.features.is_calt_enabled(), Some(true));
|
||
assert!(
|
||
font.features
|
||
.tag_value_list()
|
||
.iter()
|
||
.any(|(tag, value)| tag == "liga" && *value == 1)
|
||
);
|
||
}
|
||
|
||
/// A natively-drawn cell keeps its underline.
|
||
///
|
||
/// Underlines ride on the `TextRun`, so the Solo arm's early return for
|
||
/// Powerline separators and box-drawing characters used to drop them: an
|
||
/// `ESC[4m` span or a hovered URL containing `─`, `│` or `` showed a
|
||
/// one-column hole where the line should have run through. The residue is
|
||
/// what closes it — a space shaped in the cell's own style, carrying the
|
||
/// underline and no glyph ink.
|
||
#[test]
|
||
fn natively_drawn_cells_still_carry_their_underline() {
|
||
let plain = GlyphStyle::of(&cell('│'));
|
||
assert_eq!(
|
||
native_cell_residue(&plain),
|
||
None,
|
||
"an unstyled box character has nothing left to shape"
|
||
);
|
||
|
||
for kind in [
|
||
UnderlineKind::Single,
|
||
UnderlineKind::Double,
|
||
UnderlineKind::Curly,
|
||
] {
|
||
let mut c = cell('│');
|
||
c.underline = kind;
|
||
assert_eq!(
|
||
native_cell_residue(&GlyphStyle::of(&c)),
|
||
Some(' '),
|
||
"{kind:?} underline dropped on a box-drawing cell"
|
||
);
|
||
}
|
||
|
||
// A hovered link underlines even without an emulator underline, and
|
||
// the characters it spans may well be box drawing or a separator.
|
||
for ch in ['│', '─', '╭', '█', '\u{e0b0}'] {
|
||
let mut c = cell(ch);
|
||
c.link_hover = true;
|
||
assert_eq!(
|
||
native_cell_residue(&GlyphStyle::of(&c)),
|
||
Some(' '),
|
||
"hovered-link underline dropped on U+{:04X}",
|
||
ch as u32
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn segment_row_keeps_powerline_separators_solo() {
|
||
// The native-draw intercept lives in the Solo arm of `paint_glyphs`;
|
||
// if separators ever started batching into Run/Wide segments they'd
|
||
// silently bypass it and fall back to font rasterization.
|
||
let row = vec![cell('a'), cell('\u{e0b0}'), cell('\u{e0b4}'), cell('b')];
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[
|
||
run(0, 1, "a"),
|
||
RowSeg::Solo { col: 1 },
|
||
RowSeg::Solo { col: 2 },
|
||
run(3, 1, "b")
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn segment_row_gives_each_wide_glyph_its_own_segment() {
|
||
// A CJK phrase emits one segment per glyph, each covering its glyph +
|
||
// spacer. Batching them was wrong: gpui's `apply_force_width_to_layout`
|
||
// only starts a new column when the shaped x has advanced past *half*
|
||
// the forced width, and fullwidth punctuation doesn't. `(` advances
|
||
// 0.472 em where half a two-cell slot is 0.6 em, so the glyph after it
|
||
// was classified as a combining mark, painted at the punctuation's own
|
||
// advance instead of the next column, and the two overlapped. Shaping
|
||
// each glyph alone makes it the first glyph of its line, which that
|
||
// function always treats as a base.
|
||
let row = wide_cells("你好世界");
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[
|
||
wide(0, 2, "你"),
|
||
wide(2, 2, "好"),
|
||
wide(4, 2, "世"),
|
||
wide(6, 2, "界")
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn segment_row_isolates_narrow_fullwidth_punctuation() {
|
||
// The exact reproduction: `(这样` painted `这` on top of `(`.
|
||
let row = wide_cells("(这样");
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[wide(0, 2, "("), wide(2, 2, "这"), wide(4, 2, "样")]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn segment_row_tracks_wide_columns_across_styles_and_gaps() {
|
||
// Per-glyph segments make a mid-phrase style change a non-event: each
|
||
// glyph already carries its own style at paint. Column starts must
|
||
// still step by 2.
|
||
let mut row = wide_cells("你好世界");
|
||
row[4].fg = gpui::red(); // third glyph (cols 4-5)
|
||
row[6].fg = gpui::red();
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[
|
||
wide(0, 2, "你"),
|
||
wide(2, 2, "好"),
|
||
wide(4, 2, "世"),
|
||
wide(6, 2, "界")
|
||
]
|
||
);
|
||
|
||
// A blank cell between wide glyphs still consumes its column, so the
|
||
// glyph after it starts at 3, not 2.
|
||
let mut row = wide_cells("你好");
|
||
row.insert(2, cell(' '));
|
||
assert_eq!(segment_row(&row), [wide(0, 2, "你"), wide(3, 2, "好")]);
|
||
}
|
||
|
||
#[test]
|
||
fn segment_row_leaves_spacerless_wide_char_solo() {
|
||
// A wide char whose spacer was clipped off (last column) has no width
|
||
// marker, so it falls back to the single-cell path.
|
||
let row = vec![cell('a'), cell('字')];
|
||
assert_eq!(segment_row(&row), [run(0, 1, "a"), RowSeg::Solo { col: 1 }]);
|
||
}
|
||
|
||
#[test]
|
||
fn segment_row_ignores_blank_rows() {
|
||
let row: Vec<_> = " \0 ".chars().map(cell).collect();
|
||
assert!(segment_row(&row).is_empty());
|
||
}
|
||
|
||
fn cluster(col: usize, cells: usize, text: &str) -> RowSeg {
|
||
RowSeg::Cluster {
|
||
col,
|
||
cells,
|
||
text: text.to_string(),
|
||
wide_base: false,
|
||
}
|
||
}
|
||
|
||
fn wide_cluster(col: usize, cells: usize, text: &str) -> RowSeg {
|
||
RowSeg::Cluster {
|
||
col,
|
||
cells,
|
||
text: text.to_string(),
|
||
wide_base: true,
|
||
}
|
||
}
|
||
|
||
/// Combining marks reach the shaper attached to their base, in one string.
|
||
#[test]
|
||
fn segment_row_shapes_combining_marks_with_their_base() {
|
||
// Single column: `e` + U+0301 → é.
|
||
let mut row = vec![cell('a'), cell('e'), cell('b')];
|
||
row[1].marks = Some(Box::from(['\u{0301}']));
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[run(0, 1, "a"), cluster(1, 1, "e\u{0301}"), run(2, 1, "b"),]
|
||
);
|
||
|
||
// Two columns: the emulator widened the base, so the cluster owns the
|
||
// spacer too (❤ + U+FE0F).
|
||
let mut row = wide_cells("\u{2764}");
|
||
row[0].marks = Some(Box::from(['\u{FE0F}']));
|
||
assert_eq!(segment_row(&row), [wide_cluster(0, 2, "\u{2764}\u{FE0F}")]);
|
||
|
||
// Several marks on one base: an above-base vowel and a tone mark both
|
||
// sit on the consonant (ที่ = ท U+0E17 + ◌ี U+0E35 + ◌่ U+0E48).
|
||
let mut row = vec![cell('\u{0E17}'), cell('a')];
|
||
row[0].marks = Some(Box::from(['\u{0E35}', '\u{0E48}']));
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[cluster(0, 1, "\u{0E17}\u{0E35}\u{0E48}"), run(1, 1, "a")]
|
||
);
|
||
}
|
||
|
||
/// SARA AM (U+0E33) is the awkward Thai vowel: `Lo`, width 1, so the grid
|
||
/// gives it its own column — but the shaper decomposes it into NIKHAHIT +
|
||
/// SARA AA and reorders the nikhahit backwards onto the base consonant.
|
||
/// Shaped in its own run it has no base to reorder onto and comes out as a
|
||
/// dotted circle, so it has to join the preceding cell's cluster.
|
||
#[test]
|
||
fn segment_row_absorbs_sara_am_into_its_base() {
|
||
// น + ้ (tone) + ำ — the base already carries a mark.
|
||
let mut row = vec![cell('\u{0E19}'), cell('\u{0E33}'), cell('a')];
|
||
row[0].marks = Some(Box::from(['\u{0E49}']));
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[cluster(0, 2, "\u{0E19}\u{0E49}\u{0E33}"), run(2, 1, "a")]
|
||
);
|
||
|
||
// ก + ำ — an unmarked base still has to shape with it.
|
||
let row = vec![cell('\u{0E01}'), cell('\u{0E33}')];
|
||
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]);
|
||
|
||
// Lao SARA AM (U+0EB3) takes the same shaper path.
|
||
let row = vec![cell('\u{0E81}'), cell('\u{0EB3}')];
|
||
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E81}\u{0EB3}")]);
|
||
|
||
// A style change does not break the cluster, unlike a `Run` or `Wide`
|
||
// batch: split off, the vowel has no base and paints a dotted circle,
|
||
// so it takes the base's style instead.
|
||
let mut row = vec![cell('\u{0E01}'), cell('\u{0E33}')];
|
||
row[1].fg = gpui::red();
|
||
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]);
|
||
}
|
||
|
||
/// With nothing to attach to, SARA AM paints alone — a dotted circle is the
|
||
/// shaper's honest answer for an orphaned mark, and inventing a base would
|
||
/// be worse.
|
||
#[test]
|
||
fn segment_row_leaves_a_baseless_sara_am_alone() {
|
||
let row = vec![cell('\u{0E33}'), cell('a')];
|
||
assert_eq!(segment_row(&row), [RowSeg::Solo { col: 0 }, run(1, 1, "a")]);
|
||
|
||
// A blank before it is not a base either.
|
||
let row = vec![cell(' '), cell('\u{0E33}')];
|
||
assert_eq!(segment_row(&row), [RowSeg::Solo { col: 1 }]);
|
||
|
||
// Nor is another SARA AM: absorbing would pin the second one's glyphs
|
||
// past the cluster's two-cell clip and swallow it entirely.
|
||
let row = vec![cell('\u{0E33}'), cell('\u{0E33}')];
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[RowSeg::Solo { col: 0 }, RowSeg::Solo { col: 1 }]
|
||
);
|
||
}
|
||
|
||
/// A marked cell never joins a batch: marks add characters without adding
|
||
/// columns, which would desync `force_width`'s glyph-per-column pinning.
|
||
#[test]
|
||
fn segment_row_never_batches_a_marked_cell() {
|
||
// ASCII run splits around it.
|
||
let mut row: Vec<_> = "abc".chars().map(cell).collect();
|
||
row[1].marks = Some(Box::from(['\u{0301}']));
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[run(0, 1, "a"), cluster(1, 1, "b\u{0301}"), run(2, 1, "c"),]
|
||
);
|
||
|
||
// Wide run splits around it.
|
||
let mut row = wide_cells("你好世");
|
||
row[2].marks = Some(Box::from(['\u{FE0F}']));
|
||
assert_eq!(
|
||
segment_row(&row),
|
||
[
|
||
wide(0, 2, "你"),
|
||
wide_cluster(2, 2, "好\u{FE0F}"),
|
||
wide(4, 2, "世"),
|
||
]
|
||
);
|
||
}
|
||
|
||
/// A mark opening a line lands on the space the grid starts with — that
|
||
/// cell still has ink, so it must not be skipped as blank.
|
||
#[test]
|
||
fn segment_row_keeps_a_blank_that_carries_marks() {
|
||
let mut row = vec![cell(' '), cell(' ')];
|
||
row[0].marks = Some(Box::from(['\u{0301}']));
|
||
assert_eq!(segment_row(&row), [cluster(0, 1, " \u{0301}")]);
|
||
}
|
||
|
||
#[test]
|
||
fn char_string_memoizes_per_char() {
|
||
let a = char_string('界');
|
||
let b = char_string('界');
|
||
assert_eq!(a, b);
|
||
assert_eq!(a.as_ref(), "界");
|
||
}
|
||
|
||
/// Hand-built `PaintColors` for the snapshot tests (the real `resolve`
|
||
/// needs a live theme/App).
|
||
fn test_colors() -> PaintColors {
|
||
let fg = Rgb {
|
||
r: 10,
|
||
g: 10,
|
||
b: 10,
|
||
};
|
||
let bg = Rgb {
|
||
r: 250,
|
||
g: 250,
|
||
b: 245,
|
||
};
|
||
let wash = |a: f32| {
|
||
let mut c = to_hsla(Rgb {
|
||
r: 0x4a,
|
||
g: 0x43,
|
||
b: 0x39,
|
||
});
|
||
c.a = a;
|
||
c
|
||
};
|
||
PaintColors {
|
||
default_fg: to_hsla(fg),
|
||
default_bg: to_hsla(bg),
|
||
caret: to_hsla(fg),
|
||
selection_bg: wash(0.55),
|
||
match_bg: wash(0.32),
|
||
current_match_bg: wash(0.85),
|
||
current_match_border: to_hsla(fg),
|
||
fg_rgb: fg,
|
||
bg_rgb: bg,
|
||
}
|
||
}
|
||
|
||
/// Regression: selection must not rewrite a cell's own colors. It used to
|
||
/// force the foreground to the preset's selection text color and rely on an
|
||
/// opaque fill — which erased background-only cells entirely (fastfetch
|
||
/// color swatches, colored diff blocks vanished while selected). Selection
|
||
/// now only flags the cell; the paint pass lays a translucent wash on top.
|
||
#[test]
|
||
fn selected_cells_keep_their_own_colors_for_the_translucent_wash() {
|
||
let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
||
palette[1] = Rgb {
|
||
r: 0xcc,
|
||
g: 0x22,
|
||
b: 0x22,
|
||
};
|
||
palette[2] = Rgb {
|
||
r: 0x22,
|
||
g: 0x88,
|
||
b: 0x22,
|
||
};
|
||
let colors = test_colors();
|
||
let point = AlacPoint::new(AlacLine(0), AlacColumn(0));
|
||
let range = SelectionRange::new(point, point, false);
|
||
|
||
// A fastfetch-style swatch: a space whose information IS its background.
|
||
let swatch = Cell {
|
||
bg: AnsiColor::Indexed(1),
|
||
..Cell::default()
|
||
};
|
||
let rc = snapshot_cell(&swatch, point, &palette, &colors, Some(&range));
|
||
assert!(rc.selected);
|
||
assert!(rc.draw_bg, "the swatch background still paints");
|
||
assert_eq!(rc.bg, to_hsla(palette[1]), "background not replaced");
|
||
|
||
// Colored text keeps its syntax color while selected.
|
||
let text = Cell {
|
||
c: 'x',
|
||
fg: AnsiColor::Indexed(2),
|
||
..Cell::default()
|
||
};
|
||
let rc = snapshot_cell(&text, point, &palette, &colors, Some(&range));
|
||
assert!(rc.selected);
|
||
assert_eq!(rc.fg, to_hsla(palette[2]), "foreground not forced");
|
||
|
||
// And the wash itself is translucent, or the kept colors could never
|
||
// read through it.
|
||
assert!(colors.selection_bg.a < 1.0);
|
||
}
|
||
|
||
/// SGR 7 swaps the two colors, and the swapped background must paint even
|
||
/// when the cell sat on the *default* background — otherwise an inverse
|
||
/// block (`ls` selections, status bars) silently vanishes.
|
||
#[test]
|
||
fn inverse_swaps_colors_and_always_paints_the_background() {
|
||
let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
||
palette[2] = Rgb {
|
||
r: 0x22,
|
||
g: 0x88,
|
||
b: 0x22,
|
||
};
|
||
let colors = test_colors();
|
||
let point = AlacPoint::new(AlacLine(0), AlacColumn(0));
|
||
|
||
let cell = Cell {
|
||
c: 'x',
|
||
fg: AnsiColor::Indexed(2),
|
||
flags: Flags::INVERSE,
|
||
..Cell::default()
|
||
};
|
||
let rc = snapshot_cell(&cell, point, &palette, &colors, None);
|
||
assert_eq!(rc.fg, to_hsla(colors.bg_rgb), "fg takes the old background");
|
||
assert_eq!(rc.bg, to_hsla(palette[2]), "bg takes the old foreground");
|
||
assert!(
|
||
rc.draw_bg,
|
||
"inverse cells paint their background even on the default bg"
|
||
);
|
||
}
|
||
|
||
/// SGR 8 conceals text by drawing it in the background color — whatever
|
||
/// that background is — without turning a default background opaque.
|
||
#[test]
|
||
fn hidden_paints_the_foreground_as_the_background() {
|
||
let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
||
palette[1] = Rgb {
|
||
r: 0xcc,
|
||
g: 0x22,
|
||
b: 0x22,
|
||
};
|
||
let colors = test_colors();
|
||
let point = AlacPoint::new(AlacLine(0), AlacColumn(0));
|
||
|
||
// On the default background the glyph melts into the theme bg and the
|
||
// cell still skips the background fill.
|
||
let on_default = Cell {
|
||
c: 's',
|
||
fg: AnsiColor::Indexed(1),
|
||
flags: Flags::HIDDEN,
|
||
..Cell::default()
|
||
};
|
||
let rc = snapshot_cell(&on_default, point, &palette, &colors, None);
|
||
assert_eq!(rc.fg, rc.bg, "concealed text is invisible");
|
||
assert_eq!(rc.fg, to_hsla(colors.bg_rgb));
|
||
assert!(!rc.draw_bg);
|
||
|
||
// On a colored background it melts into *that* color instead.
|
||
let on_colored = Cell {
|
||
c: 's',
|
||
bg: AnsiColor::Indexed(1),
|
||
flags: Flags::HIDDEN,
|
||
..Cell::default()
|
||
};
|
||
let rc = snapshot_cell(&on_colored, point, &palette, &colors, None);
|
||
assert_eq!(rc.fg, to_hsla(palette[1]));
|
||
assert_eq!(rc.fg, rc.bg);
|
||
}
|
||
|
||
/// Each underline SGR maps to its own variant, and the specific bits must
|
||
/// win over a plain UNDERLINE that may be set alongside them — a curly
|
||
/// diagnostic squiggle degrading to a straight line is exactly the kind of
|
||
/// regression a human eyeball misses.
|
||
#[test]
|
||
fn underline_flag_bits_map_to_their_variants() {
|
||
let palette = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
||
let colors = test_colors();
|
||
let point = AlacPoint::new(AlacLine(0), AlacColumn(0));
|
||
let kind = |flags: Flags| {
|
||
let cell = Cell {
|
||
c: 'u',
|
||
flags,
|
||
..Cell::default()
|
||
};
|
||
snapshot_cell(&cell, point, &palette, &colors, None).underline
|
||
};
|
||
|
||
assert_eq!(kind(Flags::empty()), UnderlineKind::None);
|
||
assert_eq!(kind(Flags::UNDERLINE), UnderlineKind::Single);
|
||
assert_eq!(kind(Flags::DOUBLE_UNDERLINE), UnderlineKind::Double);
|
||
assert_eq!(kind(Flags::UNDERCURL), UnderlineKind::Curly);
|
||
assert_eq!(kind(Flags::DOTTED_UNDERLINE), UnderlineKind::Dotted);
|
||
assert_eq!(kind(Flags::DASHED_UNDERLINE), UnderlineKind::Dashed);
|
||
// A variant bit set together with plain UNDERLINE keeps the variant.
|
||
assert_eq!(
|
||
kind(Flags::UNDERLINE | Flags::UNDERCURL),
|
||
UnderlineKind::Curly
|
||
);
|
||
}
|
||
|
||
/// SGR 58 sets a dedicated underline color that resolves through the
|
||
/// palette; without it the field stays `None` so paint falls back to the
|
||
/// glyph's foreground.
|
||
#[test]
|
||
fn sgr58_underline_color_resolves_through_the_palette() {
|
||
let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
||
palette[5] = Rgb {
|
||
r: 0xd0,
|
||
g: 0x30,
|
||
b: 0x30,
|
||
};
|
||
let colors = test_colors();
|
||
let point = AlacPoint::new(AlacLine(0), AlacColumn(0));
|
||
|
||
let mut cell = Cell {
|
||
c: 'e',
|
||
flags: Flags::UNDERCURL,
|
||
..Cell::default()
|
||
};
|
||
cell.set_underline_color(Some(AnsiColor::Indexed(5)));
|
||
let rc = snapshot_cell(&cell, point, &palette, &colors, None);
|
||
assert_eq!(rc.underline_color, Some(to_hsla(palette[5])));
|
||
|
||
let plain = Cell {
|
||
c: 'e',
|
||
flags: Flags::UNDERCURL,
|
||
..Cell::default()
|
||
};
|
||
let rc = snapshot_cell(&plain, point, &palette, &colors, None);
|
||
assert_eq!(
|
||
rc.underline_color, None,
|
||
"no SGR 58 → paint falls back to fg"
|
||
);
|
||
}
|
||
|
||
/// BOLD_ITALIC is its own flag bit — it must light up both emphases, not
|
||
/// require BOLD and ITALIC to also be set.
|
||
#[test]
|
||
fn bold_italic_flag_sets_both_emphases() {
|
||
let palette = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
||
let colors = test_colors();
|
||
let point = AlacPoint::new(AlacLine(0), AlacColumn(0));
|
||
let emphases = |flags: Flags| {
|
||
let cell = Cell {
|
||
c: 'b',
|
||
flags,
|
||
..Cell::default()
|
||
};
|
||
let rc = snapshot_cell(&cell, point, &palette, &colors, None);
|
||
(rc.bold, rc.italic)
|
||
};
|
||
|
||
assert_eq!(emphases(Flags::BOLD), (true, false));
|
||
assert_eq!(emphases(Flags::ITALIC), (false, true));
|
||
assert_eq!(emphases(Flags::BOLD_ITALIC), (true, true));
|
||
}
|
||
|
||
/// Wide-char spacers only mark the column as occupied; everything else —
|
||
/// colors, underline, selection — is the leading cell's job. A spacer that
|
||
/// painted anything would double-draw under every CJK glyph.
|
||
#[test]
|
||
fn wide_char_spacers_defer_to_the_leading_cell() {
|
||
let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
||
palette[1] = Rgb {
|
||
r: 0xcc,
|
||
g: 0x22,
|
||
b: 0x22,
|
||
};
|
||
let colors = test_colors();
|
||
let point = AlacPoint::new(AlacLine(0), AlacColumn(0));
|
||
|
||
for flags in [Flags::WIDE_CHAR_SPACER, Flags::LEADING_WIDE_CHAR_SPACER] {
|
||
let cell = Cell {
|
||
c: ' ',
|
||
bg: AnsiColor::Indexed(1),
|
||
flags: flags | Flags::UNDERLINE,
|
||
..Cell::default()
|
||
};
|
||
let rc = snapshot_cell(&cell, point, &palette, &colors, None);
|
||
assert!(rc.spacer);
|
||
assert!(!rc.draw_bg, "spacers never paint");
|
||
assert_eq!(rc.underline, UnderlineKind::None);
|
||
}
|
||
}
|
||
|
||
/// SGR 58, OSC 8 and combining marks all live in alacritty's one lazily
|
||
/// boxed `extra`, so `Cell::zerowidth` says `Some(&[])` for a cell that
|
||
/// merely carries a color or a link. Only real marks may set `marks`: an
|
||
/// empty list is still `Some`, and `segment_row` would take every linked
|
||
/// cell off the batched run path onto a `shape_line` of its own.
|
||
#[test]
|
||
fn only_real_combining_marks_set_marks() {
|
||
let palette = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
||
let colors = test_colors();
|
||
let point = AlacPoint::new(AlacLine(0), AlacColumn(0));
|
||
|
||
let mut colored = Cell {
|
||
c: 'e',
|
||
flags: Flags::UNDERCURL,
|
||
..Cell::default()
|
||
};
|
||
colored.set_underline_color(Some(AnsiColor::Indexed(5)));
|
||
assert_eq!(
|
||
snapshot_cell(&colored, point, &palette, &colors, None).marks,
|
||
None,
|
||
"SGR 58 alone is not a combining mark"
|
||
);
|
||
|
||
let mut linked = Cell {
|
||
c: 'e',
|
||
..Cell::default()
|
||
};
|
||
linked.set_hyperlink(Some(alacritty_terminal::term::cell::Hyperlink::new(
|
||
Some("id"),
|
||
"https://example.com".to_string(),
|
||
)));
|
||
assert_eq!(
|
||
snapshot_cell(&linked, point, &palette, &colors, None).marks,
|
||
None,
|
||
"OSC 8 alone is not a combining mark"
|
||
);
|
||
|
||
let mut marked = Cell {
|
||
c: '\u{2764}',
|
||
..Cell::default()
|
||
};
|
||
marked.push_zerowidth('\u{FE0F}');
|
||
assert_eq!(
|
||
snapshot_cell(&marked, point, &palette, &colors, None)
|
||
.marks
|
||
.as_deref(),
|
||
Some(&['\u{FE0F}'][..]),
|
||
);
|
||
}
|
||
}
|