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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 46 additions & 31 deletions frontends/rioterm/src/application.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -2016,52 +2016,67 @@ impl ApplicationHandler<EventPayload> 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();
}
}
}
}
Expand Down
8 changes: 1 addition & 7 deletions frontends/rioterm/src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -57,7 +56,6 @@ pub struct Context<T: EventListener> {
pub rich_text_id: usize,
pub dimension: ContextDimension,
pub title: ContextTitle,
pub ime: Ime,
_io_thread: Option<JoinHandle<(Machine<teletypewriter::Pty, T>, performer::State)>>,
}

Expand Down Expand Up @@ -95,9 +93,7 @@ impl<T: EventListener> Context<T> {
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,
}
}
}
Expand Down Expand Up @@ -172,7 +168,6 @@ pub fn create_dead_context<T: rio_backend::event::EventListener>(
rich_text_id,
dimension,
title: ContextTitle::default(),
ime: Ime::new(),
_io_thread: None,
}
}
Expand Down Expand Up @@ -346,7 +341,6 @@ impl<T: EventListener + Clone + std::marker::Send + 'static> ContextManager<T> {
renderable_content: RenderableContent::new(cursor_state.0.clone()),
dimension,
title: ContextTitle::default(),
ime: Ime::new(),
_io_thread: io_thread,
})
}
Expand Down
9 changes: 5 additions & 4 deletions frontends/rioterm/src/context/renderable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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)
}
Expand Down
69 changes: 46 additions & 23 deletions frontends/rioterm/src/ime.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<usize>,

/// The cursor offset from the end of the preedit in char width.
pub cursor_end_offset: Option<usize>,
/// 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<usize>) -> 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());
}
}
10 changes: 10 additions & 0 deletions frontends/rioterm/src/renderer/assistant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions frontends/rioterm/src/renderer/command_palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 20 additions & 11 deletions frontends/rioterm/src/renderer/island.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventProxy>,
) -> 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 {
Expand All @@ -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
}

Expand Down
23 changes: 8 additions & 15 deletions frontends/rioterm/src/renderer/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading