diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 99a9894f3..2a8778591 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -688,10 +688,18 @@ 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) => { 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: active_range_bytes.map(|(start_bytes, end_bytes)| { + let start_char = text[..start_bytes].chars().count(); + let end_char = + start_char + text[start_bytes..end_bytes].chars().count(); + start_char..end_char + }), + })); } winit::event::Ime::Commit(text) => { self.egui_input diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 7a104a95e..a6f94a32b 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -612,7 +612,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 c88ee45fe..1f72f2233 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -1038,6 +1038,7 @@ pub struct Visuals { pub widgets: Widgets, pub selection: Selection, + pub ime_preedit: ImePreedit, /// The color used for [`crate::Hyperlink`], pub hyperlink_color: Color32, @@ -1208,6 +1209,13 @@ pub struct Selection { pub stroke: Stroke, } +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct ImePreedit { + pub active_underline_stroke: Stroke, +} + /// Shape of the handle for sliders and similar widgets. #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -1491,6 +1499,7 @@ impl Visuals { weak_text_color: None, widgets: Widgets::default(), selection: Selection::default(), + ime_preedit: ImePreedit::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 @@ -1554,6 +1563,7 @@ impl Visuals { }, widgets: Widgets::light(), selection: Selection::light(), + ime_preedit: ImePreedit::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 @@ -1617,6 +1627,28 @@ impl Default for Selection { } } +impl ImePreedit { + fn dark() -> Self { + Self { + // Same as the default value of [`TextCursorStyle::stroke`] in dark mode. + active_underline_stroke: Stroke::new(2.0, Color32::from_rgb(192, 222, 255)), + } + } + + fn light() -> Self { + Self { + // Same as the default value of [`TextCursorStyle::stroke`] in light mode. + active_underline_stroke: Stroke::new(2.0, Color32::from_rgb(0, 83, 125)), + } + } +} + +impl Default for ImePreedit { + fn default() -> Self { + Self::dark() + } +} + impl Widgets { pub fn dark() -> Self { Self { @@ -2138,6 +2170,22 @@ impl Selection { } } +impl ImePreedit { + pub fn ui(&mut self, ui: &mut crate::Ui) { + let Self { + active_underline_stroke, + } = self; + + ui.label("IME preedit"); + + Grid::new("ime_preedit").num_columns(2).show(ui, |ui| { + ui.label("Active underline stroke"); + ui.add(active_underline_stroke); + ui.end_row(); + }); + } +} + impl WidgetVisuals { pub fn ui(&mut self, ui: &mut crate::Ui) { let Self { @@ -2194,6 +2242,7 @@ impl Visuals { weak_text_color, widgets, selection, + ime_preedit, hyperlink_color, faint_bg_color, extreme_bg_color, @@ -2401,6 +2450,7 @@ impl Visuals { ui.collapsing("Widgets", |ui| widgets.ui(ui)); ui.collapsing("Selection", |ui| selection.ui(ui)); + ui.collapsing("IME preedit", |ui| ime_preedit.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 e114ddb55..22c402e5f 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -1,6 +1,14 @@ use std::sync::Arc; -use crate::{Galley, Painter, Rect, Ui, Visuals, pos2, vec2}; +use emath::Pos2; +use epaint::{ + Stroke, + text::cursor::{CCursor, LayoutCursor}, +}; + +use crate::{ + Galley, Painter, Rect, Ui, Visuals, pos2, text_selection::text_cursor_state::cursor_rect, vec2, +}; use super::CCursorRange; @@ -121,6 +129,93 @@ pub fn paint_text_selection( } } +#[expect(clippy::too_many_arguments)] +pub fn paint_ime_preedit_text_visuals( + pos: Pos2, + ui: &Ui, + painter: &Painter, + galley: &Arc, + visuals: &Visuals, + row_height: f32, + preedit_range: std::ops::Range, + relative_active_range: Option>, + time_since_last_interaction: f64, +) { + if preedit_range.is_empty() { + return; + } + + let active_underline_stroke = visuals.ime_preedit.active_underline_stroke; + let inactive_underline_stroke = Stroke { + width: active_underline_stroke.width, + color: active_underline_stroke.color.linear_multiply(0.5), + }; + + paint_underlines( + pos, + painter, + galley, + galley.layout_from_cursor(preedit_range.start), + galley.layout_from_cursor(preedit_range.end), + inactive_underline_stroke, + ); + + let Some(relative_active_range) = relative_active_range else { + return; + }; + + if relative_active_range.is_empty() { + 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, + ); + } else { + 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), + inactive_underline_stroke, + ); + } +} + +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 + }; + + painter.line_segment( + [pos + vec2(left, row.size.y), pos + vec2(right, row.size.y)], + stroke, + ); + } +} + /// Paint one end of the selection, e.g. the primary cursor. /// /// This will never blink. @@ -130,7 +225,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 9905a2a55..3c5ea29ad 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -822,7 +822,10 @@ impl TextEdit<'_> { 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() + && 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); } @@ -854,12 +857,33 @@ 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; + match &state.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, + ui.visuals(), + 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 @@ -1173,25 +1197,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/epaint/src/text/cursor.rs b/crates/epaint/src/text/cursor.rs index a436ca1b1..3725612de 100644 --- a/crates/epaint/src/text/cursor.rs +++ b/crates/epaint/src/text/cursor.rs @@ -69,6 +69,13 @@ impl std::ops::SubAssign for CCursor { } } +impl PartialOrd for CCursor { + #[inline] + fn partial_cmp(&self, other: &Self) -> Option { + self.index.partial_cmp(&other.index) + } +} + /// Row/column cursor. /// /// This refers to rows and columns in layout terms--text wrapping creates multiple rows.