From a560fc14de8caffed69e08475a5e53d1822feb0f Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Fri, 13 Mar 2026 23:59:28 +0100 Subject: [PATCH] feat(prompt): add support for wide characters in prompt preview --- Cargo.lock | 1 + Cargo.toml | 1 + src/commit/types/scope.rs | 5 +- src/prompts/prompter.rs | 103 ++++++++++++++++++++++++++++++++++++-- src/prompts/workflow.rs | 6 ++- 5 files changed, 109 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eaf6a74..62baedc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1664,6 +1664,7 @@ dependencies = [ "textwrap", "thiserror", "tokio", + "unicode-width", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 24d9f42..dfca6b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ lazy-regex = { version = "3.5.1", features = ["lite"] } thiserror = "2.0.18" tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } textwrap = "0.16.2" +unicode-width = "0.2.2" [dev-dependencies] assert_cmd = "2.1.2" diff --git a/src/commit/types/scope.rs b/src/commit/types/scope.rs index 9e738ec..2ad88f4 100644 --- a/src/commit/types/scope.rs +++ b/src/commit/types/scope.rs @@ -555,7 +555,10 @@ mod tests { let result = Scope::parse(&input); assert_eq!( result.unwrap_err(), - ScopeError::TooLong { actual: 31, max: 30 }, + ScopeError::TooLong { + actual: 31, + max: 30 + }, "actual should be the char count (31), not the byte count (32)", ); } diff --git a/src/prompts/prompter.rs b/src/prompts/prompter.rs index 548e718..fb6582c 100644 --- a/src/prompts/prompter.rs +++ b/src/prompts/prompter.rs @@ -6,6 +6,7 @@ //! in production while accepting mock implementations in tests. use inquire::{Confirm, Text}; +use unicode_width::UnicodeWidthStr; use crate::{ commit::types::{BreakingChange, CommitType, Description, Scope}, @@ -40,14 +41,19 @@ pub trait Prompter: Send + Sync { } fn format_message_box(message: &str) -> String { - let preview_width = 72 + 2; // max width + space padding + let preview_width = message + .split('\n') + .map(|line| line.width()) + .max() + .unwrap_or(0) + .max(72); let mut lines: Vec = Vec::new(); - lines.push(format!("┌{}┐", "─".repeat(preview_width))); - for line in message.split("\n") { - let padding = 72_usize.saturating_sub(line.chars().count()); + lines.push(format!("┌{}┐", "─".repeat(preview_width + 2))); + for line in message.split('\n') { + let padding = preview_width.saturating_sub(line.width()); lines.push(format!("│ {line}{:padding$} │", "")); } - lines.push(format!("└{}┘", "─".repeat(preview_width))); + lines.push(format!("└{}┘", "─".repeat(preview_width + 2))); lines.join("\n") } @@ -270,4 +276,91 @@ mod tests { let expected = format!("│ {line_72} │"); assert_eq!(lines[1], expected); } + + /// A single CJK character (display width 2) is padded as if it occupies 2 columns, + /// not 1 — so the right-hand padding is 70 spaces, not 71 + #[test] + fn format_message_box_single_cjk_char() { + let result = format_message_box("字"); + let lines: Vec<&str> = result.split('\n').collect(); + let expected = format!("│ 字{:70} │", ""); + assert_eq!(lines[1], expected); + } + + /// A single emoji (display width 2) is padded as if it occupies 2 columns + #[test] + fn format_message_box_single_emoji() { + let result = format_message_box("🦀"); + let lines: Vec<&str> = result.split('\n').collect(); + let expected = format!("│ 🦀{:70} │", ""); + assert_eq!(lines[1], expected); + } + + /// Mixed ASCII and CJK: padding accounts for the display width of the whole line + /// + /// "feat: " = 6 display cols, "漢字" = 4 display cols → total 10, padding = 62 + #[test] + fn format_message_box_mixed_ascii_and_cjk() { + let result = format_message_box("feat: 漢字"); + let lines: Vec<&str> = result.split('\n').collect(); + let expected = format!("│ feat: 漢字{:62} │", ""); + assert_eq!(lines[1], expected); + } + + /// When a line exceeds 72 display columns the border expands to fit (width + 2 dashes) + #[test] + fn format_message_box_border_expands_beyond_72() { + let line_73 = "a".repeat(73); + let result = format_message_box(&line_73); + let lines: Vec<&str> = result.split('\n').collect(); + let dashes = "─".repeat(75); // 73 + 2 + assert_eq!(lines[0], format!("┌{dashes}┐")); + assert_eq!(lines[lines.len() - 1], format!("└{dashes}┘")); + } + + /// A line that sets the box width gets zero right-hand padding + #[test] + fn format_message_box_widest_line_has_no_padding() { + let line_73 = "a".repeat(73); + let result = format_message_box(&line_73); + let lines: Vec<&str> = result.split('\n').collect(); + assert_eq!(lines[1], format!("│ {line_73} │")); + } + + /// In a multi-line message, shorter lines are padded out to match the widest line + #[test] + fn format_message_box_shorter_lines_padded_to_widest() { + let long_line = "a".repeat(80); + let result = format_message_box(&format!("{long_line}\nshort")); + let lines: Vec<&str> = result.split('\n').collect(); + assert_eq!(lines[1], format!("│ {long_line} │")); + assert_eq!(lines[2], format!("│ short{:75} │", "")); // 80 - 5 = 75 + } + + /// All rows have equal char count when the box expands beyond 72 + #[test] + fn format_message_box_all_rows_same_width_when_expanded() { + let long_line = "a".repeat(80); + let result = format_message_box(&format!("{long_line}\nshort")); + let widths: Vec = result.split('\n').map(|l| l.chars().count()).collect(); + let expected = widths[0]; + assert!( + widths.iter().all(|&w| w == expected), + "rows have differing widths: {:?}", + widths + ); + } + + /// Wide characters can also trigger box expansion beyond 72 columns + /// + /// 37 CJK characters × 2 display columns = 74 display columns → border uses 76 dashes + #[test] + fn format_message_box_wide_chars_expand_box() { + let wide_line = "字".repeat(37); // 74 display cols + let result = format_message_box(&wide_line); + let lines: Vec<&str> = result.split('\n').collect(); + let dashes = "─".repeat(76); // 74 + 2 + assert_eq!(lines[0], format!("┌{dashes}┐")); + assert_eq!(lines[1], format!("│ {wide_line} │")); // no padding + } } diff --git a/src/prompts/workflow.rs b/src/prompts/workflow.rs index 84af738..bfa4e12 100644 --- a/src/prompts/workflow.rs +++ b/src/prompts/workflow.rs @@ -606,7 +606,11 @@ mod tests { let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let result: Result<(), Error> = workflow.run().await; - assert!(result.is_ok(), "expected workflow to succeed, got: {:?}", result); + assert!( + result.is_ok(), + "expected workflow to succeed, got: {:?}", + result + ); let messages = workflow.executor.describe_messages(); assert_eq!(messages.len(), 1, "expected exactly one describe() call");