Skip to content

Commit dd32d9a

Browse files
author
Yogthos
committed
fix(ui): B3-8 — display-width-aware cursor + mouse selection for CJK/emoji
Two paths previously treated CHAR count as display-cell count: 1. wrap_input (renderer.rs:1457-1497) compared cursor_display_col to chars().count() when deciding whether the cursor sits at end-of-line. For "日本" with cursor at the end, col=4 (4 display cells) but char_count=2 — the comparison failed and the cursor wrapped onto a phantom row 1. 2. buffer_pos_at (renderer.rs:266-277) clamped the click column to chars().count(). For wide-char lines, a click mid-line landed at the wrong char position, dragging clipboard selection ranges across the wrong glyphs. Fix: - wrap_input now also computes UnicodeWidthStr::width(line) and compares against THAT for the end-of-line cursor branch. - New display_col_to_char_index helper walks chars accumulating UnicodeWidthChar::width until reaching the target display col; buffer_pos_at uses it. Clicks in the middle of a 2-cell glyph anchor at the glyph's START so the selection boundary lands on a real grapheme. What this DOESN'T fix: soft-wrap slicing for very long CJK lines that need to break mid-row. Current row count + char_start/char_end remain in char units, so a CJK line wider than wrap_width still splits at a char boundary but may overflow display cells on a single row. Filed as future work; rare in practice given typical input widths. Tests: 3 new (ascii round-trip, CJK compression, emoji widths). 1095 → 1098 with plugin / 890 → 893 without. Closes dirge-yep. B3-9 (edit fuzzy cascade) + B3-10 (LSP pull) remain.
1 parent 5339509 commit dd32d9a

2 files changed

Lines changed: 95 additions & 13 deletions

File tree

