diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 1da7525ce..60d617d81 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -69,15 +69,55 @@ impl TextAgent { input.blur().ok(); input.focus().ok(); } - // if `is_composing` is true, then user is using IME, for example: emoji, pinyin, kanji, hangul, etc. - // In that case, the browser emits both `input` and `compositionupdate` events, - // and we need to ignore the `input` event. - if !text.is_empty() && !event.is_composing() { + + if event.is_composing() { + // if `is_composing` is true, then user is using IME, for + // example: emoji, pinyin, kanji, hangul, etc. In that case, + // the browser emits both `input` and `compositionupdate` + // events. + // We handle the composition update here instead of in the + // `compositionupdate` event because the selection range + // has not yet been updated when `compositionupdate` fires. + + let Some(text) = event.data() else { return }; + let selection_start = input + .selection_start() + .unwrap_or(None) + .map(|pos| pos as usize); + let selection_end = input + .selection_end() + .unwrap_or(None) + .map(|pos| pos as usize); + let active_range_chars = if let Some(selection_start) = selection_start + && let Some(selection_end) = selection_end + { + let text_utf16 = text.encode_utf16().collect::>(); + let text_before_selection = + String::from_utf16_lossy(&text_utf16[..selection_start]); + let text_in_selection = + String::from_utf16_lossy(&text_utf16[selection_start..selection_end]); + let count_before_selection = text_before_selection.chars().count(); + let count_in_selection = text_in_selection.chars().count(); + Some(count_before_selection..count_before_selection + count_in_selection) + } else { + None + }; + let event = egui::Event::Ime(egui::ImeEvent::Preedit { + text, + active_range_chars, + }); + runner.input.raw.events.push(event); + } else { + if text.is_empty() { + return; + } + input.set_value(""); let event = egui::Event::Text(text); runner.input.raw.events.push(event); - runner.needs_repaint.repaint_asap(); } + + runner.needs_repaint.repaint_asap(); } }; @@ -89,15 +129,6 @@ impl TextAgent { } }; - let on_composition_update = { - move |event: web_sys::CompositionEvent, runner: &mut AppRunner| { - let Some(text) = event.data() else { return }; - let event = egui::Event::Ime(egui::ImeEvent::Preedit(text)); - runner.input.raw.events.push(event); - runner.needs_repaint.repaint_asap(); - } - }; - let on_composition_end = { let input = input.clone(); move |event: web_sys::CompositionEvent, runner: &mut AppRunner| { @@ -111,7 +142,6 @@ impl TextAgent { runner_ref.add_event_listener(&input, "input", on_input)?; runner_ref.add_event_listener(&input, "compositionstart", on_composition_start)?; - runner_ref.add_event_listener(&input, "compositionupdate", on_composition_update)?; runner_ref.add_event_listener(&input, "compositionend", on_composition_end)?; // The canvas doesn't get keydown/keyup events when the text agent is focused, diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 5385eb948..908ac8ff3 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -699,10 +699,41 @@ impl State { // Wayland, but it doesn't matter to us. // See winit::event::Ime::Enabled | winit::event::Ime::Disabled => {} - winit::event::Ime::Preedit(text, _) => { + winit::event::Ime::Preedit(text, active_range_bytes) => { + let active_range_chars = match *active_range_bytes { + Some((start_bytes, end_bytes)) => { + if let (Some(start_chars), Some(middle_chars)) = ( + text.get(..start_bytes).map(|s| s.chars().count()), + text.get(start_bytes..end_bytes).map(|s| s.chars().count()), + ) { + if cfg!(target_os = "windows") && start_chars == 0 && middle_chars == 0 + { + // Workaround for a bug on Windows where `winit` + // incorrectly reports the cursor position at + // the start of the preedit text during + // composition with the builtin Korean IME. + // See: https://github.com/emilk/egui/pull/8083#issuecomment-4206742668 + // TODO(umajho): Remove this workaround once the + // `winit` bug is fixed and we've updated to a + // version that includes the fix. + None + } else { + Some(start_chars..start_chars + middle_chars) + } + } else { + log::warn!("ignoring {ime:?}'s range because it is invalid"); + None + } + } + None => None, + }; + self.egui_input .events - .push(egui::Event::Ime(egui::ImeEvent::Preedit(text.clone()))); + .push(egui::Event::Ime(egui::ImeEvent::Preedit { + text: text.clone(), + active_range_chars, + })); } winit::event::Ime::Commit(text) => { self.egui_input diff --git a/crates/egui/src/data/input/ime_event.rs b/crates/egui/src/data/input/ime_event.rs index fb28c1cb4..de12a920f 100644 --- a/crates/egui/src/data/input/ime_event.rs +++ b/crates/egui/src/data/input/ime_event.rs @@ -12,7 +12,10 @@ pub enum ImeEvent { /// /// An empty preedit string indicates that the IME has been dismissed, while /// a non-empty preedit string indicates that the IME is active. - Preedit(String), + Preedit { + text: String, + active_range_chars: Option>, + }, /// IME composition ended with this final result. /// diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index b32f2fb4c..83a05beff 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -1026,6 +1026,7 @@ pub struct Visuals { pub widgets: Widgets, pub selection: Selection, + pub ime_composition: ImeComposition, /// The color used for [`crate::Hyperlink`], pub hyperlink_color: Color32, @@ -1192,6 +1193,36 @@ pub struct Selection { pub stroke: Stroke, } +/// Visual style for IME composition. +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct ImeComposition { + /// Stroke used to underline the actively composed segment. + pub active_underline_stroke: Stroke, + + /// Stroke used to underline those non-active segments. + pub inactive_underline_stroke: Stroke, + + /// If `true`, IME (Input Method Editor) composition (preedit) text is rendered + /// the legacy way: visually indistinguishable from a text selection, with the + /// cursor always shown at the end of the composition. + /// + /// If `false`, egui renders proper IME composition visuals: the cursor position + /// inside the composition is shown, and the active conversion segment is + /// highlighted (using the strokes configured above) distinctly from the rest of the + /// composition. This makes composing Chinese, Japanese and Korean text much + /// clearer. + /// + /// The legacy visuals have known shortcomings, but the new visuals are not yet + /// fully reliable on every platform either (e.g. `winit` reports an incorrect + /// cursor position for Korean IMEs on Windows), so this remains configurable. + /// + /// Defaults to `true` on Windows (because of the aforementioned `winit` bug) and + /// to `false` everywhere else. + pub legacy_visuals: bool, +} + /// Shape of the handle for sliders and similar widgets. #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -1468,6 +1499,7 @@ impl Visuals { weak_text_color: None, widgets: Widgets::default(), selection: Selection::default(), + ime_composition: ImeComposition::default(), hyperlink_color: Color32::from_rgb(90, 170, 255), faint_bg_color: Color32::from_additive_luminance(5), // visible, but barely so extreme_bg_color: Color32::from_gray(10), // e.g. TextEdit background @@ -1531,6 +1563,7 @@ impl Visuals { }, widgets: Widgets::light(), selection: Selection::light(), + ime_composition: ImeComposition::light(), hyperlink_color: Color32::from_rgb(0, 155, 255), faint_bg_color: Color32::from_additive_luminance(5), // visible, but barely so extreme_bg_color: Color32::from_gray(255), // e.g. TextEdit background @@ -1594,6 +1627,48 @@ impl Default for Selection { } } +impl ImeComposition { + fn dark() -> Self { + // Same as the default value of [`TextCursorStyle::stroke`] in dark mode. + let active_underline_stroke = Stroke::new(2.0, Color32::from_rgb(192, 222, 255)); + let inactive_underline_stroke = Stroke { + width: active_underline_stroke.width, + color: active_underline_stroke.color.linear_multiply(0.5), + }; + Self { + active_underline_stroke, + inactive_underline_stroke, + legacy_visuals: Self::default_legacy_visuals(), + } + } + + fn light() -> Self { + // Same as the default value of [`TextCursorStyle::stroke`] in light mode. + let active_underline_stroke = Stroke::new(2.0, Color32::from_rgb(0, 83, 125)); + let inactive_underline_stroke = Stroke { + width: active_underline_stroke.width, + color: active_underline_stroke.color.linear_multiply(0.5), + }; + Self { + active_underline_stroke, + inactive_underline_stroke, + legacy_visuals: Self::default_legacy_visuals(), + } + } + + /// The default of [`Self::legacy_visuals`]: `true` on Windows (where `winit` + /// reports an incorrect cursor position for Korean IMEs), `false` elsewhere. + const fn default_legacy_visuals() -> bool { + cfg!(windows) + } +} + +impl Default for ImeComposition { + fn default() -> Self { + Self::dark() + } +} + impl Widgets { pub fn dark() -> Self { Self { @@ -2113,6 +2188,34 @@ impl Selection { } } +impl ImeComposition { + pub fn ui(&mut self, ui: &mut crate::Ui) { + let Self { + active_underline_stroke, + inactive_underline_stroke, + legacy_visuals, + } = self; + + ui.label("IME composition"); + + ui.checkbox(legacy_visuals, "Legacy visuals").on_hover_text( + "If enabled, IME composition (preedit) text looks like a text selection \ + with the cursor at the end. If disabled, the cursor position and active \ + conversion segment are shown.", + ); + + Grid::new("ime_composition").num_columns(2).show(ui, |ui| { + ui.label("Active underline stroke"); + ui.add(active_underline_stroke); + ui.end_row(); + + ui.label("Inactive underline stroke"); + ui.add(inactive_underline_stroke); + ui.end_row(); + }); + } +} + impl WidgetVisuals { pub fn ui(&mut self, ui: &mut crate::Ui) { let Self { @@ -2169,6 +2272,7 @@ impl Visuals { weak_text_color, widgets, selection, + ime_composition, hyperlink_color, faint_bg_color, extreme_bg_color, @@ -2376,6 +2480,7 @@ impl Visuals { ui.collapsing("Widgets", |ui| widgets.ui(ui)); ui.collapsing("Selection", |ui| selection.ui(ui)); + ui.collapsing("IME composition", |ui| ime_composition.ui(ui)); ui.collapsing("Misc", |ui| { ui.add(Slider::new(resize_corner_size, 0.0..=20.0).text("resize_corner_size")); diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs index b18cbc820..5b41fd902 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -1,6 +1,17 @@ use std::sync::Arc; -use crate::{Galley, Painter, Rect, Ui, Visuals, pos2, vec2}; +use emath::Pos2; +use epaint::{ + Stroke, + text::{ + CharIndex, + cursor::{CCursor, LayoutCursor}, + }, +}; + +use crate::{ + Galley, Painter, Rect, Ui, Visuals, pos2, text_selection::text_cursor_state::cursor_rect, vec2, +}; use super::CCursorRange; @@ -121,6 +132,135 @@ pub fn paint_text_selection( } } +#[expect(clippy::too_many_arguments)] +pub(crate) fn paint_ime_preedit_text_visuals( + pos: Pos2, + ui: &Ui, + painter: &Painter, + galley: &Arc, + row_height: f32, + preedit_range: std::ops::Range, + mut relative_active_range: Option>, + time_since_last_interaction: f64, +) { + /// Instead of implementing [`PartialOrd`] and [`Ord`] for [`CCursor`] to + /// make [`std::ops::Range::is_empty`] available, we use this helper + /// function instead. + /// + /// These traits are intentionally not implemented because + /// [`CCursor::prefer_next_row`] makes it difficult to define a clear + /// ordering between two [`CCursor`]s. + fn is_cursor_range_empty(range: &std::ops::Range) -> bool { + range.start.index == range.end.index + } + + if is_cursor_range_empty(&preedit_range) { + return; + } + + if let Some(relative_active_range) = &mut relative_active_range + && relative_active_range.end.index > preedit_range.end.index - preedit_range.start.index + { + relative_active_range.end.index = preedit_range.end.index - preedit_range.start.index; + } + + let visuals = ui.visuals(); + let active_underline_stroke = visuals.ime_composition.active_underline_stroke; + let inactive_underline_stroke = visuals.ime_composition.inactive_underline_stroke; + + if let Some(relative_active_range) = &relative_active_range + && !is_cursor_range_empty(relative_active_range) + { + if relative_active_range.start.index > CharIndex::ZERO { + paint_underlines( + pos, + painter, + galley, + galley.layout_from_cursor(preedit_range.start), + galley.layout_from_cursor(preedit_range.start + relative_active_range.start.index), + inactive_underline_stroke, + ); + } + + paint_underlines( + pos, + painter, + galley, + galley.layout_from_cursor(preedit_range.start + relative_active_range.start.index), + galley.layout_from_cursor(preedit_range.start + relative_active_range.end.index), + active_underline_stroke, + ); + + if !is_cursor_range_empty( + &(relative_active_range.end..(preedit_range.end - preedit_range.start.index)), + ) { + paint_underlines( + pos, + painter, + galley, + galley.layout_from_cursor(preedit_range.start + relative_active_range.end.index), + galley.layout_from_cursor(preedit_range.end), + inactive_underline_stroke, + ); + } + } else { + paint_underlines( + pos, + painter, + galley, + galley.layout_from_cursor(preedit_range.start), + galley.layout_from_cursor(preedit_range.end), + inactive_underline_stroke, + ); + } + + if let Some(relative_active_range) = relative_active_range + && is_cursor_range_empty(&relative_active_range) + { + let active_cursor = preedit_range.start + relative_active_range.start.index; + let cursor_rect = cursor_rect(galley, &active_cursor, row_height); + + paint_text_cursor( + ui, + painter, + cursor_rect.translate(pos.to_vec2()), + time_since_last_interaction, + ); + } +} + +fn paint_underlines( + pos: Pos2, + painter: &Painter, + galley: &Arc, + min: LayoutCursor, + max: LayoutCursor, + stroke: Stroke, +) { + for ri in min.row..=max.row { + let placed_row = &galley.rows[ri]; + let row = &placed_row.row; + + let left = if ri == min.row { + row.x_offset(min.column) + } else { + 0.0 + }; + let right = if ri == max.row { + row.x_offset(max.column) + } else { + row.size.x + }; + + let offset_y = placed_row.pos.y + row.size.y; + + painter.line_segment( + [pos + vec2(left, offset_y), pos + vec2(right, offset_y)], + stroke, + ); + } +} + /// Paint one end of the selection, e.g. the primary cursor. /// /// This will never blink. @@ -130,7 +270,7 @@ pub fn paint_cursor_end(painter: &Painter, visuals: &Visuals, cursor_rect: Rect) let top = cursor_rect.center_top(); let bottom = cursor_rect.center_bottom(); - painter.line_segment([top, bottom], (stroke.width, stroke.color)); + painter.line_segment([top, bottom], stroke); if false { // Roof/floor: diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 8075c57e8..ccc3a56c8 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -827,10 +827,15 @@ impl TextEdit<'_> { false }; + let should_paint_ime_visuals_the_legacy_way = ui.visuals().ime_composition.legacy_visuals; + if ui.is_rect_visible(inner_rect) { let has_focus = ui.memory(|mem| mem.has_focus(id)); - if has_focus && let Some(cursor_range) = state.cursor.range(&galley) { + if has_focus + && (state.cursor_purpose.is_selection() || should_paint_ime_visuals_the_legacy_way) + && let Some(cursor_range) = state.cursor.range(&galley) + { // Add text selection rectangles to the galley: paint_text_selection(&mut galley, ui.visuals(), &cursor_range, None); } @@ -862,12 +867,37 @@ impl TextEdit<'_> { // * Don't repaint the ui because of a blinking cursor in an app that is not in focus let viewport_has_focus = ui.input(|i| i.focused); if viewport_has_focus { - text_selection::visuals::paint_text_cursor( - ui, - &painter, - primary_cursor_rect, - now - state.last_interaction_time, - ); + let time_since_last_interaction = now - state.last_interaction_time; + let cursor_purpose = if should_paint_ime_visuals_the_legacy_way { + &TextEditCursorPurpose::Selection + } else { + &state.cursor_purpose + }; + match cursor_purpose { + TextEditCursorPurpose::Selection => { + text_selection::visuals::paint_text_cursor( + ui, + &painter, + primary_cursor_rect, + time_since_last_interaction, + ); + } + TextEditCursorPurpose::ImeComposition { active_range } => { + text_selection::visuals::paint_ime_preedit_text_visuals( + galley_pos, + ui, + &painter, + &galley, + row_height, + { + let [start, end] = cursor_range.sorted_cursors(); + start..end + }, + active_range.clone(), + time_since_last_interaction, + ); + } + } } if ui.memory(|mem| mem.owns_ime_events(id)) { // Set IME output (in screen coords) when text is editable and visible @@ -1182,25 +1212,37 @@ fn events( // integration, but since this guard is harmless for well- // behaved integrations and also fixes the issue described // above, it is good enough for now. - ImeEvent::Preedit(composition_text) | ImeEvent::Commit(composition_text) + ImeEvent::Preedit { + text: composition_text, + .. + } + | ImeEvent::Commit(composition_text) if composition_text.is_empty() - && !matches!( - state.cursor_purpose, - TextEditCursorPurpose::ImeComposition - ) => + && !state.cursor_purpose.is_ime_composition() => { None } - ImeEvent::Preedit(composition_text) | ImeEvent::Commit(composition_text) + ImeEvent::Preedit { + text: composition_text, + .. + } + | ImeEvent::Commit(composition_text) if composition_text == "\n" || composition_text == "\r" => { None } - ImeEvent::Preedit(preedit_text) => { + ImeEvent::Preedit { + text: preedit_text, + active_range_chars, + } => { state.cursor_purpose = if preedit_text.is_empty() { TextEditCursorPurpose::Selection } else { - TextEditCursorPurpose::ImeComposition + TextEditCursorPurpose::ImeComposition { + active_range: active_range_chars.clone().map(|range| { + CCursor::new(range.start)..CCursor::new(range.end) + }), + } }; let mut ccursor = clear_preedit_text(text, &cursor_range); diff --git a/crates/egui/src/widgets/text_edit/state.rs b/crates/egui/src/widgets/text_edit/state.rs index 48935c4f7..61ffb21ea 100644 --- a/crates/egui/src/widgets/text_edit/state.rs +++ b/crates/egui/src/widgets/text_edit/state.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use epaint::text::cursor::CCursor; + use crate::mutex::Mutex; use crate::{ @@ -85,6 +87,24 @@ pub(crate) enum TextEditCursorPurpose { #[default] Selection, - /// The cursor is used for IME composition. - ImeComposition, + /// The cursor is used for IME composition. Its direction is irrelevant in + /// this case. + ImeComposition { + /// An optional cursor/segment within the composing text itself, + /// relative to the start of the composing region. Its direction is + /// irrelevant. + /// + /// When `None`, no active range is displayed. + active_range: Option>, + }, +} + +impl TextEditCursorPurpose { + pub(crate) fn is_selection(&self) -> bool { + matches!(self, Self::Selection) + } + + pub(crate) fn is_ime_composition(&self) -> bool { + matches!(self, Self::ImeComposition { .. }) + } } diff --git a/crates/egui_kittest/tests/snapshots/test_ime_composition_visuals_cursor.png b/crates/egui_kittest/tests/snapshots/test_ime_composition_visuals_cursor.png new file mode 100644 index 000000000..ec2b4a5c7 --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/test_ime_composition_visuals_cursor.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93ce77fe1f570281d50eed4c8681f1fbb382bd5fc5de63e2d683e0c570c929ff +size 9065 diff --git a/crates/egui_kittest/tests/snapshots/test_ime_composition_visuals_segment.png b/crates/egui_kittest/tests/snapshots/test_ime_composition_visuals_segment.png new file mode 100644 index 000000000..305811697 --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/test_ime_composition_visuals_segment.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6664509aa41a9a235657d8af7f73bc3f1fcaf3df1aa20db1e06e02f0a2c8aeab +size 9005 diff --git a/crates/egui_kittest/tests/tests.rs b/crates/egui_kittest/tests/tests.rs index de22026a2..37d88da9a 100644 --- a/crates/egui_kittest/tests/tests.rs +++ b/crates/egui_kittest/tests/tests.rs @@ -218,3 +218,47 @@ fn test_remove_cursor() { "The button appearance should change" ); } + +#[test] +fn test_ime_composition_visuals() { + let mut harness = Harness::new_ui_state( + |ui, state| { + egui::TextEdit::multiline(state) + .desired_width(120.0) + .desired_rows(5) + .show(ui); + }, + "Hello. Bye.".to_owned(), + ); + + harness.fit_contents(); + + let text_edit = harness.get_by_role(egui::accesskit::Role::MultilineTextInput); + text_edit.focus(); + harness.run(); + + harness.key_press(egui::Key::Home); + for _ in 0.."Hello. ".len() { + harness.key_press(egui::Key::ArrowRight); + } + + let text = "Have you ever seen an IME composing English text? You now see it. "; + let text_index_1 = "Have you ever ".chars().count(); + let text_index_2 = "Have you ever seen an IME composing English text? " + .chars() + .count(); + + harness.event(egui::Event::Ime(egui::ImeEvent::Preedit { + text: text.to_owned(), + active_range_chars: Some(text_index_1..text_index_2), + })); + harness.run(); + harness.snapshot("test_ime_composition_visuals_segment"); + + harness.event(egui::Event::Ime(egui::ImeEvent::Preedit { + text: text.to_owned(), + active_range_chars: Some(text_index_2..text_index_2), + })); + harness.run(); + harness.snapshot("test_ime_composition_visuals_cursor"); +} diff --git a/crates/epaint/src/text/cursor.rs b/crates/epaint/src/text/cursor.rs index 5660c8322..ac2e4216d 100644 --- a/crates/epaint/src/text/cursor.rs +++ b/crates/epaint/src/text/cursor.rs @@ -48,6 +48,17 @@ impl std::ops::Add for CCursor { } } +impl std::ops::Add for CCursor { + type Output = Self; + + fn add(self, rhs: CharIndex) -> Self::Output { + Self { + index: self.index + rhs, + prefer_next_row: self.prefer_next_row, + } + } +} + impl std::ops::Sub for CCursor { type Output = Self; @@ -59,6 +70,17 @@ impl std::ops::Sub for CCursor { } } +impl std::ops::Sub for CCursor { + type Output = Self; + + fn sub(self, rhs: CharIndex) -> Self::Output { + Self { + index: self.index - rhs, + prefer_next_row: self.prefer_next_row, + } + } +} + impl std::ops::AddAssign for CCursor { fn add_assign(&mut self, rhs: usize) { self.index = self.index.saturating_add(rhs);