aboutsummaryrefslogtreecommitdiff
path: root/src/pane
diff options
context:
space:
mode:
authorKazunari Kamata <14287197+kazunari-kamata@users.noreply.github.com>2026-08-05 06:48:15 +0900
committerGitHub <noreply@github.com>2026-08-05 00:48:15 +0300
commitbe1891ec5265e901b897846a6113c46cf330a7dc (patch)
treeef895364bd183fec707dc382628c4ef0c427346f /src/pane
parent09cdd88d0aca35617eb05468c2421b0467656e4f (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.rs98
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();