1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -04:00

Implement proper visuals for IME composition (#8083)

<!--
Please read the "Making a PR" section of
[`CONTRIBUTING.md`](https://github.com/emilk/egui/blob/main/CONTRIBUTING.md)
before opening a Pull Request!

* Keep your PR:s small and focused.
* The PR title is what ends up in the changelog, so make it descriptive!
* If applicable, add a screenshot or gif.
* If it is a non-trivial addition, consider adding a demo for it to
`egui_demo_lib`, or a new example.
* Do NOT open PR:s from your `master` branch, as that makes it hard for
maintainers to test and add commits to your PR.
* Remember to run `cargo fmt` and `cargo clippy`.
* Open the PR as a draft until you have self-reviewed it and run
`./scripts/check.sh`.
* When you have addressed a PR comment, mark it as resolved.

Please be patient! I will review your PR, but my time is limited!
-->

* Closes N/A
* [x] I have followed the instructions in the PR template

This PR adds visual support for IME composition, including the cursor
and conversion segment.
These visuals works (mostly) well on native platforms (`egui-winit`). On
the web (`eframe/web`), support is limited by browser capabilities:
Chromium works well, Firefox shows partial improvement, and Safari
remains subpar.

> [!NOTE]
>
> For `eframe` on Windows, this feature is currently gated behind the
`windows_new_ime_composition_visuals` feature flag.

## Details

We extend `egui::ImeEvent::Preedit(String)` to `egui::ImeEvent::Preedit
{ text: String, active_range_chars: Option<std::ops::Range<usize>> }`.
The new `active_range_chars` field enables rendering of:
- the cursor (when the range is empty), and
- the conversion segment (when the range is non-empty)

in IME composition.

In `egui-winit`, we now use the range provided by
`winit::event::Ime::Preedit` instead of ignoring it.

In `eframe/web`, we derive the range from `selectionStart` and
`selectionEnd` on the text agent. This mapping is fully accurate only in
Chromium, but represents the best available approach for now.

## Demonstrations

### Chinese IMEs (Shuangpin)

We can see where the cursor is now.

| What | With this PR | Without this PR |
|-|-|-|
| macOS builtin |<video
src=https://github.com/user-attachments/assets/487c7e7c-ef6d-4a86-8dbc-8c71871b4470
/>|<video
src=https://github.com/user-attachments/assets/49bd5a60-4b90-4e4a-99e0-cd01d3f7030c
/>|
| macOS builtin (light)|<video
src=https://github.com/user-attachments/assets/e84546f6-947b-4cea-a87e-fda903f49164
/>|——|
| Windows builtin |<video
src=https://github.com/user-attachments/assets/fd331884-1f0c-4822-a99e-8140aed54815
/>|——|
| Wayland iBus Intelligent Pinyin |<video
src=https://github.com/user-attachments/assets/b6796c75-1c4e-45e5-b43a-5d8dea320485
/>|——|
| Chromium (Chrome) macOS | Identical to `macOS builtin`. |——|
| Safari macOS | We can now differentiate between IME composition and
text selection, but we still can't tell where the cursor is. |——|
| Firefox (Zen) macOS | Identical to `macOS builtin`. |——|

### Japanese IMEs

We can see where the conversion segment is now.

| What | With this PR | Without this PR |
|-|-|-|
| macOS builtin |<video
src=https://github.com/user-attachments/assets/f2994cd4-a966-4ff0-9590-d263c202ec76
/>|<video
src=https://github.com/user-attachments/assets/7cf5ff35-003d-4f60-8fbf-15c725c3ecb9
/>|
| macOS builtin (light)|<video
src=https://github.com/user-attachments/assets/6f562bdd-12fc-4486-b37b-8fcf11643295
/>|——|
| Windows builtin |<video
src=https://github.com/user-attachments/assets/f0905659-5335-4034-abda-c25cf8f2fd57
/>|——|
| Wayland iBus Anthy |<video
src=https://github.com/user-attachments/assets/94cd3a24-3158-4d79-ae02-d9b30fdfa738
/>|——|
| Chromium (Chrome) macOS | Identical to `macOS builtin`. |——|
| Safari macOS | We can now differentiate between IME composition and
text selection, but we still can't tell where the conversion segment is.
|——|
| Firefox (Zen) macOS | (Limited improvement.) <video
src=https://github.com/user-attachments/assets/3daf9b63-6e75-467b-8515-31c2a44adf61
/> |——|

### Korean IMEs

We can clearly tell whether we are in composition (in contrast to
selection) now.

| What | With this PR | Without this PR |
|-|-|-|
|macOS builtin|<video
src=https://github.com/user-attachments/assets/73ca28c7-22a0-493f-8f4d-c6e59a2dec54
/>|<video
src=https://github.com/user-attachments/assets/f582de7d-7ec0-48fe-910f-0139ef1620d3
/>|
|macOS builtin (light)|<video
src=https://github.com/user-attachments/assets/269f03bd-6f95-498b-9fb1-1adcb043c738
/>|——|
| Windows builtin| (With a workaround for [this `winit`
bug](https://github.com/emilk/egui/pull/8083#issuecomment-4206742668)
applied.) <video
src=https://github.com/user-attachments/assets/1e82583d-0c41-4f1c-98cf-0606bee5af05
/>|——|
| Wayland iBus Hangul |<video
src=https://github.com/user-attachments/assets/8c9a0de1-9027-4b37-93a3-e9da0251d176
/>|——|
| Chromium (Chrome) macOS | Identical to `macOS builtin`. |——|
| Safari macOS | Identical to `Windows builtin`. |——|
| Firefox (Zen) macOS | Identical to `macOS builtin`. (ignoring the fact
that the composition breaks when typing the second Hangul. (This bug
predates this PR.)) |——|

---------

Co-authored-by: lucasmerlin <hi@lucasmerlin.me>
This commit is contained in:
Umaĵo
2026-06-26 00:21:19 +08:00
committed by GitHub
parent a8d09eb60d
commit 5bf62ca4b3
11 changed files with 480 additions and 37 deletions

View File

@@ -69,15 +69,55 @@ impl TextAgent {
input.blur().ok(); input.blur().ok();
input.focus().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, if event.is_composing() {
// and we need to ignore the `input` event. // if `is_composing` is true, then user is using IME, for
if !text.is_empty() && !event.is_composing() { // 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::<Vec<u16>>();
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(""); input.set_value("");
let event = egui::Event::Text(text); let event = egui::Event::Text(text);
runner.input.raw.events.push(event); 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 on_composition_end = {
let input = input.clone(); let input = input.clone();
move |event: web_sys::CompositionEvent, runner: &mut AppRunner| { 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, "input", on_input)?;
runner_ref.add_event_listener(&input, "compositionstart", on_composition_start)?; 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)?; runner_ref.add_event_listener(&input, "compositionend", on_composition_end)?;
// The canvas doesn't get keydown/keyup events when the text agent is focused, // The canvas doesn't get keydown/keyup events when the text agent is focused,

View File

@@ -699,10 +699,41 @@ impl State {
// Wayland, but it doesn't matter to us. // Wayland, but it doesn't matter to us.
// See <https://github.com/rust-windowing/winit/issues/2498> // See <https://github.com/rust-windowing/winit/issues/2498>
winit::event::Ime::Enabled | winit::event::Ime::Disabled => {} 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 self.egui_input
.events .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) => { winit::event::Ime::Commit(text) => {
self.egui_input self.egui_input

View File

@@ -12,7 +12,10 @@ pub enum ImeEvent {
/// ///
/// An empty preedit string indicates that the IME has been dismissed, while /// An empty preedit string indicates that the IME has been dismissed, while
/// a non-empty preedit string indicates that the IME is active. /// a non-empty preedit string indicates that the IME is active.
Preedit(String), Preedit {
text: String,
active_range_chars: Option<std::ops::Range<usize>>,
},
/// IME composition ended with this final result. /// IME composition ended with this final result.
/// ///

View File

@@ -1026,6 +1026,7 @@ pub struct Visuals {
pub widgets: Widgets, pub widgets: Widgets,
pub selection: Selection, pub selection: Selection,
pub ime_composition: ImeComposition,
/// The color used for [`crate::Hyperlink`], /// The color used for [`crate::Hyperlink`],
pub hyperlink_color: Color32, pub hyperlink_color: Color32,
@@ -1192,6 +1193,36 @@ pub struct Selection {
pub stroke: Stroke, 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. /// Shape of the handle for sliders and similar widgets.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
@@ -1468,6 +1499,7 @@ impl Visuals {
weak_text_color: None, weak_text_color: None,
widgets: Widgets::default(), widgets: Widgets::default(),
selection: Selection::default(), selection: Selection::default(),
ime_composition: ImeComposition::default(),
hyperlink_color: Color32::from_rgb(90, 170, 255), hyperlink_color: Color32::from_rgb(90, 170, 255),
faint_bg_color: Color32::from_additive_luminance(5), // visible, but barely so faint_bg_color: Color32::from_additive_luminance(5), // visible, but barely so
extreme_bg_color: Color32::from_gray(10), // e.g. TextEdit background extreme_bg_color: Color32::from_gray(10), // e.g. TextEdit background
@@ -1531,6 +1563,7 @@ impl Visuals {
}, },
widgets: Widgets::light(), widgets: Widgets::light(),
selection: Selection::light(), selection: Selection::light(),
ime_composition: ImeComposition::light(),
hyperlink_color: Color32::from_rgb(0, 155, 255), hyperlink_color: Color32::from_rgb(0, 155, 255),
faint_bg_color: Color32::from_additive_luminance(5), // visible, but barely so faint_bg_color: Color32::from_additive_luminance(5), // visible, but barely so
extreme_bg_color: Color32::from_gray(255), // e.g. TextEdit background 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 { impl Widgets {
pub fn dark() -> Self { pub fn dark() -> Self {
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 { impl WidgetVisuals {
pub fn ui(&mut self, ui: &mut crate::Ui) { pub fn ui(&mut self, ui: &mut crate::Ui) {
let Self { let Self {
@@ -2169,6 +2272,7 @@ impl Visuals {
weak_text_color, weak_text_color,
widgets, widgets,
selection, selection,
ime_composition,
hyperlink_color, hyperlink_color,
faint_bg_color, faint_bg_color,
extreme_bg_color, extreme_bg_color,
@@ -2376,6 +2480,7 @@ impl Visuals {
ui.collapsing("Widgets", |ui| widgets.ui(ui)); ui.collapsing("Widgets", |ui| widgets.ui(ui));
ui.collapsing("Selection", |ui| selection.ui(ui)); ui.collapsing("Selection", |ui| selection.ui(ui));
ui.collapsing("IME composition", |ui| ime_composition.ui(ui));
ui.collapsing("Misc", |ui| { ui.collapsing("Misc", |ui| {
ui.add(Slider::new(resize_corner_size, 0.0..=20.0).text("resize_corner_size")); ui.add(Slider::new(resize_corner_size, 0.0..=20.0).text("resize_corner_size"));

View File

@@ -1,6 +1,17 @@
use std::sync::Arc; 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; 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<Galley>,
row_height: f32,
preedit_range: std::ops::Range<CCursor>,
mut relative_active_range: Option<std::ops::Range<CCursor>>,
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<CCursor>) -> 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<Galley>,
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. /// Paint one end of the selection, e.g. the primary cursor.
/// ///
/// This will never blink. /// 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 top = cursor_rect.center_top();
let bottom = cursor_rect.center_bottom(); let bottom = cursor_rect.center_bottom();
painter.line_segment([top, bottom], (stroke.width, stroke.color)); painter.line_segment([top, bottom], stroke);
if false { if false {
// Roof/floor: // Roof/floor:

View File

@@ -827,10 +827,15 @@ impl TextEdit<'_> {
false false
}; };
let should_paint_ime_visuals_the_legacy_way = ui.visuals().ime_composition.legacy_visuals;
if ui.is_rect_visible(inner_rect) { if ui.is_rect_visible(inner_rect) {
let has_focus = ui.memory(|mem| mem.has_focus(id)); 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: // Add text selection rectangles to the galley:
paint_text_selection(&mut galley, ui.visuals(), &cursor_range, None); 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 // * 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); let viewport_has_focus = ui.input(|i| i.focused);
if viewport_has_focus { if viewport_has_focus {
text_selection::visuals::paint_text_cursor( let time_since_last_interaction = now - state.last_interaction_time;
ui, let cursor_purpose = if should_paint_ime_visuals_the_legacy_way {
&painter, &TextEditCursorPurpose::Selection
primary_cursor_rect, } else {
now - state.last_interaction_time, &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)) { if ui.memory(|mem| mem.owns_ime_events(id)) {
// Set IME output (in screen coords) when text is editable and visible // 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- // integration, but since this guard is harmless for well-
// behaved integrations and also fixes the issue described // behaved integrations and also fixes the issue described
// above, it is good enough for now. // 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() if composition_text.is_empty()
&& !matches!( && !state.cursor_purpose.is_ime_composition() =>
state.cursor_purpose,
TextEditCursorPurpose::ImeComposition
) =>
{ {
None 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" => if composition_text == "\n" || composition_text == "\r" =>
{ {
None None
} }
ImeEvent::Preedit(preedit_text) => { ImeEvent::Preedit {
text: preedit_text,
active_range_chars,
} => {
state.cursor_purpose = if preedit_text.is_empty() { state.cursor_purpose = if preedit_text.is_empty() {
TextEditCursorPurpose::Selection TextEditCursorPurpose::Selection
} else { } 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); let mut ccursor = clear_preedit_text(text, &cursor_range);

View File

@@ -1,5 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use epaint::text::cursor::CCursor;
use crate::mutex::Mutex; use crate::mutex::Mutex;
use crate::{ use crate::{
@@ -85,6 +87,24 @@ pub(crate) enum TextEditCursorPurpose {
#[default] #[default]
Selection, Selection,
/// The cursor is used for IME composition. /// The cursor is used for IME composition. Its direction is irrelevant in
ImeComposition, /// 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<std::ops::Range<CCursor>>,
},
}
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 { .. })
}
} }

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:93ce77fe1f570281d50eed4c8681f1fbb382bd5fc5de63e2d683e0c570c929ff
size 9065

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6664509aa41a9a235657d8af7f73bc3f1fcaf3df1aa20db1e06e02f0a2c8aeab
size 9005

View File

@@ -218,3 +218,47 @@ fn test_remove_cursor() {
"The button appearance should change" "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");
}

View File

@@ -48,6 +48,17 @@ impl std::ops::Add<usize> for CCursor {
} }
} }
impl std::ops::Add<CharIndex> 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<usize> for CCursor { impl std::ops::Sub<usize> for CCursor {
type Output = Self; type Output = Self;
@@ -59,6 +70,17 @@ impl std::ops::Sub<usize> for CCursor {
} }
} }
impl std::ops::Sub<CharIndex> 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<usize> for CCursor { impl std::ops::AddAssign<usize> for CCursor {
fn add_assign(&mut self, rhs: usize) { fn add_assign(&mut self, rhs: usize) {
self.index = self.index.saturating_add(rhs); self.index = self.index.saturating_add(rhs);