fix(input-bar): read column widths from unicode-width, not a hand-rolled table (#704)

* fix(input-bar): read column widths from unicode-width, not a hand-rolled table

The input bar scored every character against a hand-written list of code-point
ranges. Anything the list missed counted as one plain column, so `🀄`, `` and
every combining mark pulled the rest of the row a column left, and clicks,
wrapping and the caret all landed off by that much (#701).

The grid gets its widths from `unicode-width` by way of `alacritty_terminal`,
so read the same table. Zero-width characters then need a cell to ride in:
group each base with the marks that follow it, so the shaper sees one run and
composes `é` instead of setting `e` and its accent side by side. An emoji
presentation sequence is re-scored as a string the way the grid re-scores it,
so `❤️` is two columns in the bar as well.

A ZWJ sequence stays two cells on purpose — that is what the grid makes of it,
and composing it here would put the bar a column off from where the text lands.

* fix(input-bar): derive click and wrap geometry from the cells the bar draws

`input_cells` re-scores an emoji presentation sequence to two columns and
hands a stranded combining mark a column of its own, but `input_char_positions`
kept walking the text character by character — so `❤️` was drawn two columns
wide and counted as one. Everything geometric read the short count: a click on
`X` in `❤️X` selected past it, wrapping broke a column early, and vertical
caret motion aimed at the wrong column.

Walk the same cells instead. Only the base of a cell carries the width, so a
click still lands on the base rather than a mark riding on it, and the riders
sit at the column the caret takes after the cell.

A cell now also tints as a unit when a selection covers any character in it —
it is one glyph, so half-highlighting it drew a mark unselected next to its
selected base.
This commit is contained in:
l0ng-ai
2026-08-20 22:35:32 +08:00
committed by GitHub
parent 07e3b26434
commit 46759b8a01
3 changed files with 327 additions and 43 deletions
Generated
+1
View File
@@ -9805,6 +9805,7 @@ dependencies = [
"tray-icon",
"tty7-core",
"unicode-segmentation",
"unicode-width",
"uuid",
"windows 0.58.0",
"windows-sys 0.61.2",
+6
View File
@@ -55,6 +55,12 @@ memchr = "2"
# emoji, and half a flag renders as a bare letter. Already in the tree via
# gpui, so this pins no new code.
unicode-segmentation = "1"
# Column widths for the input bar (`terminal::view`). The bar has to lay text
# out the way the terminal grid will, and the grid gets its widths from this
# same crate by way of `alacritty_terminal` — a hand-rolled code-point table
# drifts from it the moment Unicode adds a block. Already in the tree via
# `tty7-cli`, so this pins no new code.
unicode-width = "0.2"
smol.workspace = true
smallvec.workspace = true
serde = { workspace = true }
+320 -43
View File
@@ -5594,9 +5594,14 @@ impl TerminalView {
}
}
};
let cell = |color: gpui::Hsla, ch: char, selected: bool, caret: bool, underline: bool| {
let cell = |color: gpui::Hsla,
text: String,
width: usize,
selected: bool,
caret: bool,
underline: bool| {
let inverted = caret && block_cursor;
let w = cell_w * (display_width(ch) as f32);
let w = cell_w * (width as f32);
let mut d = div()
.relative()
.flex_none()
@@ -5613,7 +5618,7 @@ impl TerminalView {
if underline {
d = d.border_b_1().border_color(fg);
}
d = d.child(ch.to_string());
d = d.child(text);
if caret && !inverted {
d = d.child(caret_bar());
}
@@ -5627,15 +5632,24 @@ impl TerminalView {
let is_multiline = chars.contains(&'\n');
for i in 0..len {
if i == cursor && has_marked {
for mc in marked.chars() {
lines
.last_mut()
.unwrap()
.push(cell(fg, mc, false, false, true));
let marked_cells = input_cells(&marked.chars().collect::<Vec<char>>());
for c in input_cells(&chars) {
// The caret sits on the base of a cell, so an IME's in-flight text
// opens wherever the caret is drawn — anywhere inside the cell, not
// only on its first character.
if has_marked && (c.start..c.end).contains(&cursor) {
for mc in &marked_cells {
lines.last_mut().unwrap().push(cell(
fg,
mc.text.clone(),
mc.width,
false,
false,
true,
));
}
}
let i = c.start;
if chars[i] == '\n' {
if selection.is_none() && !has_marked && cursor_on && cursor == i {
lines.last_mut().unwrap().push(
@@ -5653,12 +5667,18 @@ impl TerminalView {
lines.push(Vec::new());
continue;
}
let selected = selection.is_some_and(|(s, e)| i >= s && i < e);
let caret = selection.is_none() && !has_marked && cursor_on && cursor == i;
// A cell is one glyph, so it tints as a unit: a selection that
// covers any character in it — the base or a mark riding on it —
// covers the whole thing.
let selected = selection.is_some_and(|(s, e)| s < c.end && c.start < e);
let caret = selection.is_none()
&& !has_marked
&& cursor_on
&& (c.start..c.end).contains(&cursor);
lines
.last_mut()
.unwrap()
.push(cell(colors[i], chars[i], selected, caret, false));
.push(cell(colors[i], c.text, c.width, selected, caret, false));
}
let ghost: Option<String> = if selection.is_none() && !has_marked && !is_multiline {
@@ -5672,8 +5692,8 @@ impl TerminalView {
if cursor == len {
let last = lines.last_mut().unwrap();
if has_marked {
for mc in marked.chars() {
last.push(cell(fg, mc, false, false, true));
for mc in &marked_cells {
last.push(cell(fg, mc.text.clone(), mc.width, false, false, true));
}
} else if ghost.is_none() {
let mut tail = blank(cell_w).relative();
@@ -5686,9 +5706,10 @@ impl TerminalView {
if let Some(rem) = ghost {
let last = lines.last_mut().unwrap();
for (gi, gc) in rem.chars().map(one_line_char).enumerate() {
let flat: Vec<char> = rem.chars().map(one_line_char).collect();
for (gi, gc) in input_cells(&flat).into_iter().enumerate() {
let caret = gi == 0 && cursor == len && cursor_on;
last.push(cell(muted, gc, false, caret, false));
last.push(cell(muted, gc.text, gc.width, false, caret, false));
}
}
@@ -6381,24 +6402,88 @@ fn highlight_runs(line: &str, positions: &[usize]) -> Vec<(String, bool)> {
runs
}
/// Columns this character occupies in the input bar.
///
/// The bar lays out text the terminal is about to receive, so it has to agree
/// with the grid on where every character lands. The grid gets its widths from
/// `unicode-width` by way of `alacritty_terminal`, so the bar reads the same
/// table: a hand-written range list drifts from it the moment Unicode assigns
/// another block, and it silently counted `🀄`, `⌚` and every combining mark
/// as one plain column (#701).
///
/// Control characters have no width of their own. The bar still gives them a
/// column — one was typed, and a cell that occupies nothing is a cell nobody
/// can put the caret on.
fn display_width(c: char) -> usize {
let u = c as u32;
let wide = matches!(u,
0x1100..=0x115F
| 0x2329 | 0x232A
| 0x2E80..=0x303E
| 0x3041..=0x33FF
| 0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xA000..=0xA4CF
| 0xAC00..=0xD7A3
| 0xF900..=0xFAFF
| 0xFE10..=0xFE19 | 0xFE30..=0xFE6F
| 0xFF00..=0xFF60 | 0xFFE0..=0xFFE6
| 0x1F300..=0x1FAFF
| 0x20000..=0x3FFFD
);
if wide { 2 } else { 1 }
unicode_width::UnicodeWidthChar::width(c).unwrap_or(1)
}
/// One drawn cell of the input bar.
#[derive(Debug, PartialEq)]
struct InputCell {
/// Character indices the cell covers, as `start..end`.
start: usize,
end: usize,
/// What to draw in it — a base character and whatever rides along with it.
text: String,
/// Columns it occupies. Zero for a newline, which ends the row rather than
/// taking space on it.
width: usize,
}
/// Splits input-bar text into the cells it draws.
///
/// Combining marks, variation selectors and the joiner inside an emoji
/// sequence take no column of their own, and the shaper has to see them in the
/// same run as their base to compose a single glyph — so they ride in the cell
/// of the character in front of them instead of each getting a box. Handing
/// them their own cell is what left `e` and its accent side by side, and every
/// mark shifted the rest of the row a column left (#701).
///
/// A mark with no base ahead of it — text pasted mid-sequence, an IME's
/// in-flight buffer — keeps a cell and a column, so it stays visible and the
/// caret has somewhere to sit.
fn input_cells(chars: &[char]) -> Vec<InputCell> {
let mut cells: Vec<InputCell> = Vec::with_capacity(chars.len());
for (i, &ch) in chars.iter().enumerate() {
if ch == '\n' {
cells.push(InputCell {
start: i,
end: i + 1,
text: String::new(),
width: 0,
});
continue;
}
let w = display_width(ch);
match cells.last_mut() {
// `width > 0` keeps a mark off a newline's cell, which ends a row
// and draws nothing.
Some(last) if w == 0 && last.width > 0 => {
last.text.push(ch);
last.end = i + 1;
// U+FE0F asks for the emoji glyph, and an emoji presentation
// sequence is two columns wide even where its base is one — a
// rule that only exists at string level (UTS #51), which is why
// scoring character by character misses it. The grid re-scores
// the sequence the same way (our `alacritty_terminal` fork,
// #203), so the bar has to as well or `❤️` sits a column
// narrower here than where it lands. Never narrower than the
// base: giving a column back would mean pulling the row left.
if ch == '\u{FE0F}' {
let scored = unicode_width::UnicodeWidthStr::width(last.text.as_str());
last.width = scored.max(last.width);
}
}
_ => cells.push(InputCell {
start: i,
end: i + 1,
text: ch.to_string(),
width: w.max(1),
}),
}
}
cells
}
#[derive(Debug, PartialEq)]
@@ -6734,6 +6819,17 @@ fn menu_layout(
(place_above, visible, first)
}
/// Where each character of the input bar lands: `(row, column, width)`, one
/// entry per character, plus the row and column the text ends on.
///
/// Walks the same cells the bar draws rather than re-deriving widths per
/// character — an emoji presentation sequence is two columns and a stranded
/// combining mark is one, and a second width table would put clicks, wrapping
/// and the caret a column away from the glyph on screen.
///
/// Only the base of a cell carries the width, so a click can never land on a
/// character riding along with it. Those riders are parked at the column the
/// caret takes after the cell, which is where a caret sitting on one belongs.
fn input_char_positions(
chars: &[char],
scol: usize,
@@ -6742,20 +6838,22 @@ fn input_char_positions(
let mut positions: Vec<(usize, usize, usize)> = Vec::with_capacity(chars.len());
let mut r = 0usize;
let mut c = scol;
for &ch in chars {
if ch == '\n' {
for cell in input_cells(chars) {
if chars[cell.start] == '\n' {
positions.push((r, c, 0));
r += 1;
c = 0;
continue;
}
let w = display_width(ch).max(1);
if c + w > cols {
if c + cell.width > cols {
r += 1;
c = 0;
}
positions.push((r, c, w));
c += w;
positions.push((r, c, cell.width));
c += cell.width;
for _ in cell.start + 1..cell.end {
positions.push((r, c, 0));
}
}
(positions, r, c)
}
@@ -6959,9 +7057,10 @@ mod tests {
use super::{
description_budget, drag_scroll_step, elide, encode_mouse, escape_candidate,
expand_file_command_template, fallback_chain, fig_icon_emoji, fig_icon_glyph,
focus_report_bytes, highlight_runs, input_overflow_shift, input_overlay_rows, menu_layout,
paste_bytes, select_end_copy, shell_escape_path, should_show_context_menu,
smooth_scroll_step, submit_bytes, trim_trailing_spaces, wheel_route, wrapped_click_index,
focus_report_bytes, highlight_runs, input_cells, input_char_positions,
input_overflow_shift, input_overlay_rows, menu_layout, paste_bytes, select_end_copy,
shell_escape_path, should_show_context_menu, smooth_scroll_step, submit_bytes,
trim_trailing_spaces, wheel_route, wrapped_click_index,
};
use alacritty_terminal::term::TermMode;
use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers};
@@ -8099,6 +8198,184 @@ mod tests {
assert_eq!(display_width('±'), 1);
}
/// Wide characters the grid reserves two columns for, scattered outside the
/// ranges anyone would think to hand-write: mahjong and playing cards below
/// the Miscellaneous Symbols and Pictographs block, and the handful of Wide
/// code points stranded in Misc Technical and Dingbats.
#[test]
fn display_width_covers_wide_chars_outside_the_main_emoji_blocks() {
assert_eq!(display_width('🀄'), 2);
assert_eq!(display_width('🃏'), 2);
assert_eq!(display_width('⌚'), 2);
assert_eq!(display_width('⏰'), 2);
assert_eq!(display_width('✅'), 2);
assert_eq!(display_width('❌'), 2);
}
/// Combining marks ride on the cell of the character they decorate. Giving
/// one a column of its own shifts the rest of the line and hands the mark
/// to the shaper alone, with no base to attach to.
#[test]
fn display_width_combining_marks_take_no_column() {
// COMBINING ACUTE ACCENT — the second half of a decomposed `é`.
assert_eq!(display_width('\u{0301}'), 0);
// VARIATION SELECTOR-16, which asks for the emoji glyph.
assert_eq!(display_width('\u{FE0F}'), 0);
// ZERO WIDTH JOINER, the glue inside 👩‍💻.
assert_eq!(display_width('\u{200D}'), 0);
}
/// The width table feeds the bar's own line breaking, so a character
/// counted short pulls everything after it one column left.
#[test]
fn input_char_positions_reserve_two_columns_for_wide_chars() {
let chars: Vec<char> = "a🀄b".chars().collect();
let (positions, _, _) = input_char_positions(&chars, 0, 80);
assert_eq!(positions, vec![(0, 0, 1), (0, 1, 2), (0, 3, 1)]);
}
/// Geometry and drawing read the same cells, so a sequence the bar draws
/// two columns wide is two columns wide to wrapping and clicks as well. Two
/// width tables would put `X` under the right half of the heart.
#[test]
fn input_char_positions_agree_with_the_cells_the_bar_draws() {
for text in [
"a🀄b",
"e\u{0301}X",
"\u{2764}\u{FE0F}X",
"\u{0301}ab",
"a\nb",
] {
let chars: Vec<char> = text.chars().collect();
let (positions, _, _) = input_char_positions(&chars, 0, 80);
assert_eq!(positions.len(), chars.len(), "{text:?}");
for cell in input_cells(&chars) {
let drawn = if chars[cell.start] == '\n' {
0
} else {
cell.width
};
assert_eq!(positions[cell.start].2, drawn, "{text:?} at {}", cell.start);
for i in cell.start + 1..cell.end {
assert_eq!(positions[i].2, 0, "{text:?} at {i}");
}
}
}
}
/// `❤️` is two columns in the bar, so the character after it starts at
/// column 2 — and a click on either half of it lands on the heart.
#[test]
fn input_char_positions_reserve_two_columns_for_an_emoji_presentation_sequence() {
let chars: Vec<char> = "\u{2764}\u{FE0F}X".chars().collect();
let (positions, _, _) = input_char_positions(&chars, 0, 80);
assert_eq!(positions, vec![(0, 0, 2), (0, 2, 0), (0, 2, 1)]);
assert_eq!(click("\u{2764}\u{FE0F}X", 0, 80, 0, 0), Some(0));
assert_eq!(click("\u{2764}\u{FE0F}X", 0, 80, 1, 0), Some(0));
assert_eq!(click("\u{2764}\u{FE0F}X", 0, 80, 2, 0), Some(2));
}
/// A mark with no base gets a cell of its own on screen, so it has to get a
/// column here too — otherwise everything after it clicks one column off.
#[test]
fn input_char_positions_give_a_stranded_combining_mark_a_column() {
let chars: Vec<char> = "\u{0301}ab".chars().collect();
let (positions, _, _) = input_char_positions(&chars, 0, 80);
assert_eq!(positions, vec![(0, 0, 1), (0, 1, 1), (0, 2, 1)]);
}
/// A cell wraps whole. Splitting `❤️` across rows would draw its two
/// columns on one row and count them on two.
#[test]
fn input_char_positions_wrap_a_cell_without_splitting_it() {
let chars: Vec<char> = "abc\u{2764}\u{FE0F}".chars().collect();
let (positions, r, c) = input_char_positions(&chars, 0, 4);
assert_eq!(positions[3], (1, 0, 2));
assert_eq!((r, c), (1, 2));
}
/// Clicking the right half of a wide character lands on that character, not
/// on the one after it.
#[test]
fn wrapped_click_index_hits_both_halves_of_a_wide_char() {
assert_eq!(click("a🀄b", 0, 80, 1, 0), Some(1));
assert_eq!(click("a🀄b", 0, 80, 2, 0), Some(1));
assert_eq!(click("a🀄b", 0, 80, 3, 0), Some(2));
}
/// A combining mark shares its base's column, so a click there hits the
/// base — there is nowhere on screen that is the mark and not the base.
#[test]
fn wrapped_click_index_lands_on_the_base_not_its_combining_mark() {
// e + COMBINING ACUTE ACCENT + X.
assert_eq!(click("e\u{0301}X", 0, 80, 0, 0), Some(0));
assert_eq!(click("e\u{0301}X", 0, 80, 1, 0), Some(2));
}
fn cells(text: &str) -> Vec<(usize, usize, String, usize)> {
let chars: Vec<char> = text.chars().collect();
input_cells(&chars)
.into_iter()
.map(|c| (c.start, c.end, c.text, c.width))
.collect()
}
#[test]
fn input_cells_keep_a_combining_mark_with_its_base() {
assert_eq!(
cells("e\u{0301}X"),
vec![
(0, 2, "e\u{0301}".to_string(), 1),
(2, 3, "X".to_string(), 1),
]
);
}
/// An emoji presentation sequence is two columns wide even though its base
/// is one on its own — the same re-scoring the terminal grid does.
#[test]
fn input_cells_widen_an_emoji_presentation_sequence() {
assert_eq!(
cells("\u{2764}\u{FE0F}X"),
vec![
(0, 2, "\u{2764}\u{FE0F}".to_string(), 2),
(2, 3, "X".to_string(), 1),
]
);
// U+FE0E asks for the text glyph, and stays one column.
assert_eq!(
cells("\u{2764}\u{FE0E}"),
vec![(0, 2, "\u{2764}\u{FE0E}".to_string(), 1)]
);
}
/// A ZWJ sequence stays two cells, because that is what the grid does with
/// it: the joiner rides on the first emoji and the second one still claims
/// its own two columns. Composing the pair into one glyph is a separate
/// problem (#209) and fixing it here would put the bar a column off from
/// the row the text lands on.
#[test]
fn input_cells_split_a_zwj_sequence_the_way_the_grid_does() {
assert_eq!(
cells("\u{1F469}\u{200D}\u{1F4BB}"),
vec![
(0, 2, "\u{1F469}\u{200D}".to_string(), 2),
(2, 3, "\u{1F4BB}".to_string(), 2),
]
);
}
/// A mark with no base ahead of it still needs a column, or it is invisible
/// and the caret has nowhere to sit. A newline is not a base to hang one on.
#[test]
fn input_cells_give_a_stranded_combining_mark_a_column() {
assert_eq!(cells("\u{0301}"), vec![(0, 1, "\u{0301}".to_string(), 1)]);
assert_eq!(
cells("\n\u{0301}"),
vec![(0, 1, String::new(), 0), (1, 2, "\u{0301}".to_string(), 1),]
);
}
fn click(text: &str, scol: usize, cols: usize, col: usize, row: usize) -> Option<usize> {
let chars: Vec<char> = text.chars().collect();
wrapped_click_index(&chars, scol, cols, col, row, false)