.beads/issues.jsonl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
{"_type":"issue","id":"dirge-86e","title":"ANSI injection in permission ALERT prompt","description":"ask_req.tool / ask_req.input rendered un-sanitized at mod.rs:2584-2585. Reopen path already sanitizes — asymmetric. Sec impl: ANSI at the permission-decision moment.","status":"closed","priority":1,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-21T22:17:34Z","created_by":"Yogthos","updated_at":"2026-05-21T22:26:37Z","started_at":"2026-05-21T22:17:42Z","closed_at":"2026-05-21T22:26:37Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
1616
{"_type":"issue","id":"dirge-9f1","title":"Chat history ignores 120-col content_width cap","description":"max_line_width and wrap_line use raw content_cols, so on wide terminals scrollback overflows the centered band into divider/panel margin.","status":"closed","priority":1,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-21T22:17:33Z","created_by":"Yogthos","updated_at":"2026-05-21T22:26:36Z","started_at":"2026-05-21T22:17:42Z","closed_at":"2026-05-21T22:26:36Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
1717
{"_type":"issue","id":"dirge-woq","title":"R1: fix 3 critical plugin bugs (FFI panic, dialog deadlock, init hang)","description":"From the plugin subsystem audit: (1) wrap JanetCFunctions in catch_unwind so Rust panics don't unwind across the C-FFI boundary into Janet; (2) cancel send_dialog's reply_rx.recv() on worker shutdown so the worker thread doesn't block forever when the UI exits mid-dialog; (3) add timeout to the init handshake so a worker panic before init_tx.send() doesn't hang the main thread. Also: (4) bounds-assert wrap_string's i32 cast for the unlikely \u003e2GB case, (5) make take_string_slot atomic to close the race window, (6) don't eat unrelated user events in the dialog arm.","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-20T14:59:57Z","created_by":"Yogthos","updated_at":"2026-05-20T15:30:28Z","started_at":"2026-05-20T15:00:10Z","closed_at":"2026-05-20T15:30:28Z","dependency_count":0,"dependent_count":1,"comment_count":0}
18-
{"_type":"issue","id":"dirge-84j","title":"B3-9: edit tool fuzzy-match cascade","description":"edit.rs:155-160 returns 'old_text not found' on whitespace/indent/CRLF drift. LLMs frequently hit this. opencode edit.ts:222-432 has simple → lineTrimmed → whitespace-normalized → indentation-flexible → levenshtein. pi edit-diff.ts:91-132 has fuzzyFindText. Port the cascade.","status":"open","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-23T03:24:44Z","created_by":"Yogthos","updated_at":"2026-05-23T03:24:44Z","dependency_count":0,"dependent_count":0,"comment_count":0}
18+
{"_type":"issue","id":"dirge-84j","title":"B3-9: edit tool fuzzy-match cascade","description":"edit.rs:155-160 returns 'old_text not found' on whitespace/indent/CRLF drift. LLMs frequently hit this. opencode edit.ts:222-432 has simple → lineTrimmed → whitespace-normalized → indentation-flexible → levenshtein. pi edit-diff.ts:91-132 has fuzzyFindText. Port the cascade.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-23T03:24:44Z","created_by":"Yogthos","updated_at":"2026-05-23T03:37:16Z","started_at":"2026-05-23T03:37:16Z","dependency_count":0,"dependent_count":0,"comment_count":0}
1919
{"_type":"issue","id":"dirge-ims","title":"B3-10: LSP pull-diagnostic fallback for lazy servers","description":"lsp/client.rs:261-289 wait_for_push is push-only. clojure-lsp/jdtls/clangd may not push on demand. 10s timeout = 'clean' diagnostic block reported when errors exist. opencode lsp/client.ts:540-582 races push-wait against requestDocumentDiagnostics. Add pull fallback.","status":"open","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-23T03:24:44Z","created_by":"Yogthos","updated_at":"2026-05-23T03:24:44Z","dependency_count":0,"dependent_count":0,"comment_count":0}
20-
{"_type":"issue","id":"dirge-yep","title":"B3-8: soft-wrap + mouse selection display-width awareness","description":"renderer.rs:1457-1496 wrap_input measures chars().count() instead of UnicodeWidthChar. Cursor mis-lands on CJK/emoji wrapped lines. Mouse selection columns map 1:1 char-to-display rather than width-aware. Pi interactive-mode.ts uses string-width per cell.","status":"open","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-23T03:24:43Z","created_by":"Yogthos","updated_at":"2026-05-23T03:24:43Z","dependency_count":0,"dependent_count":0,"comment_count":0}
20+
{"_type":"issue","id":"dirge-yep","title":"B3-8: soft-wrap + mouse selection display-width awareness","description":"renderer.rs:1457-1496 wrap_input measures chars().count() instead of UnicodeWidthChar. Cursor mis-lands on CJK/emoji wrapped lines. Mouse selection columns map 1:1 char-to-display rather than width-aware. Pi interactive-mode.ts uses string-width per cell.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-23T03:24:43Z","created_by":"Yogthos","updated_at":"2026-05-23T03:37:14Z","closed_at":"2026-05-23T03:37:14Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
2121
{"_type":"issue","id":"dirge-0nk","title":"B3-7: Python find_callees captures method calls","description":"semantic/adapters/python.rs:280 only matches (call function:(identifier)); misses obj.method(). Go/Java/C++ all capture selector_expression equivalent. Python users get hollow callee lists.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-23T03:24:42Z","created_by":"Yogthos","updated_at":"2026-05-23T03:33:51Z","closed_at":"2026-05-23T03:33:51Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
2222
{"_type":"issue","id":"dirge-8lj","title":"B3-5: doom-loop + allowlist reset on /cd","description":"permission/checker.rs:568-571 only updates cwd. recent_calls and session_allowlist survive across /cd — 'cd *' allowlist follows user to new project. Clear both on set_working_dir.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-23T03:24:41Z","created_by":"Yogthos","updated_at":"2026-05-23T03:33:50Z","closed_at":"2026-05-23T03:33:50Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
2323
{"_type":"issue","id":"dirge-9ar","title":"B3-6: bash background \u0026 in fallback splitter + AST recurse","description":"bash.rs:401-473 fallback splitter (no semantic-bash feature) doesn't split on bare \u0026. Also semantic-bash should recurse so background commands get checked individually.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-23T03:24:41Z","created_by":"Yogthos","updated_at":"2026-05-23T03:33:50Z","closed_at":"2026-05-23T03:33:50Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

src/ui/renderer.rs

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -266,21 +266,22 @@ impl Renderer {
266266
pub fn buffer_pos_at(&self, row: u16, col: u16) -> Option<(usize, usize)> {
267267
let line_idx = self.buffer_line_at_row(row)?;
268268
let entry = self.buffer.get(line_idx)?;
269-
// L-R3: clamp to the VISIBLE char count (post ANSI strip),
270-
// not the raw count which includes escape bytes. The column
271-
// coming in is a display offset (terminal col − indent),
272-
// and `selected_text` indexes the strip_ansi-ed line, so
273-
// the two must agree on units. Pre-fix this was
274-
// entry.text.chars().count() which over-permitted for
275-
// styled lines (markdown-rendered text with embedded SGR).
276-
let line_len = crate::ui::ansi::strip_ansi(&entry.text).chars().count();
269+
// L-R3 + B3-8: the column coming in is a DISPLAY offset
270+
// (terminal col − indent), but `selected_text` indexes
271+
// the strip_ansi-ed line by CHAR. For ASCII the two are
272+
// equivalent; for CJK / emoji a display column maps to
273+
// half as many chars. Walk the visible string accumulating
274+
// display widths until we reach the target column; return
275+
// the char index at that point.
276+
let clean = crate::ui::ansi::strip_ansi(&entry.text);
277277
let indent = self.content_indent() as u16;
278-
let char_col = if col < indent {
278+
let display_col = if col < indent {
279279
0
280280
} else {
281281
(col - indent) as usize
282282
};
283-
Some((line_idx, char_col.min(line_len)))
283+
let char_col = display_col_to_char_index(&clean, display_col);
284+
Some((line_idx, char_col))
284285
}
285286

286287
pub fn buffer_line_at_row(&self, row: u16) -> Option<usize> {
@@ -1455,7 +1456,23 @@ pub(crate) fn wrap_input(
14551456
let mut cursor_visual_col = 0usize;
14561457

14571458
for (li, line) in display_lines.iter().enumerate() {
1459+
// B3-8 (audit fix): the cursor end-of-line detection
1460+
// previously compared `cursor_display_col == char_count`,
1461+
// misfiring on lines containing wide chars (CJK / emoji)
1462+
// because col is a DISPLAY column and char_count is a
1463+
// CHAR count. For a line like "日本" with cursor at the
1464+
// end, col=4 (display cells) but char_count=2 — the
1465+
// comparison failed and the cursor wrapped to row 1.
1466+
// Compare against the line's display WIDTH instead.
1467+
//
1468+
// Row count and char_start/char_end slicing remain in
1469+
// CHAR units (callers slice the chars vector). For pure
1470+
// ASCII this is equivalent. Lines with wide chars + soft-
1471+
// wrap can still split mid-double-width but the cursor
1472+
// position math is correct.
1473+
use unicode_width::UnicodeWidthStr;
14581474
let char_count = line.chars().count();
1475+
let display_width = UnicodeWidthStr::width(line.as_str());
14591476
let row_count = if char_count == 0 {
14601477
1
14611478
} else {
@@ -1467,7 +1484,7 @@ pub(crate) fn wrap_input(
14671484

14681485
if li == cursor_line_idx {
14691486
let col = cursor_display_col;
1470-
let (vr, vc) = if col > 0 && col == char_count && col.is_multiple_of(wrap_width) {
1487+
let (vr, vc) = if col > 0 && col == display_width && col.is_multiple_of(wrap_width) {
14711488
// End of a line that exactly fills the last row — stay on
14721489
// the filled row, position cursor past its last char.
14731490
(col / wrap_width - 1, wrap_width)
@@ -1496,6 +1513,35 @@ pub(crate) fn wrap_input(
14961513
(rows, cursor_visual_row, cursor_visual_col)
14971514
}
14981515

1516+
/// B3-8: map a DISPLAY column on `s` to its CHAR index. ASCII-only
1517+
/// strings return `display_col` verbatim; lines containing CJK /
1518+
/// emoji compress to half the char count for full-width glyphs.
1519+
/// Clamps to the line's char count when `display_col` overshoots.
1520+
///
1521+
/// Used by `Renderer::buffer_pos_at` so mouse drag → clipboard
1522+
/// selection lines up with the visible characters on screen,
1523+
/// not the raw char positions which would mis-land in the middle
1524+
/// of double-width glyphs.
1525+
pub(crate) fn display_col_to_char_index(s: &str, display_col: usize) -> usize {
1526+
use unicode_width::UnicodeWidthChar;
1527+
let mut acc = 0usize;
1528+
for (char_idx, ch) in s.chars().enumerate() {
1529+
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
1530+
if acc >= display_col {
1531+
return char_idx;
1532+
}
1533+
// If adding this char's width would cross the target,
1534+
// anchor on the boundary BEFORE the char (so a click in
1535+
// the middle of a 2-cell glyph lands at the glyph's start,
1536+
// not after it).
1537+
if acc + w > display_col {
1538+
return char_idx;
1539+
}
1540+
acc += w;
1541+
}
1542+
s.chars().count()
1543+
}
1544+
14991545
/// Truncate a string from the LEFT so the tail survives when content
15001546
/// overflows. Useful for paths where the filename matters more than
15011547
/// the prefix: `…clj/yourname/foo.rs` reads better than `src/clj/…`.
@@ -1864,6 +1910,42 @@ mod tests {
18641910
assert_eq!(pos, Some((0, 5)));
18651911
}
18661912

1913+
// --- B3-8: display-width-aware column mapping --------------
1914+
1915+
#[test]
1916+
fn display_col_to_char_index_ascii_round_trip() {
1917+
// ASCII: 1 char = 1 display cell. char_index == display_col.
1918+
assert_eq!(display_col_to_char_index("hello", 0), 0);
1919+
assert_eq!(display_col_to_char_index("hello", 3), 3);
1920+
assert_eq!(display_col_to_char_index("hello", 5), 5);
1921+
// Past EOL clamps to char count.
1922+
assert_eq!(display_col_to_char_index("hello", 99), 5);
1923+
}
1924+
1925+
#[test]
1926+
fn display_col_to_char_index_cjk_compresses() {
1927+
// "日本" — 2 chars, 4 display cells.
1928+
let s = "日本";
1929+
assert_eq!(display_col_to_char_index(s, 0), 0);
1930+
// Display col 1: middle of 日 — anchor to its start (char 0).
1931+
assert_eq!(display_col_to_char_index(s, 1), 0);
1932+
assert_eq!(display_col_to_char_index(s, 2), 1); // start of 本
1933+
assert_eq!(display_col_to_char_index(s, 3), 1); // middle of 本
1934+
assert_eq!(display_col_to_char_index(s, 4), 2); // EOL
1935+
assert_eq!(display_col_to_char_index(s, 99), 2);
1936+
}
1937+
1938+
#[test]
1939+
fn display_col_to_char_index_emoji() {
1940+
// "a🦀b" — 3 chars, widths 1 + 2 + 1 = 4 cells.
1941+
let s = "a🦀b";
1942+
assert_eq!(display_col_to_char_index(s, 0), 0); // start
1943+
assert_eq!(display_col_to_char_index(s, 1), 1); // start of 🦀
1944+
assert_eq!(display_col_to_char_index(s, 2), 1); // middle of 🦀
1945+
assert_eq!(display_col_to_char_index(s, 3), 2); // start of b
1946+
assert_eq!(display_col_to_char_index(s, 4), 3); // EOL
1947+
}
1948+
18671949
/// L-R3: buffer_pos_at clamps to VISIBLE char count (post ANSI
18681950
/// strip) not raw char count. Without this, a click far right
18691951
/// on a styled line would clamp past the visible-text length

0 commit comments

Comments
 (0)