From a7b51ab8918eae3ebba85753cc8ca986015c1d88 Mon Sep 17 00:00:00 2001 From: oyoguhito Date: Tue, 4 Aug 2026 10:15:03 +0900 Subject: [PATCH] fix(terminal): render halfwidth katakana voiced marks --- src/pane/terminal.rs | 98 +++++++++++++++++++++++++++++++++++-- src/protocol/render_ansi.rs | 85 ++++++++++++++++++++++++++++++++ src/server/headless.rs | 72 ++++++++++++++++++++++++++- 3 files changed, 249 insertions(+), 6 deletions(-) diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index b90c63f13e..0005b351bb 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -2654,10 +2654,26 @@ pub(super) fn ghostty_normalize_buffer_symbol( if wide == crate::ghostty::CellWide::Narrow && actual_width == 2 { return symbol.to_string(); } + if wide == crate::ghostty::CellWide::Wide && is_halfwidth_katakana_voiced_grapheme(symbol) { + return symbol.to_string(); + } ghostty_blank_symbol_for_width(wide).to_string() } +fn is_halfwidth_katakana_voiced_grapheme(symbol: &str) -> bool { + let mut chars = symbol.chars(); + let Some(base) = chars.next() else { + return false; + }; + let Some(mark) = chars.next() else { + return false; + }; + chars.next().is_none() + && ('\u{ff66}'..='\u{ff9d}').contains(&base) + && matches!(mark, '\u{ff9e}' | '\u{ff9f}') +} + fn ghostty_buffer_symbol_into<'a>( cells: &crate::ghostty::RowCellIter<'_>, wide: crate::ghostty::CellWide, @@ -2689,6 +2705,8 @@ fn ghostty_buffer_symbol_into<'a>( let actual_width = symbol_scratch.width(); if actual_width != expected_width && !(wide == crate::ghostty::CellWide::Narrow && actual_width == 2) + && !(wide == crate::ghostty::CellWide::Wide + && is_halfwidth_katakana_voiced_grapheme(symbol_scratch)) { symbol_scratch.clear(); symbol_scratch.push_str(ghostty_blank_symbol_for_width(wide)); @@ -2721,11 +2739,7 @@ fn blank_cell_data(default_fg: Option, default_bg: Option) -> Cell fn cell_data_from_style(symbol: String, style: Style) -> CellData { CellData { - symbol: if symbol.is_empty() { - " ".to_string() - } else { - symbol - }, + symbol, fg: crate::protocol::color_to_u32(style.fg.unwrap_or(Color::Reset)), bg: crate::protocol::color_to_u32(style.bg.unwrap_or(Color::Reset)), modifier: crate::protocol::modifier_to_u16(style.add_modifier), @@ -4402,6 +4416,14 @@ mod tests { ghostty_normalize_buffer_symbol("xx", crate::ghostty::CellWide::SpacerHead), " " ); + assert_eq!( + ghostty_normalize_buffer_symbol("カ\u{ff9e}", crate::ghostty::CellWide::Wide), + "カ\u{ff9e}" + ); + assert_eq!( + ghostty_normalize_buffer_symbol("ハ\u{ff9f}", crate::ghostty::CellWide::Wide), + "ハ\u{ff9f}" + ); } fn render_cells_to_symbols( @@ -4470,6 +4492,72 @@ mod tests { ); } + #[test] + fn halfwidth_katakana_voiced_marks_render() { + let mut terminal = crate::ghostty::Terminal::new(40, 1, 0).unwrap(); + terminal.write("アイウエオ ガギグゲゴ パピプペポ".as_bytes()); + + let cells = render_cells_to_symbols(&mut terminal); + let rendered: String = cells.iter().map(|(_, symbol)| symbol.as_str()).collect(); + + assert!( + rendered.contains("アイウエオ ガギグゲゴ パピプペポ"), + "expected halfwidth katakana with voiced marks to survive, got {cells:?}" + ); + } + + #[test] + fn render_keeps_halfwidth_katakana_voiced_tail_empty() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(20, 1, 0).unwrap(); + terminal.write("ガZ".as_bytes()); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let backend = ratatui::backend::TestBackend::new(20, 1); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 1), false)) + .unwrap(); + let buffer = terminal.backend().buffer(); + + assert_eq!(buffer[(0, 0)].symbol(), "カ\u{ff9e}"); + assert_eq!( + buffer[(1, 0)].symbol(), + "", + "wide spacer tail must stay empty so the host terminal does not overwrite the voiced kana" + ); + assert_eq!(buffer[(2, 0)].symbol(), "Z"); + } + + #[test] + fn dirty_patch_keeps_halfwidth_katakana_voiced_tail_empty() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 1, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let backend = ratatui::backend::TestBackend::new(20, 1); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 1), false)) + .unwrap(); + { + let mut core = pane.core.lock().unwrap(); + core.terminal.write("ガZ".as_bytes()); + } + + let patch = match pane.collect_dirty_patch(20, 1) { + TerminalDirtyPatchOutcome::Patch(patch) => patch, + other => panic!("expected dirty patch, got {other:?}"), + }; + let row = &patch.rows[0].1; + + assert_eq!(row[0].symbol, "カ\u{ff9e}"); + assert_eq!( + row[1].symbol, "", + "wide spacer tail must stay empty in retained terminal patches" + ); + assert_eq!(row[2].symbol, "Z"); + } + #[test] fn pane_scrollback_controls_round_trip_and_clamp_without_ui_interference() { let (tx, _rx) = mpsc::channel(4); diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs index 6cee4fdea0..1e6b9aa498 100644 --- a/src/protocol/render_ansi.rs +++ b/src/protocol/render_ansi.rs @@ -522,9 +522,25 @@ fn repeat_ime_anchor_after_sync() -> bool { /// Writes all cells in the frame (full redraw). fn cell_width(cell: &CellData) -> usize { + if is_halfwidth_katakana_voiced_grapheme(&cell.symbol) { + return 2; + } cell.symbol.width() } +fn is_halfwidth_katakana_voiced_grapheme(symbol: &str) -> bool { + let mut chars = symbol.chars(); + let Some(base) = chars.next() else { + return false; + }; + let Some(mark) = chars.next() else { + return false; + }; + chars.next().is_none() + && ('\u{ff66}'..='\u{ff9d}').contains(&base) + && matches!(mark, '\u{ff9e}' | '\u{ff9f}') +} + #[derive(Clone, Copy)] struct HostCursorState { position: (u16, u16), @@ -819,6 +835,7 @@ mod tests { use crate::protocol::{CellData, CursorState}; const WIDE_GRAPHEME: &str = "💡"; + const HALFWIDTH_VOICED_KANA: &str = "カ\u{ff9e}"; fn make_cell(symbol: &str, fg: u32, bg: u32, modifier: u16) -> CellData { CellData { @@ -831,6 +848,12 @@ mod tests { } } + fn make_skip_cell(symbol: &str, fg: u32, bg: u32, modifier: u16) -> CellData { + let mut cell = make_cell(symbol, fg, bg, modifier); + cell.skip = true; + cell + } + fn make_frame(width: u16, height: u16, cells: Vec) -> FrameData { FrameData { cells, @@ -1828,6 +1851,30 @@ mod tests { assert!(output_str.contains("\x1b[1;3H")); } + #[test] + fn full_redraw_skips_trailing_cells_covered_by_halfwidth_voiced_kana() { + let frame = FrameData { + cells: vec![ + make_cell(HALFWIDTH_VOICED_KANA, 0, 0, 0), + make_skip_cell(" ", 0, 0, 0), + make_cell("Z", 0, 0, 0), + ], + width: 3, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + let output_str = String::from_utf8(output).unwrap(); + + assert!(output_str.contains("\x1b[1;1H")); + assert!(!output_str.contains("\x1b[1;2H")); + assert!(output_str.contains("\x1b[1;3H")); + } + #[test] fn diff_redraw_reveals_cells_hidden_by_previous_wide_graphemes() { let prev = FrameData { @@ -1900,4 +1947,42 @@ mod tests { assert!(output_str.contains("\x1b[1;1H")); assert!(!output_str.contains("\x1b[1;2H")); } + + #[test] + fn diff_redraw_reveals_cells_hidden_by_previous_halfwidth_voiced_kana() { + let prev = FrameData { + cells: vec![ + make_cell(HALFWIDTH_VOICED_KANA, 0, 0, 0), + make_skip_cell(" ", 0, 0, 0), + make_cell("Z", 0, 0, 0), + ], + width: 3, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let curr = FrameData { + cells: vec![ + make_cell("A", 0, 0, 0), + make_cell(" ", 0, 0, 0), + make_cell("Z", 0, 0, 0), + ], + width: 3, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &curr, Some(&prev)); + let output_str = String::from_utf8(output).unwrap(); + + assert!(output_str.contains("\x1b[1;1H")); + assert!( + output_str.contains("\x1b[1;2H"), + "cells hidden by a previous halfwidth voiced kana must be redrawn when visible" + ); + } } diff --git a/src/server/headless.rs b/src/server/headless.rs index f72d1a94ef..6b28ce9f5e 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -4897,7 +4897,8 @@ mod tests { use super::*; use crate::app::AppState; - use crate::protocol::CursorState; + use crate::protocol::{CellData, CursorState}; + use unicode_width::UnicodeWidthStr; #[path = "pane_graphics.rs"] mod pane_graphics_tests; @@ -5337,6 +5338,16 @@ mod tests { for (idx, (actual_cell, expected_cell)) in actual.cells.iter().zip(expected.cells.iter()).enumerate() { + if cells_equivalent_for_frame_compare( + &actual.cells, + &expected.cells, + usize::from(actual.width), + idx, + actual_cell, + expected_cell, + ) { + continue; + } assert_eq!( actual_cell, expected_cell, @@ -5347,6 +5358,65 @@ mod tests { } } + fn cells_equivalent_for_frame_compare( + actual_cells: &[CellData], + expected_cells: &[CellData], + width: usize, + idx: usize, + actual: &CellData, + expected: &CellData, + ) -> bool { + if actual == expected { + return true; + } + if !cell_style_without_symbol_eq(actual, expected) { + return false; + } + if !matches!( + (actual.symbol.as_str(), expected.symbol.as_str()), + ("", " ") | (" ", "") + ) { + return false; + } + covered_by_previous_wide_cell(actual_cells, width, idx) + || covered_by_previous_wide_cell(expected_cells, width, idx) + } + + fn cell_style_without_symbol_eq(a: &CellData, b: &CellData) -> bool { + a.fg == b.fg + && a.bg == b.bg + && a.modifier == b.modifier + && a.skip == b.skip + && a.hyperlink == b.hyperlink + } + + fn covered_by_previous_wide_cell(cells: &[CellData], width: usize, idx: usize) -> bool { + if idx == 0 || idx.is_multiple_of(width) { + return false; + } + frame_cell_display_width(&cells[idx - 1]) > 1 + } + + fn frame_cell_display_width(cell: &CellData) -> usize { + if is_halfwidth_katakana_voiced_grapheme(&cell.symbol) { + return 2; + } + cell.symbol.width() + } + + fn is_halfwidth_katakana_voiced_grapheme(symbol: &str) -> bool { + let mut chars = symbol.chars(); + let Some(base) = chars.next() else { + return false; + }; + let Some(mark) = chars.next() else { + return false; + }; + chars.next().is_none() + && ('\u{ff66}'..='\u{ff9d}').contains(&base) + && matches!(mark, '\u{ff9e}' | '\u{ff9f}') + } + #[test] fn foreground_client_applies_client_keybindings() { let mut server = test_headless_server();