diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7660eed..8e1ec53 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -513,6 +513,8 @@ dependencies = [ "serde", "serde_json", "signal-hook", + "unicode-segmentation", + "unicode-width", "uuid", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 776114f..a0aeb0c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -18,6 +18,8 @@ ratatui = "0.30" serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } +unicode-segmentation = "1" +unicode-width = "0.2" [target.'cfg(unix)'.dependencies] rustix = { version = "1", features = ["process"] } diff --git a/rust/src/editor.rs b/rust/src/editor.rs index 2bec8f8..d183b54 100644 --- a/rust/src/editor.rs +++ b/rust/src/editor.rs @@ -5,7 +5,12 @@ use std::time::Duration; use chrono::{SecondsFormat, Utc}; use ratatui::Frame; -use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, +}; +use ratatui::crossterm::execute; +use ratatui::crossterm::style::Print; use ratatui::layout::{Position, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::Line; @@ -14,7 +19,7 @@ use serde_json::Value; use uuid::Uuid; use crate::format::{sanitize_terminal_text, wrap_text}; -use crate::layout::layout_comment; +use crate::layout::{cursor_at_visual_position, editor_viewport_start, layout_comment}; use crate::paths::state_dir; use crate::store::{append_annotation, append_annotation_context_first}; use crate::termination::Termination; @@ -22,7 +27,7 @@ use crate::types::{ Annotation, PendingAnnotation, javascript_trim, parse_pending_annotation, pending_annotation_from_invocation, }; -use crate::width::{char_width, string_width, truncate_to_width}; +use crate::width::{graphemes, string_width, truncate_to_width}; #[cfg(test)] const DEFAULT_COLS: u16 = 86; @@ -36,6 +41,7 @@ pub struct EditorApp { saved_field_order: SavedFieldOrder, comment: Vec, cursor: usize, + editor_start: usize, status: String, quit: bool, } @@ -58,13 +64,14 @@ impl EditorApp { saved_field_order, comment: Vec::new(), cursor: 0, + editor_start: 0, status: String::new(), quit: false, } } /// Draw the same selected-text, comment, and footer regions as the TypeScript editor. - pub fn draw(&self, frame: &mut Frame<'_>) { + pub fn draw(&mut self, frame: &mut Frame<'_>) { let area = frame.area(); frame.render_widget(Clear, area); let cols = usize::from(area.width.max(20)); @@ -79,9 +86,13 @@ impl EditorApp { ); let selected = wrapped_selection.iter().take(selection_rows); let editing = layout_comment(&self.comment, self.cursor, inner_width); - let editor_start = editing - .cursor_row - .saturating_sub(editor_rows.saturating_sub(1)); + self.editor_start = editor_viewport_start( + self.editor_start, + editing.cursor_row, + editing.lines.len(), + editor_rows, + ); + let editor_start = self.editor_start; render_line( frame, @@ -207,6 +218,7 @@ impl EditorApp { } } KeyCode::Enter => self.insert('\n'), + KeyCode::Tab => self.insert(' '), KeyCode::Char(character) if !key .modifiers @@ -219,6 +231,38 @@ impl EditorApp { false } + pub fn handle_mouse(&mut self, mouse: MouseEvent, width: u16, height: u16) { + let MouseEventKind::Down(MouseButton::Left) = mouse.kind else { + return; + }; + let cols = usize::from(width.max(20)); + let rows = usize::from(height.max(10)); + let left = 2usize; + let inner_width = cols.saturating_sub(4).max(1); + let selection_rows = ((rows.saturating_sub(6)) / 2).clamp(3, 7); + let editor_rows = rows.saturating_sub(selection_rows + 5).max(1); + let editor_top = 3 + selection_rows; + let x = usize::from(mouse.column); + let y = usize::from(mouse.row); + if x < left || x > left + inner_width || y < editor_top || y >= editor_top + editor_rows { + return; + } + let editing = layout_comment(&self.comment, self.cursor, inner_width); + self.editor_start = editor_viewport_start( + self.editor_start, + editing.cursor_row, + editing.lines.len(), + editor_rows, + ); + self.cursor = cursor_at_visual_position( + &self.comment, + self.editor_start + y - editor_top, + x - left, + inner_width, + ); + self.status.clear(); + } + fn insert(&mut self, character: char) { self.comment.insert(self.cursor, character); self.cursor += 1; @@ -239,13 +283,13 @@ impl EditorApp { .map(|line| line.chars().count() + 1) .sum::(); let mut used = 0; - for character in lines.get(target_row).copied().unwrap_or_default().chars() { - let width = char_width(character); + for grapheme in graphemes(lines.get(target_row).copied().unwrap_or_default()) { + let width = string_width(grapheme); if used + width > col { break; } used += width; - next += 1; + next += grapheme.chars().count(); } self.cursor = next; } @@ -350,6 +394,8 @@ pub fn run() -> Result<(), String> { let termination = Termination::install(); let mut terminal = ratatui::init(); let result = (|| -> Result<(), String> { + execute!(std::io::stdout(), Print("\x1b[?1000h\x1b[?1006h")) + .map_err(|error| error.to_string())?; while !app.quit && !termination.requested() { terminal .draw(|frame| app.draw(frame)) @@ -361,22 +407,30 @@ pub fn run() -> Result<(), String> { if termination.requested() { break; } - let event = event::read().map_err(|error| error.to_string())?; - if let Event::Key(key) = event - && app.handle_key(key) - && app.save(dir.as_deref()) - { - terminal - .draw(|frame| app.draw(frame)) - .map_err(|error| error.to_string())?; - std::thread::sleep(Duration::from_millis(250)); - app.quit = true; + let Ok(event) = event::read() else { + continue; + }; + match event { + Event::Key(key) if app.handle_key(key) && app.save(dir.as_deref()) => { + terminal + .draw(|frame| app.draw(frame)) + .map_err(|error| error.to_string())?; + std::thread::sleep(Duration::from_millis(250)); + app.quit = true; + } + Event::Mouse(mouse) => { + let size = terminal.size().map_err(|error| error.to_string())?; + app.handle_mouse(mouse, size.width, size.height); + } + _ => {} } } Ok(()) })(); + let mouse_cleanup = execute!(std::io::stdout(), Print("\x1b[?1000l\x1b[?1006l")) + .map_err(|error| error.to_string()); ratatui::restore(); - result + result.and(mouse_cleanup) } #[cfg(test)] @@ -402,7 +456,16 @@ mod tests { }) } - fn draw(app: &EditorApp) -> Vec { + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + fn draw(app: &mut EditorApp) -> Vec { let mut terminal = Terminal::new(TestBackend::new(DEFAULT_COLS, DEFAULT_ROWS)).expect("terminal"); terminal.draw(|frame| app.draw(frame)).expect("draw"); @@ -419,7 +482,7 @@ mod tests { #[test] fn headless_frame_contains_selection_editor_and_keys() { - let rows = draw(&app()); + let rows = draw(&mut app()); assert!(rows.iter().any(|row| row.contains("Selected text"))); assert!(rows.iter().any(|row| row.contains("first selected line"))); assert!(rows.iter().any(|row| row.contains("Comment"))); @@ -440,6 +503,13 @@ mod tests { assert_eq!(empty.status, "Write a comment before saving."); } + #[test] + fn tab_inserts_a_renderable_space() { + let mut editor = app(); + editor.handle_key(KeyEvent::from(KeyCode::Tab)); + assert_eq!(editor.comment, [' ']); + } + #[test] fn escape_and_control_c_quit() { let mut escape = app(); @@ -487,4 +557,117 @@ mod tests { let _ = std::fs::remove_file(&missing); assert_eq!(remove_pending_file(&missing), Ok(())); } + + #[test] + fn mouse_clicks_map_narrow_and_wide_glyph_cells() { + let mut editor = app(); + editor.comment = "a한b".chars().collect(); + editor.cursor = editor.comment.len(); + editor.handle_mouse( + mouse(MouseEventKind::Down(MouseButton::Left), 2, 10), + 86, + 22, + ); + assert_eq!(editor.cursor, 0); + editor.handle_mouse( + mouse(MouseEventKind::Down(MouseButton::Left), 3, 10), + 86, + 22, + ); + assert_eq!(editor.cursor, 1); + editor.handle_mouse( + mouse(MouseEventKind::Down(MouseButton::Left), 4, 10), + 86, + 22, + ); + assert_eq!(editor.cursor, 2); + editor.handle_mouse( + mouse(MouseEventKind::Down(MouseButton::Left), 6, 10), + 86, + 22, + ); + assert_eq!(editor.cursor, 3); + } + + #[test] + fn mouse_clicks_map_wraps_and_preserve_the_pre_click_scroll_offset() { + let mut wrapped = app(); + wrapped.comment = format!("{}한", "a".repeat(15)).chars().collect(); + wrapped.cursor = wrapped.comment.len(); + wrapped.handle_mouse( + mouse(MouseEventKind::Down(MouseButton::Left), 17, 6), + 20, + 10, + ); + assert_eq!(wrapped.cursor, 15); + wrapped.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 7), 20, 10); + assert_eq!(wrapped.cursor, 15); + + let mut scrolled = app(); + scrolled.comment = (0..11) + .map(|line| line.to_string()) + .collect::>() + .join("\n") + .chars() + .collect(); + scrolled.cursor = scrolled.comment.len(); + scrolled.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 6), 20, 10); + assert_eq!(scrolled.cursor, 18); + assert_eq!(scrolled.editor_start, 9); + } + + #[test] + fn mouse_clicks_map_explicit_newlines_and_buffer_end() { + let mut editor = app(); + editor.comment = "a\n\nb".chars().collect(); + editor.cursor = editor.comment.len(); + editor.handle_mouse( + mouse(MouseEventKind::Down(MouseButton::Left), 2, 11), + 86, + 22, + ); + assert_eq!(editor.cursor, 2); + editor.handle_mouse( + mouse(MouseEventKind::Down(MouseButton::Left), 2, 13), + 86, + 22, + ); + assert_eq!(editor.cursor, 4); + } + + #[test] + fn mouse_click_reaches_a_full_width_line_end() { + let mut editor = app(); + editor.comment = "abcdefghijklmnop".chars().collect(); + editor.cursor = 0; + editor.handle_mouse( + mouse(MouseEventKind::Down(MouseButton::Left), 18, 6), + 20, + 10, + ); + assert_eq!(editor.cursor, editor.comment.len()); + } + + #[test] + fn mouse_releases_other_kinds_and_outside_clicks() { + let mut editor = app(); + editor.comment = "abc".chars().collect(); + editor.cursor = 1; + for kind in [ + MouseEventKind::Up(MouseButton::Left), + MouseEventKind::Drag(MouseButton::Left), + MouseEventKind::Moved, + MouseEventKind::ScrollDown, + MouseEventKind::Down(MouseButton::Right), + ] { + editor.handle_mouse(mouse(kind, 2, 10), 86, 22); + } + editor.handle_mouse( + mouse(MouseEventKind::Down(MouseButton::Left), 1, 10), + 86, + 22, + ); + editor.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 9), 86, 22); + assert_eq!(editor.cursor, 1); + } } diff --git a/rust/src/format.rs b/rust/src/format.rs index 2a16a95..df48c6b 100644 --- a/rust/src/format.rs +++ b/rust/src/format.rs @@ -1,7 +1,7 @@ //! Terminal-safe text and Markdown export. use crate::types::Annotation; -use crate::width::char_width; +use crate::width::{graphemes, string_width}; /// Remove terminal control characters while retaining useful whitespace. pub fn sanitize_terminal_text(text: &str) -> String { @@ -34,14 +34,14 @@ pub fn wrap_text(text: &str, width: usize) -> Vec { } let mut line = String::new(); let mut used = 0; - for character in source_line.chars() { - let cells = char_width(character); + for grapheme in graphemes(source_line) { + let cells = string_width(grapheme); if used + cells > safe_width && !line.is_empty() { output.push(line); line = String::new(); used = 0; } - line.push(character); + line.push_str(grapheme); used += cells; } output.push(line); @@ -137,6 +137,8 @@ mod tests { assert_eq!(wrap_text("한글한글", 4), ["한글", "한글"]); assert_eq!(wrap_text("한글한", 5), ["한글", "한"]); assert_eq!(wrap_text("a한b한", 4), ["a한b", "한"]); + assert_eq!(wrap_text("🇺🇸x", 2), ["🇺🇸", "x"]); + assert_eq!(wrap_text("a\u{1ab0}b", 1), ["a\u{1ab0}", "b"]); } #[test] diff --git a/rust/src/layout.rs b/rust/src/layout.rs index 19b817e..e4c2028 100644 --- a/rust/src/layout.rs +++ b/rust/src/layout.rs @@ -1,6 +1,6 @@ //! Comment-editor layout in terminal cells. -use crate::width::char_width; +use crate::width::{graphemes, string_width}; /// Laid-out comment lines and cursor position. #[derive(Debug, Clone, PartialEq, Eq)] @@ -13,35 +13,45 @@ pub struct CommentLayout { /// Lay the comment out and report the cursor in terminal cells. pub fn layout_comment(comment: &[char], cursor: usize, width: usize) -> CommentLayout { let safe_width = width.max(1); + let text = comment.iter().collect::(); let mut lines = vec![String::new()]; let mut row = 0; let mut col = 0; let mut cursor_row = 0; let mut cursor_col = 0; - for index in 0..=comment.len() { - let cells = comment.get(index).copied().map_or(0, char_width); + let mut scalar_index = 0; + for grapheme in graphemes(&text) { + let length = grapheme.chars().count(); + let end = scalar_index + length; + let cells = string_width(grapheme); if col > 0 && col + cells > safe_width { lines.push(String::new()); row += 1; col = 0; } - if index == cursor { + if cursor == scalar_index { cursor_row = row; cursor_col = col; } - let Some(character) = comment.get(index).copied() else { - break; - }; - if character == '\n' { + if grapheme == "\n" { lines.push(String::new()); row += 1; col = 0; } else { if let Some(line) = lines.get_mut(row) { - line.push(character); + line.push_str(grapheme); } col += cells; } + if cursor > scalar_index && cursor < end { + cursor_row = row; + cursor_col = col; + } + scalar_index = end; + } + if cursor >= comment.len() { + cursor_row = row; + cursor_col = col; } CommentLayout { lines, @@ -50,6 +60,75 @@ pub fn layout_comment(comment: &[char], cursor: usize, width: usize) -> CommentL } } +pub fn cursor_at_visual_position( + comment: &[char], + row: usize, + column: usize, + width: usize, +) -> usize { + let safe_width = width.max(1); + let text = comment.iter().collect::(); + let mut visual_row = 0; + let mut visual_column = 0; + let mut scalar_index = 0; + + for grapheme in graphemes(&text) { + let length = grapheme.chars().count(); + let end = scalar_index + length; + let cells = string_width(grapheme); + if grapheme == "\n" { + if visual_row == row { + return scalar_index; + } + visual_row += 1; + visual_column = 0; + scalar_index = end; + continue; + } + if visual_column > 0 && visual_column + cells > safe_width { + if visual_row == row { + return scalar_index; + } + visual_row += 1; + visual_column = 0; + } + if visual_row == row { + if column < visual_column { + return scalar_index; + } + if cells > 0 && column < visual_column + cells { + return if column == visual_column { + scalar_index + } else { + end + }; + } + } + visual_column += cells; + scalar_index = end; + } + + comment.len() +} + +pub fn editor_viewport_start( + current_start: usize, + cursor_row: usize, + line_count: usize, + visible_rows: usize, +) -> usize { + let rows = visible_rows.max(1); + let max_start = line_count.saturating_sub(rows); + let start = current_start.min(max_start); + if cursor_row < start { + cursor_row + } else if cursor_row >= start + rows { + cursor_row.saturating_sub(rows - 1).min(max_start) + } else { + start + } +} + #[cfg(test)] mod tests { use super::*; @@ -80,4 +159,65 @@ mod tests { assert_eq!(result.lines, ["한", "글"]); assert_eq!((result.cursor_row, result.cursor_col), (1, 2)); } + + #[test] + fn emoji_graphemes_occupy_terminal_cells() { + for text in ["🇺🇸", "👨‍👩‍👧‍👦", "1️⃣"] { + let comment = text.chars().collect::>(); + assert_eq!(layout_comment(&comment, comment.len(), 40).cursor_col, 2); + } + } + #[test] + fn cursor_mapping_uses_glyph_cell_boundaries_and_wraps() { + let comment = "a한b".chars().collect::>(); + assert_eq!(cursor_at_visual_position(&comment, 0, 0, 3), 0); + assert_eq!(cursor_at_visual_position(&comment, 0, 1, 3), 1); + assert_eq!(cursor_at_visual_position(&comment, 0, 2, 3), 2); + assert_eq!(cursor_at_visual_position(&comment, 0, 3, 3), 2); + assert_eq!(cursor_at_visual_position(&comment, 1, 0, 3), 2); + } + + #[test] + fn cursor_mapping_keeps_combining_marks_with_their_base_glyph() { + let narrow = "a\u{301}b".chars().collect::>(); + assert_eq!(cursor_at_visual_position(&narrow, 0, 1, 8), 2); + let wide = "한\u{301}b".chars().collect::>(); + assert_eq!(cursor_at_visual_position(&wide, 0, 1, 8), 2); + } + + #[test] + fn cursor_mapping_returns_only_emoji_grapheme_boundaries() { + for text in ["🇺🇸", "👨‍👩‍👧‍👦", "1️⃣"] { + let comment = format!("{text}x").chars().collect::>(); + let boundary = text.chars().count(); + assert_eq!(cursor_at_visual_position(&comment, 0, 0, 40), 0); + assert_eq!(cursor_at_visual_position(&comment, 0, 1, 40), boundary); + } + } + + #[test] + fn viewport_start_preserves_visible_cursor_rows() { + assert_eq!(editor_viewport_start(3, 3, 5, 2), 3); + assert_eq!(editor_viewport_start(3, 2, 5, 2), 2); + assert_eq!(editor_viewport_start(1, 4, 5, 2), 3); + } + + #[test] + fn cursor_mapping_preserves_explicit_newlines_and_blank_rows() { + let comment = "a\n\nb".chars().collect::>(); + assert_eq!(cursor_at_visual_position(&comment, 0, 4, 8), 1); + assert_eq!(cursor_at_visual_position(&comment, 1, 0, 8), 2); + assert_eq!(cursor_at_visual_position(&comment, 2, 0, 8), 3); + assert_eq!(cursor_at_visual_position(&comment, 2, 1, 8), 4); + assert_eq!(cursor_at_visual_position(&comment, 3, 0, 8), comment.len()); + } + + #[test] + fn cursor_mapping_reaches_a_full_width_line_end() { + let comment = "abcdefghijklmnop".chars().collect::>(); + assert_eq!( + cursor_at_visual_position(&comment, 0, 16, 16), + comment.len() + ); + } } diff --git a/rust/src/width.rs b/rust/src/width.rs index 093d962..8b9f817 100644 --- a/rust/src/width.rs +++ b/rust/src/width.rs @@ -1,51 +1,20 @@ //! Terminal cell-width helpers matching the TypeScript implementation. -const WIDE_RANGES: &[(u32, u32)] = &[ - (0x1100, 0x115f), - (0x2e80, 0x303e), - (0x3041, 0x33ff), - (0x3400, 0x4dbf), - (0x4e00, 0x9fff), - (0xa000, 0xa4cf), - (0xa960, 0xa97f), - (0xac00, 0xd7a3), - (0xf900, 0xfaff), - (0xfe10, 0xfe19), - (0xfe30, 0xfe6f), - (0xff00, 0xff60), - (0xffe0, 0xffe6), - (0x1f300, 0x1f64f), - (0x1f900, 0x1f9ff), - (0x20000, 0x3fffd), -]; +use unicode_segmentation::{Graphemes, UnicodeSegmentation}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -fn is_wide(code_point: u32) -> bool { - for &(start, end) in WIDE_RANGES { - if code_point < start { - return false; - } - if code_point <= end { - return true; - } - } - false +pub fn graphemes(text: &str) -> Graphemes<'_> { + text.graphemes(true) } /// Cells occupied by a single character. Control characters count as zero. pub fn char_width(character: char) -> usize { - let code_point = u32::from(character); - if code_point < 0x20 || (0x7f..0xa0).contains(&code_point) { - return 0; - } - if (0x0300..=0x036f).contains(&code_point) || (0x200b..=0x200f).contains(&code_point) { - return 0; - } - if is_wide(code_point) { 2 } else { 1 } + character.width().unwrap_or(0) } /// Cells occupied by a string. pub fn string_width(text: &str) -> usize { - text.chars().map(char_width).sum() + text.width() } /// Longest prefix of `text` that fits without splitting a character. @@ -54,9 +23,9 @@ pub fn truncate_to_width(text: &str, width: usize) -> String { return String::new(); } let mut used = 0; - text.chars() - .take_while(|character| { - let next = char_width(*character); + graphemes(text) + .take_while(|grapheme| { + let next = string_width(grapheme); let fits = used + next <= width; if fits { used += next; @@ -85,6 +54,9 @@ mod tests { fn string_width_adds_character_cells() { assert_eq!(string_width("한글abc"), 7); assert_eq!(string_width(""), 0); + for text in ["🇺🇸", "👨‍👩‍👧‍👦", "1️⃣"] { + assert_eq!(string_width(text), 2); + } } #[test] @@ -92,5 +64,6 @@ mod tests { assert_eq!(truncate_to_width("한글", 3), "한"); assert_eq!(truncate_to_width("한글", 4), "한글"); assert_eq!(truncate_to_width("한", 0), ""); + assert_eq!(truncate_to_width("🇺🇸x", 2), "🇺🇸"); } } diff --git a/scripts/parity-lite.py b/scripts/parity-lite.py index ead1aee..35b6c50 100755 --- a/scripts/parity-lite.py +++ b/scripts/parity-lite.py @@ -1081,8 +1081,11 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]: } editor_steps = [ Step("chars", "alpha 한글 e\u0301".encode(), ("editor:chars",)), + Step("emoji", " 🇺🇸 👨‍👩‍👧‍👦".encode(), ("editor:chars",)), Step("enter", b"\r", ("editor:enter",)), Step("chars-second-line", b"beta", ("editor:chars",)), + Step("tab", b"\t", ("editor:tab",)), + Step("side-button-ignored", b"\x1b[<128;3;11M", ("editor:side-button",)), Step("home", b"\x1b[H", ("editor:home",)), Step("right", b"\x1b[C", ("editor:right",)), Step("delete", b"\x1b[3~", ("editor:delete",)), diff --git a/src/editor.ts b/src/editor.ts index 1a45d01..d2d3ffd 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -4,8 +4,14 @@ import fs from "node:fs"; import readline from "node:readline"; import { sanitizeTerminalText, wrapText } from "./format"; import { stateDir } from "./paths"; -import { layoutComment } from "./layout"; -import { charWidth, stringWidth, truncateToWidth } from "./width"; +import { + cursorAtEditorCell, + editorGeometry, + editorViewportStart, + layoutComment, +} from "./layout"; +import { isLeftMousePress, SgrMouseDecoder } from "./mouse"; +import { graphemes, stringWidth, truncateToWidth } from "./width"; import type { StoreResult } from "./store"; import { parsePendingAnnotation, @@ -46,6 +52,7 @@ try { const out = (value: string) => process.stdout.write(value); const comment: string[] = []; let cursor = 0; +let editorStart = 0; let status = ""; let finished = false; @@ -61,11 +68,11 @@ function moveCursorVertical(delta: number): void { // Land on the character whose cell column is closest to the current one. let offset = 0; let used = 0; - for (const char of allLines[targetRow] as string) { - const width = charWidth(char); + for (const grapheme of graphemes(allLines[targetRow] as string)) { + const width = stringWidth(grapheme); if (used + width > col) break; used += width; - offset += 1; + for (const _character of grapheme) offset += 1; } cursor = next + offset; } @@ -75,34 +82,35 @@ function writeAt(row: number, col: number, text: string): void { } function render(): void { - const cols = Math.max(20, process.stdout.columns || 86); - const rows = Math.max(10, process.stdout.rows || 22); - const left = 3; - const innerWidth = Math.max(1, cols - 4); - const selectionRows = Math.max(3, Math.min(7, Math.floor((rows - 6) / 2))); - const editorRows = Math.max(1, rows - selectionRows - 5); - const wrappedSelection = wrapText(sanitizeTerminalText(pending.selectedText), innerWidth); - const selected = wrappedSelection.slice(0, selectionRows); - const editing = layoutComment(comment, cursor, innerWidth); - const editorStart = Math.max(0, editing.cursorRow - editorRows + 1); - const visibleEditor = editing.lines.slice(editorStart, editorStart + editorRows); + const geometry = editorGeometry(process.stdout.columns || 86, process.stdout.rows || 22); + const left = geometry.left + 1; + const wrappedSelection = wrapText(sanitizeTerminalText(pending.selectedText), geometry.innerWidth); + const selected = wrappedSelection.slice(0, geometry.selectionRows); + const editing = layoutComment(comment, cursor, geometry.innerWidth); + editorStart = editorViewportStart( + editorStart, + editing.cursorRow, + editing.lines.length, + geometry.editorRows, + ); + const visibleEditor = editing.lines.slice(editorStart, editorStart + geometry.editorRows); out("\x1b[2J\x1b[H\x1b[?25l"); writeAt(2, left, "\x1b[1mSelected text\x1b[0m"); selected.forEach((line, index) => writeAt(3 + index, left, `\x1b[2m${line}\x1b[0m`)); - if (wrappedSelection.length > selectionRows) { - writeAt(2 + selectionRows, left + innerWidth - 1, "\x1b[2m…\x1b[0m"); + if (wrappedSelection.length > geometry.selectionRows) { + writeAt(2 + geometry.selectionRows, left + geometry.innerWidth - 1, "\x1b[2m…\x1b[0m"); } - const commentTitleRow = 3 + selectionRows; + const commentTitleRow = geometry.editorTop; writeAt(commentTitleRow, left, "\x1b[1mComment\x1b[0m"); visibleEditor.forEach((line, index) => writeAt(commentTitleRow + 1 + index, left, line)); const footer = status || "Ctrl+S save · Esc cancel · Enter new line"; - writeAt(rows, left, `\x1b[2m${truncateToWidth(footer, innerWidth)}\x1b[0m`); + writeAt(geometry.rows, left, `\x1b[2m${truncateToWidth(footer, geometry.innerWidth)}\x1b[0m`); const visualCursorRow = editing.cursorRow - editorStart; - if (visualCursorRow >= 0 && visualCursorRow < editorRows) { + if (visualCursorRow >= 0 && visualCursorRow < geometry.editorRows) { writeAt(commentTitleRow + 1 + visualCursorRow, left + editing.cursorCol, "\x1b[?25h"); } } @@ -111,7 +119,7 @@ function cleanup(): void { if (finished) return; finished = true; if (process.stdin.isTTY) process.stdin.setRawMode(false); - out("\x1b[?25h\x1b[2J\x1b[H\x1b[?1049l"); + out("\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[2J\x1b[H\x1b[?1049l"); } function exit(code: number): void { @@ -169,7 +177,39 @@ process.stdout.on("resize", render); readline.emitKeypressEvents(process.stdin, { escapeCodeTimeout: 20 } as any); if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.resume(); +const mouseDecoder = new SgrMouseDecoder(); process.stdin.on("keypress", (text: string, key: readline.Key) => { + const mouseInput = mouseDecoder.feed(key.sequence ?? text); + if (mouseInput.intercepted) { + let handled = false; + for (const report of mouseInput.reports) { + if (!isLeftMousePress(report)) continue; + const geometry = editorGeometry(process.stdout.columns || 86, process.stdout.rows || 22); + const editing = layoutComment(comment, cursor, geometry.innerWidth); + editorStart = editorViewportStart( + editorStart, + editing.cursorRow, + editing.lines.length, + geometry.editorRows, + ); + const nextCursor = cursorAtEditorCell( + comment, + editorStart, + report.x - 1, + report.y - 1, + geometry, + ); + if (nextCursor !== undefined) { + cursor = nextCursor; + handled = true; + } + } + if (handled) { + status = ""; + render(); + } + return; + } status = ""; if (key.ctrl && key.name === "c") return exit(0); if (key.ctrl && key.name === "s") { @@ -196,13 +236,16 @@ process.stdin.on("keypress", (text: string, key: readline.Key) => { } else if (key.name === "return") { comment.splice(cursor, 0, "\n"); cursor += 1; + } else if (key.name === "tab") { + comment.splice(cursor, 0, " "); + cursor += 1; } else if (text && !key.ctrl && !key.meta) { - const inserted = Array.from(text); + const inserted = Array.from(text.replaceAll("\t", " ")); comment.splice(cursor, 0, ...inserted); cursor += inserted.length; } render(); }); -out("\x1b[?1049h"); +out("\x1b[?1049h\x1b[?1000h\x1b[?1006h"); render(); diff --git a/src/format.ts b/src/format.ts index 3d9571f..4807646 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,5 +1,5 @@ import type { Annotation } from "./types"; -import { charWidth } from "./width"; +import { graphemes, stringWidth } from "./width"; /** Remove terminal control characters while retaining useful whitespace. */ export function sanitizeTerminalText(text: string): string { @@ -13,21 +13,20 @@ export function wrapText(text: string, width: number): string[] { const safeWidth = Math.max(1, width); const output: string[] = []; for (const sourceLine of text.replace(/\r\n/g, "\n").split("\n")) { - const chars = Array.from(sourceLine); - if (chars.length === 0) { + if (sourceLine.length === 0) { output.push(""); continue; } let line = ""; let used = 0; - for (const char of chars) { - const width = charWidth(char); + for (const grapheme of graphemes(sourceLine)) { + const width = stringWidth(grapheme); if (used + width > safeWidth && line !== "") { output.push(line); line = ""; used = 0; } - line += char; + line += grapheme; used += width; } output.push(line); diff --git a/src/layout.ts b/src/layout.ts index fc5ed5e..1331cf9 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -1,4 +1,109 @@ -import { charWidth } from "./width"; +import { graphemes, stringWidth } from "./width"; + +export type EditorGeometry = { + cols: number; + rows: number; + left: number; + innerWidth: number; + selectionRows: number; + editorRows: number; + editorTop: number; +}; + +export function editorGeometry(cols: number, rows: number): EditorGeometry { + const safeCols = Math.max(20, cols); + const safeRows = Math.max(10, rows); + const selectionRows = Math.max(3, Math.min(7, Math.floor((safeRows - 6) / 2))); + return { + cols: safeCols, + rows: safeRows, + left: 2, + innerWidth: Math.max(safeCols - 4, 1), + selectionRows, + editorRows: Math.max(safeRows - selectionRows - 5, 1), + editorTop: 3 + selectionRows, + }; +} + +type CommentGrapheme = { + text: string; + start: number; + end: number; + cells: number; +}; + +function commentGraphemes(comment: readonly string[]): CommentGrapheme[] { + const result: CommentGrapheme[] = []; + let start = 0; + for (const text of graphemes(comment.join(""))) { + let length = 0; + for (const _character of text) length += 1; + const end = start + length; + result.push({ text, start, end, cells: stringWidth(text) }); + start = end; + } + return result; +} + +export function editorViewportStart( + currentStart: number, + cursorRow: number, + lineCount: number, + visibleRows: number, +): number { + const rows = Math.max(1, visibleRows); + const maxStart = Math.max(0, lineCount - rows); + const start = Math.min(currentStart, maxStart); + if (cursorRow < start) return cursorRow; + if (cursorRow >= start + rows) return cursorRow - rows + 1; + return start; +} + +export function cursorAtEditorCell( + comment: readonly string[], + editorStart: number, + x: number, + y: number, + geometry: EditorGeometry, +): number | undefined { + if ( + x < geometry.left || + x > geometry.left + geometry.innerWidth || + y < geometry.editorTop || + y >= geometry.editorTop + geometry.editorRows + ) { + return undefined; + } + + const targetRow = editorStart + y - geometry.editorTop; + const editing = layoutComment(comment, comment.length, geometry.innerWidth); + if (targetRow >= editing.lines.length) return comment.length; + + let row = 0; + let col = 0; + const targetCol = x - geometry.left; + for (const grapheme of commentGraphemes(comment)) { + if (grapheme.text === "\n") { + if (row === targetRow) return grapheme.start; + row += 1; + col = 0; + continue; + } + if (grapheme.cells > 0 && col > 0 && col + grapheme.cells > geometry.innerWidth) { + if (row === targetRow) return grapheme.start; + row += 1; + col = 0; + } + if (row === targetRow) { + if (targetCol < col) return grapheme.start; + if (grapheme.cells > 0 && targetCol < col + grapheme.cells) { + return targetCol === col ? grapheme.start : grapheme.end; + } + } + col += grapheme.cells; + } + return comment.length; +} /** * Lay the comment out in terminal cells and report where the cursor lands. @@ -17,28 +122,32 @@ export function layoutComment( let col = 0; let cursorRow = 0; let cursorCol = 0; - for (let index = 0; index <= comment.length; index += 1) { - const cells = index < comment.length ? charWidth(comment[index] as string) : 0; - // A wide character must not straddle the right edge. - if (col > 0 && col + cells > safeWidth) { + for (const grapheme of commentGraphemes(comment)) { + if (col > 0 && col + grapheme.cells > safeWidth) { lines.push(""); row += 1; col = 0; } - if (index === cursor) { + if (cursor === grapheme.start) { cursorRow = row; cursorCol = col; } - if (index === comment.length) break; - const char = comment[index] as string; - if (char === "\n") { + if (grapheme.text === "\n") { lines.push(""); row += 1; col = 0; - continue; + } else { + lines[row] += grapheme.text; + col += grapheme.cells; } - lines[row] += char; - col += cells; + if (cursor > grapheme.start && cursor < grapheme.end) { + cursorRow = row; + cursorCol = col; + } + } + if (cursor >= comment.length) { + cursorRow = row; + cursorCol = col; } return { lines, cursorRow, cursorCol }; } diff --git a/src/mouse.ts b/src/mouse.ts new file mode 100644 index 0000000..c5396a7 --- /dev/null +++ b/src/mouse.ts @@ -0,0 +1,115 @@ +export type SgrMouseReport = { + button: number; + x: number; + y: number; + action: "press" | "release"; +}; + +export type SgrMouseInput = { + intercepted: boolean; + reports: SgrMouseReport[]; +}; + +const SGR_PREFIX = "\x1b[<"; +const LEGACY_PREFIX = "\x1b[M"; +const MAX_SGR_REPORT_LENGTH = 64; + +export function isLeftMousePress(report: SgrMouseReport): boolean { + return ( + report.action === "press" && + Number.isInteger(report.button) && + report.button >= 0 && + report.button <= 28 && + (report.button & 3) === 0 + ); +} + +export class SgrMouseDecoder { + private buffer = ""; + private discardingSgr = false; + + feed(input: string): SgrMouseInput { + if (!input && !this.buffer && !this.discardingSgr) { + return { intercepted: false, reports: [] }; + } + if (this.discardingSgr) { + let end = 0; + while (end < input.length && /[0-9;]/u.test(input[end] ?? "")) end += 1; + if (end === input.length) return { intercepted: true, reports: [] }; + this.discardingSgr = false; + return { intercepted: true, reports: [] }; + } + const data = this.buffer + input; + this.buffer = ""; + const reports: SgrMouseReport[] = []; + let intercepted = false; + let offset = 0; + + while (offset < data.length) { + const sgrStart = data.indexOf(SGR_PREFIX, offset); + const legacyStart = data.indexOf(LEGACY_PREFIX, offset); + if (sgrStart < 0 && legacyStart < 0) break; + const legacy = + legacyStart >= 0 && (sgrStart < 0 || legacyStart < sgrStart); + const start = legacy ? legacyStart : sgrStart; + intercepted = true; + + if (legacy) { + const end = start + 6; + if (end > data.length) { + this.buffer = data.slice(start); + break; + } + const button = data.charCodeAt(start + 3) - 32; + const x = data.charCodeAt(start + 4) - 32; + const y = data.charCodeAt(start + 5) - 32; + if (button >= 0 && x > 0 && y > 0) { + reports.push({ + action: (button & 3) === 3 ? "release" : "press", + button, + x, + y, + }); + } + offset = end; + continue; + } + + let end = start + SGR_PREFIX.length; + while (end < data.length && /[0-9;]/u.test(data[end] ?? "")) end += 1; + if (end === data.length) { + const candidate = data.slice(start); + if (candidate.length <= MAX_SGR_REPORT_LENGTH) { + this.buffer = candidate; + } else { + this.discardingSgr = true; + } + break; + } + const final = data[end]; + if (final !== "M" && final !== "m") { + offset = end + 1; + continue; + } + const parts = data.slice(start + SGR_PREFIX.length, end).split(";"); + if (parts.length !== 3 || parts.some((part) => part.length === 0)) { + offset = end + 1; + continue; + } + const values = parts.map((part) => Number(part)); + if (values.some((value) => !Number.isInteger(value) || value < 0)) { + offset = end + 1; + continue; + } + const [button, x, y] = values; + if (button === undefined || x === undefined || y === undefined) { + offset = end + 1; + continue; + } + reports.push({ button, x, y, action: final === "M" ? "press" : "release" }); + offset = end + 1; + } + + return { intercepted, reports }; + } +} diff --git a/src/width.ts b/src/width.ts index 1d2ca2a..13d7a8e 100644 --- a/src/width.ts +++ b/src/width.ts @@ -6,49 +6,21 @@ * cursor and lets IME preedit overlays land inside existing text. */ -const WIDE_RANGES: readonly (readonly [number, number])[] = [ - [0x1100, 0x115f], // Hangul Jamo initial consonants - [0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols and punctuation - [0x3041, 0x33ff], // Hiragana through CJK compatibility - [0x3400, 0x4dbf], // CJK unified ideographs extension A - [0x4e00, 0x9fff], // CJK unified ideographs - [0xa000, 0xa4cf], // Yi syllables - [0xa960, 0xa97f], // Hangul Jamo extended-A - [0xac00, 0xd7a3], // Hangul syllables - [0xf900, 0xfaff], // CJK compatibility ideographs - [0xfe10, 0xfe19], // Vertical forms - [0xfe30, 0xfe6f], // CJK compatibility forms, small form variants - [0xff00, 0xff60], // Fullwidth forms - [0xffe0, 0xffe6], // Fullwidth signs - [0x1f300, 0x1f64f], // Emoji - [0x1f900, 0x1f9ff], // Supplemental symbols and pictographs - [0x20000, 0x3fffd], // CJK extensions B and beyond -]; -function isWide(codePoint: number): boolean { - for (const [start, end] of WIDE_RANGES) { - if (codePoint < start) return false; - if (codePoint <= end) return true; - } - return false; +const GRAPHEME_SEGMENTER = new Intl.Segmenter("en", { granularity: "grapheme" }); + +export function* graphemes(text: string): IterableIterator { + for (const entry of GRAPHEME_SEGMENTER.segment(text)) yield entry.segment; } /** Cells occupied by a single character. Control characters count as zero. */ export function charWidth(char: string): number { - const codePoint = char.codePointAt(0); - if (codePoint === undefined) return 0; - if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) return 0; - // Combining marks attach to the preceding character. - if (codePoint >= 0x0300 && codePoint <= 0x036f) return 0; - if (codePoint >= 0x200b && codePoint <= 0x200f) return 0; - return isWide(codePoint) ? 2 : 1; + return Bun.stringWidth(char); } /** Cells occupied by a string. */ export function stringWidth(text: string): number { - let total = 0; - for (const char of text) total += charWidth(char); - return total; + return Bun.stringWidth(text); } /** Longest prefix of `text` that fits in `width` cells without splitting a character. */ @@ -56,10 +28,10 @@ export function truncateToWidth(text: string, width: number): string { if (width <= 0) return ""; let used = 0; let output = ""; - for (const char of text) { - const next = charWidth(char); + for (const grapheme of graphemes(text)) { + const next = stringWidth(grapheme); if (used + next > width) break; - output += char; + output += grapheme; used += next; } return output; diff --git a/test/editor-mouse.test.ts b/test/editor-mouse.test.ts new file mode 100644 index 0000000..04f322f --- /dev/null +++ b/test/editor-mouse.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { isLeftMousePress, SgrMouseDecoder } from "../src/mouse"; + +describe("SgrMouseDecoder", () => { + test("assembles fragmented reports without exposing fragments", () => { + const decoder = new SgrMouseDecoder(); + expect(decoder.feed("\x1b[<")).toEqual({ intercepted: true, reports: [] }); + expect(decoder.feed("0;")).toEqual({ intercepted: true, reports: [] }); + expect(decoder.feed("12;")).toEqual({ intercepted: true, reports: [] }); + expect(decoder.feed("7M")).toEqual({ + intercepted: true, + reports: [{ action: "press", button: 0, x: 12, y: 7 }], + }); + }); + + test("parses complete press and release reports", () => { + const decoder = new SgrMouseDecoder(); + expect(decoder.feed("\x1b[<0;4;5M")).toEqual({ + intercepted: true, + reports: [{ action: "press", button: 0, x: 4, y: 5 }], + }); + expect(decoder.feed("\x1b[<0;4;5m")).toEqual({ + intercepted: true, + reports: [{ action: "release", button: 0, x: 4, y: 5 }], + }); + }); + + test("parses fragmented legacy press and release reports", () => { + const decoder = new SgrMouseDecoder(); + expect(decoder.feed("\x1b[M")).toEqual({ intercepted: true, reports: [] }); + expect(decoder.feed(" ")).toEqual({ intercepted: true, reports: [] }); + expect(decoder.feed("$%")).toEqual({ + intercepted: true, + reports: [{ action: "press", button: 0, x: 4, y: 5 }], + }); + expect(decoder.feed("\x1b[M#$%")).toEqual({ + intercepted: true, + reports: [{ action: "release", button: 3, x: 4, y: 5 }], + }); + }); + + test("discards oversized fragmented SGR reports", () => { + const decoder = new SgrMouseDecoder(); + expect(decoder.feed(`\x1b[<${"1".repeat(65)}`)).toEqual({ + intercepted: true, + reports: [], + }); + expect(decoder.feed("123;")).toEqual({ intercepted: true, reports: [] }); + expect(decoder.feed("M")).toEqual({ intercepted: true, reports: [] }); + expect(decoder.feed("a")).toEqual({ intercepted: false, reports: [] }); + }); + + test("does not intercept ordinary key input", () => { + const decoder = new SgrMouseDecoder(); + expect(decoder.feed("a")).toEqual({ intercepted: false, reports: [] }); + }); +}); + +describe("isLeftMousePress", () => { + test("accepts modified left presses and rejects side buttons", () => { + expect(isLeftMousePress({ action: "press", button: 0, x: 1, y: 1 })).toBe(true); + expect(isLeftMousePress({ action: "press", button: 28, x: 1, y: 1 })).toBe(true); + expect(isLeftMousePress({ action: "press", button: 128, x: 1, y: 1 })).toBe(false); + expect(isLeftMousePress({ action: "release", button: 0, x: 1, y: 1 })).toBe(false); + }); +}); diff --git a/test/format.test.ts b/test/format.test.ts index 476e599..a9e8bda 100644 --- a/test/format.test.ts +++ b/test/format.test.ts @@ -6,6 +6,11 @@ describe("wrapText", () => { test("wraps and preserves explicit newlines", () => { expect(wrapText("abcdef\nxy", 3)).toEqual(["abc", "def", "xy"]); }); + + test("wraps on emoji grapheme boundaries", () => { + expect(wrapText("🇺🇸x", 2)).toEqual(["🇺🇸", "x"]); + expect(wrapText("a\u1ab0b", 1)).toEqual(["a\u1ab0", "b"]); + }); }); test("terminal display strips control characters", () => { diff --git a/test/layout.test.ts b/test/layout.test.ts index 77be267..394e22b 100644 --- a/test/layout.test.ts +++ b/test/layout.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { layoutComment } from "../src/layout"; +import { + cursorAtEditorCell, + editorGeometry, + editorViewportStart, + layoutComment, +} from "../src/layout"; const chars = (text: string): string[] => Array.from(text); @@ -35,4 +40,105 @@ describe("layoutComment", () => { expect(result.cursorRow).toBe(1); expect(result.cursorCol).toBe(2); }); + + test("measures emoji grapheme clusters as terminal cells", () => { + for (const text of ["🇺🇸", "👨‍👩‍👧‍👦", "1️⃣"]) { + const comment = chars(text); + expect(layoutComment(comment, comment.length, 40).cursorCol).toBe(2); + } + }); +}); + +describe("cursorAtEditorCell", () => { + const geometry = editorGeometry(20, 12); + const chars = (text: string): string[] => Array.from(text); + + test("maps narrow glyph cells and the line end", () => { + const comment = chars("abc"); + expect(cursorAtEditorCell(comment, 0, geometry.left, geometry.editorTop, geometry)).toBe(0); + expect(cursorAtEditorCell(comment, 0, geometry.left + 1, geometry.editorTop, geometry)).toBe(1); + expect(cursorAtEditorCell(comment, 0, geometry.left + 5, geometry.editorTop, geometry)).toBe(3); + }); + + test("maps wide glyph cells to before and after positions", () => { + const comment = chars("a한b"); + expect(cursorAtEditorCell(comment, 0, geometry.left + 1, geometry.editorTop, geometry)).toBe(1); + expect(cursorAtEditorCell(comment, 0, geometry.left + 2, geometry.editorTop, geometry)).toBe(2); + expect(cursorAtEditorCell(comment, 0, geometry.left + 3, geometry.editorTop, geometry)).toBe(2); + expect(cursorAtEditorCell(comment, 0, geometry.left + 4, geometry.editorTop, geometry)).toBe(3); + }); + + test("keeps combining marks attached to their base glyph", () => { + const narrow = chars("a\u0301b"); + expect(cursorAtEditorCell(narrow, 0, geometry.left + 1, geometry.editorTop, geometry)).toBe(2); + const wide = chars("한\u0301b"); + expect(cursorAtEditorCell(wide, 0, geometry.left + 1, geometry.editorTop, geometry)).toBe(2); + }); + + test("returns only emoji grapheme boundaries", () => { + for (const text of ["🇺🇸", "👨‍👩‍👧‍👦", "1️⃣"]) { + const comment = chars(`${text}x`); + const boundary = chars(text).length; + expect(cursorAtEditorCell(comment, 0, geometry.left, geometry.editorTop, geometry)).toBe(0); + expect(cursorAtEditorCell(comment, 0, geometry.left + 1, geometry.editorTop, geometry)).toBe( + boundary, + ); + } + }); + + test("maps wrapped rows", () => { + const narrow = editorGeometry(20, 12); + const comment = chars("abcdefghijklmnopq"); + expect(cursorAtEditorCell(comment, 0, narrow.left, narrow.editorTop + 1, narrow)).toBe(16); + expect(cursorAtEditorCell(comment, 0, narrow.left + 1, narrow.editorTop + 1, narrow)).toBe(17); + }); + + test("maps explicit newline rows", () => { + const comment = chars("ab\ncd"); + expect(cursorAtEditorCell(comment, 0, geometry.left + 4, geometry.editorTop, geometry)).toBe(2); + expect(cursorAtEditorCell(comment, 0, geometry.left, geometry.editorTop + 1, geometry)).toBe(3); + expect(cursorAtEditorCell(comment, 0, geometry.left + 4, geometry.editorTop + 1, geometry)).toBe(5); + }); + + test("uses the explicit viewport to preserve scroll offset", () => { + const comment = chars("a\nb\nc\nd"); + const scrolled = editorGeometry(20, 10); + expect(cursorAtEditorCell(comment, 2, scrolled.left, scrolled.editorTop, scrolled)).toBe(4); + expect(cursorAtEditorCell(comment, 2, scrolled.left, scrolled.editorTop + 1, scrolled)).toBe(6); + }); + + test("keeps a clicked row visible without shifting the viewport", () => { + expect(editorViewportStart(3, 3, 5, 2)).toBe(3); + expect(editorViewportStart(3, 2, 5, 2)).toBe(2); + expect(editorViewportStart(1, 4, 5, 2)).toBe(3); + }); + + test("returns the buffer end for blank rows and rejects outside clicks", () => { + const comment = chars("a"); + expect(cursorAtEditorCell(comment, 0, geometry.left + 5, geometry.editorTop + 1, geometry)).toBe(1); + expect(cursorAtEditorCell(comment, 0, geometry.left - 1, geometry.editorTop, geometry)).toBeUndefined(); + expect(cursorAtEditorCell(comment, 0, geometry.left, geometry.editorTop - 1, geometry)).toBeUndefined(); + expect( + cursorAtEditorCell( + comment, + 0, + geometry.left + geometry.innerWidth + 1, + geometry.editorTop, + geometry, + ), + ).toBeUndefined(); + }); + + test("maps the cursor gutter after a full-width line", () => { + const comment = chars("abcdefghijklmnop"); + expect( + cursorAtEditorCell( + comment, + 0, + geometry.left + geometry.innerWidth, + geometry.editorTop, + geometry, + ), + ).toBe(comment.length); + }); }); diff --git a/test/width.test.ts b/test/width.test.ts index 06ef5a5..ec1a8c1 100644 --- a/test/width.test.ts +++ b/test/width.test.ts @@ -28,6 +28,10 @@ describe("stringWidth", () => { expect(stringWidth("한글abc")).toBe(7); expect(stringWidth("")).toBe(0); }); + + test("measures emoji grapheme clusters", () => { + for (const text of ["🇺🇸", "👨‍👩‍👧‍👦", "1️⃣"]) expect(stringWidth(text)).toBe(2); + }); }); describe("truncateToWidth", () => { @@ -36,6 +40,10 @@ describe("truncateToWidth", () => { expect(truncateToWidth("한글", 4)).toBe("한글"); }); + test("never splits an emoji grapheme cluster", () => { + expect(truncateToWidth("🇺🇸x", 2)).toBe("🇺🇸"); + }); + test("returns nothing when no cells are available", () => { expect(truncateToWidth("한", 0)).toBe(""); });