mirror of
https://github.com/emilk/egui.git
synced 2026-08-29 04:40:03 -04:00
Feat: implement IME preedit visuals for native platforms
This commit is contained in:
@@ -688,10 +688,18 @@ 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) => {
|
||||||
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: 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) => {
|
winit::event::Ime::Commit(text) => {
|
||||||
self.egui_input
|
self.egui_input
|
||||||
|
|||||||
@@ -612,7 +612,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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1038,6 +1038,7 @@ pub struct Visuals {
|
|||||||
pub widgets: Widgets,
|
pub widgets: Widgets,
|
||||||
|
|
||||||
pub selection: Selection,
|
pub selection: Selection,
|
||||||
|
pub ime_preedit: ImePreedit,
|
||||||
|
|
||||||
/// The color used for [`crate::Hyperlink`],
|
/// The color used for [`crate::Hyperlink`],
|
||||||
pub hyperlink_color: Color32,
|
pub hyperlink_color: Color32,
|
||||||
@@ -1208,6 +1209,13 @@ pub struct Selection {
|
|||||||
pub stroke: Stroke,
|
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.
|
/// 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))]
|
||||||
@@ -1491,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_preedit: ImePreedit::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
|
||||||
@@ -1554,6 +1563,7 @@ impl Visuals {
|
|||||||
},
|
},
|
||||||
widgets: Widgets::light(),
|
widgets: Widgets::light(),
|
||||||
selection: Selection::light(),
|
selection: Selection::light(),
|
||||||
|
ime_preedit: ImePreedit::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
|
||||||
@@ -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 {
|
impl Widgets {
|
||||||
pub fn dark() -> Self {
|
pub fn dark() -> Self {
|
||||||
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 {
|
impl WidgetVisuals {
|
||||||
pub fn ui(&mut self, ui: &mut crate::Ui) {
|
pub fn ui(&mut self, ui: &mut crate::Ui) {
|
||||||
let Self {
|
let Self {
|
||||||
@@ -2194,6 +2242,7 @@ impl Visuals {
|
|||||||
weak_text_color,
|
weak_text_color,
|
||||||
widgets,
|
widgets,
|
||||||
selection,
|
selection,
|
||||||
|
ime_preedit,
|
||||||
hyperlink_color,
|
hyperlink_color,
|
||||||
faint_bg_color,
|
faint_bg_color,
|
||||||
extreme_bg_color,
|
extreme_bg_color,
|
||||||
@@ -2401,6 +2450,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 preedit", |ui| ime_preedit.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,14 @@
|
|||||||
use std::sync::Arc;
|
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;
|
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.
|
/// Paint one end of the selection, e.g. the primary cursor.
|
||||||
///
|
///
|
||||||
/// This will never blink.
|
/// 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 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:
|
||||||
|
|||||||
@@ -822,7 +822,10 @@ impl TextEdit<'_> {
|
|||||||
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()
|
||||||
|
&& 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);
|
||||||
}
|
}
|
||||||
@@ -854,12 +857,33 @@ 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,
|
match &state.cursor_purpose {
|
||||||
&painter,
|
TextEditCursorPurpose::Selection => {
|
||||||
primary_cursor_rect,
|
text_selection::visuals::paint_text_cursor(
|
||||||
now - state.last_interaction_time,
|
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)) {
|
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
|
||||||
@@ -1173,25 +1197,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 { .. })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// Row/column cursor.
|
||||||
///
|
///
|
||||||
/// This refers to rows and columns in layout terms--text wrapping creates multiple rows.
|
/// This refers to rows and columns in layout terms--text wrapping creates multiple rows.
|
||||||
|
|||||||
Reference in New Issue
Block a user