From 6e57bc3255caf29e8792d0c13b9508baa28345d7 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Fri, 1 May 2026 01:55:54 +0900 Subject: [PATCH 1/8] Render IME preedit as wezterm-style block in the grid renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v4 grid renderer (build_row_bg/fg in grid_emit) reads cells straight from terminal state and has no hook for IME composition, so preedit text was invisible past the first char that landed on the cursor cell. Plumb a PreeditOverlay through the row emit pass so the composing string actually paints, and shape it like wezterm: every composition cell takes the cursor color as a block bg with the glyph inverted on top, the IME caret breaks the block as a thin vertical beam, and CJK wide chars stay contiguous (Spacer cells emit no glyph so the wide leading char's two-cell advance covers the continuation without literal-space gaps). - ime: tighten Preedit::new (reject non-char-boundary byte_offset); pin cursor anchor for the active session so a composition started right after Home/Ctrl-A or a commit doesn't snap to the previous line end while the PTY echo catches up. - application: mark TerminalDamage::Full on preedit change so the overlay refreshes — the grid would otherwise keep its previous CPU buffers. - renderer/preedit: PreeditOverlay maps the composition string onto visible cells, tracks the IME cursor position, and exposes a per-row "any preedit?" bit for the build_row_* fast paths. - renderer/mod (Renderer::run): capture the cursor the user last saw before the snapshot overwrites it; if a preedit is active, swap in the anchored position via Ime::preedit_anchor_or_init. - grid_emit: PreeditRow threaded through build_row_bg/fg; build_row_bg overrides bg to the cursor color on preedit cells; build_row_fg breaks runs at preedit boundaries and emits each composition char as a single-cell shape with bg-color fg + BOOL_IS_CURSOR_GLYPH so the cell-text shader keeps our inverse color instead of swapping it. Plain font attrs are forced so an italic prompt segment under the cursor doesn't bleed into the composing text. Underlines / strikethroughs skip preedit cells. - DecorationStyle::ImeCaret: thin full-cell-height vertical bar rasterized as a grayscale sprite, emitted at the IME cursor column to break the block where arrow-key navigation lands. Co-authored-by: KOGA Mitsuhiro --- frontends/rioterm/src/application.rs | 16 ++ frontends/rioterm/src/grid_emit.rs | 318 +++++++++++++++++++++- frontends/rioterm/src/ime.rs | 96 ++++++- frontends/rioterm/src/renderer/mod.rs | 1 + frontends/rioterm/src/renderer/preedit.rs | 253 +++++++++++++++++ frontends/rioterm/src/screen/mod.rs | 46 +++- 6 files changed, 711 insertions(+), 19 deletions(-) create mode 100644 frontends/rioterm/src/renderer/preedit.rs diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index 5f396f2aa5..807928b400 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -1940,6 +1940,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/grid_emit.rs b/frontends/rioterm/src/grid_emit.rs index 82238da233..1acc2f5b45 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.map_or(false, |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.map_or(false, |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,197 @@ 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)`. +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 +2525,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.map_or(false, |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 +2603,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.map_or(false, |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..950523a776 100644 --- a/frontends/rioterm/src/ime.rs +++ b/frontends/rioterm/src/ime.rs @@ -1,3 +1,4 @@ +use rio_backend::crosswords::pos::Pos; use unicode_width::UnicodeWidthChar; #[derive(Debug, Default)] pub struct Ime { @@ -6,6 +7,23 @@ pub struct Ime { /// Current IME preedit. preedit: Option, + + /// Pinned cursor position for the active preedit session. + /// + /// The terminal cursor tracked by `terminal.cursor()` is updated + /// asynchronously as the PTY thread parses shell output. When a + /// composition begins, that cursor may still be catching up with the + /// echo of a just-committed string or with the response to a + /// cursor-movement key (e.g. Home/Ctrl-A) that the user pressed + /// right before starting the next IME session. If we placed the + /// overlay at whatever `terminal.cursor()` returned at render time, + /// the preedit would briefly snap back to the previous line end + /// until the PTY caught up — visible as the cursor "jumping" away + /// from where the user is typing. Anchoring fixes this: the first + /// frame that paints a non-empty preedit captures the cursor + /// position the user actually saw, and every subsequent frame in + /// the same composition reuses that anchor. + preedit_anchor: Option, } impl Ime { @@ -31,6 +49,9 @@ impl Ime { #[inline] pub fn set_preedit(&mut self, preedit: Option) { + if preedit.is_none() { + self.preedit_anchor = None; + } self.preedit = preedit; } @@ -38,6 +59,13 @@ impl Ime { pub fn preedit(&self) -> Option<&Preedit> { self.preedit.as_ref() } + + /// Return the anchor for the current preedit session, seeding it with + /// `fallback` on the first call of the session. + #[inline] + pub fn preedit_anchor_or_init(&mut self, fallback: Pos) -> Pos { + *self.preedit_anchor.get_or_insert(fallback) + } } #[derive(Debug, Default, PartialEq, Eq)] @@ -56,16 +84,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 +99,56 @@ 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 preedit_anchor_seeds_on_first_call_and_stays_pinned() { + use rio_backend::crosswords::pos::{Column, Line, Pos}; + + let mut ime = Ime::new(); + ime.set_preedit(Some(Preedit::new("a".to_string(), Some(1)))); + + let seen = Pos::new(Line(5), Column(0)); + assert_eq!(ime.preedit_anchor_or_init(seen), seen); + + // A later snapshot pointing somewhere else (e.g. PTY echo advancing + // the cursor past a just-committed run) must not drag the anchor. + let stale_snapshot = Pos::new(Line(5), Column(12)); + assert_eq!(ime.preedit_anchor_or_init(stale_snapshot), seen); + } + + #[test] + fn preedit_anchor_clears_when_preedit_cleared() { + use rio_backend::crosswords::pos::{Column, Line, Pos}; + + let mut ime = Ime::new(); + ime.set_preedit(Some(Preedit::new("a".to_string(), Some(1)))); + let first = Pos::new(Line(5), Column(0)); + ime.preedit_anchor_or_init(first); + + // Clearing preedit (synthetic empty Preedit / commit) ends the + // session. The next composition must reseed from the new fallback. + ime.set_preedit(None); + + let next = Pos::new(Line(5), Column(4)); + ime.set_preedit(Some(Preedit::new("b".to_string(), Some(1)))); + assert_eq!(ime.preedit_anchor_or_init(next), next); + } +} diff --git a/frontends/rioterm/src/renderer/mod.rs b/frontends/rioterm/src/renderer/mod.rs index 6bbc5b98d9..db89a47656 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; 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..b797e7a393 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -3928,6 +3928,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 +4049,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) as usize; + let rows_usize = dim.lines.max(1) as usize; + 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 +4080,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 +4097,7 @@ impl Screen<'_> { hovered_hyperlink, hint_labels, label_style_base, + preedit_overlay, }); } @@ -4199,6 +4225,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 +4247,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 +4273,7 @@ impl Screen<'_> { p.cell_h, row_sel, &hint_scratch, + preedit_row.as_ref(), &font_library, p.route_id, cursor_col_for_row, From c90183bb6ceef79a311f3bbee64a8732ed93204a Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Fri, 24 Apr 2026 21:02:58 +0900 Subject: [PATCH 2/8] [ime] fix: follow the live cursor for IME preedit instead of pinning c39803cb3e pinned the preedit overlay to the cursor captured at composition start so it would not drift as the PTY thread caught up with a just-committed run or a cursor-movement key. Pinning cures the drift but freezes whichever transient position the first frame happens to sample: - seeding from the previous render's cursor leaves the anchor on the pre-movement line end when a Home (or Up) after a wrapped line coalesces with the preedit event into one frame, so the overlay snaps to the wrap's end; - seeding from the snapshot cursor instead can catch the PTY mid-escape-sequence for Home (e.g. row-move processed, column move not yet), freezing the overlay a few cells to the right of the real line end. Either seed trades drift for a stuck-at-wrong-position bug whose exact symptom depends on which phase of PTY processing the first frame lands on. wezterm's renderer reads `term.cursor_pos()` on every frame and accepts the short flicker while PTY output settles; the overlay always converges to the right place. Match that: drop the anchor + pinning machinery and let the overlay follow whatever `terminal_snapshot.cursor` reports each frame. --- frontends/rioterm/src/ime.rs | 59 ++---------------------------------- 1 file changed, 3 insertions(+), 56 deletions(-) diff --git a/frontends/rioterm/src/ime.rs b/frontends/rioterm/src/ime.rs index 950523a776..8098033749 100644 --- a/frontends/rioterm/src/ime.rs +++ b/frontends/rioterm/src/ime.rs @@ -1,4 +1,3 @@ -use rio_backend::crosswords::pos::Pos; use unicode_width::UnicodeWidthChar; #[derive(Debug, Default)] pub struct Ime { @@ -7,23 +6,6 @@ pub struct Ime { /// Current IME preedit. preedit: Option, - - /// Pinned cursor position for the active preedit session. - /// - /// The terminal cursor tracked by `terminal.cursor()` is updated - /// asynchronously as the PTY thread parses shell output. When a - /// composition begins, that cursor may still be catching up with the - /// echo of a just-committed string or with the response to a - /// cursor-movement key (e.g. Home/Ctrl-A) that the user pressed - /// right before starting the next IME session. If we placed the - /// overlay at whatever `terminal.cursor()` returned at render time, - /// the preedit would briefly snap back to the previous line end - /// until the PTY caught up — visible as the cursor "jumping" away - /// from where the user is typing. Anchoring fixes this: the first - /// frame that paints a non-empty preedit captures the cursor - /// position the user actually saw, and every subsequent frame in - /// the same composition reuses that anchor. - preedit_anchor: Option, } impl Ime { @@ -49,9 +31,6 @@ impl Ime { #[inline] pub fn set_preedit(&mut self, preedit: Option) { - if preedit.is_none() { - self.preedit_anchor = None; - } self.preedit = preedit; } @@ -59,13 +38,6 @@ impl Ime { pub fn preedit(&self) -> Option<&Preedit> { self.preedit.as_ref() } - - /// Return the anchor for the current preedit session, seeding it with - /// `fallback` on the first call of the session. - #[inline] - pub fn preedit_anchor_or_init(&mut self, fallback: Pos) -> Pos { - *self.preedit_anchor.get_or_insert(fallback) - } } #[derive(Debug, Default, PartialEq, Eq)] @@ -119,36 +91,11 @@ mod tests { } #[test] - fn preedit_anchor_seeds_on_first_call_and_stays_pinned() { - use rio_backend::crosswords::pos::{Column, Line, Pos}; - + fn set_preedit_clears_on_none() { let mut ime = Ime::new(); ime.set_preedit(Some(Preedit::new("a".to_string(), Some(1)))); - - let seen = Pos::new(Line(5), Column(0)); - assert_eq!(ime.preedit_anchor_or_init(seen), seen); - - // A later snapshot pointing somewhere else (e.g. PTY echo advancing - // the cursor past a just-committed run) must not drag the anchor. - let stale_snapshot = Pos::new(Line(5), Column(12)); - assert_eq!(ime.preedit_anchor_or_init(stale_snapshot), seen); - } - - #[test] - fn preedit_anchor_clears_when_preedit_cleared() { - use rio_backend::crosswords::pos::{Column, Line, Pos}; - - let mut ime = Ime::new(); - ime.set_preedit(Some(Preedit::new("a".to_string(), Some(1)))); - let first = Pos::new(Line(5), Column(0)); - ime.preedit_anchor_or_init(first); - - // Clearing preedit (synthetic empty Preedit / commit) ends the - // session. The next composition must reseed from the new fallback. + assert!(ime.preedit().is_some()); ime.set_preedit(None); - - let next = Pos::new(Line(5), Column(4)); - ime.set_preedit(Some(Preedit::new("b".to_string(), Some(1)))); - assert_eq!(ime.preedit_anchor_or_init(next), next); + assert!(ime.preedit().is_none()); } } From 8d9bd209eb29333641c2afa3fca58c338332021a Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Sat, 25 Apr 2026 02:10:05 +0900 Subject: [PATCH 3/8] [ime] fix: paint via a custom posted message so IME key-repeat renders live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under sustained IME key-repeat (corvus-skk hiragana holding a vowel) each tick floods the message queue with WM_KEYDOWN + the three WM_IME_* messages + a TerminalDamaged user event. The drain loop never sees an empty queue, so `about_to_wait` — which is the only site that calls `scheduler.update()` — never fires. The render scheduler timer therefore never expires, and even if it did, its RedrawRequested would still depend on WM_PAINT, the lowest-priority slot in a Win32 message queue and also starved by the same flood. Net effect: every `あ` is pasted to the PTY and echoed by the shell while held, but the screen only repaints when the key is released and the queue finally drains. Register a custom `REDRAW_REQUESTED_MSG` via `RegisterWindowMessageA` and post it from `Window::request_redraw`. Regular posted messages are returned by `PeekMessageW` ahead of the synthesized WM_PAINT, so the paint trigger interleaves with the IME flood. On the application side, `RioEvent::TerminalDamaged` skips the scheduler timer and calls `request_redraw` directly — vblank-rate pacing still belongs to the DwmFlush VSync worker via the dirty flag, so we aren't burning frames. --- frontends/rioterm/src/application.rs | 5 +++++ .../src/platform_impl/windows/event_loop.rs | 19 +++++++++++++++++++ .../src/platform_impl/windows/window.rs | 19 +++++++++++++------ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index 807928b400..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(); } } diff --git a/rio-window/src/platform_impl/windows/event_loop.rs b/rio-window/src/platform_impl/windows/event_loop.rs index ffc2939284..e1d8aab10a 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 = @@ -2666,6 +2673,18 @@ 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() { + // 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/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] From 083184a76cc6c01dc22402e669daf253ea5ffcd3 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Sat, 25 Apr 2026 02:56:59 +0900 Subject: [PATCH 4/8] [ime] perf: stop double-painting when REDRAW_REQUESTED_MSG already fired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's `Window::request_redraw` both posts `REDRAW_REQUESTED_MSG` *and* raises the DwmFlush worker's dirty flag, so each paint request caused two `RedrawRequested` dispatches per vblank: one via the custom message, one via the worker's `RedrawWindow(RDW_INVALIDATE)` -> `WM_PAINT` path. The second one is a no-op inside per-context render (the dirty flag was already cleared by the first pass) but still burns a frame of `begin_render` / `pre_present_notify` / present work. Clear the worker's dirty flag from the `REDRAW_REQUESTED_MSG` handler so the next tick sees it false and skips the redundant invalidate. Also drop the `present_after_input` fallback (and the `last_input_timestamp` / `mark_input_received` plumbing that fed it) since rio already drives redraws explicitly via `request_redraw` on every event that can mutate the terminal — the fallback was driving another 60 fps stream of invalidations during any input window, stacking on top of the custom-message paints. --- .../src/platform_impl/windows/event_loop.rs | 18 ++---- .../platform_impl/windows/event_loop/vsync.rs | 57 ++++++++----------- 2 files changed, 28 insertions(+), 47 deletions(-) diff --git a/rio-window/src/platform_impl/windows/event_loop.rs b/rio-window/src/platform_impl/windows/event_loop.rs index e1d8aab10a..73230eee18 100644 --- a/rio-window/src/platform_impl/windows/event_loop.rs +++ b/rio-window/src/platform_impl/windows/event_loop.rs @@ -1229,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 => { @@ -2674,6 +2660,10 @@ unsafe fn public_window_callback_inner( }); 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() { 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; From ce8d5e09f479912bede8af348a794ff07f0d2249 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Sat, 25 Apr 2026 04:51:52 +0900 Subject: [PATCH 5/8] [ime] perf: skip IME STARTCOMPOSITION / ENDCOMPOSITION DefWindowProc handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracing the WndProc for a held 'a' in corvus-skk hiragana direct-input showed a very sharp pattern: WM_IME_STARTCOMPOSITION was delivered immediately after WM_KEYDOWN, but the paired WM_IME_COMPOSITION only arrived ~95–100 ms later, with the main thread completely idle in between. The OS auto-repeat (~30 Hz) kept queuing WM_KEYDOWN messages with accumulating repeat counts (lparam low word 2, 4, …) while we were stuck waiting, so the user only saw ~10 cps. Forwarding the IME messages to `DefWindowProc` runs a synchronous handshake with the TIP — that handshake is the 100 ms wait. Return 0 for both START and ENDCOMPOSITION the way wezterm does for ENDCOMPOSITION. The composition result still arrives via `WM_IME_COMPOSITION`, just without the handshake stall, and the per-keystroke cycle drops to ~31 ms — matching the OS auto-repeat cadence (confirmed on a corvus-skk hiragana trace: 21.502086 s START → 21.502169 s COMPOSITION, i.e. 0.08 ms vs. the prior ~98 ms). Drop the `Ime::Enabled` / `Ime::Disabled` dispatches that START / END used to send: the app's handler only toggled an unread `enabled` flag, so for IMEs that fire this whole trio every keystroke we were paying one full event-handler round-trip per press for nothing. The pre-commit `Ime::Preedit("")` in the GCS_RESULTSTR branch is intentionally kept: most IMEs (MS-IME, Google Japanese Input, ATOK) fire WM_IME_COMPOSITION on confirm with only GCS_RESULTSTR — no GCS_COMPSTR — so the GCS_COMPSTR branch below never runs and the app's `ime.preedit` would otherwise stay `Some(...)` from the last preedit update. `process_key_event` short-circuits while a preedit is active, so without this clear every keystroke after a commit is silently dropped. 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 one no-op handler call, paid only on commit (not auto-repeat). --- .../src/platform_impl/windows/event_loop.rs | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/rio-window/src/platform_impl/windows/event_loop.rs b/rio-window/src/platform_impl/windows/event_loop.rs index 73230eee18..ee53553879 100644 --- a/rio-window/src/platform_impl/windows/event_loop.rs +++ b/rio-window/src/platform_impl/windows/event_loop.rs @@ -1637,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 => { @@ -1668,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)), @@ -1727,14 +1744,18 @@ unsafe fn public_window_callback_inner( } 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 => { From b9d62e05da9f7ad3eb866c650fa269aa6239187b Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Mon, 10 Aug 2026 00:02:43 +0200 Subject: [PATCH 6/8] windows: clear stale preedit when composition ends without commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WM_IME_ENDCOMPOSITION no longer dispatches Ime::Disabled, which was the last guaranteed preedit-clear on the cancel path. If an IME ends composition without a final WM_IME_COMPOSITION carrying an empty GCS_COMPSTR (Esc, focus loss, IME switch), the app's ime.preedit stays Some and process_key_event short-circuits every subsequent key press — dead keyboard until the next composition. Send a synthetic empty Preedit when ENDCOMPOSITION fires with a live preedit and no composed result to recover. --- rio-window/src/platform_impl/windows/event_loop.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/rio-window/src/platform_impl/windows/event_loop.rs b/rio-window/src/platform_impl/windows/event_loop.rs index ee53553879..a1fd912b4a 100644 --- a/rio-window/src/platform_impl/windows/event_loop.rs +++ b/rio-window/src/platform_impl/windows/event_loop.rs @@ -1740,6 +1740,20 @@ 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)), + }); } } From da4ab8d722de035ad2438e9186c4e281e04ff48d Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Mon, 10 Aug 2026 00:03:29 +0200 Subject: [PATCH 7/8] rioterm: drop the dead first-preedit-char cursor swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block wrote renderable_content.cursor.content / is_ime_enabled every frame, but nothing has read either field since the cursor pipeline moved to PanelFrame uniforms — and the preedit overlay now renders the whole composition inline, so the single-char cursor swap it fed is superseded anyway. Drop the now-unread is_ime_enabled field with it. --- frontends/rioterm/src/context/mod.rs | 1 - frontends/rioterm/src/context/renderable.rs | 2 -- frontends/rioterm/src/renderer/mod.rs | 15 --------------- frontends/rioterm/src/screen/mod.rs | 1 - 4 files changed, 19 deletions(-) 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/renderer/mod.rs b/frontends/rioterm/src/renderer/mod.rs index db89a47656..c7bcf4a62c 100644 --- a/frontends/rioterm/src/renderer/mod.rs +++ b/frontends/rioterm/src/renderer/mod.rs @@ -295,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/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index b797e7a393..e5b33643f3 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( From 248caca637173658a0a9250d68d35c3b646c26aa Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Mon, 10 Aug 2026 00:09:05 +0200 Subject: [PATCH 8/8] rioterm: appease clippy on the preedit emit path map_or(false, ..) -> is_some_and, drop two usize -> usize casts, and allow too_many_arguments on emit_ime_caret to match the neighboring emit helpers. --- frontends/rioterm/src/grid_emit.rs | 9 +++++---- frontends/rioterm/src/screen/mod.rs | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frontends/rioterm/src/grid_emit.rs b/frontends/rioterm/src/grid_emit.rs index 1acc2f5b45..420aefbf0e 100644 --- a/frontends/rioterm/src/grid_emit.rs +++ b/frontends/rioterm/src/grid_emit.rs @@ -1761,7 +1761,7 @@ pub fn build_row_fg( // 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.map_or(false, |p| p.cell(x).is_some()) { + if preedit.is_some_and(|p| p.cell(x).is_some()) { x += 1; continue; } @@ -1977,7 +1977,7 @@ pub fn build_row_fg( while end < cols { // Stop the run before stepping into a preedit cell — the // composition takes over those cells in the dedicated pass. - if preedit.map_or(false, |p| p.cell(end).is_some()) { + if preedit.is_some_and(|p| p.cell(end).is_some()) { break; } let sq2 = row[Column(end)]; @@ -2476,6 +2476,7 @@ fn emit_preedit_char( } /// Emit the IME caret beam decoration glyph at `(col, y)`. +#[allow(clippy::too_many_arguments)] fn emit_ime_caret( col: u16, y: u16, @@ -2532,7 +2533,7 @@ fn emit_underlines( // Suppress decorations on preedit cells — composing text // shouldn't pick up the underline / hover affordance of // whatever was under the cursor. - if preedit.map_or(false, |p| p.cell(x).is_some()) { + if preedit.is_some_and(|p| p.cell(x).is_some()) { continue; } let sq = row[Column(x)]; @@ -2607,7 +2608,7 @@ fn emit_strikethroughs( fg_scratch: &mut Vec, ) { for x in 0..cols { - if preedit.map_or(false, |p| p.cell(x).is_some()) { + if preedit.is_some_and(|p| p.cell(x).is_some()) { continue; } let sq = row[Column(x)]; diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index e5b33643f3..5b84a302e3 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -4057,8 +4057,8 @@ impl Screen<'_> { // 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) as usize; - let rows_usize = dim.lines.max(1) as usize; + 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 =