diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index 5f396f2aa5..e8fdd822d8 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -460,6 +460,11 @@ impl ApplicationHandler for Application<'_> { // Just mark dirty — damage will be extracted from // the terminal when the renderer locks it. ctx_item.val.renderable_content.pending_update.set_dirty(); + // Skip the scheduler timer (only firable from + // `about_to_wait`, which the IME key-repeat + // message flood starves) and request the + // paint directly. Vblank pacing stays with + // the VSync worker. route.request_redraw(); } } @@ -1940,6 +1945,22 @@ impl ApplicationHandler for Application<'_> { if route.window.screen.context_manager.current().ime.preedit() != preedit.as_ref() { + // Force a full terminal repaint so the + // preedit overlay's cells re-emit on the + // next frame. Without this the grid keeps + // its previous CPU buffers and the overlay + // wouldn't refresh until something else + // damages the row. + route + .window + .screen + .context_manager + .current_mut() + .renderable_content + .pending_update + .set_terminal_damage( + rio_backend::event::TerminalDamage::Full, + ); route .window .screen diff --git a/frontends/rioterm/src/context/mod.rs b/frontends/rioterm/src/context/mod.rs index 5f5944375f..1c59830fbe 100644 --- a/frontends/rioterm/src/context/mod.rs +++ b/frontends/rioterm/src/context/mod.rs @@ -116,7 +116,6 @@ impl Context { state: self.renderable_content.cursor.state.new_from_self(), content: self.renderable_content.cursor.content_ref, content_ref: self.renderable_content.cursor.content_ref, - is_ime_enabled: false, } } } diff --git a/frontends/rioterm/src/context/renderable.rs b/frontends/rioterm/src/context/renderable.rs index d645e2eb98..987c2b1693 100644 --- a/frontends/rioterm/src/context/renderable.rs +++ b/frontends/rioterm/src/context/renderable.rs @@ -27,7 +27,6 @@ pub struct Cursor { pub state: CursorState, pub content: char, pub content_ref: char, - pub is_ime_enabled: bool, } #[derive(Clone, Copy, Debug)] @@ -145,7 +144,6 @@ impl RenderableContent { content: config_cursor.shape.into(), content_ref: config_cursor.shape.into(), state: CursorState::new(config_cursor.shape.into()), - is_ime_enabled: false, }; Self::new(cursor) } diff --git a/frontends/rioterm/src/grid_emit.rs b/frontends/rioterm/src/grid_emit.rs index 82238da233..420aefbf0e 100644 --- a/frontends/rioterm/src/grid_emit.rs +++ b/frontends/rioterm/src/grid_emit.rs @@ -39,6 +39,7 @@ use smallvec::SmallVec; /// cells. The renderer reads via `extras.get(&id)`. pub(crate) type ExtrasMap = FxHashMap; +use crate::renderer::preedit::{PreeditCell, PreeditOverlay}; use crate::renderer::Renderer; #[inline(always)] @@ -53,6 +54,26 @@ pub(crate) fn resolve_style(style_table: &[Style], sq: Square) -> Style { style_table.get(sid).copied().unwrap_or_default() } +/// Per-row IME preedit cells. The overlay is panel-wide; this struct +/// just pins the visible-row index the emit pass is currently +/// processing so the lookup is one indirection per cell. +pub struct PreeditRow<'a> { + pub overlay: &'a PreeditOverlay, + pub row: usize, +} + +impl<'a> PreeditRow<'a> { + #[inline] + fn cell(&self, col: usize) -> Option { + self.overlay.get(self.row, col) + } + + #[inline] + fn caret_col(&self) -> Option { + self.overlay.ime_cursor_in_row(self.row) + } +} + /// Per-row selection interval, in column indices. `None` = row is /// outside the selection. Block selections reduce to the same /// `[lo, hi]` on every row; linear selections expand middle rows to @@ -399,6 +420,11 @@ enum DecorationStyle { DashedUnderline = 3, CurlyUnderline = 4, Strikethrough = 5, + /// Thin vertical bar pinned to the left edge of a cell, used to + /// break the wezterm-style preedit block at the IME cursor so the + /// user can see where arrow-key navigation lands inside the + /// composition. + ImeCaret = 6, } /// Sentinel font_id base for decoration sprites. Real font_ids come @@ -889,6 +915,18 @@ fn rasterize_decoration( let bearing_y = center_from_bottom as i16 + (thickness as i16 + 1) / 2; (bytes, cell_w, thickness, bearing_y) } + DecorationStyle::ImeCaret => { + // Vertical bar pinned to the left edge of the cell, full + // cell height. Width is `thickness` (the same metric used + // for underlines), so the caret reads as a beam in the + // same visual weight as the rest of the decoration sprites. + // bearing_y == cell_h means the sprite's top sits at cell + // top — the bar covers the full vertical extent. + let w = thickness.max(1).min(cell_w); + let h = cell_h; + let bytes = vec![0xFFu8; (w * h) as usize]; + (bytes, w, h, h as i16) + } } } @@ -1059,18 +1097,26 @@ pub fn build_row_bg( term_colors: &TermColors, row_sel: Option, row_hints: &[RowHint], + preedit: Option<&PreeditRow<'_>>, bg_scratch: &mut Vec, ) { bg_scratch.clear(); - // Fast path: row has no selection and no color-changing hints - // (HyperlinkHover only contributes an underline, never bg). The - // overwhelming majority of rows in idle terminals hit this path — - // strip the per-cell `cell_in_row_sel` / `cell_in_row_hints` - // checks and just walk cells. + // Cursor color used as the wezterm-style block fill behind every + // composition cell. Computed once per row to keep the per-cell + // override branchless beyond the slot check. + let preedit_block_bg = preedit + .as_ref() + .map(|_| normalized_to_u8(renderer.named_colors.cursor)); + + // Fast path: row has no selection, no color-changing hints, and no + // active preedit. (HyperlinkHover only contributes an underline, + // never bg). The overwhelming majority of rows in idle terminals + // hit this path — strip the per-cell `cell_in_row_sel` / + // `cell_in_row_hints` checks and just walk cells. let has_sel = row_sel.is_some(); let has_color_hints = row_hints.iter().any(|rh| rh.tag != HintTag::HyperlinkHover); - if !has_sel && !has_color_hints { + if !has_sel && !has_color_hints && preedit.is_none() { bg_scratch.reserve(cols); for x in 0..cols { let sq = row[Column(x)]; @@ -1081,7 +1127,7 @@ pub fn build_row_bg( return; } - // Slow path: selection and/or hint highlighting present. + // Slow path: selection, hint highlighting, or preedit present. let sel_bg = if has_sel { Some(normalized_to_u8(renderer.named_colors.selection_background)) } else { @@ -1103,7 +1149,17 @@ pub fn build_row_bg( let sq = row[Column(x)]; let style = resolve_style(style_table, sq); let col = x as u16; - let rgba = if cell_in_row_sel(row_sel, col) { + // Preedit wins over selection / hint backgrounds: the user is + // actively composing here, that signal needs to read first. + // Both Char and Spacer cells take the block fill so wide + // glyphs span as one continuous region. + let preedit_here = match (preedit, preedit_block_bg) { + (Some(p), Some(bg)) if p.cell(x).is_some() => Some(bg), + _ => None, + }; + let rgba = if let Some(bg) = preedit_here { + bg + } else if cell_in_row_sel(row_sel, col) { // Selection bg wins over hint bg and the cell's own bg, // matching `generic.zig:2775-2800` (selection check // runs before highlight check). @@ -1629,6 +1685,7 @@ pub fn build_row_fg( cell_h: f32, row_sel: Option, row_hints: &[RowHint], + preedit: Option<&PreeditRow<'_>>, font_library: &FontLibrary, route_id: usize, // Column of the cursor on this row, or `None` if the cursor isn't @@ -1683,6 +1740,7 @@ pub fn build_row_fg( thickness, row_sel, row_hints, + preedit, fg_scratch, ); @@ -1698,6 +1756,15 @@ pub fn build_row_fg( let mut x: usize = 0; while x < max { let sq = row[Column(x)]; + // Preedit cells are emitted in their own pass below — the + // composition string (with overridden fg = bg color + + // BOOL_IS_CURSOR_GLYPH for the inverse-on-cursor swap) goes + // through a single-cell shaping path so it can't accidentally + // form ligatures with the underlying terminal text. + if preedit.is_some_and(|p| p.cell(x).is_some()) { + x += 1; + continue; + } if is_run_breaker(sq) { x += 1; continue; @@ -1908,6 +1975,11 @@ pub fn build_row_fg( // Extend the run while (font_id, style_flags) match. let mut end = x + 1; while end < cols { + // Stop the run before stepping into a preedit cell — the + // composition takes over those cells in the dedicated pass. + if preedit.is_some_and(|p| p.cell(end).is_some()) { + break; + } let sq2 = row[Column(end)]; if is_run_breaker(sq2) { break; @@ -2201,6 +2273,36 @@ pub fn build_row_fg( x = end; } + // Phase 2.5: preedit pass. Each composition Char becomes its own + // single-cell run — shaped per cell so it doesn't form ligatures + // with surrounding terminal text. Spacer cells get no glyph; the + // wide leading char's advance covers them. Color is forced to the + // terminal background and BOOL_IS_CURSOR_GLYPH is set so the + // cell-text shader inverts the glyph against the cursor block bg + // we painted in `build_row_bg`. + if let Some(pre) = preedit { + let preedit_text_fg = normalized_to_u8(renderer.named_colors.background.0); + for col in 0..cols { + let Some(PreeditCell::Char(ch)) = pre.cell(col) else { + continue; + }; + emit_preedit_char( + ch, + col as u16, + y, + rasterizer, + grid, + font_library, + route_id, + size_u16, + size_bucket, + cell_h, + preedit_text_fg, + fg_scratch, + ); + } + } + // Phase 3: strikethrough pass. Emitted last so the strike overlays // the glyph. emit_strikethroughs( @@ -2216,8 +2318,198 @@ pub fn build_row_fg( thickness, row_sel, row_hints, + preedit, fg_scratch, ); + + // Phase 4: IME caret. Drawn after strikethroughs so the beam sits + // on top of every other glyph and reads as the topmost element of + // the composition. The caret cell may or may not carry a preedit + // char (the IME cursor at end-of-text lands on the cell just past + // the composition), so this is independent of phase 2.5. + if let Some(pre) = preedit { + if let Some(caret_col) = pre.caret_col() { + if caret_col < cols { + emit_ime_caret( + caret_col as u16, + y, + grid, + cell_w_u32, + cell_h_u32, + thickness, + normalized_to_u8(renderer.named_colors.cursor), + fg_scratch, + ); + } + } + } +} + +/// Shape a single character as a one-cell run and emit its glyphs at +/// `(grid_col, y)` with a forced foreground color. Used by the preedit +/// pass where every composition cell needs a fresh shape (no ligatures +/// with neighbors) and a guaranteed plain font style. +#[allow(clippy::too_many_arguments)] +fn emit_preedit_char( + ch: char, + grid_col: u16, + y: u16, + rasterizer: &mut GridGlyphRasterizer, + grid: &mut GridRenderer, + font_library: &FontLibrary, + route_id: usize, + size_u16: u16, + size_bucket: u16, + cell_h: f32, + text_fg: [u8; 4], + fg_scratch: &mut Vec, +) { + // No bold/italic — composing text shouldn't pick up styling that + // happened to be active at the cursor position (e.g. an italic + // prompt segment from Starship/oh-my-posh). + let run_style_flags = 0u8; + let (font_id, is_emoji) = + rasterizer.resolve_font(ch, run_style_flags, font_library, route_id); + + #[cfg(target_os = "macos")] + { + rasterizer.run_utf16_scratch.clear(); + rasterizer.run_cell_starts.clear(); + rasterizer + .run_cell_starts + .push(rasterizer.run_utf16_scratch.len() as u32); + let mut buf = [0u16; 2]; + rasterizer + .run_utf16_scratch + .extend_from_slice(ch.encode_utf16(&mut buf)); + } + #[cfg(not(target_os = "macos"))] + { + rasterizer.run_str_scratch.clear(); + rasterizer.run_str_scratch.push(ch); + } + + // One-cell run hash, mirroring the main emit loop's scheme: + // `(codepoint, cluster=0)` for the single cell, then the + // `(cell_count, font_id, size_bucket)` finalizer. No combining + // marks — the preedit string is plain chars mapped one per cell. + rasterizer.run_hasher = rapidhash::fast::RapidHasher::default(); + rasterizer.run_hasher.write_u32(ch as u32); + rasterizer.run_hasher.write_u32(0); + rasterizer.run_hasher.write_u32(1); + rasterizer.run_hasher.write_u32(font_id); + rasterizer.run_hasher.write_u16(size_bucket); + let hash = rasterizer.run_hasher.finish(); + + let ascent_px = if run_cache_get(&mut rasterizer.run_cache, hash).is_some() { + rasterizer + .ascent_cache + .get(&(font_id, size_bucket)) + .copied() + .unwrap_or(0) + } else { + #[cfg(target_os = "macos")] + let shaped_opt = + shape_run_ct(rasterizer, font_id, size_u16, size_bucket, font_library); + #[cfg(not(target_os = "macos"))] + let shaped_opt = + shape_run_swash(rasterizer, font_id, size_u16, size_bucket, font_library); + let Some((glyphs, ascent_px)) = shaped_opt else { + return; + }; + run_cache_put(&mut rasterizer.run_cache, RunCacheEntry { hash, glyphs }); + ascent_px + }; + + let (synthetic_bold, synthetic_italic) = + rasterizer.get_synthesis(font_id, font_library); + + let mut glyph_ids: SmallVec<[u16; 4]> = SmallVec::new(); + { + let glyphs = + run_cache_get(&mut rasterizer.run_cache, hash).expect("just inserted"); + for g in glyphs { + glyph_ids.push(g.id); + } + } + + for glyph_id in glyph_ids { + let Some((_, slot, is_color)) = ensure_glyph_by_id( + rasterizer, + grid, + font_id, + glyph_id, + size_bucket, + size_u16, + cell_h, + ascent_px, + is_emoji, + synthetic_italic, + synthetic_bold, + ) else { + continue; + }; + if slot.w == 0 || slot.h == 0 { + continue; + } + let (atlas, color) = if is_color { + (CellText::ATLAS_COLOR, [255, 255, 255, 255]) + } else { + (CellText::ATLAS_GRAYSCALE, text_fg) + }; + fg_scratch.push(CellText { + glyph_pos: [slot.x as u32, slot.y as u32], + glyph_size: [slot.w as u32, slot.h as u32], + bearings: [slot.bearing_x, slot.bearing_y], + grid_pos: [grid_col, y], + color, + atlas, + // BOOL_IS_CURSOR_GLYPH tells the cell-text fragment shader + // not to swap our color for `cursor_color` even though the + // bg quad behind us is the cursor block — we already + // computed the inverse fg ourselves. + bools: CellText::BOOL_IS_CURSOR_GLYPH, + page: slot.page, + _pad: 0, + }); + } +} + +/// Emit the IME caret beam decoration glyph at `(col, y)`. +#[allow(clippy::too_many_arguments)] +fn emit_ime_caret( + col: u16, + y: u16, + grid: &mut GridRenderer, + cell_w: u32, + cell_h: u32, + thickness: u32, + color: [u8; 4], + fg_scratch: &mut Vec, +) { + let Some(slot) = ensure_decoration_slot( + grid, + DecorationStyle::ImeCaret, + cell_w, + cell_h, + thickness, + ) else { + return; + }; + if slot.w == 0 || slot.h == 0 { + return; + } + fg_scratch.push(CellText { + glyph_pos: [slot.x as u32, slot.y as u32], + glyph_size: [slot.w as u32, slot.h as u32], + bearings: [slot.bearing_x, slot.bearing_y], + grid_pos: [col, y], + color, + atlas: CellText::ATLAS_GRAYSCALE, + bools: CellText::BOOL_IS_CURSOR_GLYPH, + page: slot.page, + _pad: 0, + }); } #[allow(clippy::too_many_arguments)] @@ -2234,9 +2526,16 @@ fn emit_underlines( thickness: u32, row_sel: Option, row_hints: &[RowHint], + preedit: Option<&PreeditRow<'_>>, fg_scratch: &mut Vec, ) { for x in 0..cols { + // Suppress decorations on preedit cells — composing text + // shouldn't pick up the underline / hover affordance of + // whatever was under the cursor. + if preedit.is_some_and(|p| p.cell(x).is_some()) { + continue; + } let sq = row[Column(x)]; let style = resolve_style(style_table, sq); let col = x as u16; @@ -2305,9 +2604,13 @@ fn emit_strikethroughs( thickness: u32, row_sel: Option, row_hints: &[RowHint], + preedit: Option<&PreeditRow<'_>>, fg_scratch: &mut Vec, ) { for x in 0..cols { + if preedit.is_some_and(|p| p.cell(x).is_some()) { + continue; + } let sq = row[Column(x)]; let style = resolve_style(style_table, sq); if !style.flags.contains(StyleFlags::STRIKEOUT) { diff --git a/frontends/rioterm/src/ime.rs b/frontends/rioterm/src/ime.rs index 9dd58b882c..8098033749 100644 --- a/frontends/rioterm/src/ime.rs +++ b/frontends/rioterm/src/ime.rs @@ -56,16 +56,13 @@ pub struct Preedit { impl Preedit { pub fn new(text: String, cursor_byte_offset: Option) -> Self { - let cursor_end_offset = if let Some(byte_offset) = cursor_byte_offset { - // Convert byte offset into char offset. - let cursor_end_offset = text[byte_offset..] + let cursor_byte_offset = + cursor_byte_offset.filter(|&byte_offset| text.is_char_boundary(byte_offset)); + let cursor_end_offset = cursor_byte_offset.map(|byte_offset| { + text[byte_offset..] .chars() - .fold(0, |acc, ch| acc + ch.width().unwrap_or(1)); - - Some(cursor_end_offset) - } else { - None - }; + .fold(0, |acc, ch| acc + ch.width().unwrap_or(1)) + }); Self { text, @@ -74,3 +71,31 @@ impl Preedit { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preedit_new_rejects_invalid_byte_offset() { + let preedit = Preedit::new("啊a".to_string(), Some(1)); + assert!(preedit.cursor_byte_offset.is_none()); + assert!(preedit.cursor_end_offset.is_none()); + } + + #[test] + fn preedit_new_computes_cursor_end_offset() { + let preedit = Preedit::new("啊a".to_string(), Some(0)); + assert_eq!(preedit.cursor_byte_offset, Some(0)); + assert_eq!(preedit.cursor_end_offset, Some(3)); + } + + #[test] + fn set_preedit_clears_on_none() { + let mut ime = Ime::new(); + ime.set_preedit(Some(Preedit::new("a".to_string(), Some(1)))); + assert!(ime.preedit().is_some()); + ime.set_preedit(None); + assert!(ime.preedit().is_none()); + } +} diff --git a/frontends/rioterm/src/renderer/mod.rs b/frontends/rioterm/src/renderer/mod.rs index 6bbc5b98d9..c7bcf4a62c 100644 --- a/frontends/rioterm/src/renderer/mod.rs +++ b/frontends/rioterm/src/renderer/mod.rs @@ -4,6 +4,7 @@ pub mod confirm_quit; pub mod custom_cursor; pub mod helpers; pub mod island; +pub mod preedit; pub mod scrollbar; pub mod search; pub mod trail_cursor; @@ -294,21 +295,6 @@ impl Renderer { let panel_rect = grid_context.layout_rect; let context = grid_context.context_mut(); - let mut has_ime = false; - if let Some(preedit) = context.ime.preedit() { - if let Some(content) = preedit.text.chars().next() { - context.renderable_content.cursor.content = content; - context.renderable_content.cursor.is_ime_enabled = true; - has_ime = true; - } - } - - if !has_ime { - context.renderable_content.cursor.is_ime_enabled = false; - context.renderable_content.cursor.content = - context.renderable_content.cursor.content_ref; - } - let force_full_damage = has_active_changed || self.is_game_mode_enabled; let is_dirty = context.renderable_content.pending_update.is_dirty(); diff --git a/frontends/rioterm/src/renderer/preedit.rs b/frontends/rioterm/src/renderer/preedit.rs new file mode 100644 index 0000000000..eb03fcd560 --- /dev/null +++ b/frontends/rioterm/src/renderer/preedit.rs @@ -0,0 +1,253 @@ +//! IME preedit overlay. +//! +//! Plotting model. The IME hands us a `Preedit` carrying the in-flight +//! composition string and an optional cursor byte offset. We map that +//! string onto the visible terminal grid starting from the cursor cell, +//! producing a sparse `(row, col) -> PreeditCell` overlay that the row +//! emit pass consults per cell. +//! +//! Wide chars (CJK / emoji) consume two cells: the leading cell carries +//! `PreeditCell::Char(ch)` and the trailing cell carries +//! `PreeditCell::Spacer`. The renderer skips spacer cells so the wide +//! glyph's two-cell advance covers the continuation; emitting a literal +//! ' ' there caused visible gaps between CJK characters in the +//! composition. +//! +//! `ime_cursor_pos` records the cell where the IME cursor sits (when +//! the IME provides a `cursor_byte_offset`). The renderer paints a +//! caret beam on that cell to break the wezterm-style block so the user +//! can see where arrow-key navigation lands inside the composition. + +use crate::ime::Preedit; +use unicode_width::UnicodeWidthChar; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PreeditCell { + Char(char), + Spacer, +} + +pub struct PreeditOverlay { + columns: usize, + cells: Vec>, + /// Per-row "any preedit cell present?" bitmap, used by the + /// `build_row_*` fast paths so they don't have to scan a row's + /// columns just to discover the row is uninvolved. + row_has_any: Vec, + /// Position of the IME cursor within the preedit, in (row, col) of + /// the visible grid. `None` when the IME didn't report a cursor + /// offset. + ime_cursor_pos: Option<(usize, usize)>, +} + +impl PreeditOverlay { + pub fn new( + preedit: &Preedit, + start_row: usize, + start_col: usize, + columns: usize, + rows: usize, + ) -> Option { + if preedit.text.is_empty() || columns == 0 || rows == 0 { + return None; + } + + let mut cells = vec![None; rows.saturating_mul(columns)]; + let mut row_has_any = vec![false; rows]; + let mut row = start_row; + let mut col = start_col; + let mut byte: usize = 0; + let mut ime_cursor_pos = None; + let cursor_byte_offset = preedit.cursor_byte_offset; + + let record_ime_cursor = |r: usize, c: usize, out: &mut Option<(usize, usize)>| { + if r < rows && c < columns { + *out = Some((r, c)); + } + }; + + for ch in preedit.text.chars() { + // Record the IME cursor position BEFORE placing the char + // when the cursor's byte offset lands right before it. We + // do this before any wrap adjustments so the cursor sits + // next to the cell the IME is about to edit. + if cursor_byte_offset == Some(byte) && ime_cursor_pos.is_none() { + if col >= columns && row + 1 < rows { + record_ime_cursor(row + 1, 0, &mut ime_cursor_pos); + } else { + record_ime_cursor(row, col, &mut ime_cursor_pos); + } + } + + if row >= rows { + break; + } + + if col >= columns { + row += 1; + col = 0; + } + if row >= rows { + break; + } + + let width = ch.width().unwrap_or(1).max(1); + if width > 1 && col + 1 >= columns { + row += 1; + col = 0; + if row >= rows { + break; + } + } + + let idx = row * columns + col; + if let Some(cell) = cells.get_mut(idx) { + *cell = Some(PreeditCell::Char(ch)); + if let Some(slot) = row_has_any.get_mut(row) { + *slot = true; + } + } + + if width > 1 && col + 1 < columns { + let spacer_idx = idx + 1; + if let Some(cell) = cells.get_mut(spacer_idx) { + *cell = Some(PreeditCell::Spacer); + } + } + + byte = byte.saturating_add(ch.len_utf8()); + col = col.saturating_add(width); + if col >= columns { + row += 1; + col = 0; + } + } + + // IME cursor at the end of the preedit text. + if cursor_byte_offset == Some(preedit.text.len()) && ime_cursor_pos.is_none() { + record_ime_cursor(row, col, &mut ime_cursor_pos); + } + + Some(Self { + columns, + cells, + row_has_any, + ime_cursor_pos, + }) + } + + #[inline] + pub fn get(&self, row: usize, col: usize) -> Option { + let idx = row.checked_mul(self.columns)?.saturating_add(col); + self.cells.get(idx).copied().flatten() + } + + #[inline] + pub fn has_any_in_row(&self, row: usize) -> bool { + self.row_has_any.get(row).copied().unwrap_or(false) + } + + #[allow(dead_code)] // exposed for tests / future renderer paths + #[inline] + pub fn is_ime_cursor_at(&self, row: usize, col: usize) -> bool { + self.ime_cursor_pos == Some((row, col)) + } + + #[inline] + pub fn ime_cursor_in_row(&self, row: usize) -> Option { + match self.ime_cursor_pos { + Some((r, c)) if r == row => Some(c), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn places_wide_chars_and_spacers() { + let preedit = Preedit::new("啊a".to_string(), None); + let overlay = PreeditOverlay::new(&preedit, 0, 0, 4, 1).unwrap(); + + assert_eq!(overlay.get(0, 0), Some(PreeditCell::Char('啊'))); + assert_eq!(overlay.get(0, 1), Some(PreeditCell::Spacer)); + assert_eq!(overlay.get(0, 2), Some(PreeditCell::Char('a'))); + assert_eq!(overlay.get(0, 3), None); + assert!(overlay.has_any_in_row(0)); + } + + #[test] + fn wraps_wide_chars() { + let preedit = Preedit::new("啊".to_string(), None); + let overlay = PreeditOverlay::new(&preedit, 0, 2, 3, 2).unwrap(); + + assert_eq!(overlay.get(0, 2), None); + assert_eq!(overlay.get(1, 0), Some(PreeditCell::Char('啊'))); + assert_eq!(overlay.get(1, 1), Some(PreeditCell::Spacer)); + assert!(!overlay.has_any_in_row(0)); + assert!(overlay.has_any_in_row(1)); + } + + #[test] + fn tracks_ime_cursor_at_end() { + // Typical Japanese IME: "あい" with the IME cursor at the end + // of the composition (byte_offset == text.len()). Width is 4 + // cells; the cursor lands on the cell just past the + // composition. + let text = "あい".to_string(); + let len = text.len(); + let preedit = Preedit::new(text, Some(len)); + let overlay = PreeditOverlay::new(&preedit, 0, 0, 10, 1).unwrap(); + + assert_eq!(overlay.get(0, 0), Some(PreeditCell::Char('あ'))); + assert_eq!(overlay.get(0, 1), Some(PreeditCell::Spacer)); + assert_eq!(overlay.get(0, 2), Some(PreeditCell::Char('い'))); + assert_eq!(overlay.get(0, 3), Some(PreeditCell::Spacer)); + assert!(overlay.is_ime_cursor_at(0, 4)); + assert_eq!(overlay.ime_cursor_in_row(0), Some(4)); + assert!(!overlay.is_ime_cursor_at(0, 0)); + assert!(!overlay.is_ime_cursor_at(0, 3)); + } + + #[test] + fn tracks_ime_cursor_inside_preedit() { + // IME cursor placed between the two wide characters of "あい". + // "あ" is 3 bytes, so cursor_byte_offset == 3 puts the cursor + // at column 2 (start of the second wide char). + let preedit = Preedit::new("あい".to_string(), Some(3)); + let overlay = PreeditOverlay::new(&preedit, 0, 0, 10, 1).unwrap(); + + assert!(overlay.is_ime_cursor_at(0, 2)); + assert!(!overlay.is_ime_cursor_at(0, 0)); + assert!(!overlay.is_ime_cursor_at(0, 4)); + } + + #[test] + fn no_ime_cursor_when_offset_none() { + let preedit = Preedit::new("hi".to_string(), None); + let overlay = PreeditOverlay::new(&preedit, 0, 0, 10, 1).unwrap(); + + assert!(!overlay.is_ime_cursor_at(0, 0)); + assert!(!overlay.is_ime_cursor_at(0, 1)); + assert!(!overlay.is_ime_cursor_at(0, 2)); + assert_eq!(overlay.ime_cursor_in_row(0), None); + } + + #[test] + fn ime_cursor_at_start_matches_terminal_cursor() { + // cursor_byte_offset == 0 places the IME cursor on the same + // cell as the terminal cursor (the preedit start column). + let preedit = Preedit::new("abc".to_string(), Some(0)); + let overlay = PreeditOverlay::new(&preedit, 0, 2, 10, 1).unwrap(); + + assert!(overlay.is_ime_cursor_at(0, 2)); + } + + #[test] + fn empty_preedit_returns_none() { + let preedit = Preedit::new(String::new(), None); + assert!(PreeditOverlay::new(&preedit, 0, 0, 10, 1).is_none()); + } +} diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index c031f0c450..5b84a302e3 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -264,7 +264,6 @@ impl Screen<'_> { content: config.cursor.shape.into(), content_ref: config.cursor.shape.into(), state: CursorState::new(config.cursor.shape.into()), - is_ime_enabled: false, }; let context_manager = context::ContextManager::start( @@ -3928,6 +3927,12 @@ impl Screen<'_> { )>, hint_labels: Option>, label_style_base: Option, + /// Active IME preedit overlay anchored at the cursor + /// position the user last saw. `None` when no + /// composition is active. The row-emit pass paints + /// the composition cells as a wezterm-style block and + /// breaks it with a caret beam at the IME cursor. + preedit_overlay: Option, } let (active_key, scaled_margin) = { @@ -4043,6 +4048,25 @@ impl Screen<'_> { let cursor_color = term_colors [rio_backend::config::colors::NamedColor::Cursor as usize] .unwrap_or(self.renderer.named_colors.cursor); + // Cursor cell precomputed once — the cursor-block + // uniforms and the IME preedit overlay both anchor from + // these values so they can never disagree mid-frame. + let cursor_col = cursor.state.pos.col.0 as u16; + let cursor_row = cursor.state.pos.row.0.max(0) as u16; + // Build the IME preedit overlay against the live + // cursor position. `cursor.state.pos` is screen-relative + // (Line within 0..screen_lines). + let preedit_overlay = ctx.ime.preedit().and_then(|preedit| { + let cols_usize = dim.columns.max(1); + let rows_usize = dim.lines.max(1); + let start_row = + (cursor_row as usize).min(rows_usize.saturating_sub(1)); + let start_col = + (cursor_col as usize).min(cols_usize.saturating_sub(1)); + crate::renderer::preedit::PreeditOverlay::new( + preedit, start_row, start_col, cols_usize, rows_usize, + ) + }); panels.push(PanelFrame { route_id: ctx.route_id, layout_rect: item.layout_rect, @@ -4055,8 +4079,8 @@ impl Screen<'_> { style_table, extras, term_colors, - cursor_col: cursor.state.pos.col.0 as u16, - cursor_row: cursor.state.pos.row.0 as u16, + cursor_col, + cursor_row, cursor_visible: cursor.state.is_visible(), cursor_shape, cursor_blinking, @@ -4072,6 +4096,7 @@ impl Screen<'_> { hovered_hyperlink, hint_labels, label_style_base, + preedit_overlay, }); } @@ -4199,6 +4224,20 @@ impl Screen<'_> { } _ => row, }; + // Only thread the overlay through to the emit pass + // for rows that actually carry preedit cells or the + // IME caret. The two emit functions hit a hot loop + // per cell, so the per-row "any preedit?" bit shaves + // one pointer chase off non-IME frames. + let preedit_row = p.preedit_overlay.as_ref().and_then(|o| { + let any = o.has_any_in_row(y); + let caret = o.ime_cursor_in_row(y).is_some(); + if any || caret { + Some(crate::grid_emit::PreeditRow { overlay: o, row: y }) + } else { + None + } + }); crate::grid_emit::build_row_bg( row, cols, @@ -4207,6 +4246,7 @@ impl Screen<'_> { &p.term_colors, row_sel, &hint_scratch, + preedit_row.as_ref(), &mut bg_scratch, ); let cursor_col_for_row = if p.cursor_visible @@ -4232,6 +4272,7 @@ impl Screen<'_> { p.cell_h, row_sel, &hint_scratch, + preedit_row.as_ref(), &font_library, p.route_id, cursor_col_for_row, diff --git a/rio-window/src/platform_impl/windows/event_loop.rs b/rio-window/src/platform_impl/windows/event_loop.rs index ffc2939284..a1fd912b4a 100644 --- a/rio-window/src/platform_impl/windows/event_loop.rs +++ b/rio-window/src/platform_impl/windows/event_loop.rs @@ -992,6 +992,13 @@ static EXEC_MSG_ID: LazyMessageId = LazyMessageId::new("Winit::ExecMsg\0"); // WPARAM and LPARAM are unused. pub(crate) static DESTROY_MSG_ID: LazyMessageId = LazyMessageId::new("Winit::DestroyMsg\0"); +// Message posted by `Window::request_redraw` to drive a paint via the +// normal posted-message queue. Sustained input (IME key repeat) can +// starve the low-priority `WM_PAINT` slot until key release; a regular +// registered message interleaves with input and reaches the WndProc +// per-frame. WPARAM and LPARAM are unused. +pub(crate) static REDRAW_REQUESTED_MSG_ID: LazyMessageId = + LazyMessageId::new("Winit::RedrawRequested\0"); // WPARAM is a bool specifying the `WindowFlags::MARKER_RETAIN_STATE_ON_SIZE` flag. See the // documentation in the `window_state` module for more information. pub(crate) static SET_RETAIN_STATE_ON_SIZE_MSG_ID: LazyMessageId = @@ -1222,20 +1229,6 @@ unsafe fn public_window_callback_inner( ) -> LRESULT { let mut result = ProcResult::DefWindowProc(wparam); - // Mark any input message before further processing so the - // DwmFlush worker keeps fanning out redraws for the next 1 s. - // Mirrors macOS / Wayland / X11. - match msg { - WM_KEYDOWN | WM_SYSKEYDOWN | WM_KEYUP | WM_SYSKEYUP | WM_MOUSEMOVE - | WM_MOUSEWHEEL | WM_MOUSEHWHEEL | WM_LBUTTONDOWN | WM_LBUTTONUP - | WM_RBUTTONDOWN | WM_RBUTTONUP | WM_MBUTTONDOWN | WM_MBUTTONUP - | WM_XBUTTONDOWN | WM_XBUTTONUP | WM_TOUCH | WM_POINTERDOWN - | WM_POINTERUPDATE | WM_POINTERUP => { - userdata.vsync_state.mark_input_received(); - } - _ => (), - } - // Send new modifiers before sending key events. let mods_changed_callback = || match msg { WM_KEYDOWN | WM_SYSKEYDOWN | WM_KEYUP | WM_SYSKEYUP => { @@ -1644,17 +1637,22 @@ unsafe fn public_window_callback_inner( } WM_IME_STARTCOMPOSITION => { + // Consume instead of calling `DefWindowProc`. The default + // handler spins a synchronous handshake with the IME that + // blocks this thread for ~100 ms before the paired + // `WM_IME_COMPOSITION` is delivered — measurably so under + // corvus-skk's direct-input mode, where every keystroke + // fires START → COMPOSITION(GCS_RESULTSTR) → END and the + // auto-repeat ends up capped at ~10 cps. Returning 0 + // (wezterm also consumes the paired ENDCOMPOSITION) skips + // the handshake; the composition result still arrives via + // `WM_IME_COMPOSITION`, just on the OS key-repeat cadence. let ime_allowed = userdata.window_state_lock().ime_allowed; if ime_allowed { userdata.window_state_lock().ime_state = ImeState::Enabled; - - userdata.send_event(Event::WindowEvent { - window_id: RootWindowId(WindowId(window)), - event: WindowEvent::Ime(Ime::Enabled), - }); } - result = ProcResult::DefWindowProc(wparam); + result = ProcResult::Value(0); } WM_IME_COMPOSITION => { @@ -1675,11 +1673,23 @@ unsafe fn public_window_callback_inner( } // Google Japanese Input and ATOK have both flags, so - // first, receive composing result if exist. + // first, receive composing result if exist. We must + // dispatch `Ime::Preedit("")` before `Ime::Commit`: + // most IMEs (MS-IME, Google Japanese Input, ATOK) + // fire WM_IME_COMPOSITION with only GCS_RESULTSTR on + // confirm — no GCS_COMPSTR — so the GCS_COMPSTR + // branch below never runs and the app's `ime.preedit` + // stays `Some(...)` from the last preedit update. + // `process_key_event` short-circuits while a preedit + // is active, so without this clear every subsequent + // keystroke is silently dropped after each commit. + // For direct-input IMEs (corvus-skk hiragana) that + // never had a preedit, the app's handler skips + // damage/redraw because `None != None` is false — the + // extra event is just one no-op handler call. if (lparam as u32 & GCS_RESULTSTR) != 0 { if let Some(text) = unsafe { ime_context.get_composed_text() } { userdata.window_state_lock().ime_state = ImeState::Enabled; - userdata.send_event(Event::WindowEvent { window_id: RootWindowId(WindowId(window)), event: WindowEvent::Ime(Ime::Preedit(String::new(), None)), @@ -1730,18 +1740,36 @@ unsafe fn public_window_callback_inner( window_id: RootWindowId(WindowId(window)), event: WindowEvent::Ime(Ime::Commit(text)), }); + } else { + // Composition ended with a live preedit but no + // result string — the IME was cancelled (Esc, + // focus loss, IME switch) and no final + // WM_IME_COMPOSITION with an empty GCS_COMPSTR + // is guaranteed to follow. `Ime::Disabled` is + // no longer dispatched, so this clear is the + // only thing standing between the app and a + // stale `ime.preedit` that short-circuits + // every subsequent key press. + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::Ime(Ime::Preedit(String::new(), None)), + }); } } userdata.window_state_lock().ime_state = ImeState::Disabled; - - userdata.send_event(Event::WindowEvent { - window_id: RootWindowId(WindowId(window)), - event: WindowEvent::Ime(Ime::Disabled), - }); + // `Ime::Disabled` is intentionally not dispatched — the + // app's handler only toggled an unread `enabled` flag, + // so the dispatch was pure overhead on every + // STARTCOMPOSITION / ENDCOMPOSITION cycle (e.g. every + // corvus-skk direct-input keystroke). } - result = ProcResult::DefWindowProc(wparam); + // Consume (matches wezterm) — the default handler, like + // STARTCOMPOSITION's, does a synchronous IME handshake we + // don't need. Skipping it keeps the per-keystroke cycle + // short under direct-input IMEs. + result = ProcResult::Value(0); } WM_IME_SETCONTEXT => { @@ -2666,6 +2694,22 @@ unsafe fn public_window_callback_inner( f.set(WindowFlags::MARKER_RETAIN_STATE_ON_SIZE, wparam != 0) }); result = ProcResult::Value(0); + } else if msg == REDRAW_REQUESTED_MSG_ID.get() { + // Clear the VSync worker's dirty flag so it skips its + // own `RedrawWindow` on the next tick; the paint is + // already queued here. + userdata.vsync_state.clear_dirty(window); + // If we're nested inside another handler, defer to the + // buffered-event flush via `redraw_requested`. + if !userdata.event_loop_runner.should_buffer() { + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::RedrawRequested, + }); + } else { + userdata.window_state_lock().redraw_requested = true; + } + result = ProcResult::Value(0); } else if msg == TASKBAR_CREATED.get() { let window_state = userdata.window_state_lock(); unsafe { set_skip_taskbar(window, window_state.skip_taskbar) }; diff --git a/rio-window/src/platform_impl/windows/event_loop/vsync.rs b/rio-window/src/platform_impl/windows/event_loop/vsync.rs index ba396f9c8f..6c60cc5f67 100644 --- a/rio-window/src/platform_impl/windows/event_loop/vsync.rs +++ b/rio-window/src/platform_impl/windows/event_loop/vsync.rs @@ -3,18 +3,10 @@ //! Mirrors the macOS CVDisplayLink model: `Window::request_redraw` //! sets a per-window `Arc` dirty flag, and the worker //! is the single source of frame timing. Per composition cycle it -//! iterates the window registry and, for each window where -//! `dirty || should_present_after_input`, fires +//! iterates the window registry and, for each dirty window, fires //! `RedrawWindow(.., RDW_INVALIDATE)`. The app's `WM_PAINT` / //! `RedrawRequested` path is unchanged. //! -//! `should_present_after_input` keeps the loop firing for one -//! second *after a high-rate input burst* (≥ 60 events/sec over -//! a 100 ms window) even if the app never sets the dirty flag — -//! same gate macOS / Wayland / X11 use via `InputRateTracker`. -//! Single keystrokes / lone mouse events no longer force a -//! 1-second redraw storm. -//! //! When DWM is disabled, the monitor is unplugged, or under some //! RDP modes, `DwmFlush` returns immediately. The 1 ms threshold //! catches that and we fall back to `thread::sleep` at the queried @@ -23,12 +15,10 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, RwLock}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; -use crate::platform_impl::input_rate::InputRateTracker; - use windows_sys::Win32::Foundation::{HWND, S_OK}; use windows_sys::Win32::Graphics::Dwm::{ DwmFlush, DwmGetCompositionTimingInfo, DWM_TIMING_INFO, @@ -42,25 +32,18 @@ const DEFAULT_VSYNC_INTERVAL: Duration = Duration::from_micros(16_666); // ~60Hz /// State shared between the event loop, window-callback thread, /// and the DwmFlush worker thread. Holds the per-window dirty-flag -/// registry plus the rate-gated post-input sustain tracker. +/// registry. pub(crate) struct VSyncSharedState { /// HWND (as `usize` for `Hash`/`Eq`) → per-window dirty flag. /// `Window::request_redraw` sets the flag; the worker reads /// and clears it on each tick. windows: RwLock>>, - /// Rate-gated post-input sustain. Only sustained high-rate - /// input (≥ 60 events/sec over 100 ms) keeps the worker fanning - /// out redraws after the app stops marking windows dirty. - /// `Mutex` because the worker thread and the window-message - /// thread both touch this. See `platform_impl::input_rate`. - input_rate_tracker: Mutex, } impl VSyncSharedState { pub(crate) fn new() -> Arc { Arc::new(Self { windows: RwLock::new(HashMap::new()), - input_rate_tracker: Mutex::new(InputRateTracker::new()), }) } @@ -81,18 +64,19 @@ impl VSyncSharedState { self.windows.write().unwrap().remove(&(hwnd as usize)); } - pub(crate) fn window_count(&self) -> usize { - self.windows.read().unwrap().len() - } - - #[inline] - pub(crate) fn mark_input_received(&self) { - self.input_rate_tracker.lock().unwrap().record_input(); + /// Clear a window's dirty flag without invalidating it. Called by + /// the `REDRAW_REQUESTED_MSG` handler so the DwmFlush worker's + /// next tick skips the dirty-path `RedrawWindow` — the paint is + /// already happening via the custom message, a second `WM_PAINT` + /// would just dispatch a no-op `RedrawRequested`. + pub(crate) fn clear_dirty(&self, hwnd: HWND) { + if let Some(flag) = self.windows.read().unwrap().get(&(hwnd as usize)) { + flag.store(false, Ordering::Release); + } } - #[inline] - pub(crate) fn should_present_after_input(&self) -> bool { - self.input_rate_tracker.lock().unwrap().is_high_rate() + pub(crate) fn window_count(&self) -> usize { + self.windows.read().unwrap().len() } } @@ -117,8 +101,6 @@ impl VSyncThread { break; } - let present_after_input = state.should_present_after_input(); - // Snapshot HWND + flag pairs so we don't hold // the registry lock across `RedrawWindow`. let snapshot: Vec<(usize, Arc)> = state @@ -130,8 +112,17 @@ impl VSyncThread { .collect(); for (hwnd_bits, flag) in snapshot { + // Only invalidate when something explicitly asked + // for a redraw. The previous + // `present_after_input` fallback kept painting at + // vblank for a second after every input, which + // doubles every paint when the app (rio) already + // drives redraws via `REDRAW_REQUESTED_MSG` — + // each IME key-repeat tick becomes a paint from + // the custom message *and* a paint from the + // worker, halving throughput. let was_dirty = flag.swap(false, Ordering::AcqRel); - if !(was_dirty || present_after_input) { + if !was_dirty { continue; } let hwnd = hwnd_bits as HWND; diff --git a/rio-window/src/platform_impl/windows/window.rs b/rio-window/src/platform_impl/windows/window.rs index 5940994836..040b086a84 100644 --- a/rio-window/src/platform_impl/windows/window.rs +++ b/rio-window/src/platform_impl/windows/window.rs @@ -65,7 +65,9 @@ use crate::platform_impl::platform::dpi::{ dpi_to_scale_factor, enable_non_client_dpi_scaling, hwnd_dpi, }; use crate::platform_impl::platform::drop_handler::FileDropHandler; -use crate::platform_impl::platform::event_loop::{self, ActiveEventLoop, DESTROY_MSG_ID}; +use crate::platform_impl::platform::event_loop::{ + self, ActiveEventLoop, DESTROY_MSG_ID, REDRAW_REQUESTED_MSG_ID, +}; use crate::platform_impl::platform::icon::{self, IconType, WinCursor}; use crate::platform_impl::platform::ime::ImeContext; use crate::platform_impl::platform::keyboard::KeyEventBuilder; @@ -199,12 +201,17 @@ impl Window { #[inline] pub fn request_redraw(&self) { - // Defer the actual `RedrawWindow` to the DwmFlush worker; - // we just flag the window as dirty here. Mirrors macOS's - // `needs_redraw` flag consumed by the CVDisplayLink - // callback. self.window_state.lock().unwrap().redraw_requested = true; - self.redraw_pending.store(true, Ordering::Release); + // Post `REDRAW_REQUESTED_MSG` as a fallback to `WM_PAINT`'s + // low-priority slot, which gets starved under sustained input + // (IME key repeat). Coalesce duplicates in the same paint + // cycle; the VSync worker clears the flag at the next vblank. + let already_pending = self.redraw_pending.swap(true, Ordering::AcqRel); + if !already_pending { + unsafe { + PostMessageW(self.hwnd(), REDRAW_REQUESTED_MSG_ID.get(), 0, 0); + } + } } #[inline]