Skip to content

Commit 64e1a2d

Browse files
committed
Fix IME preedit overlay and add tests
1 parent b65c72f commit 64e1a2d

File tree

3 files changed

+174
-22
lines changed

3 files changed

+174
-22
lines changed

frontends/rioterm/src/application.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,6 +1249,14 @@ impl ApplicationHandler<EventPayload> for Application<'_> {
12491249
if route.window.screen.context_manager.current().ime.preedit()
12501250
!= preedit.as_ref()
12511251
{
1252+
route
1253+
.window
1254+
.screen
1255+
.context_manager
1256+
.current_mut()
1257+
.renderable_content
1258+
.pending_update
1259+
.set_ui_damage(rio_backend::event::TerminalDamage::Full);
12521260
route
12531261
.window
12541262
.screen

frontends/rioterm/src/ime.rs

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,13 @@ pub struct Preedit {
5656

5757
impl Preedit {
5858
pub fn new(text: String, cursor_byte_offset: Option<usize>) -> Self {
59-
let cursor_end_offset = if let Some(byte_offset) = cursor_byte_offset {
60-
// Convert byte offset into char offset.
61-
let cursor_end_offset = text[byte_offset..]
59+
let cursor_byte_offset =
60+
cursor_byte_offset.filter(|&byte_offset| text.is_char_boundary(byte_offset));
61+
let cursor_end_offset = cursor_byte_offset.map(|byte_offset| {
62+
text[byte_offset..]
6263
.chars()
63-
.fold(0, |acc, ch| acc + ch.width().unwrap_or(1));
64-
65-
Some(cursor_end_offset)
66-
} else {
67-
None
68-
};
64+
.fold(0, |acc, ch| acc + ch.width().unwrap_or(1))
65+
});
6966

7067
Self {
7168
text,
@@ -74,3 +71,22 @@ impl Preedit {
7471
}
7572
}
7673
}
74+
75+
#[cfg(test)]
76+
mod tests {
77+
use super::*;
78+
79+
#[test]
80+
fn preedit_new_rejects_invalid_byte_offset() {
81+
let preedit = Preedit::new("好a".to_string(), Some(1));
82+
assert!(preedit.cursor_byte_offset.is_none());
83+
assert!(preedit.cursor_end_offset.is_none());
84+
}
85+
86+
#[test]
87+
fn preedit_new_computes_cursor_end_offset() {
88+
let preedit = Preedit::new("好a".to_string(), Some(0));
89+
assert_eq!(preedit.cursor_byte_offset, Some(0));
90+
assert_eq!(preedit.cursor_end_offset, Some(3));
91+
}
92+
}

frontends/rioterm/src/renderer/mod.rs

Lines changed: 141 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,78 @@ use std::collections::{BTreeSet, HashMap};
3232
use std::ops::RangeInclusive;
3333

3434
use unicode_width::UnicodeWidthChar;
35+
use crate::ime::Preedit;
36+
37+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38+
enum PreeditCell {
39+
Char(char),
40+
Spacer,
41+
}
42+
43+
struct PreeditOverlay {
44+
columns: usize,
45+
cells: Vec<Option<PreeditCell>>,
46+
}
47+
48+
impl PreeditOverlay {
49+
fn new(preedit: &Preedit, start_row: usize, start_col: usize, columns: usize, rows: usize) -> Option<Self> {
50+
if preedit.text.is_empty() || columns == 0 || rows == 0 {
51+
return None;
52+
}
53+
54+
let mut cells = vec![None; rows.saturating_mul(columns)];
55+
let mut row = start_row;
56+
let mut col = start_col;
57+
58+
for ch in preedit.text.chars() {
59+
if row >= rows {
60+
break;
61+
}
62+
63+
if col >= columns {
64+
row += 1;
65+
col = 0;
66+
}
67+
if row >= rows {
68+
break;
69+
}
70+
71+
let width = ch.width().unwrap_or(1).max(1);
72+
if width > 1 && col + 1 >= columns {
73+
row += 1;
74+
col = 0;
75+
if row >= rows {
76+
break;
77+
}
78+
}
79+
80+
let idx = row * columns + col;
81+
if let Some(cell) = cells.get_mut(idx) {
82+
*cell = Some(PreeditCell::Char(ch));
83+
}
84+
85+
if width > 1 && col + 1 < columns {
86+
let spacer_idx = idx + 1;
87+
if let Some(cell) = cells.get_mut(spacer_idx) {
88+
*cell = Some(PreeditCell::Spacer);
89+
}
90+
}
91+
92+
col = col.saturating_add(width);
93+
if col >= columns {
94+
row += 1;
95+
col = 0;
96+
}
97+
}
98+
99+
Some(Self { columns, cells })
100+
}
101+
102+
fn get(&self, row: usize, col: usize) -> Option<PreeditCell> {
103+
let idx = row.checked_mul(self.columns)?.saturating_add(col);
104+
self.cells.get(idx).copied().flatten()
105+
}
106+
}
35107

36108
#[derive(Default)]
37109
pub struct Search {
@@ -256,9 +328,11 @@ impl Renderer {
256328
builder: &mut Content,
257329
row: &Row<Square>,
258330
has_cursor: bool,
331+
visible_row_index: usize,
259332
line_opt: Option<usize>,
260333
line: Line,
261334
renderable_content: &RenderableContent,
335+
ime_preedit: Option<&PreeditOverlay>,
262336
hint_matches: Option<&[rio_backend::crosswords::search::Match]>,
263337
focused_match: &Option<RangeInclusive<Pos>>,
264338
term_colors: &TermColors,
@@ -279,8 +353,10 @@ impl Renderer {
279353
// First pass: collect all styles and identify font cache misses
280354
for column in 0..columns {
281355
let square = &row.inner[column];
356+
let preedit_cell =
357+
ime_preedit.and_then(|preedit| preedit.get(visible_row_index, column));
282358

283-
if square.flags.contains(Flags::WIDE_CHAR_SPACER) {
359+
if square.flags.contains(Flags::WIDE_CHAR_SPACER) && preedit_cell.is_none() {
284360
continue;
285361
}
286362

@@ -374,10 +450,28 @@ impl Renderer {
374450
);
375451
}
376452

453+
if let Some(cell) = preedit_cell {
454+
match cell {
455+
PreeditCell::Char(ch) => {
456+
square_content = ch;
457+
}
458+
PreeditCell::Spacer => {
459+
square_content = ' ';
460+
}
461+
}
462+
if !(has_cursor && column == cursor.state.pos.col) {
463+
style.color =
464+
self.color(NamedColor::DimForeground as usize, term_colors);
465+
style.decoration = None;
466+
style.decoration_color = None;
467+
}
468+
}
469+
377470
if !is_active {
378471
style.color[3] = self.unfocused_split_opacity;
379472
if let Some(mut background_color) = style.background_color {
380473
background_color[3] = self.unfocused_split_opacity;
474+
style.background_color = Some(background_color);
381475
}
382476
}
383477

@@ -940,6 +1034,15 @@ impl Renderer {
9401034

9411035
// Update cursor state from snapshot
9421036
context.renderable_content.cursor.state = terminal_snapshot.cursor;
1037+
let preedit_overlay = context.ime.preedit().and_then(|preedit| {
1038+
PreeditOverlay::new(
1039+
preedit,
1040+
context.renderable_content.cursor.state.pos.row.0.max(0) as usize,
1041+
context.renderable_content.cursor.state.pos.col.0,
1042+
terminal_snapshot.columns,
1043+
terminal_snapshot.visible_rows.len(),
1044+
)
1045+
});
9431046

9441047
let mut specific_lines: Option<BTreeSet<LineDamage>> = None;
9451048

@@ -1033,18 +1136,20 @@ impl Renderer {
10331136
for (i, row) in terminal_snapshot.visible_rows.iter().enumerate() {
10341137
let has_cursor = is_cursor_visible
10351138
&& context.renderable_content.cursor.state.pos.row == i;
1036-
self.create_line(
1037-
content,
1038-
row,
1039-
has_cursor,
1040-
None,
1041-
Line((i as i32) - terminal_snapshot.display_offset as i32),
1042-
&context.renderable_content,
1043-
hint_matches,
1044-
focused_match,
1045-
&terminal_snapshot.colors,
1046-
is_active,
1047-
);
1139+
self.create_line(
1140+
content,
1141+
row,
1142+
has_cursor,
1143+
i,
1144+
None,
1145+
Line((i as i32) - terminal_snapshot.display_offset as i32),
1146+
&context.renderable_content,
1147+
preedit_overlay.as_ref(),
1148+
hint_matches,
1149+
focused_match,
1150+
&terminal_snapshot.colors,
1151+
is_active,
1152+
);
10481153
}
10491154
content.build();
10501155
// let _duration = start.elapsed();
@@ -1063,12 +1168,14 @@ impl Renderer {
10631168
content,
10641169
visible_row,
10651170
has_cursor,
1171+
line,
10661172
Some(line),
10671173
Line(
10681174
(line as i32)
10691175
- terminal_snapshot.display_offset as i32,
10701176
),
10711177
&context.renderable_content,
1178+
preedit_overlay.as_ref(),
10721179
hint_matches,
10731180
focused_match,
10741181
&terminal_snapshot.colors,
@@ -1314,4 +1421,25 @@ mod tests {
13141421
Pos::new(Line(2), Column(12))
13151422
));
13161423
}
1424+
1425+
#[test]
1426+
fn preedit_overlay_places_wide_chars_and_spacers() {
1427+
let preedit = Preedit::new("好a".to_string(), None);
1428+
let overlay = PreeditOverlay::new(&preedit, 0, 0, 4, 1).unwrap();
1429+
1430+
assert_eq!(overlay.get(0, 0), Some(PreeditCell::Char('啊')));
1431+
assert_eq!(overlay.get(0, 1), Some(PreeditCell::Spacer));
1432+
assert_eq!(overlay.get(0, 2), Some(PreeditCell::Char('a')));
1433+
assert_eq!(overlay.get(0, 3), None);
1434+
}
1435+
1436+
#[test]
1437+
fn preedit_overlay_wraps_wide_chars() {
1438+
let preedit = Preedit::new("好".to_string(), None);
1439+
let overlay = PreeditOverlay::new(&preedit, 0, 2, 3, 2).unwrap();
1440+
1441+
assert_eq!(overlay.get(0, 2), None);
1442+
assert_eq!(overlay.get(1, 0), Some(PreeditCell::Char('啊')));
1443+
assert_eq!(overlay.get(1, 1), Some(PreeditCell::Spacer));
1444+
}
13171445
}

0 commit comments

Comments
 (0)