266 lines
8.6 KiB
Rust
266 lines
8.6 KiB
Rust
//! Custom text field widget with IME workaround
|
||
//!
|
||
//! WORKAROUND for IBus Wayland bug (egui issue #7485):
|
||
//! https://github.com/emilk/egui/issues/7485
|
||
//!
|
||
//! IBus on Wayland only delivers one character through normal TextEdit handling.
|
||
//! This widget renders a custom text field and handles all input manually.
|
||
//!
|
||
//! TODO: Remove this workaround once the upstream issue is fixed.
|
||
|
||
use eframe::egui;
|
||
|
||
/// Convert egui Key to character for manual text input handling.
|
||
/// Uses egui's name()/symbol_or_name() where possible, with shift handling
|
||
/// for uppercase letters and shifted symbols (US keyboard layout).
|
||
fn key_to_char(key: egui::Key, shift: bool) -> Option<char> {
|
||
let symbol = key.symbol_or_name();
|
||
|
||
// If it's a single character, we can use it (with shift handling)
|
||
if symbol.chars().count() == 1 {
|
||
let c = symbol.chars().next().unwrap();
|
||
|
||
// Handle letters - apply shift for case
|
||
if c.is_ascii_alphabetic() {
|
||
return Some(if shift { c.to_ascii_uppercase() } else { c.to_ascii_lowercase() });
|
||
}
|
||
|
||
// Handle digits with shift -> symbols (US keyboard layout)
|
||
if c.is_ascii_digit() && shift {
|
||
return Some(match c {
|
||
'0' => ')',
|
||
'1' => '!',
|
||
'2' => '@',
|
||
'3' => '#',
|
||
'4' => '$',
|
||
'5' => '%',
|
||
'6' => '^',
|
||
'7' => '&',
|
||
'8' => '*',
|
||
'9' => '(',
|
||
_ => c,
|
||
});
|
||
}
|
||
|
||
// Handle punctuation with shift (US keyboard layout)
|
||
if shift {
|
||
return Some(match c {
|
||
'-' | '−' => '_', // Minus (egui uses special minus char)
|
||
'=' => '+',
|
||
'[' => '{',
|
||
']' => '}',
|
||
'\\' => '|',
|
||
';' => ':',
|
||
'\'' => '"',
|
||
',' => '<',
|
||
'.' => '>',
|
||
'/' => '?',
|
||
'`' => '~',
|
||
_ => c,
|
||
});
|
||
}
|
||
|
||
return Some(c);
|
||
}
|
||
|
||
// Special case: Space returns "Space" not " "
|
||
if matches!(key, egui::Key::Space) {
|
||
return Some(' ');
|
||
}
|
||
|
||
None // Non-printable keys (arrows, function keys, etc.)
|
||
}
|
||
|
||
/// Response from the IME text field widget
|
||
#[allow(dead_code)] // Standard widget response fields; callers will use as features expand
|
||
pub struct ImeTextFieldResponse {
|
||
/// The egui response for the text field area
|
||
pub response: egui::Response,
|
||
/// Whether the text was changed
|
||
pub changed: bool,
|
||
/// Whether Enter was pressed (for single-line fields)
|
||
pub submitted: bool,
|
||
/// Whether Escape was pressed
|
||
pub cancelled: bool,
|
||
}
|
||
|
||
/// A text field widget that works around IBus Wayland IME issues.
|
||
///
|
||
/// This is a temporary workaround for egui issue #7485. Use this instead of
|
||
/// `egui::TextEdit` when you need text input to work on Wayland with IBus.
|
||
pub struct ImeTextField<'a> {
|
||
text: &'a mut String,
|
||
placeholder: Option<&'a str>,
|
||
font_size: f32,
|
||
desired_width: Option<f32>,
|
||
request_focus: bool,
|
||
}
|
||
|
||
impl<'a> ImeTextField<'a> {
|
||
/// Create a new text field widget
|
||
pub fn new(text: &'a mut String) -> Self {
|
||
Self {
|
||
text,
|
||
placeholder: None,
|
||
font_size: 14.0,
|
||
desired_width: None,
|
||
request_focus: false,
|
||
}
|
||
}
|
||
|
||
/// Set placeholder text shown when the field is empty and unfocused
|
||
pub fn placeholder(mut self, placeholder: &'a str) -> Self {
|
||
self.placeholder = Some(placeholder);
|
||
self
|
||
}
|
||
|
||
/// Set the font size (default: 14.0)
|
||
pub fn font_size(mut self, size: f32) -> Self {
|
||
self.font_size = size;
|
||
self
|
||
}
|
||
|
||
/// Set the desired width of the field
|
||
pub fn desired_width(mut self, width: f32) -> Self {
|
||
self.desired_width = Some(width);
|
||
self
|
||
}
|
||
|
||
/// Request focus on this field
|
||
pub fn request_focus(mut self) -> Self {
|
||
self.request_focus = true;
|
||
self
|
||
}
|
||
|
||
/// Show the text field widget
|
||
pub fn show(self, ui: &mut egui::Ui) -> ImeTextFieldResponse {
|
||
let desired_size = egui::vec2(
|
||
self.desired_width.unwrap_or(ui.available_width()),
|
||
self.font_size + 8.0,
|
||
);
|
||
|
||
let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click());
|
||
let id = response.id;
|
||
|
||
// Handle click to focus
|
||
if response.clicked() {
|
||
ui.memory_mut(|m| m.request_focus(id));
|
||
}
|
||
|
||
// Handle focus request
|
||
if self.request_focus {
|
||
ui.memory_mut(|m| m.request_focus(id));
|
||
}
|
||
|
||
let has_focus = ui.memory(|m| m.has_focus(id));
|
||
|
||
// Draw the text field background
|
||
let bg_color = if has_focus {
|
||
egui::Color32::from_rgb(50, 50, 55)
|
||
} else {
|
||
egui::Color32::from_rgb(40, 40, 45)
|
||
};
|
||
let stroke = if has_focus {
|
||
egui::Stroke::new(1.0, egui::Color32::from_rgb(100, 150, 255))
|
||
} else {
|
||
egui::Stroke::new(1.0, egui::Color32::from_rgb(60, 60, 65))
|
||
};
|
||
ui.painter().rect(rect, 3.0, bg_color, stroke, egui::StrokeKind::Middle);
|
||
|
||
// Draw the text or placeholder
|
||
let text_pos = rect.min + egui::vec2(6.0, (rect.height() - self.font_size) / 2.0);
|
||
if self.text.is_empty() && !has_focus {
|
||
if let Some(placeholder) = self.placeholder {
|
||
ui.painter().text(
|
||
text_pos,
|
||
egui::Align2::LEFT_TOP,
|
||
placeholder,
|
||
egui::FontId::proportional(self.font_size),
|
||
egui::Color32::from_gray(100),
|
||
);
|
||
}
|
||
} else {
|
||
ui.painter().text(
|
||
text_pos,
|
||
egui::Align2::LEFT_TOP,
|
||
self.text.as_str(),
|
||
egui::FontId::proportional(self.font_size),
|
||
egui::Color32::from_gray(220),
|
||
);
|
||
|
||
// Draw cursor when focused
|
||
if has_focus {
|
||
let text_width = ui.painter().layout_no_wrap(
|
||
self.text.clone(),
|
||
egui::FontId::proportional(self.font_size),
|
||
egui::Color32::WHITE,
|
||
).rect.width();
|
||
|
||
let cursor_x = text_pos.x + text_width + 1.0;
|
||
let blink = (ui.input(|i| i.time) * 2.0).fract() < 0.5;
|
||
if blink {
|
||
ui.painter().line_segment(
|
||
[
|
||
egui::pos2(cursor_x, rect.min.y + 4.0),
|
||
egui::pos2(cursor_x, rect.max.y - 4.0),
|
||
],
|
||
egui::Stroke::new(1.0, egui::Color32::WHITE),
|
||
);
|
||
}
|
||
ui.ctx().request_repaint(); // For cursor blinking
|
||
}
|
||
}
|
||
|
||
// Handle keyboard input when focused
|
||
let mut changed = false;
|
||
let mut submitted = false;
|
||
let mut cancelled = false;
|
||
|
||
if has_focus {
|
||
ui.input(|i| {
|
||
for event in &i.events {
|
||
if let egui::Event::Key { key, pressed: true, modifiers, .. } = event {
|
||
// Skip if modifier keys are held (except shift)
|
||
if modifiers.ctrl || modifiers.alt || modifiers.command {
|
||
continue;
|
||
}
|
||
|
||
match key {
|
||
egui::Key::Backspace => {
|
||
if !self.text.is_empty() {
|
||
self.text.pop();
|
||
changed = true;
|
||
}
|
||
}
|
||
egui::Key::Enter => {
|
||
submitted = true;
|
||
}
|
||
egui::Key::Escape => {
|
||
cancelled = true;
|
||
}
|
||
_ => {
|
||
if let Some(c) = key_to_char(*key, modifiers.shift) {
|
||
self.text.push(c);
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Lose focus on Escape
|
||
if cancelled {
|
||
ui.memory_mut(|m| m.surrender_focus(id));
|
||
}
|
||
}
|
||
|
||
ImeTextFieldResponse {
|
||
response,
|
||
changed,
|
||
submitted,
|
||
cancelled,
|
||
}
|
||
}
|
||
}
|