Skip to main content

zeus_widgets/
secure_text_edit.rs

1use egui::{
2   Align, Align2, Color32, CursorIcon, Event, EventFilter, FontId, FontSelection, Galley,
3   IMEPurpose, Id, ImeEvent, Key, KeyboardShortcut, Margin, Modifiers, NumExt, Response, Sense,
4   Shape, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, epaint, output,
5   text::{self, LayoutJob},
6   text_selection::{self, CCursorRange, text_cursor_state::byte_index_from_char_index},
7   vec2,
8};
9
10use std::{fmt::Debug, hash::Hash, sync::Arc};
11use zeus_theme::TextEditVisuals;
12
13#[cfg(feature = "secure-types")]
14use secure_types::{SecureString, Zeroize};
15
16pub trait TextBuffer {
17   fn is_secure(&self) -> bool;
18   fn insert_text_at_char_idx(&mut self, char_idx: usize, text_to_insert: &str) -> usize;
19   fn delete_text_char_range(&mut self, char_range: core::ops::Range<usize>);
20   fn char_len(&self) -> usize;
21   fn to_string(&self) -> String;
22}
23
24#[cfg(feature = "secure-types")]
25impl TextBuffer for SecureString {
26   fn is_secure(&self) -> bool {
27      true
28   }
29
30   fn insert_text_at_char_idx(&mut self, char_idx: usize, text_to_insert: &str) -> usize {
31      self.insert_text_at_char_idx(char_idx, text_to_insert)
32   }
33
34   fn delete_text_char_range(&mut self, char_range: core::ops::Range<usize>) {
35      self.delete_text_char_range(char_range)
36   }
37
38   fn char_len(&self) -> usize {
39      self.char_len()
40   }
41
42   fn to_string(&self) -> String {
43      self.unlock_str(|s| s.to_string())
44   }
45}
46
47impl TextBuffer for String {
48   fn is_secure(&self) -> bool {
49      false
50   }
51
52   fn insert_text_at_char_idx(&mut self, char_idx: usize, text_to_insert: &str) -> usize {
53      let byte_idx = byte_index_from_char_index(self.as_str(), char_idx.into());
54      self.insert_str(byte_idx.into(), text_to_insert);
55
56      text_to_insert.chars().count()
57   }
58
59   fn delete_text_char_range(&mut self, char_range: core::ops::Range<usize>) {
60      assert!(
61         char_range.start <= char_range.end,
62         "start must be <= end, but got {char_range:?}"
63      );
64
65      let byte_start = byte_index_from_char_index(self.as_str(), char_range.start.into());
66      let byte_end = byte_index_from_char_index(self.as_str(), char_range.end.into());
67
68      self.drain(byte_start.0..byte_end.0);
69   }
70
71   fn char_len(&self) -> usize {
72      self.chars().count()
73   }
74
75   fn to_string(&self) -> String {
76      self.to_owned()
77   }
78}
79
80#[derive(Clone, Debug, Default)]
81pub struct SecureTextEditState {
82   pub cursor: text_selection::TextCursorState,
83   pub singleline_offset: f32,
84   pub last_interaction_time: f64,
85   /// True while a non-empty IME preedit is in the buffer.
86   ///
87   /// Not tied to the deprecated `ImeEvent::Enabled`. Empty Preedit/Commit
88   /// events (GNOME/ibus on Wayland) must be ignored when this is false,
89   /// or the cursor jumps to index 0 and text is typed in reverse.
90   pub ime_enabled: bool,
91   /// Span of the current preedit, if any. Do not use this as the delete
92   /// target — always mutate the live cursor range, like egui 0.36.
93   pub ime_cursor_range: CCursorRange,
94}
95
96impl SecureTextEditState {
97   pub fn load(ctx: &egui::Context, id: egui::Id) -> Option<Self> {
98      ctx.data_mut(|d| d.get_persisted(id))
99   }
100
101   pub fn store(self, ctx: &egui::Context, id: egui::Id) {
102      ctx.data_mut(|d| d.insert_persisted(id, self));
103   }
104}
105
106pub struct SecureTextEditOutput {
107   pub response: Response,
108   pub state: SecureTextEditState,
109   pub cursor_range: Option<CCursorRange>,
110}
111
112/// A widget for editing text.
113///
114/// This widget is identical to [`egui::TextEdit`], but it uses a custom [`TextBuffer`]
115/// that can take either a [`SecureString`] or a [`String`].
116///
117/// To use it with a [`SecureString`], the feature `secure-types` must be enabled.
118///
119/// ## Notes
120///
121/// - Accessability like screen readers is disabled to avoid multiple unsecure allocations of the entered text.
122/// - If you want to make sure the text you enter doesn't stay in memory in any way you have to set [`Self::password`] to `true`.
123///
124/// Otherwise egui will make copies of that text and some of the copied allocations will stay in memory.
125#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
126pub struct SecureTextEdit<'a> {
127   text: &'a mut dyn TextBuffer,
128   visuals: Option<TextEditVisuals>,
129   hint_text: WidgetText,
130   id: Option<Id>,
131   id_salt: Option<Id>,
132   font_selection: FontSelection,
133   text_color: Option<Color32>,
134   password: bool,
135   frame: bool,
136   margin: Margin,
137   multiline: bool,
138   interactive: bool,
139   desired_width: Option<f32>,
140   desired_height_rows: usize,
141   event_filter: EventFilter,
142   cursor_at_end: bool,
143   min_size: Vec2,
144   align: Align2,
145   clip_text: bool,
146   char_limit: usize,
147   return_key: Option<KeyboardShortcut>,
148   background_color: Option<Color32>,
149}
150
151impl<'a> SecureTextEdit<'a> {
152   pub fn singleline(text: &'a mut dyn TextBuffer) -> Self {
153      Self {
154         text,
155         visuals: None,
156         hint_text: Default::default(),
157         id: None,
158         id_salt: None,
159         font_selection: FontSelection::default(),
160         text_color: None,
161         password: false,
162         frame: true,
163         margin: Margin::symmetric(4, 2),
164         multiline: false,
165         interactive: true,
166         desired_width: None,
167         desired_height_rows: 1,
168         event_filter: EventFilter {
169            horizontal_arrows: true,
170            vertical_arrows: true,
171            tab: false,
172            ..Default::default()
173         },
174         cursor_at_end: true,
175         min_size: Vec2::ZERO,
176         align: Align2::LEFT_CENTER,
177         clip_text: true,
178         char_limit: usize::MAX,
179         return_key: Some(KeyboardShortcut::new(Modifiers::NONE, Key::Enter)),
180         background_color: None,
181      }
182   }
183
184   pub fn multiline(text: &'a mut dyn TextBuffer) -> Self {
185      Self {
186         text,
187         visuals: None,
188         hint_text: Default::default(),
189         id: None,
190         id_salt: None,
191         font_selection: FontSelection::default(),
192         text_color: None,
193         password: false,
194         frame: true,
195         margin: Margin::symmetric(4, 2),
196         multiline: true,
197         interactive: true,
198         desired_width: None,
199         desired_height_rows: 4,
200         event_filter: EventFilter {
201            horizontal_arrows: true,
202            vertical_arrows: true,
203            tab: false,
204            ..Default::default()
205         },
206         cursor_at_end: true,
207         min_size: Vec2::ZERO,
208         align: Align2::LEFT_TOP,
209         clip_text: false,
210         char_limit: usize::MAX,
211         return_key: Some(KeyboardShortcut::new(Modifiers::NONE, Key::Enter)),
212         background_color: None,
213      }
214   }
215
216   pub fn visuals(mut self, visuals: TextEditVisuals) -> Self {
217      self.visuals = Some(visuals);
218      self
219   }
220
221   pub fn id(mut self, id: Id) -> Self {
222      self.id = Some(id);
223      self
224   }
225
226   pub fn id_source(self, id_source: impl Hash + Debug) -> Self {
227      self.id_salt(id_source)
228   }
229
230   pub fn id_salt(mut self, id_salt: impl Hash + Debug) -> Self {
231      self.id_salt = Some(Id::new(id_salt));
232      self
233   }
234
235   pub fn hint_text(mut self, hint_text: impl Into<WidgetText>) -> Self {
236      self.hint_text = hint_text.into();
237      self
238   }
239
240   pub fn font(mut self, font_selection: impl Into<FontSelection>) -> Self {
241      self.font_selection = font_selection.into();
242      self
243   }
244
245   pub fn text_color(mut self, text_color: Color32) -> Self {
246      self.text_color = Some(text_color);
247      self
248   }
249
250   pub fn text_color_opt(mut self, text_color: Option<Color32>) -> Self {
251      self.text_color = text_color;
252      self
253   }
254
255   pub fn password(mut self, password: bool) -> Self {
256      self.password = password;
257      self
258   }
259
260   pub fn frame(mut self, frame: bool) -> Self {
261      self.frame = frame;
262      self
263   }
264
265   pub fn margin(mut self, margin: impl Into<Margin>) -> Self {
266      self.margin = margin.into();
267      self
268   }
269
270   pub fn interactive(mut self, interactive: bool) -> Self {
271      self.interactive = interactive;
272      self
273   }
274
275   pub fn desired_width(mut self, desired_width: f32) -> Self {
276      self.desired_width = Some(desired_width);
277      self
278   }
279
280   pub fn desired_rows(mut self, desired_height_rows: usize) -> Self {
281      self.desired_height_rows = desired_height_rows;
282      self
283   }
284
285   pub fn lock_focus(mut self, tab_will_indent: bool) -> Self {
286      self.event_filter.tab = tab_will_indent;
287      self
288   }
289
290   pub fn cursor_at_end(mut self, b: bool) -> Self {
291      self.cursor_at_end = b;
292      self
293   }
294
295   pub fn min_size(mut self, min_size: Vec2) -> Self {
296      self.min_size = min_size;
297      self
298   }
299
300   pub fn horizontal_align(mut self, align: Align) -> Self {
301      self.align.0[0] = align;
302      self
303   }
304
305   pub fn vertical_align(mut self, align: Align) -> Self {
306      self.align.0[1] = align;
307      self
308   }
309
310   pub fn clip_text(mut self, b: bool) -> Self {
311      if !self.multiline {
312         self.clip_text = b;
313      }
314      self
315   }
316
317   pub fn char_limit(mut self, limit: usize) -> Self {
318      self.char_limit = limit;
319      self
320   }
321
322   pub fn return_key(mut self, return_key: impl Into<Option<KeyboardShortcut>>) -> Self {
323      self.return_key = return_key.into();
324      self
325   }
326
327   pub fn background_color(mut self, color: Color32) -> Self {
328      self.background_color = Some(color);
329      self
330   }
331
332   pub fn show(self, ui: &mut Ui) -> SecureTextEditOutput {
333      let frame = self.frame;
334      let where_to_put_background = ui.painter().add(Shape::Noop);
335      let background_color =
336         self.visuals.as_ref().map(|v| v.bg).unwrap_or(ui.visuals().extreme_bg_color);
337      let is_interactive = self.interactive;
338
339      let (output, text_edit_visuals) = self.show_content(ui);
340
341      let outer_rect_for_frame = output.response.rect;
342      if frame {
343         let visuals = ui.style().interact(&output.response);
344         let frame_rect = outer_rect_for_frame.expand(visuals.expansion);
345         let corner = text_edit_visuals
346            .as_ref()
347            .map(|v| v.corner_radius)
348            .unwrap_or(visuals.corner_radius);
349
350         let shape = if is_interactive {
351            if output.response.has_focus() || output.response.hovered() {
352               let border =
353                  text_edit_visuals.as_ref().map(|v| v.border_open).unwrap_or(visuals.bg_stroke);
354               epaint::RectShape::new(
355                  frame_rect,
356                  corner,
357                  background_color,
358                  border,
359                  epaint::StrokeKind::Inside,
360               )
361            } else {
362               let border =
363                  text_edit_visuals.as_ref().map(|v| v.border).unwrap_or(visuals.bg_stroke);
364               epaint::RectShape::new(
365                  frame_rect,
366                  corner,
367                  background_color,
368                  border,
369                  epaint::StrokeKind::Inside,
370               )
371            }
372         } else {
373            // Not interactive
374            let visuals = &ui.style().visuals.widgets.inactive;
375            let border = text_edit_visuals.as_ref().map(|v| v.border).unwrap_or(visuals.bg_stroke);
376            epaint::RectShape::stroke(
377               frame_rect,
378               corner,
379               border,
380               epaint::StrokeKind::Inside,
381            )
382         };
383         ui.painter().set(where_to_put_background, shape);
384      }
385      output
386   }
387
388   #[allow(clippy::too_many_lines)]
389   fn show_content(mut self, ui: &mut Ui) -> (SecureTextEditOutput, Option<TextEditVisuals>) {
390      let font_id = self.font_selection.resolve(ui.style());
391      let text_color = ui
392         .visuals()
393         .override_text_color
394         .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
395
396      let text_color = self.visuals.map(|v| v.text).unwrap_or(text_color);
397
398      let row_height = ui.fonts_mut(|f| f.row_height(&font_id));
399      let available_width = (ui.available_width() - self.margin.sum().x).at_least(24.0); // Min width
400      let desired_width = self.desired_width.unwrap_or_else(|| ui.spacing().text_edit_width);
401      let wrap_width = if ui.layout().horizontal_justify() {
402         available_width
403      } else {
404         desired_width.min(available_width)
405      };
406
407      // --- Layout Galley ---
408
409      let display_text_cow = if self.password {
410         // Generate '●' string based on actual char count
411         std::borrow::Cow::<'_, str>::Owned(
412            std::iter::repeat(epaint::text::PASSWORD_REPLACEMENT_CHAR)
413               .take(self.text.char_len())
414               .collect::<String>(),
415         )
416      } else {
417         std::borrow::Cow::Owned(self.text.to_string()) // !
418      };
419
420      let mut job = if self.multiline {
421         LayoutJob::simple(
422            (*display_text_cow).to_owned(),
423            font_id.clone(),
424            text_color,
425            wrap_width,
426         )
427      } else {
428         LayoutJob::simple_singleline(
429            (*display_text_cow).to_owned(),
430            font_id.clone(),
431            text_color,
432         )
433      };
434
435      job.halign = self.align.0[0];
436      let mut galley: Arc<Galley> = ui.fonts_mut(|f| f.layout_job(job));
437
438      // --- Size & Allocation ---
439      let desired_inner_width = if self.clip_text && !self.multiline {
440         wrap_width
441      } else {
442         galley.size().x.max(wrap_width)
443      };
444      let desired_height = (self.desired_height_rows.at_least(1) as f32) * row_height;
445      let desired_inner_size = vec2(
446         desired_inner_width,
447         galley.size().y.max(desired_height),
448      );
449      let desired_outer_size = (desired_inner_size + self.margin.sum()).at_least(self.min_size);
450
451      let (auto_id, outer_rect) = ui.allocate_space(desired_outer_size);
452      let text_draw_rect = outer_rect - self.margin;
453
454      let id = self.id.unwrap_or_else(|| {
455         if let Some(id_salt) = self.id_salt {
456            ui.make_persistent_id(id_salt)
457         } else {
458            auto_id
459         }
460      });
461      let mut state = SecureTextEditState::load(ui.ctx(), id).unwrap_or_default();
462
463      // --- Interaction ---
464      let allow_drag_to_select =
465         ui.input(|i| !i.has_touch_screen()) || ui.memory(|mem| mem.has_focus(id));
466      let sense_behavior = if self.interactive {
467         if allow_drag_to_select {
468            Sense::click_and_drag()
469         } else {
470            Sense::click()
471         }
472      } else {
473         Sense::hover()
474      };
475      let mut response = ui.interact(outer_rect, id, sense_behavior);
476      response.set_intrinsic_size(vec2(desired_width, desired_outer_size.y));
477
478      // Handle click to focus
479      if self.interactive {
480         if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
481            if response.hovered() {
482               ui.output_mut(|o| o.mutable_text_under_cursor = true);
483            }
484            let singleline_offset_vec = vec2(state.singleline_offset, 0.0);
485            let cursor_at_pointer =
486               galley.cursor_from_pos(pointer_pos - text_draw_rect.min + singleline_offset_vec);
487
488            let is_being_dragged = ui.ctx().is_being_dragged(response.id);
489            let did_interact_with_cursor = state.cursor.pointer_interaction(
490               ui,
491               &response,
492               cursor_at_pointer,
493               &galley,
494               is_being_dragged,
495            );
496
497            if did_interact_with_cursor || response.clicked() {
498               ui.memory_mut(|mem| mem.request_focus(response.id));
499               state.last_interaction_time = ui.input(|i| i.time);
500            }
501         }
502      }
503      if self.interactive && response.hovered() {
504         ui.ctx().set_cursor_icon(CursorIcon::Text);
505      }
506
507      // --- Event Handling ---
508      let mut cursor_range_after_events = None;
509      let owns_ime_events = ui.memory(|mem| mem.owns_ime_events(id));
510      if !owns_ime_events {
511         state.ime_enabled = false;
512         if let Some(range) = state.cursor.char_range() {
513            state.cursor.set_char_range(Some(CCursorRange::one(range.primary)));
514         }
515      }
516
517      if self.interactive && ui.memory(|mem| mem.has_focus(id)) {
518         ui.memory_mut(|mem| mem.set_focus_lock_filter(id, self.event_filter));
519
520         let default_cursor_range = if self.cursor_at_end {
521            CCursorRange::one(galley.end())
522         } else {
523            CCursorRange::default()
524         };
525
526         let (text_changed_by_event, new_cursor_range, updated_galley) = secure_text_edit_events(
527            ui,
528            &mut state,
529            self.text,
530            &galley,
531            id,
532            self.multiline,
533            self.password,
534            default_cursor_range,
535            owns_ime_events,
536            self.char_limit,
537            self.event_filter,
538            self.return_key,
539            &font_id,
540            text_color,
541            wrap_width,
542            self.align.0[0],
543         );
544
545         if text_changed_by_event {
546            response.mark_changed();
547            galley = updated_galley;
548         }
549         cursor_range_after_events = Some(new_cursor_range);
550         state.cursor.set_char_range(Some(new_cursor_range));
551      }
552
553      // --- Galley Positioning & Single-line Offset ---
554      let mut galley_pos = self.align.align_size_within_rect(galley.size(), text_draw_rect).min;
555      if self.clip_text && !self.multiline {
556         let current_cursor_primary_x =
557            match cursor_range_after_events.or_else(|| state.cursor.range(&galley)) {
558               Some(cr) => galley.pos_from_cursor(cr.primary).min.x,
559               None => 0.0,
560            };
561         let visible_width = text_draw_rect.width();
562         let mut offset_x = state.singleline_offset;
563         let visible_range_start = offset_x;
564         let visible_range_end = offset_x + visible_width;
565
566         if current_cursor_primary_x < visible_range_start {
567            offset_x = current_cursor_primary_x;
568         } else if current_cursor_primary_x > visible_range_end {
569            offset_x = current_cursor_primary_x - visible_width;
570         }
571         offset_x = offset_x.at_most(galley.size().x - visible_width).at_least(0.0);
572         state.singleline_offset = offset_x;
573         galley_pos.x -= offset_x;
574      } else {
575         // For multiline or non-clip singleline, capture any alignment offset
576         // state.singleline_offset = text_draw_rect.left() - galley_pos.x;
577         state.singleline_offset = 0.0;
578         // And ensure galley_pos respects it if it was aligned (e.g. center/right)
579         state.singleline_offset = text_draw_rect.left() - galley_pos.x;
580      }
581
582      // --- Painting ---
583      if ui.is_rect_visible(text_draw_rect) {
584         let is_text_empty = self.text.char_len() == 0;
585         if is_text_empty && !self.hint_text.is_empty() {
586            let hint_text_color = ui.visuals().weak_text_color();
587            let hint_font_id = FontSelection::default();
588            let hint_galley = self.hint_text.clone().into_galley(
589               ui,
590               Some(TextWrapMode::Wrap),
591               text_draw_rect.width(),
592               hint_font_id,
593            );
594            let hint_galley_pos =
595               self.align.align_size_within_rect(hint_galley.size(), text_draw_rect).min;
596            ui.painter_at(text_draw_rect)
597               .galley(hint_galley_pos, hint_galley, hint_text_color);
598         }
599
600         let mut galley_for_paint = galley.clone();
601         if ui.memory(|mem| mem.has_focus(id)) {
602            if let Some(cursor_range_for_sel) = state.cursor.range(&galley_for_paint) {
603               text_selection::visuals::paint_text_selection(
604                  &mut galley_for_paint,
605                  ui.visuals(),
606                  &cursor_range_for_sel,
607                  None,
608               );
609            }
610         }
611         ui.painter_at(text_draw_rect).galley(galley_pos, galley_for_paint, text_color);
612
613         // Paint cursor
614         if self.interactive && ui.memory(|mem| mem.has_focus(id)) {
615            if let Some(cursor_range_for_cursor_paint) = state.cursor.range(&galley) {
616               // Use original galley for metrics
617               let primary_cursor_rect_ui = text_selection::text_cursor_state::cursor_rect(
618                  &galley,
619                  &cursor_range_for_cursor_paint.primary,
620                  row_height,
621               )
622               .translate(galley_pos.to_vec2());
623
624               if response.changed() {
625                  // Could also check selection_changed
626                  ui.scroll_to_rect(
627                     primary_cursor_rect_ui.expand(self.margin.sum().y / 2.0),
628                     None,
629                  );
630               }
631
632               if ui.ctx().input(|i| i.focused) {
633                  // Viewport has focus
634                  let time_since_last_interaction =
635                     ui.input(|i| i.time) - state.last_interaction_time;
636                  text_selection::visuals::paint_text_cursor(
637                     ui,
638                     &ui.painter_at(text_draw_rect.expand(1.0)), // Expand for cursor
639                     primary_cursor_rect_ui,
640                     time_since_last_interaction,
641                  );
642               }
643               // IME output
644               let to_global =
645                  ui.ctx().layer_transform_to_global(ui.layer_id()).unwrap_or_default();
646               ui.ctx().output_mut(|o| {
647                  o.ime = Some(output::IMEOutput {
648                     purpose: if self.password {
649                        IMEPurpose::Password
650                     } else {
651                        IMEPurpose::Normal
652                     },
653                     rect: to_global * text_draw_rect,
654                     cursor_rect: to_global * primary_cursor_rect_ui,
655                     should_interrupt_composition: false,
656                  });
657               });
658            }
659         }
660      }
661
662      state.clone().store(ui.ctx(), id);
663
664      // !
665      // This is only for accessibility, so set them to empty is fine
666      /*
667      let _ = self.text.str_scope(|s| {
668         if self.password {
669            std::iter::repeat(epaint::text::PASSWORD_REPLACEMENT_CHAR)
670               .take(s.chars().count())
671               .collect()
672         } else {
673            s.to_string()
674         }
675      });
676      */
677      response.widget_info(|| {
678         WidgetInfo::text_edit(
679            ui.is_enabled(),
680            String::new(),
681            String::new(),
682            String::new(),
683         )
684      });
685
686      let output = SecureTextEditOutput {
687         response,
688         state,
689         cursor_range: cursor_range_after_events,
690      };
691
692      let visuals = self.visuals.take();
693      (output, visuals)
694   }
695}
696
697impl<'a> Widget for SecureTextEdit<'a> {
698   fn ui(self, ui: &mut Ui) -> Response {
699      self.show(ui).response
700   }
701}
702
703#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
704fn secure_text_edit_events(
705   ui: &Ui,
706   state: &mut SecureTextEditState,
707   text: &mut dyn TextBuffer,
708   initial_galley: &Arc<Galley>,
709   id: Id,
710   multiline: bool,
711   password: bool,
712   default_cursor_range: CCursorRange,
713   owns_ime_events: bool,
714   char_limit: usize,
715   event_filter: EventFilter,
716   return_key: Option<KeyboardShortcut>,
717   font_id: &FontId,
718   text_color: Color32,
719   wrap_width: f32,
720   text_align_horizontal: Align,
721) -> (bool, CCursorRange, Arc<Galley>) {
722   let os = ui.ctx().os();
723   let mut current_galley = initial_galley.clone();
724   let mut cursor_range = state.cursor.range(&current_galley).unwrap_or(default_cursor_range);
725   let mut text_changed_in_total = false;
726
727   let events_filtered = ui.input(|i| i.filtered_events(&event_filter));
728
729   for event in events_filtered {
730      let current_char_len_before_event = text.char_len();
731      let mut text_mutated_this_event = false;
732
733      // Pass current_galley to on_event. If it modifies cursor_range, it uses current_galley.
734      if cursor_range.on_event(os, &event, &current_galley, id) {
735         state.last_interaction_time = ui.input(|i| i.time);
736         continue;
737      }
738
739      let new_ccursor_range_opt: Option<CCursorRange> = match event {
740         // For now don't allow copy/cut on any text
741         Event::Copy => None,
742         Event::Cut => None,
743         #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
744         Event::Paste(mut text_to_paste) => {
745            if !text_to_paste.is_empty() {
746               let [min, max] = cursor_range.sorted_cursors();
747               let selection_char_len = max.index - min.index;
748               text.delete_text_char_range(min.index.0..max.index.0);
749
750               let space_available = char_limit.saturating_sub(
751                  current_char_len_before_event.saturating_sub(selection_char_len.0),
752               );
753               let mut final_text_to_paste = if text_to_paste.chars().count() > space_available {
754                  text_to_paste.chars().take(space_available).collect::<String>()
755               } else {
756                  text_to_paste.clone()
757               };
758
759               let mut current_ccursor = min;
760               let chars_inserted =
761                  text.insert_text_at_char_idx(current_ccursor.index.0, &final_text_to_paste);
762               current_ccursor.index += chars_inserted;
763               text_mutated_this_event = true; // Mark mutation
764
765               #[cfg(feature = "secure-types")]
766               {
767                  text_to_paste.zeroize();
768                  final_text_to_paste.zeroize();
769               }
770
771               Some(text::CCursorRange::one(current_ccursor))
772            } else {
773               None
774            }
775         }
776         #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
777         Event::Text(mut text_to_insert) => {
778            if !text_to_insert.is_empty() && text_to_insert != "\n" && text_to_insert != "\r" {
779               let [min, max] = cursor_range.sorted_cursors();
780               let selection_char_len = max.index - min.index;
781               text.delete_text_char_range(min.index.0..max.index.0);
782
783               let space_available = char_limit.saturating_sub(
784                  current_char_len_before_event.saturating_sub(selection_char_len.0),
785               );
786               let mut final_text_to_insert = if text_to_insert.chars().count() > space_available {
787                  text_to_insert.chars().take(space_available).collect::<String>()
788               } else {
789                  text_to_insert.clone()
790               };
791
792               let mut current_ccursor = min;
793               let chars_inserted =
794                  text.insert_text_at_char_idx(current_ccursor.index.0, &final_text_to_insert);
795               current_ccursor.index += chars_inserted;
796               text_mutated_this_event = true;
797
798               #[cfg(feature = "secure-types")]
799               {
800                  text_to_insert.zeroize();
801                  final_text_to_insert.zeroize();
802               }
803               Some(text::CCursorRange::one(current_ccursor))
804            } else {
805               None
806            }
807         }
808         Event::Key {
809            key: Key::Enter,
810            pressed: true,
811            modifiers,
812            ..
813         } if return_key.is_some_and(|rk| {
814            Key::Enter == rk.logical_key && modifiers.matches_logically(rk.modifiers)
815         }) =>
816         {
817            if multiline {
818               let [min, max] = cursor_range.sorted_cursors();
819               let selection_char_len = max.index - min.index;
820               text.delete_text_char_range(min.index.0..max.index.0);
821
822               let current_len_after_delete =
823                  current_char_len_before_event.saturating_sub(selection_char_len.0);
824               let space_available = char_limit.saturating_sub(current_len_after_delete);
825
826               if space_available > 0 {
827                  let mut current_ccursor = min;
828                  let chars_inserted = text.insert_text_at_char_idx(current_ccursor.index.0, "\n");
829                  current_ccursor.index += chars_inserted;
830                  text_mutated_this_event = true; // Mark mutation
831                  Some(text::CCursorRange::one(current_ccursor))
832               } else {
833                  None
834               }
835            } else {
836               ui.memory_mut(|mem| mem.surrender_focus(id));
837               None
838            }
839         }
840         Event::Key {
841            key: Key::Backspace,
842            pressed: true,
843            ..
844         } => {
845            // Modifiers for word/para delete later
846            let [min, max] = cursor_range.sorted_cursors();
847            let mut new_cursor_idx = min.index;
848            if min == max {
849               // No selection
850               if min.index.0 > 0 {
851                  text.delete_text_char_range(min.index.0 - 1..min.index.0);
852                  new_cursor_idx = min.index - 1;
853                  text_mutated_this_event = true;
854               }
855            } else {
856               // Selection exists
857               text.delete_text_char_range(min.index.0..max.index.0);
858               // new_cursor_idx is already min.ccursor.index
859               text_mutated_this_event = true;
860            }
861            if text_mutated_this_event {
862               Some(text::CCursorRange::one(text::CCursor::new(
863                  new_cursor_idx,
864               )))
865            } else {
866               None
867            }
868         }
869         Event::Key {
870            key: Key::Delete,
871            pressed: true,
872            ..
873         } => {
874            // Modifiers for word/para delete later
875            let [min, max] = cursor_range.sorted_cursors();
876            if min == max {
877               if min.index < current_char_len_before_event.into() {
878                  // Before deleting
879                  text.delete_text_char_range(min.index.0..min.index.0 + 1);
880                  text_mutated_this_event = true;
881               }
882            } else {
883               text.delete_text_char_range(min.index.0..max.index.0);
884               text_mutated_this_event = true;
885            }
886            if text_mutated_this_event {
887               Some(text::CCursorRange::one(min))
888            } else {
889               None
890            }
891         }
892         Event::Key {
893            key: Key::Tab,
894            pressed: true,
895            modifiers,
896            ..
897         } if multiline && event_filter.tab => {
898            let [min, _max] = cursor_range.sorted_cursors();
899            let mut current_ccursor = min;
900            if modifiers.shift {
901            } else {
902               let space_available = char_limit.saturating_sub(current_char_len_before_event);
903               if space_available > 0 {
904                  // Enough for at least '\t'
905                  let chars_inserted = text.insert_text_at_char_idx(current_ccursor.index.0, "\t");
906                  current_ccursor.index += chars_inserted;
907                  text_mutated_this_event = true;
908               }
909            }
910            if text_mutated_this_event {
911               Some(text::CCursorRange::one(current_ccursor))
912            } else {
913               None
914            }
915         }
916         Event::Ime(ime_event) if owns_ime_events => {
917            // GNOME 48+ / ibus on Wayland
918            // emits empty Preedit/Commit around Latin keystrokes; those must not
919            // rewrite the cursor using a stale composition range (that typed
920            // backwards). Operate on the live cursor_range, and ignore empty
921            // Preedit/Commit when no composition is active.
922            match ime_event {
923               #[allow(deprecated)]
924               ImeEvent::Enabled | ImeEvent::Disabled => None,
925               ImeEvent::Preedit {
926                  text: ref composition_text,
927                  ..
928               }
929               | ImeEvent::Commit(ref composition_text)
930                  if composition_text.is_empty() && !state.ime_enabled =>
931               {
932                  None
933               }
934               ImeEvent::Preedit {
935                  text: ref composition_text,
936                  ..
937               }
938               | ImeEvent::Commit(ref composition_text)
939                  if composition_text == "\n" || composition_text == "\r" =>
940               {
941                  None
942               }
943               #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
944               ImeEvent::Preedit {
945                  text: mut preedit_text,
946                  active_range_chars: _,
947               } => {
948                  let [min, max] = cursor_range.sorted_cursors();
949                  text.delete_text_char_range(min.index.0..max.index.0);
950                  let mut c = min;
951
952                  if preedit_text.is_empty() {
953                     state.ime_enabled = false;
954                     text_mutated_this_event = true;
955                     Some(text::CCursorRange::one(c))
956                  } else {
957                     let space_available = char_limit.saturating_sub(text.char_len());
958                     let mut final_text = if preedit_text.chars().count() > space_available {
959                        preedit_text.chars().take(space_available).collect::<String>()
960                     } else {
961                        preedit_text.clone()
962                     };
963                     let inserted = text.insert_text_at_char_idx(c.index.0, &final_text);
964                     c.index += inserted;
965                     state.ime_enabled = true;
966                     state.ime_cursor_range = text::CCursorRange::two(min, c);
967                     text_mutated_this_event = true;
968
969                     #[cfg(feature = "secure-types")]
970                     {
971                        preedit_text.zeroize();
972                        final_text.zeroize();
973                     }
974
975                     Some(text::CCursorRange::two(min, c))
976                  }
977               }
978               #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
979               ImeEvent::Commit(mut commit_text) => {
980                  state.ime_enabled = false;
981                  let [min, max] = cursor_range.sorted_cursors();
982                  text.delete_text_char_range(min.index.0..max.index.0);
983                  let mut c = min;
984
985                  if !commit_text.is_empty() {
986                     let space_available = char_limit.saturating_sub(text.char_len());
987                     let mut final_text = if commit_text.chars().count() > space_available {
988                        commit_text.chars().take(space_available).collect::<String>()
989                     } else {
990                        commit_text.clone()
991                     };
992                     let inserted = text.insert_text_at_char_idx(c.index.0, &final_text);
993                     c.index += inserted;
994
995                     #[cfg(feature = "secure-types")]
996                     final_text.zeroize();
997                  }
998                  text_mutated_this_event = true;
999
1000                  #[cfg(feature = "secure-types")]
1001                  commit_text.zeroize();
1002
1003                  Some(text::CCursorRange::one(c))
1004               }
1005               ImeEvent::DeleteSurrounding {
1006                  before_chars,
1007                  after_chars,
1008               } => {
1009                  let [min, max] = cursor_range.sorted_cursors();
1010                  let mut new_range = cursor_range;
1011                  let mut mutated = false;
1012
1013                  if after_chars > 0 {
1014                     let end = (max.index.0 + after_chars).min(text.char_len());
1015                     if end > max.index.0 {
1016                        text.delete_text_char_range(max.index.0..end);
1017                        mutated = true;
1018                     }
1019                  }
1020                  if before_chars > 0 {
1021                     let start = min.index.0.saturating_sub(before_chars);
1022                     if start < min.index.0 {
1023                        let deleted = min.index.0 - start;
1024                        text.delete_text_char_range(start..min.index.0);
1025                        new_range.primary.index =
1026                           (new_range.primary.index.0.saturating_sub(deleted)).into();
1027                        new_range.secondary.index =
1028                           (new_range.secondary.index.0.saturating_sub(deleted)).into();
1029                        mutated = true;
1030                     }
1031                  }
1032
1033                  if mutated {
1034                     text_mutated_this_event = true;
1035                     Some(new_range)
1036                  } else {
1037                     None
1038                  }
1039               }
1040            }
1041         }
1042         _ => None,
1043      };
1044
1045      if text_mutated_this_event {
1046         text_changed_in_total = true;
1047
1048         // --- Re-layout galley ---
1049
1050         let display_text_for_layout = if password {
1051            std::iter::repeat(epaint::text::PASSWORD_REPLACEMENT_CHAR)
1052               .take(text.char_len())
1053               .collect::<String>()
1054         } else {
1055            text.to_string() // !
1056         };
1057
1058         let mut job = if multiline {
1059            LayoutJob::simple(
1060               display_text_for_layout,
1061               font_id.clone(),
1062               text_color,
1063               wrap_width,
1064            )
1065         } else {
1066            LayoutJob::simple_singleline(
1067               display_text_for_layout,
1068               font_id.clone(),
1069               text_color,
1070            )
1071         };
1072
1073         job.halign = text_align_horizontal;
1074         current_galley = ui.fonts_mut(|f| f.layout_job(job));
1075      }
1076
1077      if let Some(new_range) = new_ccursor_range_opt {
1078         state.last_interaction_time = ui.input(|i| i.time);
1079         cursor_range = new_range;
1080      }
1081      state.cursor.set_char_range(Some(cursor_range));
1082   }
1083
1084   (
1085      text_changed_in_total,
1086      cursor_range,
1087      current_galley,
1088   )
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093   use super::*;
1094   use egui::{Context, Event, Id, ImeEvent, RawInput};
1095
1096   fn run_pass(ctx: &Context, text: &mut String, id: Id, events: Vec<Event>) {
1097      let output = ctx.run_ui(
1098         RawInput {
1099            events,
1100            ..Default::default()
1101         },
1102         |ui| {
1103            ui.ctx().memory_mut(|mem| mem.request_focus(id));
1104            ui.add(SecureTextEdit::singleline(text).id(id));
1105         },
1106      );
1107      output.drop_without_applying_deltas();
1108   }
1109
1110   fn ime_preedit(text: &str) -> Event {
1111      Event::Ime(ImeEvent::Preedit {
1112         text: text.to_owned(),
1113         active_range_chars: None,
1114      })
1115   }
1116
1117   fn ime_commit(text: &str) -> Event {
1118      Event::Ime(ImeEvent::Commit(text.to_owned()))
1119   }
1120
1121   /// GNOME 48+ / ibus on Wayland emits this sequence per Latin keystroke.
1122   fn gnome_wayland_keystroke(ch: char) -> Vec<Event> {
1123      let s = ch.to_string();
1124      vec![ime_preedit(&s), ime_commit(&s), ime_preedit("")]
1125   }
1126
1127   #[test]
1128   fn gnome_wayland_ime_types_forwards_not_in_reverse() {
1129      let ctx = Context::default();
1130      let id = Id::new("secure_ime_gnome");
1131      let mut text = String::new();
1132
1133      #[allow(deprecated)]
1134      run_pass(
1135         &ctx,
1136         &mut text,
1137         id,
1138         vec![Event::Ime(ImeEvent::Enabled)],
1139      );
1140
1141      for ch in ['h', 'e', 'l', 'l', 'o'] {
1142         run_pass(&ctx, &mut text, id, gnome_wayland_keystroke(ch));
1143      }
1144
1145      assert_eq!(
1146         text, "hello",
1147         "IME keystrokes must append, not insert at 0"
1148      );
1149   }
1150
1151   #[test]
1152   fn plain_text_events_type_forwards() {
1153      let ctx = Context::default();
1154      let id = Id::new("secure_text_events");
1155      let mut text = String::new();
1156
1157      for ch in ['h', 'e', 'l', 'l', 'o'] {
1158         run_pass(
1159            &ctx,
1160            &mut text,
1161            id,
1162            vec![Event::Text(ch.to_string())],
1163         );
1164      }
1165
1166      assert_eq!(text, "hello");
1167   }
1168
1169   #[test]
1170   fn empty_ime_preedit_without_composition_does_not_reset_cursor() {
1171      let ctx = Context::default();
1172      let id = Id::new("secure_empty_preedit");
1173      let mut text = String::new();
1174
1175      run_pass(
1176         &ctx,
1177         &mut text,
1178         id,
1179         vec![Event::Text("ab".to_owned())],
1180      );
1181      run_pass(&ctx, &mut text, id, vec![ime_preedit("")]);
1182      run_pass(
1183         &ctx,
1184         &mut text,
1185         id,
1186         vec![Event::Text("c".to_owned())],
1187      );
1188
1189      assert_eq!(
1190         text, "abc",
1191         "spurious empty Preedit must not move the cursor to the start"
1192      );
1193   }
1194
1195   #[test]
1196   fn ime_composition_updates_replace_preedit_then_commit() {
1197      let ctx = Context::default();
1198      let id = Id::new("secure_ime_compose");
1199      let mut text = String::new();
1200
1201      run_pass(&ctx, &mut text, id, vec![ime_preedit("n")]);
1202      assert_eq!(text, "n");
1203      run_pass(&ctx, &mut text, id, vec![ime_preedit("ni")]);
1204      assert_eq!(text, "ni");
1205      run_pass(&ctx, &mut text, id, vec![ime_preedit("に")]);
1206      assert_eq!(text, "に");
1207      run_pass(&ctx, &mut text, id, vec![ime_commit("に")]);
1208      assert_eq!(text, "に");
1209      run_pass(&ctx, &mut text, id, gnome_wayland_keystroke('!'));
1210      assert_eq!(text, "に!");
1211   }
1212}