diff options
| author | Kazunari Kamata <14287197+kazunari-kamata@users.noreply.github.com> | 2026-08-05 06:48:15 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-05 00:48:15 +0300 |
| commit | be1891ec5265e901b897846a6113c46cf330a7dc (patch) | |
| tree | ef895364bd183fec707dc382628c4ef0c427346f /src/pane | |
| parent | 09cdd88d0aca35617eb05468c2421b0467656e4f (diff) | |
fix(terminal): render halfwidth katakana voiced marks (#2257)
Co-authored-by: oyoguhito <oyoguhito@kamatanoMacBook-Pro.local>
Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/pane')
| -rw-r--r-- | src/pane/terminal.rs | 98 |
1 files changed, 93 insertions, 5 deletions
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index d4f167a9..4bd50669 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<Color>, default_bg: Option<Color>) -> 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), @@ -4441,6 +4455,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( @@ -4510,6 +4532,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); let mut terminal = crate::ghostty::Terminal::new(80, 3, 100).unwrap(); |
