Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 93 additions & 5 deletions src/pane/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
85 changes: 85 additions & 0 deletions src/protocol/render_ansi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
);
}
}
Loading