Files
tty7/src/terminal/element.rs
T
thomasandClaude Fable 5 b8dc38fb98 fix(input): anchor the IME candidate window at the fake caret, not the parked cursor (#275)
Cursor-hiding TUIs (Kimi CLI, Ink apps) draw their caret as a reverse-video
cell and leave the real cursor wherever the frame's last write ended — for
Kimi that is the input box's right border, and the IME candidate list was
stranded there. When the cursor is hidden and its row holds exactly one
caret-sized inverse run, snap the IME anchor (and the marked-text preview)
to that run; rendering is untouched.

The gpui side (bumped here) now also answers IMR_QUERYCHARPOSITION — the
query the Windows 11 Microsoft Pinyin IME uses instead of CANDIDATEFORM —
and re-anchors the candidate window on every WM_IME_COMPOSITION.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 23:48:01 +08:00

2321 lines
75 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Corners, 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;
use crate::core::config::Config;
#[derive(Clone, Copy, PartialEq, Default, Debug)]
enum UnderlineKind {
#[default]
None,
Single,
Double,
Curly,
Dotted,
Dashed,
}
#[derive(Clone)]
struct RenderCell {
c: char,
marks: Option<Box<[char]>>,
fg: Hsla,
bg: Hsla,
draw_bg: bool,
bold: bool,
italic: bool,
underline: UnderlineKind,
underline_color: Option<Hsla>,
spacer: bool,
selected: bool,
match_hit: bool,
match_current: bool,
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: 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()
}
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
};
if f.features.tag_value_list().is_empty() {
f.features = gpui::FontFeatures::disable_ligatures();
}
f
}
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,
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),
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
},
underline_color: cell
.underline_color()
.map(|c| to_hsla(resolve(c, palette, colors.fg_rgb, colors.bg_rgb).0)),
..RenderCell::default()
};
if selection.is_some_and(|s| s.contains(point)) {
rc.selected = true;
}
rc
}
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,
},
}
}
struct PaintColors {
default_fg: Hsla,
default_bg: Hsla,
caret: Hsla,
selection_bg: Hsla,
match_bg: Hsla,
current_match_bg: Hsla,
current_match_border: Hsla,
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;
let selection_bg = {
let mut c = default_fg;
c.a = 0.24;
c
};
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),
}
}
}
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];
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));
}
}
}
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];
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));
}
}
}
}
#[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,
}
}
fn draws_on_blanks(&self) -> bool {
self.underline != UnderlineKind::None || self.link_hover
}
fn underline_style(&self) -> Option<gpui::UnderlineStyle> {
self.draws_on_blanks().then(|| {
let wavy = self.underline == UnderlineKind::Curly;
let thickness = if self.underline == UnderlineKind::Double {
px(2.)
} else {
px(1.)
};
gpui::UnderlineStyle {
thickness,
color: Some(self.underline_color.unwrap_or(self.fg)),
wavy,
}
})
}
}
fn is_blank(cell: &RenderCell) -> bool {
(cell.c == '\0' || cell.c == ' ') && cell.marks.is_none()
}
#[derive(Debug, PartialEq)]
enum RowSeg {
Run {
start: usize,
cells: usize,
text: String,
},
Wide {
start: usize,
cells: usize,
text: SharedString,
},
Solo {
col: usize,
},
Cluster {
col: usize,
cells: usize,
text: String,
wide_base: bool,
},
}
fn push_cell(text: &mut String, cell: &RenderCell) {
text.push(cell.c);
text.extend(cell.marks.iter().flat_map(|marks| marks.iter()));
}
fn is_sara_am(c: char) -> bool {
matches!(c, '\u{0E33}' | '\u{0EB3}')
}
fn sara_am_at(row: &[RenderCell], col: usize) -> Option<&RenderCell> {
row.get(col)
.filter(|cell| !cell.spacer && is_sara_am(cell.c))
}
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;
}
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);
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() {
if col + 1 < row.len() && row[col + 1].spacer {
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)
{
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;
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! {
static CHAR_STRINGS: RefCell<HashMap<char, SharedString>> = RefCell::new(HashMap::new());
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()
})
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum PowerlineShape {
TriangleRight,
TriangleLeft,
HalfCircleRight,
HalfCircleLeft,
SlantLowerLeft,
SlantLowerRight,
SlantUpperLeft,
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,
})
}
}
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 => {
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
}
}
}
fn native_cell_residue(style: &GlyphStyle) -> Option<char> {
style.draws_on_blanks().then_some(' ')
}
fn seg_clip_width(solo: bool, cells: usize, cell_width: Pixels) -> Pixels {
if solo {
cell_width * 2.
} else {
cell_width * cells as f32
}
}
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>,
) {
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,
),
RowSeg::Wide { start, cells, text } => {
(start, cells, text, Some(geom.cell_width * 2.), false)
}
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),
);
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,
Some(c) => (col, 1, char_string(c), None, false),
}
}
}
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);
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,
);
});
}
}
}
#[derive(Clone, Copy)]
struct GridCursor {
row: usize,
col: usize,
// Where the IME candidate window should anchor: the fake caret drawn by
// cursor-hiding TUIs when one is identifiable, else `col`.
ime_col: usize,
hidden: bool,
style: crate::core::config::CursorStyle,
}
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;
}
let mut c = caret;
c.a = 0.55;
match style {
CursorStyle::Block => window.paint_quad(fill(rect, c)),
CursorStyle::Bar => {
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 => {
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,
}
}
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,
);
}
struct GridSnapshot {
cursor: Option<GridCursor>,
sliver: Option<Vec<RenderCell>>,
any_selected: bool,
any_match: bool,
any_current: bool,
/// Scrollback state at snapshot time, so the paint pass can map a kitty
/// image's absolute anchor row back to a screen row: screen_row =
/// anchor_row - history_size + display_offset.
display_offset: i32,
history_size: usize,
}
impl TerminalElement {
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 history_size;
{
let mut palette = self.view.read(cx).terminal.palette;
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;
history_size = term.grid().history_size();
let selection = content.selection;
let cur = content.cursor;
let cursor_row = cur.point.line.0 + display_offset;
let cursor_hidden = matches!(cur.shape, CursorShape::Hidden);
// TUIs that hide the hardware cursor (Kimi CLI, Ink apps) draw
// their own caret as a reverse-video cell and park the real
// cursor wherever the frame's last write ended, which strands
// the IME candidate window there (#275). A lone short inverse
// run on the cursor's row is that fake caret; collect runs so
// the IME anchor can snap to it.
let mut inverse_runs: Vec<(usize, usize)> = Vec::new();
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;
}
if cursor_hidden
&& row == cursor_row
&& cell.cell.flags.contains(Flags::INVERSE)
&& !cell
.cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
match inverse_runs.last_mut() {
Some((start, len)) if *start + *len == col => *len += 1,
_ => inverse_runs.push((col, 1)),
}
}
let rc = snapshot_cell(cell.cell, cell.point, &palette, colors, selection.as_ref());
any_selected |= rc.selected;
buf[row as usize * cols + col] = rc;
}
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);
}
let row = cursor_row;
let col = cur.point.column.0;
if row >= 0 && (row as usize) < rows && col < cols {
// Snap the IME anchor to the fake caret when there is
// exactly one caret-sized inverse run on the row.
let ime_col = match inverse_runs.as_slice() {
[(start, len)] if cursor_hidden && *len <= 2 => *start,
_ => col,
};
cursor = Some(GridCursor {
row: row as usize,
col,
ime_col,
hidden: cursor_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,
display_offset,
history_size,
}
}
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;
};
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;
}
}
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);
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;
}
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)
}
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| {
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| {
let link_modifier = mods.secondary() || v.link_modifier_down();
if link_modifier
&& button == MouseButton::Left
&& v.open_link_at(col, row, window, cx)
{
return;
}
if v.mouse_mode() && !mods.shift {
v.mouse_press(button, col, row, &mods);
return;
}
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 {
let inside = hitbox.is_hovered(window);
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 {
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;
}
if button == MouseButton::Left && v.editor_drag(col, raw_row, cx) {
return;
}
if button == MouseButton::Left {
v.on_select_update(col, row, left, cx);
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();
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;
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, window.scale_factor());
});
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,
) {
let fps_start = super::fps::enabled().then(std::time::Instant::now);
let frac = self.view.read(cx).scroll_frac.clamp(0., 1.);
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);
let cursor_visible = self.view.read(cx).cursor_visible;
let bell_flash = self.view.read(cx).bell_flash;
let editor_active = self.view.read(cx).input_active();
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();
let cursor_cell = cursor.map(|c| (c.row, c.ime_col));
let render_cursor = cursor
.filter(|c| !c.hidden)
.map(|c| (c.row, c.col, c.style));
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();
// Kitty-graphics placements for this pane. Each image anchors to an
// absolute scrollback row (recorded when its command arrived in-stream);
// map that back to a screen row with the snapshot's scroll state and
// drop anything scrolled out of the viewport.
let image_store = self.view.read(cx).terminal.images();
// Take the retired list *before* the snapshot. The decode worker runs on
// its own thread and can retire a frame between the two calls; taking
// retired second would hand us a list containing an image the snapshot
// still says to paint, and `sprite_atlas.remove` takes effect
// immediately — so the frame would paint and then vanish. In this order
// the worst case is evicting one paint late, which is invisible.
let retired_images = image_store.take_retired();
let images = image_store.snapshot();
window.with_content_mask(Some(ContentMask { bounds }), |window| {
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(),
);
// Kitty-graphics images, painted over the placeholder cells they
// occupy. `anchor_row` is absolute (measured from the top of
// scrollback); convert it to a screen row with the same scroll state
// `build_grid` captured. Rows spanning past the viewport top/bottom
// are clipped by the surrounding content mask.
//
// Sizing: per the kitty spec, an image with no `c=`/`r=` is shown at
// its natural size — one image pixel per terminal *device* pixel. A
// sender like terminal-browser renders its frame to exactly fill the
// pixel area we reported to the child via `ws_xpixel`/`ws_ypixel`,
// which the daemon sets to `cols × round(cell_w × scale)` /
// `rows × round(cell_h × scale)` — the cell size in *device* pixels
// (see `set_grid_size`). So the frame is at device resolution; to map
// it back to a cell span we divide by that *same* device cell size,
// `round(cell_logical × scale)`. Painting the resulting cell-span
// bounds (in logical px) lets gpui blit the device-resolution bitmap
// ~1:1 on the framebuffer — sharp, and the right size — instead of
// upscaling a half-resolution one. Deriving here, not at placement,
// keeps it correct across font-size / zoom / display-scale changes.
let scale = window.scale_factor();
let scale = if scale.is_finite() && scale > 0. {
scale
} else {
1.
};
for img in &images {
let round_w = (geom.cell_width.as_f32() * scale).round().max(1.);
let round_h = (geom.line_height.as_f32() * scale).round().max(1.);
let span_cols = if img.cols > 0 {
img.cols as f32
} else {
(img.width_px as f32 / round_w).round().max(1.)
};
let span_rows = if img.rows > 0 {
img.rows as f32
} else {
(img.height_px as f32 / round_h).round().max(1.)
};
let screen_row =
img.anchor_row - snap.history_size as i64 + snap.display_offset as i64;
// Fully above or below the viewport: nothing visible to paint.
if screen_row + span_rows as i64 <= 0 || screen_row >= geom.rows as i64 {
continue;
}
let top = geom.origin.y + geom.line_height * screen_row as f32;
let left = geom.origin.x + geom.cell_width * img.anchor_col as f32;
let bounds = Bounds {
origin: point(left, top),
size: size(geom.cell_width * span_cols, geom.line_height * span_rows),
};
let _ = window.paint_image(bounds, Corners::default(), img.data.clone(), 0, false);
}
// Evict superseded / deleted frames from the sprite atlas. Without
// this a browser re-transmitting at 60fps would leak one GPU tile per
// frame, growing the atlas without bound until the compositor stalls.
for retired in &retired_images {
let _ = window.drop_image(retired.clone());
}
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(),
);
}
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,
);
}
if bell_flash {
let mut c = colors.default_fg;
c.a = 0.12;
window.paint_quad(fill(bounds, c));
}
});
GRID_BUF.with(|b| *b.borrow_mut() = buf);
self.register_mouse_handlers(geom, bounds, prepaint.hitbox.id, window);
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());
}
}
}
#[derive(Clone, Copy)]
struct CellGeom {
origin: Point<Pixels>,
cell_width: Pixels,
line_height: Pixels,
cols: usize,
rows: usize,
}
impl CellGeom {
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),
)
}
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)
}
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
}
}
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");
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() {
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,
};
let spec = Rgb { r: 1, g: 2, b: 3 };
assert_eq!(
resolve(AnsiColor::Spec(spec), &palette, fg, bg),
(spec, false)
);
let (rgb, is_def) = resolve(AnsiColor::Indexed(5), &palette, fg, bg);
assert_eq!(rgb.r, 5);
assert!(!is_def);
assert_eq!(
resolve(AnsiColor::Named(NamedColor::Foreground), &palette, fg, bg),
(fg, true)
);
assert_eq!(
resolve(AnsiColor::Named(NamedColor::Background), &palette, fg, bg),
(bg, true)
);
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);
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.));
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,
};
let (c, r, left) = geom.pos_to_cell(point(px(2.), px(5.)));
assert_eq!((c, r), (0, 0));
assert!(left);
let (_, _, left) = geom.pos_to_cell(point(px(8.), px(5.)));
assert!(!left);
let (c, r, _) = geom.pos_to_cell(point(px(-100.), px(-100.)));
assert_eq!((c, r), (0, 0));
let (c, r, _) = geom.pos_to_cell(point(px(9999.), px(9999.)));
assert_eq!((c, r), (4, 2));
}
#[test]
fn drag_overshoot_signed_by_edge_and_zero_inside() {
let bounds = Bounds::new(point(px(0.), px(100.)), size(px(200.), px(100.)));
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.);
assert_eq!(drag_overshoot(px(80.), bounds, px(10.)), 2.);
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
);
}
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(),
}
}
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() {
let row: Vec<_> = " ab cd ".chars().map(cell).collect();
assert_eq!(segment_row(&row), [run(1, 6, "ab cd")]);
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() {
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")]);
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() {
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")]);
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() {
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")]
);
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() {
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);
}
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() {
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.);
assert_eq!(seg_clip_width(false, 1, cell), px(10.));
assert_eq!(seg_clip_width(false, 5, cell), px(50.));
assert_eq!(seg_clip_width(false, 2, cell), px(20.));
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)
);
}
#[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"
);
}
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() {
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() {
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() {
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() {
let mut row = wide_cells("你好世界");
row[4].fg = gpui::red();
row[6].fg = gpui::red();
assert_eq!(
segment_row(&row),
[
wide(0, 2, "你"),
wide(2, 2, "好"),
wide(4, 2, "世"),
wide(6, 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() {
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,
}
}
#[test]
fn segment_row_shapes_combining_marks_with_their_base() {
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"),]
);
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}")]);
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")]
);
}
#[test]
fn segment_row_absorbs_sara_am_into_its_base() {
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")]
);
let row = vec![cell('\u{0E01}'), cell('\u{0E33}')];
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]);
let row = vec![cell('\u{0E81}'), cell('\u{0EB3}')];
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E81}\u{0EB3}")]);
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}")]);
}
#[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")]);
let row = vec![cell(' '), cell('\u{0E33}')];
assert_eq!(segment_row(&row), [RowSeg::Solo { col: 1 }]);
let row = vec![cell('\u{0E33}'), cell('\u{0E33}')];
assert_eq!(
segment_row(&row),
[RowSeg::Solo { col: 0 }, RowSeg::Solo { col: 1 }]
);
}
#[test]
fn segment_row_never_batches_a_marked_cell() {
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"),]
);
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, "世"),
]
);
}
#[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(), "界");
}
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,
}
}
#[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);
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");
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");
assert!(colors.selection_bg.a < 1.0);
}
#[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"
);
}
#[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));
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);
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);
}
#[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);
assert_eq!(
kind(Flags::UNDERLINE | Flags::UNDERCURL),
UnderlineKind::Curly
);
}
#[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"
);
}
#[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));
}
#[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);
}
}
#[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}'][..]),
);
}
}