//! 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 { 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, 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, } } }