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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { .. })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:93ce77fe1f570281d50eed4c8681f1fbb382bd5fc5de63e2d683e0c570c929ff
|
||||||
|
size 9065
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6664509aa41a9a235657d8af7f73bc3f1fcaf3df1aa20db1e06e02f0a2c8aeab
|
||||||
|
size 9005
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user