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

Feat: implement IME preedit visuals for native platforms

This commit is contained in:
umajho
2026-04-08 17:14:05 +08:00
parent 1fdc5c0775
commit 41b877f3f5
7 changed files with 241 additions and 22 deletions

View File

@@ -688,10 +688,18 @@ impl State {
// Wayland, but it doesn't matter to us.
// See <https://github.com/rust-windowing/winit/issues/2498>
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

View File

@@ -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<std::ops::Range<usize>>,
},
/// IME composition ended with this final result.
///

View File

@@ -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"));

View File

@@ -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<Galley>,
visuals: &Visuals,
row_height: f32,
preedit_range: std::ops::Range<CCursor>,
relative_active_range: Option<std::ops::Range<CCursor>>,
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<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
};
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:

View File

@@ -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);

View File

@@ -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<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

@@ -69,6 +69,13 @@ impl std::ops::SubAssign<usize> for CCursor {
}
}
impl PartialOrd for CCursor {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.index.partial_cmp(&other.index)
}
}
/// Row/column cursor.
///
/// This refers to rows and columns in layout terms--text wrapping creates multiple rows.