aboutsummaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/pane/terminal.rs98
-rw-r--r--src/protocol/render_ansi.rs85
-rw-r--r--src/server/headless.rs72
3 files changed, 249 insertions, 6 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();
diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs
index 6cee4fde..1e6b9aa4 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<CellData>) -> FrameData {
FrameData {
cells,
@@ -1829,6 +1852,30 @@ mod tests {
}
#[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 {
cells: vec![
@@ -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 b4f433f7..859e02af 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -4910,7 +4910,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;
@@ -5350,6 +5351,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,
@@ -5360,6 +5371,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();