diff --git a/Cargo.lock b/Cargo.lock index 59b1eea66a..5fe465b540 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3924,6 +3924,7 @@ version = "0.5.27" dependencies = [ "rapidhash", "rio-backend", + "rio-unicode", "rustc-hash 2.1.3", "smallvec", ] diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index e0bbfbde9f..c98db1e230 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -1,5 +1,5 @@ use crate::event::{ClickState, EventPayload, EventProxy, RioEvent, RioEventType}; -use crate::ime::Preedit; +use crate::ime::{Preedit, PreeditCursor}; use crate::renderer::utils::update_colors_based_on_theme; use crate::router::{routes::RoutePath, Router}; use crate::scheduler::{Scheduler, TimerId, Topic}; @@ -2016,52 +2016,67 @@ impl ApplicationHandler for Application<'_> { } WindowEvent::Ime(ime) => { - if route.window.screen.renderer.assistant.is_active() { - return; - } - + // Modal overlays own keyboard input (`modal_owns_input` + // walks the `has_key_wait` roster): while one is up, + // composition input must not reach the terminal, but + // any stored preedit must still CLEAR: a live + // composition would keep painting into the grid behind + // the overlay and its key gate would swallow every + // plain keystroke after the overlay closes. Search + // stays open to IME (commits route into the search + // input via `paste`). match ime { Ime::Commit(text) => { + // Text-input overlays (island rename, palette) + // consume commits first, in `has_key_wait`'s + // order; other modals swallow them; only a bare + // terminal receives the text. + if route.overlay_commit_text(&text) { + return; + } + if route.modal_owns_input() { + return; + } // Don't use bracketed paste for single char input. route.window.screen.paste(&text, text.chars().count() > 1); } Ime::Preedit(text, cursor_offset) => { - let preedit = if text.is_empty() { + let preedit = if text.is_empty() || route.modal_owns_input() { None } else { - Some(Preedit::new(text, cursor_offset.map(|offset| offset.0))) + // The platform's `None` means the IME asked + // for a hidden caret (candidate paging), + // NOT end-of-text, which arrives as an + // explicit offset. + let cursor = match cursor_offset { + Some((start, _)) => PreeditCursor::Byte(start), + None => PreeditCursor::Hidden, + }; + Some(Preedit::new(text, cursor)) }; - if route.window.screen.context_manager.current().ime.preedit() - != preedit.as_ref() - { - route - .window - .screen - .context_manager - .current_mut() - .ime - .set_preedit(preedit); + // `set_ime_preedit` owns the composing side + // effects (scroll snap, selection, dirty mark) + // and their search-mode exceptions. + if route.window.screen.set_ime_preedit(preedit) { route.request_redraw(); } } Ime::Enabled => { - route - .window - .screen - .context_manager - .current_mut() - .ime - .set_enabled(true); + route.window.screen.ime.set_enabled(true); } Ime::Disabled => { - route - .window - .screen - .context_manager - .current_mut() - .ime - .set_enabled(false); + // Disabling wipes any live preedit (input + // source switched mid-composition): mark dirty + // and repaint like the Preedit arm, or the + // block ghosts on screen until unrelated + // damage. + let had_preedit = route.window.screen.ime.preedit().is_some(); + route.window.screen.ime.set_enabled(false); + if had_preedit { + route.window.screen.mark_dirty(); + route.request_redraw(); + } } } } diff --git a/frontends/rioterm/src/context/mod.rs b/frontends/rioterm/src/context/mod.rs index 750585a266..18b717f20b 100644 --- a/frontends/rioterm/src/context/mod.rs +++ b/frontends/rioterm/src/context/mod.rs @@ -7,7 +7,6 @@ use crate::context::title::{ }; use crate::event::sync::FairMutex; use crate::event::{Msg, RioEvent}; -use crate::ime::Ime; pub use crate::layout::{ContextDimension, ContextGrid, ContextGridItem}; use crate::messenger::Messenger; use crate::performer::{self, Machine}; @@ -57,7 +56,6 @@ pub struct Context { pub rich_text_id: usize, pub dimension: ContextDimension, pub title: ContextTitle, - pub ime: Ime, _io_thread: Option, performer::State)>>, } @@ -95,9 +93,7 @@ impl Context { pub fn cursor_from_ref(&self) -> Cursor { Cursor { 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, + content: self.renderable_content.cursor.content, } } } @@ -172,7 +168,6 @@ pub fn create_dead_context( rich_text_id, dimension, title: ContextTitle::default(), - ime: Ime::new(), _io_thread: None, } } @@ -346,7 +341,6 @@ impl ContextManager { renderable_content: RenderableContent::new(cursor_state.0.clone()), dimension, title: ContextTitle::default(), - ime: Ime::new(), _io_thread: io_thread, }) } diff --git a/frontends/rioterm/src/context/renderable.rs b/frontends/rioterm/src/context/renderable.rs index c4fc3a0680..69e0623066 100644 --- a/frontends/rioterm/src/context/renderable.rs +++ b/frontends/rioterm/src/context/renderable.rs @@ -22,12 +22,15 @@ pub enum WindowUpdate { Background(BackgroundState), } +/// `content` is the configured cursor shape as a char, read once to +/// seed the terminal's cursor shape. The glyph actually drawn each +/// frame comes from `state`; the old `content`/`content_ref` pair +/// (drawn vs configured) collapsed when the IME preview stopped +/// swapping the drawn char. #[derive(Default, Clone, Debug)] pub struct Cursor { pub state: CursorState, pub content: char, - pub content_ref: char, - pub is_ime_enabled: bool, } #[derive(Clone, Copy, Debug)] @@ -144,9 +147,7 @@ impl RenderableContent { pub fn from_cursor_config(config_cursor: &CursorConfig) -> Self { let cursor = Cursor { 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/ime.rs b/frontends/rioterm/src/ime.rs index 13d3633b3a..4fd735697e 100644 --- a/frontends/rioterm/src/ime.rs +++ b/frontends/rioterm/src/ime.rs @@ -1,4 +1,5 @@ -use rio_unicode::UnicodeWidthChar; +pub use rio_grid::preedit::PreeditCursor; + #[derive(Debug, Default)] pub struct Ime { /// Whether the IME is enabled. @@ -40,37 +41,59 @@ impl Ime { } } -#[derive(Debug, Default, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub struct Preedit { /// The preedit text. pub text: String, - /// Byte offset for cursor start into the preedit text. + /// The IME caret. /// - /// `None` means that the cursor is invisible. - pub cursor_byte_offset: Option, - - /// The cursor offset from the end of the preedit in char width. - pub cursor_end_offset: Option, + /// Byte offsets are clamped to the text length but otherwise kept + /// verbatim, including intra-cluster and non-char-boundary values + /// (macOS reports UTF-16 ranges that can split a surrogate pair; + /// jamo-level Korean IMEs report offsets inside an NFC syllable). + /// Nothing downstream slices on the offset: the layout only + /// compares it against cluster starts, snapping the caret to the + /// cluster CONTAINING it. + pub cursor: PreeditCursor, } 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..] - .chars() - .fold(0, |acc, ch| acc + ch.width().unwrap_or(1)); - - Some(cursor_end_offset) - } else { - None + pub fn new(text: String, cursor: PreeditCursor) -> Self { + let cursor = match cursor { + PreeditCursor::Byte(offset) => PreeditCursor::Byte(offset.min(text.len())), + PreeditCursor::Hidden => PreeditCursor::Hidden, }; + Self { text, cursor } + } +} - Self { - text, - cursor_byte_offset, - cursor_end_offset, - } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preedit_new_clamps_byte_offset() { + // Intra-char offsets pass through verbatim: the layout snaps + // them to the containing cluster and never slices on them. + let preedit = Preedit::new("啊a".to_string(), PreeditCursor::Byte(1)); + assert_eq!(preedit.cursor, PreeditCursor::Byte(1)); + let preedit = Preedit::new("啊a".to_string(), PreeditCursor::Byte(3)); + assert_eq!(preedit.cursor, PreeditCursor::Byte(3)); + // Past-the-end offsets clamp to the length. + let preedit = Preedit::new("啊a".to_string(), PreeditCursor::Byte(9)); + assert_eq!(preedit.cursor, PreeditCursor::Byte(4)); + // A hidden caret stays hidden. + let preedit = Preedit::new("啊a".to_string(), PreeditCursor::Hidden); + assert_eq!(preedit.cursor, PreeditCursor::Hidden); + } + + #[test] + fn set_preedit_clears_on_none() { + let mut ime = Ime::new(); + ime.set_preedit(Some(Preedit::new("a".to_string(), PreeditCursor::Byte(1)))); + assert!(ime.preedit().is_some()); + ime.set_preedit(None); + assert!(ime.preedit().is_none()); } } diff --git a/frontends/rioterm/src/renderer/assistant.rs b/frontends/rioterm/src/renderer/assistant.rs index 8ad725b51b..a24041ded9 100644 --- a/frontends/rioterm/src/renderer/assistant.rs +++ b/frontends/rioterm/src/renderer/assistant.rs @@ -88,6 +88,16 @@ impl AssistantOverlay { self.error.is_some() } + /// Whether the active toast is a hard error. Errors are modal + /// (keys and IME blocked, Enter dismisses); warnings render but + /// must never block input over a working terminal. + #[inline] + pub fn is_error(&self) -> bool { + self.error + .as_ref() + .is_some_and(|error| error.level == RioErrorLevel::Error) + } + #[inline] pub fn set_error(&mut self, error: RioError) { self.error = Some(error); diff --git a/frontends/rioterm/src/renderer/command_palette.rs b/frontends/rioterm/src/renderer/command_palette.rs index 999f7074e0..59101b2af7 100644 --- a/frontends/rioterm/src/renderer/command_palette.rs +++ b/frontends/rioterm/src/renderer/command_palette.rs @@ -506,6 +506,19 @@ impl CommandPalette { self.last_scroll_time = None; } + /// Append committed or typed text to the query, applying the + /// shared overlay input policy (`is_printable_text`) so the key + /// path and the IME commit path can never drift. Returns whether + /// anything was appended. + pub fn append_query(&mut self, text: &str) -> bool { + if !crate::renderer::is_printable_text(text) { + return false; + } + let query = format!("{}{}", self.query, text); + self.set_query(query); + true + } + pub fn set_query(&mut self, query: String) { self.query = query; self.selected_index = 0; diff --git a/frontends/rioterm/src/renderer/island.rs b/frontends/rioterm/src/renderer/island.rs index f2d38d3ca8..3e3353b1f5 100644 --- a/frontends/rioterm/src/renderer/island.rs +++ b/frontends/rioterm/src/renderer/island.rs @@ -1086,20 +1086,20 @@ impl Island { /// Handle keyboard input while the color picker (with rename field) is open. /// Returns true if input was consumed. + /// Handle one key event for the rename input. The caller + /// (`has_key_wait`'s `Modal::IslandRename` arm) already verified + /// the picker is open via `active_modal` and consumes the event + /// unconditionally, so there is nothing to return. pub fn handle_rename_input( &mut self, key_event: &rio_window::event::KeyEvent, context_manager: &mut ContextManager, - ) -> bool { + ) { use rio_window::event::ElementState; use rio_window::keyboard::{Key, NamedKey}; - if self.color_picker_tab.is_none() { - return false; - } - if key_event.state != ElementState::Pressed { - return true; // consume release events too + return; // consume release events too } match &key_event.logical_key { @@ -1118,14 +1118,23 @@ impl Island { } _ => { if let Some(text) = key_event.text.as_ref() { - let s = text.as_str(); - if !s.is_empty() && s.chars().all(|c| !c.is_control()) { - self.rename_input.push_str(s); - self.rename_caret_time = Instant::now(); - } + self.append_rename_text(text.as_str()); } } } + } + + /// Append committed or typed text to the rename input, applying + /// the shared overlay input policy (`is_printable_text`) so the + /// key path and the IME commit path can never drift. Returns + /// whether text was actually appended (same contract as + /// `CommandPalette::append_query`). + pub fn append_rename_text(&mut self, text: &str) -> bool { + if self.color_picker_tab.is_none() || !crate::renderer::is_printable_text(text) { + return false; + } + self.rename_input.push_str(text); + self.rename_caret_time = Instant::now(); true } diff --git a/frontends/rioterm/src/renderer/mod.rs b/frontends/rioterm/src/renderer/mod.rs index ca388600b9..62f7c11145 100644 --- a/frontends/rioterm/src/renderer/mod.rs +++ b/frontends/rioterm/src/renderer/mod.rs @@ -1,3 +1,11 @@ +/// The input policy every overlay text sink applies, in ONE place so +/// the key path and the IME commit path can never drift: non-empty and +/// free of control characters. +#[inline] +pub(crate) fn is_printable_text(text: &str) -> bool { + !text.is_empty() && text.chars().all(|c| !c.is_control()) +} + pub mod assistant; pub mod command_palette; pub mod confirm_quit; @@ -454,21 +462,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/router/mod.rs b/frontends/rioterm/src/router/mod.rs index 77059c8995..2e081dd2bd 100644 --- a/frontends/rioterm/src/router/mod.rs +++ b/frontends/rioterm/src/router/mod.rs @@ -32,6 +32,23 @@ use std::time::{Duration, Instant}; // #[cfg(not(any(target_os = "macos", target_os = "windows")))] const RIO_TITLE: &str = "▲"; +/// The modal overlays that can own keyboard/IME input, in dispatch +/// priority order (see [`Route::active_modal`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Modal { + /// The island tab-rename input (a text sink). + IslandRename, + /// The command palette (a text sink). + CommandPalette, + /// The quit confirmation dialog. + ConfirmQuit, + /// The error assistant; `report_error` can activate it WITHOUT + /// leaving `RoutePath::Terminal`. + Assistant, + /// A non-terminal route (the welcome / config screens). + Route, +} + pub struct Route<'a> { pub assistant: assistant::Assistant, pub path: RoutePath, @@ -136,6 +153,84 @@ impl Route<'_> { self.request_overlay_redraw(); } + /// The modal overlay currently owning keyboard/IME input, in + /// dispatch priority order. THE roster for KEYBOARD AND IME + /// dispatch: `has_key_wait`, `modal_owns_input`, and + /// `overlay_commit_text` all derive from this, so a new overlay + /// added here is key/IME-gated at once. Mouse paths (hint + /// hover/click, the application.rs pointer handlers) still walk + /// their own overlay checks and need separate wiring. + pub fn active_modal(&self) -> Option { + if self + .window + .screen + .renderer + .island + .as_ref() + .is_some_and(|island| island.is_color_picker_open()) + { + return Some(Modal::IslandRename); + } + if self.window.screen.renderer.command_palette.is_enabled() { + return Some(Modal::CommandPalette); + } + if self.window.screen.renderer.confirm_quit.is_active() { + return Some(Modal::ConfirmQuit); + } + // Only hard errors are modal: a warning toast (font not + // found on live reload, say) renders over a WORKING terminal + // and must never swallow typing or Ctrl+C. + if self.window.screen.renderer.assistant.is_error() { + return Some(Modal::Assistant); + } + if self.path != RoutePath::Terminal { + return Some(Modal::Route); + } + None + } + + /// Route committed IME text (dead keys, CJK) into the overlay that + /// owns input, when it is a text sink. Composed characters arrive + /// ONLY as commits, never as key text, so without this the + /// overlays would be ASCII-only. Returns whether the text was + /// consumed (an open text sink swallows even rejected text, the + /// way `has_key_wait` blocks all keys for it). + pub fn overlay_commit_text(&mut self, text: &str) -> bool { + match self.active_modal() { + Some(Modal::IslandRename) => { + if let Some(ref mut island) = self.window.screen.renderer.island { + if island.append_rename_text(text) { + self.request_overlay_redraw(); + } + } + true + } + Some(Modal::CommandPalette) => { + if self + .window + .screen + .renderer + .command_palette + .append_query(text) + { + self.request_overlay_redraw(); + } + true + } + _ => false, + } + } + + /// Whether a modal overlay currently owns keyboard input, so IME + /// composition must not reach the terminal behind it. Derived from + /// the same roster as `has_key_wait`, so keys and IME are gated + /// identically (the welcome screen blocks both: its PTY is live + /// but invisible). + #[inline] + pub fn modal_owns_input(&self) -> bool { + self.active_modal().is_some() + } + #[inline] pub fn quit(&mut self) { std::process::exit(0); @@ -149,215 +244,218 @@ impl Route<'_> { ) -> bool { use rio_window::event::ElementState; - // Handle island color picker / rename input - if let Some(ref mut island) = self.window.screen.renderer.island { - if island.is_color_picker_open() { - let consumed = island.handle_rename_input( - key_event, - &mut self.window.screen.context_manager, - ); - if consumed { - self.request_overlay_redraw(); - return true; + // One dispatch on THE modal roster; each arm keeps its + // existing handling. `active_modal` already checked each + // overlay's open state. + let Some(modal) = self.active_modal() else { + return false; + }; + match modal { + Modal::IslandRename => { + if let Some(ref mut island) = self.window.screen.renderer.island { + island.handle_rename_input( + key_event, + &mut self.window.screen.context_manager, + ); } + self.request_overlay_redraw(); + true } - } - // Handle command palette input first (works in all routes) - if self.window.screen.renderer.command_palette.is_enabled() { - if key_event.state == ElementState::Pressed { - match &key_event.logical_key { - Key::Named(NamedKey::Escape) => { - self.window - .screen - .renderer - .command_palette - .set_enabled(false); - self.request_overlay_redraw(); - } - Key::Named(NamedKey::ArrowUp) => { - self.window - .screen - .renderer - .command_palette - .move_selection_up(); - self.request_overlay_redraw(); - } - Key::Named(NamedKey::ArrowDown) => { - self.window - .screen - .renderer - .command_palette - .move_selection_down(); - self.request_overlay_redraw(); - } - Key::Named(NamedKey::Tab) => { - self.window - .screen - .renderer - .command_palette - .move_selection_down(); - self.request_overlay_redraw(); - } - Key::Named(NamedKey::Enter) => { - // Snapshot what the palette wants to do FIRST, - // before taking a mut-borrow on it, so we can - // freely call other `self.window.screen.*` - // methods in the match arms without tripping - // the borrow checker on nested disjoint borrows. - let selected_font = self - .window - .screen - .renderer - .command_palette - .get_selected_font(); - let selected_action = self - .window - .screen - .renderer - .command_palette - .get_selected_action(); - use crate::renderer::command_palette::PaletteAction; - - // Fonts-mode Enter: copy the family name to - // the system clipboard and close. The copy - // icon on each row advertises this. - if let Some(font) = selected_font { - clipboard.set( - rio_backend::clipboard::ClipboardType::Clipboard, - font, - ); + Modal::CommandPalette => { + if key_event.state == ElementState::Pressed { + match &key_event.logical_key { + Key::Named(NamedKey::Escape) => { self.window .screen .renderer .command_palette .set_enabled(false); self.request_overlay_redraw(); - return true; } - - match selected_action { - // `ListFonts` stays inside the palette — - // swap the palette's contents from the - // command list to the registered font - // family names and keep it open. - Some(PaletteAction::ListFonts) => { - let fonts = - self.window.screen.sugarloaf.font_family_names(); - self.window - .screen - .renderer - .command_palette - .enter_fonts_mode(fonts); - } - // Any other command is a one-shot: close - // the palette first, then dispatch. - Some(action) => { + Key::Named(NamedKey::ArrowUp) => { + self.window + .screen + .renderer + .command_palette + .move_selection_up(); + self.request_overlay_redraw(); + } + Key::Named(NamedKey::ArrowDown) => { + self.window + .screen + .renderer + .command_palette + .move_selection_down(); + self.request_overlay_redraw(); + } + Key::Named(NamedKey::Tab) => { + self.window + .screen + .renderer + .command_palette + .move_selection_down(); + self.request_overlay_redraw(); + } + Key::Named(NamedKey::Enter) => { + // Snapshot what the palette wants to do FIRST, + // before taking a mut-borrow on it, so we can + // freely call other `self.window.screen.*` + // methods in the match arms without tripping + // the borrow checker on nested disjoint borrows. + let selected_font = self + .window + .screen + .renderer + .command_palette + .get_selected_font(); + let selected_action = self + .window + .screen + .renderer + .command_palette + .get_selected_action(); + use crate::renderer::command_palette::PaletteAction; + + // Fonts-mode Enter: copy the family name to + // the system clipboard and close. The copy + // icon on each row advertises this. + if let Some(font) = selected_font { + clipboard.set( + rio_backend::clipboard::ClipboardType::Clipboard, + font, + ); self.window .screen .renderer .command_palette .set_enabled(false); - self.window - .screen - .execute_palette_action(action, clipboard); + self.request_overlay_redraw(); + return true; + } + + match selected_action { + // `ListFonts` stays inside the palette: + // swap the palette's contents from the + // command list to the registered font + // family names and keep it open. + Some(PaletteAction::ListFonts) => { + let fonts = + self.window.screen.sugarloaf.font_family_names(); + self.window + .screen + .renderer + .command_palette + .enter_fonts_mode(fonts); + } + // Any other command is a one-shot: close + // the palette first, then dispatch. + Some(action) => { + self.window + .screen + .renderer + .command_palette + .set_enabled(false); + self.window + .screen + .execute_palette_action(action, clipboard); + } + // No match at all: Enter just closes. + None => { + self.window + .screen + .renderer + .command_palette + .set_enabled(false); + } } - // No match at all — Enter just closes. - None => { + self.request_overlay_redraw(); + } + Key::Named(NamedKey::Backspace) => { + let current_query = + self.window.screen.renderer.command_palette.query.clone(); + if !current_query.is_empty() { + let mut chars = current_query.chars().collect::>(); + chars.pop(); self.window .screen .renderer .command_palette - .set_enabled(false); + .set_query(chars.into_iter().collect()); + self.request_overlay_redraw(); } } - self.request_overlay_redraw(); - } - Key::Named(NamedKey::Backspace) => { - let current_query = - self.window.screen.renderer.command_palette.query.clone(); - if !current_query.is_empty() { - let mut chars = current_query.chars().collect::>(); - chars.pop(); - self.window - .screen - .renderer - .command_palette - .set_query(chars.into_iter().collect()); - self.request_overlay_redraw(); - } - } - _ => { - if let Some(text) = key_event.text.as_ref() { - // Filter out control characters - let text_str = text.as_str(); - if !text_str.is_empty() - && text_str.chars().all(|c| !c.is_control()) - { - let current_query = self + _ => { + if let Some(text) = key_event.text.as_ref() { + if self .window .screen .renderer .command_palette - .query - .clone(); - self.window - .screen - .renderer - .command_palette - .set_query(format!("{}{}", current_query, text_str)); - self.request_overlay_redraw(); + .append_query(text.as_str()) + { + self.request_overlay_redraw(); + } } } } } + true // Block all input when command palette is active } - return true; // Block all input when command palette is active - } - if self.window.screen.renderer.confirm_quit.is_active() { - if key_event.state == rio_window::event::ElementState::Pressed { - match &key_event.logical_key { - Key::Character(c) if c.as_str() == "n" || c.as_str() == "N" => { - self.window.screen.renderer.confirm_quit.set_active(false); - self.request_overlay_redraw(); - } - Key::Named(NamedKey::Escape) => { - self.window.screen.renderer.confirm_quit.set_active(false); - self.request_overlay_redraw(); - } - Key::Character(c) if c.as_str() == "y" || c.as_str() == "Y" => { - self.quit(); - return true; + Modal::ConfirmQuit => { + if key_event.state == rio_window::event::ElementState::Pressed { + match &key_event.logical_key { + Key::Character(c) if c.as_str() == "n" || c.as_str() == "N" => { + self.window.screen.renderer.confirm_quit.set_active(false); + self.request_overlay_redraw(); + } + Key::Named(NamedKey::Escape) => { + self.window.screen.renderer.confirm_quit.set_active(false); + self.request_overlay_redraw(); + } + Key::Character(c) if c.as_str() == "y" || c.as_str() == "Y" => { + self.quit(); + return true; + } + _ => {} } - _ => {} } + true } - return true; - } - - if self.path == RoutePath::Terminal { - return false; - } - - let is_enter = key_event.logical_key == Key::Named(NamedKey::Enter); - // Handle assistant overlay dismiss - if self.window.screen.renderer.assistant.is_active() { - if is_enter { - self.assistant.clear(); - self.window.screen.renderer.assistant.clear(); - self.request_overlay_redraw(); + // Path-independent, so a `report_error` toast raised at + // `RoutePath::Terminal` blocks keys symmetrically with the + // IME gate and Enter can dismiss it (previously only a mouse + // click could, while plain keys leaked to the shell). Only + // the press dismisses: acting on the release would let a + // toast appearing mid-keystroke vanish unseen, and would + // send the orphaned release to the PTY under kitty's + // report-event-types mode. + Modal::Assistant => { + if key_event.state == ElementState::Pressed + && key_event.logical_key == Key::Named(NamedKey::Enter) + { + self.assistant.clear(); + self.window.screen.renderer.assistant.clear(); + self.request_overlay_redraw(); + } + true } - return true; - } - if self.path == RoutePath::Welcome && is_enter { - rio_backend::config::create_config_file(None); - self.path = RoutePath::Terminal; + Modal::Route => { + let is_enter = key_event.state == ElementState::Pressed + && key_event.logical_key == Key::Named(NamedKey::Enter); + if self.path == RoutePath::Welcome && is_enter { + rio_backend::config::create_config_file(None); + self.path = RoutePath::Terminal; + } + // Block everything else: the PTY behind the welcome + // screen is live, and keys reaching it would execute + // invisibly once the terminal appears. + true + } } - - false } } diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index 08974db679..666a1d908f 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -73,7 +73,17 @@ pub struct Screen<'screen> { pub renderer: Renderer, pub sugarloaf: Sugarloaf<'screen>, pub context_manager: context::ContextManager, - last_ime_cursor_pos: Option<(f32, f32)>, + /// IME state is per window, not per context: the platform IME + /// composes into whichever context is current, and exactly one + /// composition can exist per view. Keeping it here (instead of on + /// `Context`) makes a stale preedit on a background tab or split + /// structurally impossible. + pub ime: crate::ime::Ime, + /// Screen row the preedit rendered on last frame, so the row can + /// be rebuilt when the composition moves or ends even when + /// terminal damage reports nothing. + last_preedit_row: Option, + last_ime_cursor_pos: Option<(f32, f32, f32)>, hints_config: Vec>, /// Hint regexes compiled on first use, keyed by pattern. Hover /// hit-testing runs on every mouse move; recompiling the URL @@ -288,9 +298,7 @@ impl Screen<'_> { let cursor = Cursor { 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( @@ -334,6 +342,8 @@ impl Screen<'_> { mouse_bindings: crate::bindings::default_mouse_bindings(), modifiers: Modifiers::default(), context_manager, + ime: crate::ime::Ime::new(), + last_preedit_row: None, sugarloaf, mouse: Mouse::new(config.scroll.multiplier, config.scroll.divider), touchpurpose: TouchPurpose::default(), @@ -384,6 +394,68 @@ impl Screen<'_> { .set_dirty(); } + /// Window-level IME preedit update, with the side effects composing + /// implies. Returns whether a repaint is needed. + /// + /// Composing always snaps out of scrollback: the overlay renders + /// only at `display_offset == 0` while the preedit key gate + /// swallows input, so a scrolled viewport would mean a live but + /// invisible composition and a terminal that looks frozen. This + /// holds during search too; the snap composes with the relative + /// `Scroll::Delta` restore in `search_reset_state` exactly like a + /// manual mid-search scroll does, and committing the query re-runs + /// `goto_match`, which scrolls back to the focused match. The snap + /// runs on every composition event, not only on changes: candidate + /// paging re-reports identical text, and that event must still + /// restore visibility after a mid-composition scroll. + /// + /// Selection follows what the equivalent plain typing does: plain + /// input drops it (`send_bytes`), search typing drops it only + /// outside vi mode (`search_input` keeps a vi visual selection). + pub fn set_ime_preedit(&mut self, preedit: Option) -> bool { + let composing = preedit.is_some(); + let changed = self.ime.preedit() != preedit.as_ref(); + if changed { + self.ime.set_preedit(preedit); + } + + let mut needs_render = changed; + if composing { + let mut terminal = self.ctx_mut().current_mut().terminal.lock(); + let snapped_offset = terminal.display_offset(); + if snapped_offset != 0 { + terminal.scroll_display(Scroll::Bottom); + needs_render = true; + } + drop(terminal); + if snapped_offset != 0 && self.search_active() { + // Keep the vi-origin restore honest: `search_reset_state` + // applies a relative `Scroll::Delta`, so the snap's + // displacement must be recorded the way `goto_match` + // records its own scrolls, or Esc after composing lands + // the viewport clamped at the bottom instead of at the + // vi origin. + self.search_state.display_offset_delta += snapped_offset as i32; + } + + if changed { + if self.search_active() { + if !self.get_mode().contains(Mode::VI) { + // Clear selection so we do not obstruct any matches. + self.context_manager.current_mut().set_selection(None); + } + } else { + self.clear_selection(); + } + } + } + + if changed { + self.mark_dirty(); + } + needs_render + } + #[inline] pub fn set_modifiers(&mut self, modifiers: Modifiers) { self.modifiers = modifiers; @@ -731,7 +803,7 @@ impl Screen<'_> { key: &rio_window::event::KeyEvent, clipboard: &mut Clipboard, ) { - if self.context_manager.current().ime.preedit().is_some() { + if self.ime.preedit().is_some() { return; } @@ -4000,6 +4072,12 @@ impl Screen<'_> { rio_backend::crosswords::pos::Pos, )>, hint_labels: Option>, + /// Active IME composition, laid out on the cursor row. + /// Only ever `Some` for the active panel with an + /// unscrolled viewport: the composition belongs to the + /// focused context, and a scrolled viewport has no + /// on-screen cursor row to anchor it to. + preedit_line: Option, } let (active_key, scaled_margin) = { @@ -4095,7 +4173,25 @@ impl Screen<'_> { let cursor_blinking = ctx.renderable_content.has_blinking_enabled; let cursor_blink_visible = !cursor_blinking || ctx.renderable_content.is_blinking_cursor_visible; - let cursor_preedit = ctx.ime.preedit().is_some(); + // IME state is window-level (`self.ime`); it renders + // on the active panel only, and never over scrollback + // (the cursor row is off-viewport there — an anchor + // computed from it would paint on history). + let preedit_line = if is_active && display_offset == 0 { + self.ime.preedit().and_then(|preedit| { + rio_grid::preedit::PreeditLine::new( + &preedit.text, + preedit.cursor, + (cursor.state.pos.row.0.max(0) as usize) + .min(ctx.renderable_content.screen_lines.max(1) - 1), + cursor.state.pos.col.0, + ctx.renderable_content.columns.max(1), + ) + }) + } else { + None + }; + let cursor_preedit = preedit_line.is_some(); // OSC 12 wins; otherwise fall back to the named-color // theme value. `Renderer::color`'s fallback (the // indexed-color List) is not populated for the Cursor @@ -4132,6 +4228,7 @@ impl Screen<'_> { focused_match, hovered_hyperlink, hint_labels, + preedit_line, }); } @@ -4282,6 +4379,15 @@ impl Screen<'_> { } None => (row, row_styles), }; + // Thread the composition only into its own + // row: everything else renders untouched. + let preedit_row = + p.preedit_line.as_ref().filter(|line| line.row == y).map( + |line| rio_grid::PreeditRow { + line, + block_bg: rio_grid::normalized_to_u8(p.cursor_color), + }, + ); rio_grid::build_row_bg( row, cols, @@ -4290,6 +4396,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 @@ -4315,6 +4422,7 @@ impl Screen<'_> { p.cell_h, row_sel, &hint_scratch, + preedit_row.as_ref(), &font_library, p.route_id, cursor_col_for_row, @@ -4356,6 +4464,29 @@ impl Screen<'_> { } } + // The composition is painted into the row's CPU + // cells, so its row must rebuild whenever the overlay + // exists, moved, or just disappeared — even when + // terminal damage says nothing changed (the text under + // it didn't; the overlay did). Cheap: at most two rows. + if p.is_active { + let current = p.preedit_line.as_ref().map(|line| line.row); + for row in [ + self.last_preedit_row + .filter(|_| self.last_preedit_row != current), + current, + ] + .into_iter() + .flatten() + { + if row < p.visible_rows.len() { + rebuild_row(p, row, grid, rasterizer); + p.visible_rows[row].dirty = false; + } + } + self.last_preedit_row = current; + } + // Atlas-full recovery: the backend cleared the atlas // during the rebuild above, so rows written before the // clear reference stale slots. Re-emit everything. @@ -4592,6 +4723,39 @@ impl Screen<'_> { let layout = current_item.val.dimension; let cursor_pos = current_item.val.renderable_content.cursor.state.pos; + // While composing, the candidate popup follows the IME caret, + // not the terminal cursor: the composition renders inline and + // can slide away from the cursor cell, and a popup opening + // tens of cells from the caret overlaps the freshly drawn + // text. The reported area's width spans from the caret to the + // composition's end, so the OS also knows how much freshly + // drawn text to avoid covering. + // Mirrors the layout the renderer uses (same inputs). + let content = ¤t_item.val.renderable_content; + let (anchor_row, anchor_col, anchor_cells) = + match self.ime.preedit().filter(|_| { + content.display_offset == 0 + && content.columns > 0 + && content.screen_lines > 0 + }) { + Some(preedit) => match rio_grid::preedit::PreeditLine::new( + &preedit.text, + preedit.cursor, + (cursor_pos.row.0.max(0) as usize).min(content.screen_lines - 1), + cursor_pos.col.0, + content.columns, + ) { + Some(line) => { + let col = line.popup_anchor_col().min(content.columns - 1); + let cells = + line.end_col().min(content.columns).max(col + 1) - col; + (line.row, col, cells) + } + None => (cursor_pos.row.0.max(0) as usize, cursor_pos.col.0, 1), + }, + None => (cursor_pos.row.0.max(0) as usize, cursor_pos.col.0, 1), + }; + // Calculate pixel position of cursor — canonical integer // stride (line_height already baked into cell_height). let cell_width = layout.cell.cell_width as f32; @@ -4614,9 +4778,8 @@ impl Screen<'_> { let origin_y = panel_rect[1] + scaled_margin.top; // Convert grid position to pixel position - let pixel_x = - origin_x + (cursor_pos.col.0 as f32 * cell_width) + (cell_width * 0.5); - let pixel_y = origin_y + (cursor_pos.row.0 as f32 * cell_height); + let pixel_x = origin_x + (anchor_col as f32 * cell_width) + (cell_width * 0.5); + let pixel_y = origin_y + (anchor_row as f32 * cell_height); // Validate final coordinates if pixel_x.is_nan() || pixel_y.is_nan() || pixel_x < 0.0 || pixel_y < 0.0 { @@ -4624,20 +4787,27 @@ impl Screen<'_> { return; } - // Check if position has changed significantly to avoid unnecessary updates - if let Some((last_x, last_y)) = self.last_ime_cursor_pos { - if (pixel_x - last_x).abs() < 1.0 && (pixel_y - last_y).abs() < 1.0 { - return; // Position hasn't changed significantly + // A PastEnd caret sits one cell past the composition, where + // `end_col - col` is 0; the area is always at least one cell. + let area_width = anchor_cells as f32 * cell_width; + + // Check if the area changed significantly to avoid unnecessary updates + if let Some((last_x, last_y, last_w)) = self.last_ime_cursor_pos { + if (pixel_x - last_x).abs() < 1.0 + && (pixel_y - last_y).abs() < 1.0 + && (area_width - last_w).abs() < 1.0 + { + return; // Area hasn't changed significantly } } - // Update last position - self.last_ime_cursor_pos = Some((pixel_x, pixel_y)); + // Update last area + self.last_ime_cursor_pos = Some((pixel_x, pixel_y, area_width)); // Set IME cursor area window.set_ime_cursor_area( rio_window::dpi::PhysicalPosition::new(pixel_x as f64, pixel_y as f64), - rio_window::dpi::PhysicalSize::new(cell_width as f64, cell_height as f64), + rio_window::dpi::PhysicalSize::new(area_width as f64, cell_height as f64), ); } diff --git a/libsugarloaf/src/capi.rs b/libsugarloaf/src/capi.rs index 9227fc5666..985547105f 100644 --- a/libsugarloaf/src/capi.rs +++ b/libsugarloaf/src/capi.rs @@ -1232,6 +1232,9 @@ pub unsafe extern "C" fn sl_render_surface( &term_colors, row_sel, &hint_scratch, + // IME composition is a frontend concern; the C API + // renders no preedit overlay. + None, &mut bg_scratch, ); // Break shaping runs around the cursor cell so partial @@ -1260,6 +1263,7 @@ pub unsafe extern "C" fn sl_render_surface( cell_h, row_sel, &hint_scratch, + None, &font_library, 0, cursor_col_for_row, diff --git a/rio-grid/Cargo.toml b/rio-grid/Cargo.toml index f07e5fbadb..fda7a69edb 100644 --- a/rio-grid/Cargo.toml +++ b/rio-grid/Cargo.toml @@ -14,6 +14,7 @@ path = "src/lib.rs" [dependencies] rio-backend = { workspace = true } +rio-unicode = { workspace = true } rustc-hash = { workspace = true } smallvec = { workspace = true } rapidhash = { version = "4.4.1", default-features = false } diff --git a/rio-grid/src/lib.rs b/rio-grid/src/lib.rs index 1586f8778f..713b7f0326 100644 --- a/rio-grid/src/lib.rs +++ b/rio-grid/src/lib.rs @@ -32,7 +32,7 @@ use rio_backend::config::colors::{AnsiColor, NamedColor}; use rio_backend::crosswords::grid::row::Row; use rio_backend::crosswords::pos::{Column, Line, Pos}; use rio_backend::crosswords::search::Match; -use rio_backend::crosswords::square::{ContentTag, Extras, Square}; +use rio_backend::crosswords::square::{ContentTag, Extras, Square, Wide}; use rio_backend::crosswords::style::{Style, StyleFlags, UnderlineKind}; use rio_backend::selection::SelectionRange; use rustc_hash::FxHashMap; @@ -43,6 +43,54 @@ use smallvec::SmallVec; /// cells. The renderer reads via `extras.get(&id)`. pub type ExtrasMap = FxHashMap; +pub mod preedit; +use preedit::{PreeditCaret, PreeditCell, PreeditLine}; + +/// The IME composition threaded into a row build. Only handed to the +/// emit passes for the single row the composition lives on. +pub struct PreeditRow<'a> { + pub line: &'a PreeditLine, + /// The cursor color the frame resolved once (OSC 12 wins, then the + /// theme): the same value the cursor-block uniforms use, threaded + /// here so the block fill, the PastEnd beam, and the cursor can + /// never diverge. + pub block_bg: [u8; 4], +} + +impl PreeditRow<'_> { + #[inline] + fn cell(&self, col: usize) -> Option { + self.line.cell(col) + } + + /// Whether ink drawn at `col` spanning `span` cells would land on + /// any composition cell. Used where the span is dynamic (a custom + /// glyph's render span); per-cell emitters use [`Self::suppresses`]. + #[inline] + fn covers_ink(&self, col: usize, span: usize) -> bool { + (col..col.saturating_add(span)).any(|c| self.cell(c).is_some()) + } + + /// THE suppression policy for per-cell fg emitters (glyphs and + /// their decorations) while composing: drop the cell when it is + /// under the block, when it is a wide base whose spacer is, or + /// when it is a spacer whose base is. Half-covered wide glyphs + /// vanish whole (the rule the grid applies when half of a wide + /// char is overwritten), and neither half may leave a floating + /// decoration behind. + #[inline] + fn suppresses(&self, sq: Square, col: usize) -> bool { + if self.cell(col).is_some() { + return true; + } + match sq.wide() { + Wide::Wide => self.cell(col + 1).is_some(), + Wide::Spacer => col > 0 && self.cell(col - 1).is_some(), + _ => false, + } + } +} + /// Color/palette operations the emit code needs from its host /// renderer. Implemented by the frontend (e.g. rioterm's `Renderer`) /// and passed by generic reference into the per-cell hot path, so the @@ -449,6 +497,15 @@ enum DecorationStyle { DashedUnderline = 3, CurlyUnderline = 4, Strikethrough = 5, + /// Thick underline marking the IME caret on a composition cell. + /// Drawn in the terminal background color so it reads against the + /// cursor-colored block — a beam there would be cursor-on-cursor + /// and invisible. + ImeCaretUnderline = 6, + /// Vertical beam marking the IME caret one cell past the + /// composition, where there is no block behind it; drawn in the + /// cursor color against the normal background. + ImeCaretBeam = 7, } /// Sentinel font_id base for decoration sprites. Real font_ids come @@ -939,13 +996,33 @@ fn rasterize_decoration( let bearing_y = center_from_bottom as i16 + (thickness as i16 + 1) / 2; (bytes, cell_w, thickness, bearing_y) } + DecorationStyle::ImeCaretUnderline => { + // The cursor's underline sprite at doubled thickness: one + // rasterizer for both, so a change to the underline + // position can't leave the IME caret misaligned with the + // underline cursor. + let t2 = (thickness * 2).max(2).min(cell_h); + let (bytes, w, h, _bearing_x, bearing_y) = + rasterize_cursor(CursorSpriteStyle::Underline, cell_w, cell_h, t2); + (bytes, w as u32, h as u32, bearing_y) + } + DecorationStyle::ImeCaretBeam => { + // Full-height beam pinned to the cell's left edge, in the + // same visual weight as the underline decorations. + let w = thickness.max(1).min(cell_w); + let bytes = vec![0xFFu8; (w * cell_h) as usize]; + (bytes, w, cell_h, cell_h as i16) + } } } -/// Look up or insert a decoration sprite into the grid atlas. Key is -/// (decoration font_id sentinel, cell_w as glyph_id, thickness as -/// size_bucket) — the same cache that backs regular glyphs, so -/// decorations ride the grid's glyph-eviction policy for free. +/// Look up or insert a decoration sprite into the grid atlas. Keyed by +/// (decoration font_id sentinel, cell_w as glyph_id, thickness+cell_h +/// as size_bucket) — the same cache that backs regular glyphs, so +/// decorations ride the grid's glyph-eviction policy for free. Every +/// decoration's bearing (and the IME beam's height) depends on +/// `cell_h`, so it must key the sprite or a line-height-only config +/// reload serves stale-height sprites until eviction. fn ensure_decoration_slot( grid: &mut GridRenderer, style: DecorationStyle, @@ -956,7 +1033,7 @@ fn ensure_decoration_slot( let key = GlyphKey { font_id: DECORATION_FONT_ID_BASE + style as u32, glyph_id: cell_w, - size_bucket: thickness as u16, + size_bucket: ((thickness as u16 & 0xF) << 12) | (cell_h.min(0xFFF) as u16), }; if let Some(slot) = grid.lookup_glyph(key) { return Some(slot); @@ -1084,7 +1161,7 @@ pub fn cell_bg( } #[inline] -fn normalized_to_u8(c: [f32; 4]) -> [u8; 4] { +pub fn normalized_to_u8(c: [f32; 4]) -> [u8; 4] { [ (c[0].clamp(0.0, 1.0) * 255.0) as u8, (c[1].clamp(0.0, 1.0) * 255.0) as u8, @@ -1102,18 +1179,24 @@ 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. + // Block fill behind every composition cell: the frame's resolved + // cursor color, so the block and the forced block cursor on the + // first composition cell can never be two different colors. + let preedit_block_bg = preedit.map(|p| p.block_bg); + + // Fast path: row has no selection, no color-changing hints, and no + // composition. (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)]; @@ -1148,8 +1231,18 @@ pub fn build_row_bg( let sq = row[Column(x)]; let style = resolve_style(row_styles, x); let col = x as u16; + // The composition wins over selection / hint backgrounds: the + // user is actively typing here, that signal reads first. Both + // Start and Continuation cells take the fill so wide clusters + // span one continuous block. + let preedit_here = match (preedit, preedit_block_bg) { + (Some(p), Some(bg)) if p.cell(x).is_some() => Some(bg), + _ => None, + }; let rgba = - if cell_in_row_sel(row_sel, col) { + 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). @@ -1199,6 +1292,9 @@ struct ShapedGlyph { } struct RunCacheEntry { + /// Summed glyph advance, computed once at insert so per-frame + /// consumers (the preedit overflow check) never re-walk glyphs. + advance: f32, /// 64-bit rapidhash of (font_id, size_bucket, style_flags, run bytes). /// We key on the hash alone — no stored run string, no equality /// check on lookup. `CellCacheTable` pattern @@ -1529,7 +1625,6 @@ fn is_run_breaker(sq: Square) -> bool { /// run text + hash + cluster mapping. #[inline(always)] fn is_skipped_spacer(sq: Square) -> bool { - use rio_backend::crosswords::square::Wide; matches!(sq.wide(), Wide::Spacer | Wide::LeadingSpacer) } @@ -1540,7 +1635,7 @@ fn is_skipped_spacer(sq: Square) -> bool { fn run_cache_get( buckets: &mut [Vec], hash: u64, -) -> Option<&[ShapedGlyph]> { +) -> Option<&RunCacheEntry> { let idx = (hash as usize) & (RUN_BUCKET_COUNT - 1); let bucket = &mut buckets[idx]; let last = bucket.len().checked_sub(1)?; @@ -1549,7 +1644,7 @@ fn run_cache_get( if i != last { bucket[i..=last].rotate_left(1); } - return Some(&bucket[last].glyphs); + return Some(&bucket[last]); } } None @@ -1697,6 +1792,51 @@ fn shape_run_swash( // Emission +/// Shape the rasterizer's current run scratch, keyed in the run cache +/// by `hash`, and return `(ascent, summed advance)` for +/// `(font_id, size_bucket)`; on a miss the shaped glyphs are stored +/// under `hash` with their advance. `None` means shaping failed (no +/// font handle). The one shaping-cache protocol, shared by the grid +/// run path and the preedit path. +fn shape_cached( + rasterizer: &mut GridGlyphRasterizer, + hash: u64, + font_id: u32, + size_u16: u16, + size_bucket: u16, + font_library: &FontLibrary, +) -> Option<(i16, f32)> { + if let Some(entry) = run_cache_get(&mut rasterizer.run_cache, hash) { + // Cache hit: advance stored, ascent in its own cache. + let advance = entry.advance; + return Some(( + rasterizer + .ascent_cache + .get(&(font_id, size_bucket)) + .copied() + .unwrap_or(0), + advance, + )); + } + #[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 (glyphs, ascent_px) = shaped_opt?; + let advance: f32 = glyphs.iter().map(|g| g.advance).sum(); + run_cache_put( + &mut rasterizer.run_cache, + RunCacheEntry { + hash, + glyphs, + advance, + }, + ); + Some((ascent_px, advance)) +} + /// Run-level fg emission. Shapes once per run, emits one CellText per /// shaped glyph. Works on both macOS (CoreText) and non-macOS (swash). /// @@ -1719,6 +1859,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 @@ -1773,6 +1914,8 @@ pub fn build_row_fg( thickness, row_sel, row_hints, + preedit, + glyph_registry.as_ref(), fg_scratch, ); @@ -1788,6 +1931,13 @@ pub fn build_row_fg( let mut x: usize = 0; while x < max { let sq = row[Column(x)]; + // Composition cells emit in their own pass below (shaped per + // grapheme cluster so they can't ligate with terminal text), + // and a wide glyph half-covered by the block is dropped whole. + if preedit.is_some_and(|p| p.suppresses(sq, x)) { + x += 1; + continue; + } if is_run_breaker(sq) { x += 1; continue; @@ -1853,6 +2003,13 @@ pub fn build_row_fg( if let Some((_, slot, is_color, span)) = ensure_custom_glyph_by_codepoint( grid, registry, ch as u32, cell_w_u32, cell_h, color, ) { + // A registered glyph's render span can overflow into + // the next cell: like a wide glyph, drop it while the + // composition block covers any cell its ink spans. + if preedit.is_some_and(|p| p.covers_ink(x, span as usize)) { + x += 1; + continue; + } if slot.w != 0 && slot.h != 0 { // Center the rasterised glyph in its render-span box // (`span × cell_w` wide, `cell_h` tall). The raster @@ -2005,6 +2162,13 @@ pub fn build_row_fg( let mut end = x + 1; while end < cols { let sq2 = row[Column(end)]; + // Stop before composition cells (taken over by the preedit + // pass) and before a wide glyph the block half-covers, + // whose shaping would bleed into it (see the run-start + // guard). + if preedit.is_some_and(|p| p.suppresses(sq2, end)) { + break; + } if is_run_breaker(sq2) { break; } @@ -2135,26 +2299,16 @@ pub fn build_row_fg( let hash = rasterizer.run_hasher.finish(); // Shape (cached) and capture ascent for this (font_id, size). - let ascent_px = if run_cache_get(&mut rasterizer.run_cache, hash).is_some() { - // Cache hit — ascent already stored. - 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 { - x = end; - continue; - }; - run_cache_put(&mut rasterizer.run_cache, RunCacheEntry { hash, glyphs }); - ascent_px + let Some((ascent_px, _)) = shape_cached( + rasterizer, + hash, + font_id, + size_u16, + size_bucket, + font_library, + ) else { + x = end; + continue; }; let (synthetic_bold, synthetic_italic) = @@ -2176,8 +2330,9 @@ pub fn build_row_fg( // shaped emoji runs that outgrow 64 slots spill to heap once. let mut glyph_emits: SmallVec<[(u16, u16); 64]> = SmallVec::new(); { - let glyphs = - run_cache_get(&mut rasterizer.run_cache, hash).expect("just inserted"); + let glyphs = &run_cache_get(&mut rasterizer.run_cache, hash) + .expect("just inserted") + .glyphs; let mut cell_idx_in_run: u16 = 0; // Both platforms record explicit per-cell starts into the // shaping buffer (UTF-16 units on macOS, UTF-8 bytes on @@ -2290,6 +2445,41 @@ pub fn build_row_fg( x = end; } + // Phase 2.5: composition pass. Every grapheme cluster shapes as + // its own run — never ligating with the surrounding terminal text + // — with the foreground forced to the terminal background and + // BOOL_IS_CURSOR_GLYPH set, so the glyph reads inverted against + // the cursor-colored block painted in `build_row_bg`. + if let Some(pre) = preedit { + let text_fg = normalized_to_u8(palette.named_colors().background.0); + for col in pre.line.start_col..pre.line.end_col().min(cols) { + let Some(PreeditCell::Start(cluster)) = pre.cell(col) else { + continue; + }; + let reserved_cells = if pre.cell(col + 1) == Some(PreeditCell::Continuation) { + 2 + } else { + 1 + }; + emit_preedit_cluster( + pre.line.cluster(cluster), + col as u16, + y, + reserved_cells, + rasterizer, + grid, + font_library, + route_id, + size_u16, + size_bucket, + cell_w, + cell_h, + text_fg, + fg_scratch, + ); + } + } + // Phase 3: strikethrough pass. Emitted last so the strike overlays // the glyph. emit_strikethroughs( @@ -2305,8 +2495,285 @@ pub fn build_row_fg( thickness, row_sel, row_hints, + preedit, + glyph_registry.as_ref(), fg_scratch, ); + + // Phase 4: the IME caret, topmost element of the composition. + // On a composition cell it must not be a beam — cursor color on + // the cursor-colored block is invisible — so it renders as a + // thick underline in the text color instead; past the end of the + // composition there is no block, so a beam in the cursor color + // reads correctly there. + if let Some(pre) = preedit { + let caret = match pre.line.caret { + PreeditCaret::OnCell(col) => Some(( + col, + DecorationStyle::ImeCaretUnderline, + normalized_to_u8(palette.named_colors().background.0), + )), + PreeditCaret::PastEnd(col) => { + Some((col, DecorationStyle::ImeCaretBeam, pre.block_bg)) + } + // The IME asked for no caret (candidate paging). + PreeditCaret::Hidden => None, + }; + if let Some((col, style, color)) = caret { + if col < cols { + emit_preedit_caret( + col as u16, y, grid, cell_w_u32, cell_h_u32, thickness, style, color, + fg_scratch, + ); + } + // A caret underline on a wide cluster covers both of its + // cells; one cell would underline half the kanji. + if matches!(style, DecorationStyle::ImeCaretUnderline) + && pre.cell(col + 1) == Some(PreeditCell::Continuation) + && col + 1 < cols + { + emit_preedit_caret( + (col + 1) as u16, + y, + grid, + cell_w_u32, + cell_h_u32, + thickness, + style, + color, + fg_scratch, + ); + } + } + } +} + +/// Fill the run scratch with `text`, hash it into the composition +/// cache namespace (the leading "PREE" sentinel keeps it disjoint from +/// the grid's per-cell keys), shape it (cached), and return +/// `(hash, ascent, summed advance)`. `None` means no shaping handle. +fn shape_preedit_text( + rasterizer: &mut GridGlyphRasterizer, + text: &str, + font_id: u32, + size_u16: u16, + size_bucket: u16, + font_library: &FontLibrary, +) -> Option<(u64, i16, f32)> { + #[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]; + for ch in text.chars() { + 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_str(text); + } + + rasterizer.run_hasher = rapidhash::fast::RapidHasher::default(); + rasterizer.run_hasher.write_u32(0x5052_4545); // "PREE" + for (i, ch) in text.chars().enumerate() { + rasterizer.run_hasher.write_u32(ch as u32); + rasterizer.run_hasher.write_u32(i as u32); + } + rasterizer.run_hasher.write_u32(font_id); + rasterizer.run_hasher.write_u16(size_bucket); + let hash = rasterizer.run_hasher.finish(); + + let (ascent_px, advance) = shape_cached( + rasterizer, + hash, + font_id, + size_u16, + size_bucket, + font_library, + )?; + Some((hash, ascent_px, advance)) +} + +/// Shape one grapheme cluster as a standalone run and emit its glyphs +/// at `(grid_col, y)` with a forced foreground color. Composition +/// cells always shape in the plain style: composing text shouldn't +/// inherit bold/italic from whatever prompt segment sat under the +/// cursor. +#[allow(clippy::too_many_arguments)] +fn emit_preedit_cluster( + cluster: &str, + grid_col: u16, + y: u16, + reserved_cells: usize, + rasterizer: &mut GridGlyphRasterizer, + grid: &mut GridRenderer, + font_library: &FontLibrary, + route_id: usize, + size_u16: u16, + size_bucket: u16, + cell_w: f32, + cell_h: f32, + text_fg: [u8; 4], + fg_scratch: &mut Vec, +) { + let Some(base) = cluster.chars().next() else { + return; + }; + let run_style_flags = 0u8; + let (font_id, is_emoji) = + rasterizer.resolve_font(base, run_style_flags, font_library, route_id); + + // Layout reserves 1 or 2 cells per cluster; shaping draws natural + // width. Per-char width sums misjudge both directions (a ZWJ emoji + // sums to 6 cells yet shapes to ~2, a conjunct sums to 2 yet can + // ink 3), so the overflow decision uses the SHAPED advance: a full + // cluster overflowing its reserved cells retries as the base char. + // A base (or single) char that still overflows draws anyway: + // hiding the character the user is actively composing is a worse + // artifact than its transient spill next to the block. The + // half-cell slack absorbs color-font advance quirks. + let max_advance = reserved_cells as f32 * cell_w + cell_w * 0.5; + let base_str = &cluster[..base.len_utf8()]; + let Some((hash, ascent_px, advance)) = shape_preedit_text( + rasterizer, + cluster, + font_id, + size_u16, + size_bucket, + font_library, + ) else { + return; + }; + let (hash, ascent_px) = if advance <= max_advance || base_str.len() == cluster.len() { + (hash, ascent_px) + } else { + let Some((hash, ascent_px, _)) = shape_preedit_text( + rasterizer, + base_str, + font_id, + size_u16, + size_bucket, + font_library, + ) else { + return; + }; + (hash, 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") + .glyphs; + 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, + // We computed the inverse foreground ourselves; the shader + // must not swap it again on the forced-cursor cell. + bools: CellText::BOOL_IS_CURSOR_GLYPH, + page: slot.page, + _pad: 0, + }); + } +} + +/// Emit the IME caret decoration sprite at `(col, y)`. +#[allow(clippy::too_many_arguments)] +fn emit_preedit_caret( + col: u16, + y: u16, + grid: &mut GridRenderer, + cell_w: u32, + cell_h: u32, + thickness: u32, + style: DecorationStyle, + color: [u8; 4], + fg_scratch: &mut Vec, +) { + let Some(slot) = ensure_decoration_slot(grid, style, 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, + }); +} + +/// Whether a registered custom glyph's render span reaches the +/// composition block from `col`: the fg pass drops such a glyph, so +/// its decorations must vanish with it. One predicate for both +/// decoration emitters; the span rule mirrors +/// `ensure_custom_glyph_by_codepoint`'s clamp to the protocol's 1..=2. +/// The `covers_ink(col, 2)` prefilter keeps the registry (an RwLock) +/// out of cells that are not next to the block, and callers run this +/// only after the cell is known to carry a decoration. +fn custom_glyph_ink_covered( + pre: &PreeditRow<'_>, + registry: Option<&rio_backend::sugarloaf::font::glyph_registry::GlyphRegistry>, + sq: Square, + col: usize, +) -> bool { + let Some(registry) = registry else { + return false; + }; + pre.covers_ink(col, 2) + && registry.get(sq.c() as u32).is_some_and(|entry| { + pre.covers_ink(col, (entry.width as u16).clamp(1, 2) as usize) + }) } #[allow(clippy::too_many_arguments)] @@ -2323,10 +2790,18 @@ fn emit_underlines( thickness: u32, row_sel: Option, row_hints: &[RowHint], + preedit: Option<&PreeditRow<'_>>, + glyph_registry: Option<&rio_backend::sugarloaf::font::glyph_registry::GlyphRegistry>, fg_scratch: &mut Vec, ) { for x in 0..cols { let sq = row[Column(x)]; + // Composing text takes no decorations from whatever sat under + // it, and either half of a wide glyph the fg pass dropped must + // not leave a floating decoration next to the block. + if preedit.is_some_and(|p| p.suppresses(sq, x)) { + continue; + } let style = resolve_style(row_styles, x); let col = x as u16; // SGR underline (UNDER, double, curly, …) wins over the @@ -2341,6 +2816,9 @@ fn emit_underlines( } None => continue, }; + if preedit.is_some_and(|p| custom_glyph_ink_covered(p, glyph_registry, sq, x)) { + continue; + } let Some(slot) = ensure_decoration_slot(grid, deco, cell_w, cell_h, thickness) else { continue; @@ -2394,14 +2872,25 @@ fn emit_strikethroughs( thickness: u32, row_sel: Option, row_hints: &[RowHint], + preedit: Option<&PreeditRow<'_>>, + glyph_registry: Option<&rio_backend::sugarloaf::font::glyph_registry::GlyphRegistry>, fg_scratch: &mut Vec, ) { for x in 0..cols { let sq = row[Column(x)]; + // Composing text takes no decorations from whatever sat under + // it, and either half of a wide glyph the fg pass dropped must + // not leave a floating decoration next to the block. + if preedit.is_some_and(|p| p.suppresses(sq, x)) { + continue; + } let style = resolve_style(row_styles, x); if !style.flags.contains(StyleFlags::STRIKEOUT) { continue; } + if preedit.is_some_and(|p| custom_glyph_ink_covered(p, glyph_registry, sq, x)) { + continue; + } let Some(slot) = ensure_decoration_slot( grid, DecorationStyle::Strikethrough, @@ -2825,6 +3314,119 @@ mod hint_label_tests { } } +#[cfg(test)] +mod preedit_suppression_tests { + use super::*; + use preedit::{PreeditCursor, PreeditLine}; + + /// Shape `text` through the REAL production path + /// (`shape_preedit_text`, the fn `emit_preedit_cluster` calls) and + /// return the summed advance: exactly the quantity the composed- + /// cluster overflow fallback keys on, from the same code. + fn shaped_advance( + r: &mut GridGlyphRasterizer, + lib: &FontLibrary, + text: &str, + size: u16, + ) -> Option { + let base = text.chars().next()?; + let (font_id, _) = r.resolve_font(base, 0, lib, 0); + shape_preedit_text(r, text, font_id, size, size, lib) + .map(|(_, _, advance)| advance) + } + + /// A ZWJ emoji must fit its 2 reserved cells under the shaped- + /// advance overflow rule, or IME candidate selection would degrade + /// it to the base person glyph. Per-char width sums say 6 cells; + /// the shaped advance is the truth this pins. + #[test] + fn zwj_emoji_shaped_advance_fits_reserved_cells() { + let font_library = FontLibrary::default(); + let mut r = GridGlyphRasterizer::new(); + let size: u16 = 28; + let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; + let Some(family_advance) = shaped_advance(&mut r, &font_library, family, size) + else { + // No shaping handle for this font in the environment: + // nothing to measure. + return; + }; + assert!(family_advance > 0.0); + + // In a monospace font every narrow advance IS the cell width. + // Skip like the emoji guard above when the primary font has no + // shaping handle in this environment. + let Some(cell_w) = shaped_advance(&mut r, &font_library, "m", size) else { + return; + }; + // Reserved 2 cells + the fallback's half-cell slack. + let max_advance = 2.0 * cell_w + cell_w * 0.5; + // Strict only where CI ships a real emoji font (Apple Color + // Emoji); a fontless Linux container may shape to notdef with + // arbitrary metrics. + #[cfg(target_os = "macos")] + assert!( + family_advance <= max_advance, + "family emoji advance {family_advance} exceeds {max_advance}: \ + the IME composition would degrade it to the base glyph" + ); + #[cfg(not(target_os = "macos"))] + let _ = max_advance; + } + + /// `covers_ink` is the one predicate every fg emitter consults to + /// drop ink that would land on the composition block (wide glyphs + /// whose spacer sits under it, custom glyphs whose render span + /// reaches into it). + #[test] + fn covers_ink_spans() { + // Block occupies columns 4..8 ("日本" at cursor col 4). + let line = PreeditLine::new("日本", PreeditCursor::Byte(6), 0, 4, 80).unwrap(); + let pre = PreeditRow { + line: &line, + block_bg: [0; 4], + }; + // One-cell ink left of the block never triggers. + assert!(!pre.covers_ink(3, 1)); + // Two-cell ink at column 3 reaches the block's first cell. + assert!(pre.covers_ink(3, 2)); + // Inside the block. + assert!(pre.covers_ink(5, 1)); + // Ink starting at the block's end is clear of it. + assert!(!pre.covers_ink(8, 2)); + } + + /// A wide pair half-covered by the block suppresses BOTH halves, + /// whichever half the block touches: glyphs and decorations vanish + /// together, never a floating underline under an empty half-cell. + #[test] + fn suppresses_covers_half_covered_wide_pairs() { + // Block occupies columns 4..8 ("日本" at cursor col 4). + let line = PreeditLine::new("日本", PreeditCursor::Byte(6), 0, 4, 80).unwrap(); + let pre = PreeditRow { + line: &line, + block_bg: [0; 4], + }; + let narrow = Square::default(); + let mut wide = Square::default(); + wide.set_wide(Wide::Wide); + let mut spacer = Square::default(); + spacer.set_wide(Wide::Spacer); + + // Plain cells: only covered columns suppress. + assert!(!pre.suppresses(narrow, 3)); + assert!(pre.suppresses(narrow, 4)); + // Wide base at 3: its spacer at 4 is under the block. + assert!(pre.suppresses(wide, 3)); + assert!(!pre.suppresses(wide, 1)); + // Spacer at 8: its base at 7 is under the block. + assert!(pre.suppresses(spacer, 8)); + assert!(!pre.suppresses(spacer, 9)); + // Column 0 spacer never underflows. + assert!(!pre.suppresses(spacer, 0)); + } +} + #[cfg(test)] mod cluster_text_tests { use super::*; diff --git a/rio-grid/src/preedit.rs b/rio-grid/src/preedit.rs new file mode 100644 index 0000000000..c3bccb77a6 --- /dev/null +++ b/rio-grid/src/preedit.rs @@ -0,0 +1,368 @@ +//! Inline IME preedit: the composition string drawn as a wezterm-style +//! block at the cursor, on a single row. +//! +//! Layout rules, in priority order: +//! +//! - The composition always occupies exactly one row — the cursor row. +//! When it doesn't fit between the cursor column and the right edge, +//! it slides left (the wezterm/alacritty behavior); when it is wider +//! than the whole row, leading clusters are dropped so the tail — +//! where the IME caret lives — stays visible. It never wraps to the +//! line below and never truncates silently at the right edge. +//! - Text is segmented into grapheme clusters, not chars: a cluster is +//! what the user perceives as one character, so a ZWJ emoji or a +//! decomposed `e` + U+0302 occupies one slot and shapes as one unit. +//! Cluster width is clamped to 1 or 2 cells, the only widths a +//! terminal cell can express. +//! - The caret marks where IME editing (arrow keys between conversion +//! segments) currently is: on a composition cell it renders as a +//! thick underline (visible on the cursor-colored block), past the +//! end of the text as a beam on the following cell (visible on the +//! normal background). Both presentations exist because a beam drawn +//! in the cursor color on a cursor-colored block is invisible. When +//! the IME reports a hidden caret (candidate paging), none renders. +//! +//! The caller decides visibility: the overlay is only built for the +//! active panel and never while the viewport is scrolled into history +//! (`display_offset != 0`), where the cursor row is off-screen and the +//! anchor would lie. + +use rio_unicode::grapheme::GraphemeIndices; +use rio_unicode::UnicodeWidthStr; + +/// Where the IME put its caret, as the platform reported it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreeditCursor { + /// The IME asked for no visible caret. Wayland (`cursor_begin = + /// -1`) and Windows deliver this with non-empty text while the + /// user pages through conversion candidates; drawing a caret then + /// would contradict the IME's own UI. + Hidden, + /// Caret before the cluster containing this byte offset; offsets + /// at or past the end of the text mean end-of-text. + Byte(usize), +} + +/// One cell of the composition line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreeditCell { + /// Leading cell of a grapheme cluster; index into `clusters`. + Start(u16), + /// Trailing cell of a two-cell cluster. No glyph of its own — the + /// leading cell's advance covers it — but it takes the block fill. + Continuation, +} + +/// Where and how the IME caret renders. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreeditCaret { + /// Caret on a composition cell: thick underline under that cell. + OnCell(usize), + /// Caret one past the composition: beam on that (non-block) cell. + PastEnd(usize), + /// The IME asked for no caret (candidate paging): draw none. + Hidden, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PreeditLine { + /// Screen row (viewport-relative) the composition renders on. + pub row: usize, + /// First column of the block. + pub start_col: usize, + /// Dense cells from `start_col`; never extends past the row edge. + cells: Vec, + /// Grapheme clusters, indexed by `PreeditCell::Start`. + clusters: Vec, + pub caret: PreeditCaret, +} + +impl PreeditLine { + /// Lay out the composition `text` anchored at the cursor cell. + /// Returns `None` for an empty composition or a degenerate grid. + /// `cursor` byte offsets never slice `text`, only compare against + /// cluster starts, so intra-cluster and non-char-boundary offsets + /// are safe and mark the cluster containing them. + pub fn new( + text: &str, + cursor: PreeditCursor, + cursor_row: usize, + cursor_col: usize, + columns: usize, + ) -> Option { + if text.is_empty() || columns == 0 { + return None; + } + + // Segment into clusters with terminal cell widths. + struct Seg<'a> { + byte_start: usize, + cluster: &'a str, + width: usize, + } + let mut segs: Vec = GraphemeIndices::new(text) + .map(|(byte_start, cluster)| Seg { + byte_start, + cluster, + width: match UnicodeWidthStr::width(cluster) { + 0 | 1 => 1, + _ => 2, + }, + }) + .collect(); + if segs.is_empty() { + return None; + } + + // The caret sits on the cluster CONTAINING the byte offset, so + // an intra-cluster offset (jamo-level Korean IMEs report these) + // marks the cluster being edited, not its neighbor; offsets at + // or past the end mean end-of-text. A hidden caret (candidate + // paging) stays hidden. + let mut caret_index = match cursor { + PreeditCursor::Hidden => None, + PreeditCursor::Byte(offset) if offset >= text.len() => Some(segs.len()), + PreeditCursor::Byte(offset) => Some( + segs.iter() + .rposition(|seg| seg.byte_start <= offset) + .unwrap_or(0), + ), + }; + + // Wider than the row: drop leading clusters, keeping the tail + // (and with it the caret, which lives near the end while + // composing). A caret pointing into the dropped region clamps + // to the first visible cluster. + let mut total: usize = segs.iter().map(|seg| seg.width).sum(); + let mut dropped = 0usize; + while total > columns && segs.len() > 1 { + total -= segs.remove(0).width; + dropped += 1; + } + caret_index = caret_index.map(|index| index.saturating_sub(dropped)); + if total > columns { + // A single cluster wider than the grid; nothing sane to draw. + return None; + } + + // Anchor at the cursor, sliding left so the tail stays on-row. + let start_col = cursor_col.min(columns - total); + + let mut cells = Vec::with_capacity(total); + let mut clusters = Vec::with_capacity(segs.len()); + let mut caret = match caret_index { + Some(_) => None, + None => Some(PreeditCaret::Hidden), + }; + for (i, seg) in segs.iter().enumerate() { + if caret_index == Some(i) { + caret = Some(PreeditCaret::OnCell(start_col + cells.len())); + } + cells.push(PreeditCell::Start(clusters.len() as u16)); + for _ in 1..seg.width { + cells.push(PreeditCell::Continuation); + } + clusters.push(seg.cluster.to_string()); + } + let caret = caret.unwrap_or({ + let past = start_col + cells.len(); + if past < columns { + PreeditCaret::PastEnd(past) + } else { + // Composition touches the right edge: underline the + // last cluster instead of pushing the beam off-row. + let last_start = cells + .iter() + .rposition(|c| matches!(c, PreeditCell::Start(_))) + .unwrap_or(0); + PreeditCaret::OnCell(start_col + last_start) + } + }); + + Some(Self { + row: cursor_row, + start_col, + cells, + clusters, + caret, + }) + } + + /// The composition cell at an absolute column, bounds-checked on + /// both sides. + #[inline] + pub fn cell(&self, col: usize) -> Option { + col.checked_sub(self.start_col) + .and_then(|i| self.cells.get(i)) + .copied() + } + + #[inline] + pub fn cluster(&self, index: u16) -> &str { + self.clusters + .get(index as usize) + .map(String::as_str) + .unwrap_or_default() + } + + /// Column one past the block's last cell. + #[inline] + pub fn end_col(&self) -> usize { + self.start_col + self.cells.len() + } + + /// Column the OS candidate popup should anchor to: the caret, + /// or the last composition cell when the caret is hidden. + #[inline] + pub fn popup_anchor_col(&self) -> usize { + match self.caret { + PreeditCaret::OnCell(col) | PreeditCaret::PastEnd(col) => col, + PreeditCaret::Hidden => self.end_col().saturating_sub(1), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layout(text: &str, caret: Option) -> Option { + layout_at(text, caret, 0, 0) + } + + fn layout_at( + text: &str, + caret: Option, + row: usize, + col: usize, + ) -> Option { + // `None` = end-of-text, the common IME idiom while composing. + let cursor = PreeditCursor::Byte(caret.unwrap_or(text.len())); + PreeditLine::new(text, cursor, row, col, 80) + } + + #[test] + fn ascii_at_cursor() { + let line = layout_at("abc", Some(3), 5, 10).unwrap(); + assert_eq!(line.row, 5); + assert_eq!(line.start_col, 10); + assert_eq!(line.cell(10), Some(PreeditCell::Start(0))); + assert_eq!(line.cell(12), Some(PreeditCell::Start(2))); + assert_eq!(line.cell(13), None); + assert_eq!(line.cell(9), None); + assert_eq!(line.caret, PreeditCaret::PastEnd(13)); + } + + #[test] + fn wide_clusters_take_two_cells() { + // "日本" = two clusters, two cells each. + let line = layout("日本", None).unwrap(); + assert_eq!(line.cell(0), Some(PreeditCell::Start(0))); + assert_eq!(line.cell(1), Some(PreeditCell::Continuation)); + assert_eq!(line.cell(2), Some(PreeditCell::Start(1))); + assert_eq!(line.cell(3), Some(PreeditCell::Continuation)); + assert_eq!(line.caret, PreeditCaret::PastEnd(4)); + } + + #[test] + fn grapheme_clusters_stay_whole() { + // Decomposed e + combining circumflex: one cluster, one cell. + let line = layout("e\u{302}x", None).unwrap(); + assert_eq!(line.cell(0), Some(PreeditCell::Start(0))); + assert_eq!(line.cluster(0), "e\u{302}"); + assert_eq!(line.cell(1), Some(PreeditCell::Start(1))); + assert_eq!(line.cluster(1), "x"); + + // ZWJ family emoji: one cluster, clamped to two cells. + let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; + let line = layout(family, None).unwrap(); + assert_eq!(line.cell(0), Some(PreeditCell::Start(0))); + assert_eq!(line.cell(1), Some(PreeditCell::Continuation)); + assert_eq!(line.cell(2), None); + assert_eq!(line.cluster(0), family); + } + + #[test] + fn slides_left_at_the_right_edge() { + // Cursor at col 77 of 80; "日本語" needs 6 cells → starts at 74. + let line = layout_at("日本語", None, 24, 77).unwrap(); + assert_eq!(line.start_col, 74); + assert_eq!(line.end_col(), 80); + // Caret would land at col 80 — clamped to an underline on 語. + assert_eq!(line.caret, PreeditCaret::OnCell(78)); + // Bottom row, no wrap: the row is exactly the cursor row. + assert_eq!(line.row, 24); + } + + #[test] + fn longer_than_the_row_keeps_the_tail() { + let text = "あ".repeat(50); // 100 cells wide + let line = layout_at(&text, None, 0, 10).unwrap(); + // 10 clusters dropped: 40 remain (80 cells), flush to col 0. + assert_eq!(line.start_col, 0); + assert_eq!(line.end_col(), 80); + assert_eq!(line.cluster(0), "あ"); + assert_eq!(line.caret, PreeditCaret::OnCell(78)); + } + + #[test] + fn caret_inside_composition_is_an_underline() { + // Caret before 本 (byte offset 3). + let line = layout("日本語", Some(3)).unwrap(); + assert_eq!(line.caret, PreeditCaret::OnCell(2)); + // A char-boundary offset inside a grapheme cluster (between + // `e` and its combining mark) marks the cluster being edited: + // the one CONTAINING the offset, as jamo-level Korean IMEs + // expect. + let line = layout("e\u{302}x", Some(1)).unwrap(); + assert_eq!(line.caret, PreeditCaret::OnCell(0)); + // An intra-cluster offset in a wide cluster stays on it. + let line = layout("日本語", Some(4)).unwrap(); + assert_eq!(line.caret, PreeditCaret::OnCell(2)); + } + + #[test] + fn korean_intra_syllable_caret_marks_the_syllable() { + // Jamo-level Korean IMEs report byte offsets inside an NFC + // syllable while it is being edited; the caret must sit on + // that syllable, not at the end of the composition. + let line = layout("한글", Some(1)).unwrap(); + assert_eq!(line.caret, PreeditCaret::OnCell(0)); + let line = layout("한글", Some(4)).unwrap(); + assert_eq!(line.caret, PreeditCaret::OnCell(2)); + // The syllable boundary itself belongs to the next syllable. + let line = layout("한글", Some(3)).unwrap(); + assert_eq!(line.caret, PreeditCaret::OnCell(2)); + // End-of-text stays a PastEnd beam. + let line = layout("한글", Some(6)).unwrap(); + assert_eq!(line.caret, PreeditCaret::PastEnd(4)); + } + + #[test] + fn caret_survives_tail_cropping() { + let text = "あ".repeat(50); + // Caret at the very start, which gets dropped: clamps to the + // first visible cluster. + let line = layout(&text, Some(0)).unwrap(); + assert_eq!(line.caret, PreeditCaret::OnCell(0)); + } + + #[test] + fn hidden_caret_draws_no_caret() { + // Wayland/Windows report a hidden caret while the user pages + // through candidates; no caret may render then. + let line = PreeditLine::new("日本語", PreeditCursor::Hidden, 0, 0, 80).unwrap(); + assert_eq!(line.caret, PreeditCaret::Hidden); + assert_eq!(line.popup_anchor_col(), 5); + // Cropping keeps it hidden too. + let long = "あ".repeat(50); + let line = PreeditLine::new(&long, PreeditCursor::Hidden, 0, 0, 80).unwrap(); + assert_eq!(line.caret, PreeditCaret::Hidden); + } + + #[test] + fn empty_and_degenerate_input() { + assert!(layout("", None).is_none()); + assert!(PreeditLine::new("a", PreeditCursor::Byte(1), 0, 0, 0).is_none()); + } +} diff --git a/rio-window/src/platform_impl/windows/event_loop.rs b/rio-window/src/platform_impl/windows/event_loop.rs index ffc2939284..fd9d615e1c 100644 --- a/rio-window/src/platform_impl/windows/event_loop.rs +++ b/rio-window/src/platform_impl/windows/event_loop.rs @@ -1745,9 +1745,14 @@ unsafe fn public_window_callback_inner( } WM_IME_SETCONTEXT => { - // Hide composing text drawn by IME. - let wparam = wparam & (!ISC_SHOWUICOMPOSITIONWINDOW as usize); - result = ProcResult::DefWindowProc(wparam); + // Suppress the OS-drawn floating composition window — rio + // renders the composition inline. The ISC_* UI flags live + // in `lparam` (`wparam` is the window-active BOOL); this + // used to mask `wparam` and therefore never suppressed + // anything. Same fix as upstream winit. + let lparam = lparam & !(ISC_SHOWUICOMPOSITIONWINDOW as isize); + result = + ProcResult::Value(unsafe { DefWindowProcW(window, msg, wparam, lparam) }); } // this is necessary for us to maintain minimize/restore state