mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
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:
Generated
+1
@@ -9805,6 +9805,7 @@ dependencies = [
|
||||
"tray-icon",
|
||||
"tty7-core",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"uuid",
|
||||
"windows 0.58.0",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user