Skip to main content

zeus_widgets/
secure_text_edit.rs

1use egui::{
2   Align, Align2, Color32, CursorIcon, Event, EventFilter, FontId, FontSelection, Galley, Id,
3   IMEPurpose, ImeEvent, Key, KeyboardShortcut, Margin, Modifiers, NumExt, Response, Sense, Shape,
4   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   pub ime_enabled: bool,
86   pub ime_cursor_range: CCursorRange,
87}
88
89impl SecureTextEditState {
90   pub fn load(ctx: &egui::Context, id: egui::Id) -> Option<Self> {
91      ctx.data_mut(|d| d.get_persisted(id))
92   }
93
94   pub fn store(self, ctx: &egui::Context, id: egui::Id) {
95      ctx.data_mut(|d| d.insert_persisted(id, self));
96   }
97}
98
99pub struct SecureTextEditOutput {
100   pub response: Response,
101   pub state: SecureTextEditState,
102   pub cursor_range: Option<CCursorRange>,
103}
104
105/// A widget for editing text.
106///
107/// This widget is identical to [`egui::TextEdit`], but it uses a custom [`TextBuffer`]
108/// that can take either a [`SecureString`] or a [`String`].
109///
110/// To use it with a [`SecureString`], the feature `secure-types` must be enabled.
111///
112/// ## Notes
113///
114/// - Accessability like screen readers is disabled to avoid multiple unsecure allocations of the entered text.
115/// - 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`.
116///
117/// Otherwise egui will make copies of that text and some of the copied allocations will stay in memory.
118#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
119pub struct SecureTextEdit<'a> {
120   text: &'a mut dyn TextBuffer,
121   visuals: Option<TextEditVisuals>,
122   hint_text: WidgetText,
123   id: Option<Id>,
124   id_salt: Option<Id>,
125   font_selection: FontSelection,
126   text_color: Option<Color32>,
127   password: bool,
128   frame: bool,
129   margin: Margin,
130   multiline: bool,
131   interactive: bool,
132   desired_width: Option<f32>,
133   desired_height_rows: usize,
134   event_filter: EventFilter,
135   cursor_at_end: bool,
136   min_size: Vec2,
137   align: Align2,
138   clip_text: bool,
139   char_limit: usize,
140   return_key: Option<KeyboardShortcut>,
141   background_color: Option<Color32>,
142}
143
144impl<'a> SecureTextEdit<'a> {
145   pub fn singleline(text: &'a mut dyn TextBuffer) -> Self {
146      Self {
147         text,
148         visuals: None,
149         hint_text: Default::default(),
150         id: None,
151         id_salt: None,
152         font_selection: FontSelection::default(),
153         text_color: None,
154         password: false,
155         frame: true,
156         margin: Margin::symmetric(4, 2),
157         multiline: false,
158         interactive: true,
159         desired_width: None,
160         desired_height_rows: 1,
161         event_filter: EventFilter {
162            horizontal_arrows: true,
163            vertical_arrows: true,
164            tab: false,
165            ..Default::default()
166         },
167         cursor_at_end: true,
168         min_size: Vec2::ZERO,
169         align: Align2::LEFT_CENTER,
170         clip_text: true,
171         char_limit: usize::MAX,
172         return_key: Some(KeyboardShortcut::new(Modifiers::NONE, Key::Enter)),
173         background_color: None,
174      }
175   }
176
177   pub fn multiline(text: &'a mut dyn TextBuffer) -> Self {
178      Self {
179         text,
180         visuals: None,
181         hint_text: Default::default(),
182         id: None,
183         id_salt: None,
184         font_selection: FontSelection::default(),
185         text_color: None,
186         password: false,
187         frame: true,
188         margin: Margin::symmetric(4, 2),
189         multiline: true,
190         interactive: true,
191         desired_width: None,
192         desired_height_rows: 4,
193         event_filter: EventFilter {
194            horizontal_arrows: true,
195            vertical_arrows: true,
196            tab: false,
197            ..Default::default()
198         },
199         cursor_at_end: true,
200         min_size: Vec2::ZERO,
201         align: Align2::LEFT_TOP,
202         clip_text: false,
203         char_limit: usize::MAX,
204         return_key: Some(KeyboardShortcut::new(Modifiers::NONE, Key::Enter)),
205         background_color: None,
206      }
207   }
208
209   pub fn visuals(mut self, visuals: TextEditVisuals) -> Self {
210      self.visuals = Some(visuals);
211      self
212   }
213
214   pub fn id(mut self, id: Id) -> Self {
215      self.id = Some(id);
216      self
217   }
218
219   pub fn id_source(self, id_source: impl Hash + Debug) -> Self {
220      self.id_salt(id_source)
221   }
222
223   pub fn id_salt(mut self, id_salt: impl Hash + Debug) -> Self {
224      self.id_salt = Some(Id::new(id_salt));
225      self
226   }
227
228   pub fn hint_text(mut self, hint_text: impl Into<WidgetText>) -> Self {
229      self.hint_text = hint_text.into();
230      self
231   }
232
233   pub fn font(mut self, font_selection: impl Into<FontSelection>) -> Self {
234      self.font_selection = font_selection.into();
235      self
236   }
237
238   pub fn text_color(mut self, text_color: Color32) -> Self {
239      self.text_color = Some(text_color);
240      self
241   }
242
243   pub fn text_color_opt(mut self, text_color: Option<Color32>) -> Self {
244      self.text_color = text_color;
245      self
246   }
247
248   pub fn password(mut self, password: bool) -> Self {
249      self.password = password;
250      self
251   }
252
253   pub fn frame(mut self, frame: bool) -> Self {
254      self.frame = frame;
255      self
256   }
257
258   pub fn margin(mut self, margin: impl Into<Margin>) -> Self {
259      self.margin = margin.into();
260      self
261   }
262
263   pub fn interactive(mut self, interactive: bool) -> Self {
264      self.interactive = interactive;
265      self
266   }
267
268   pub fn desired_width(mut self, desired_width: f32) -> Self {
269      self.desired_width = Some(desired_width);
270      self
271   }
272
273   pub fn desired_rows(mut self, desired_height_rows: usize) -> Self {
274      self.desired_height_rows = desired_height_rows;
275      self
276   }
277
278   pub fn lock_focus(mut self, tab_will_indent: bool) -> Self {
279      self.event_filter.tab = tab_will_indent;
280      self
281   }
282
283   pub fn cursor_at_end(mut self, b: bool) -> Self {
284      self.cursor_at_end = b;
285      self
286   }
287
288   pub fn min_size(mut self, min_size: Vec2) -> Self {
289      self.min_size = min_size;
290      self
291   }
292
293   pub fn horizontal_align(mut self, align: Align) -> Self {
294      self.align.0[0] = align;
295      self
296   }
297
298   pub fn vertical_align(mut self, align: Align) -> Self {
299      self.align.0[1] = align;
300      self
301   }
302
303   pub fn clip_text(mut self, b: bool) -> Self {
304      if !self.multiline {
305         self.clip_text = b;
306      }
307      self
308   }
309
310   pub fn char_limit(mut self, limit: usize) -> Self {
311      self.char_limit = limit;
312      self
313   }
314
315   pub fn return_key(mut self, return_key: impl Into<Option<KeyboardShortcut>>) -> Self {
316      self.return_key = return_key.into();
317      self
318   }
319
320   pub fn background_color(mut self, color: Color32) -> Self {
321      self.background_color = Some(color);
322      self
323   }
324
325   pub fn show(self, ui: &mut Ui) -> SecureTextEditOutput {
326      let frame = self.frame;
327      let where_to_put_background = ui.painter().add(Shape::Noop);
328      let background_color =
329         self.visuals.as_ref().map(|v| v.bg).unwrap_or(ui.visuals().extreme_bg_color);
330      let is_interactive = self.interactive;
331
332      let (output, text_edit_visuals) = self.show_content(ui);
333
334      let outer_rect_for_frame = output.response.rect;
335      if frame {
336         let visuals = ui.style().interact(&output.response);
337         let frame_rect = outer_rect_for_frame.expand(visuals.expansion);
338         let corner = text_edit_visuals
339            .as_ref()
340            .map(|v| v.corner_radius)
341            .unwrap_or(visuals.corner_radius);
342
343         let shape = if is_interactive {
344            if output.response.has_focus() || output.response.hovered() {
345               let border =
346                  text_edit_visuals.as_ref().map(|v| v.border_open).unwrap_or(visuals.bg_stroke);
347               epaint::RectShape::new(
348                  frame_rect,
349                  corner,
350                  background_color,
351                  border,
352                  epaint::StrokeKind::Inside,
353               )
354            } else {
355               let border =
356                  text_edit_visuals.as_ref().map(|v| v.border).unwrap_or(visuals.bg_stroke);
357               epaint::RectShape::new(
358                  frame_rect,
359                  corner,
360                  background_color,
361                  border,
362                  epaint::StrokeKind::Inside,
363               )
364            }
365         } else {
366            // Not interactive
367            let visuals = &ui.style().visuals.widgets.inactive;
368            let border = text_edit_visuals.as_ref().map(|v| v.border).unwrap_or(visuals.bg_stroke);
369            epaint::RectShape::stroke(
370               frame_rect,
371               corner,
372               border,
373               epaint::StrokeKind::Inside,
374            )
375         };
376         ui.painter().set(where_to_put_background, shape);
377      }
378      output
379   }
380
381   #[allow(clippy::too_many_lines)]
382   fn show_content(mut self, ui: &mut Ui) -> (SecureTextEditOutput, Option<TextEditVisuals>) {
383      let font_id = self.font_selection.resolve(ui.style());
384      let text_color = ui
385         .visuals()
386         .override_text_color
387         .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
388
389      let text_color = self.visuals.map(|v| v.text).unwrap_or(text_color);
390
391      let row_height = ui.fonts_mut(|f| f.row_height(&font_id));
392      let available_width = (ui.available_width() - self.margin.sum().x).at_least(24.0); // Min width
393      let desired_width = self.desired_width.unwrap_or_else(|| ui.spacing().text_edit_width);
394      let wrap_width = if ui.layout().horizontal_justify() {
395         available_width
396      } else {
397         desired_width.min(available_width)
398      };
399
400      // --- Layout Galley ---
401
402      let display_text_cow = if self.password {
403         // Generate '●' string based on actual char count
404         std::borrow::Cow::<'_, str>::Owned(
405            std::iter::repeat(epaint::text::PASSWORD_REPLACEMENT_CHAR)
406               .take(self.text.char_len())
407               .collect::<String>(),
408         )
409      } else {
410         std::borrow::Cow::Owned(self.text.to_string()) // !
411      };
412
413      let mut job = if self.multiline {
414         LayoutJob::simple(
415            (*display_text_cow).to_owned(),
416            font_id.clone(),
417            text_color,
418            wrap_width,
419         )
420      } else {
421         LayoutJob::simple_singleline(
422            (*display_text_cow).to_owned(),
423            font_id.clone(),
424            text_color,
425         )
426      };
427
428      job.halign = self.align.0[0];
429      let galley: Arc<Galley> = ui.fonts_mut(|f| f.layout_job(job));
430
431      // --- Size & Allocation ---
432      let desired_inner_width = if self.clip_text && !self.multiline {
433         wrap_width
434      } else {
435         galley.size().x.max(wrap_width)
436      };
437      let desired_height = (self.desired_height_rows.at_least(1) as f32) * row_height;
438      let desired_inner_size = vec2(
439         desired_inner_width,
440         galley.size().y.max(desired_height),
441      );
442      let desired_outer_size = (desired_inner_size + self.margin.sum()).at_least(self.min_size);
443
444      let (auto_id, outer_rect) = ui.allocate_space(desired_outer_size);
445      let text_draw_rect = outer_rect - self.margin;
446
447      let id = self.id.unwrap_or_else(|| {
448         if let Some(id_salt) = self.id_salt {
449            ui.make_persistent_id(id_salt)
450         } else {
451            auto_id
452         }
453      });
454      let mut state = SecureTextEditState::load(ui.ctx(), id).unwrap_or_default();
455
456      // --- Interaction ---
457      let allow_drag_to_select =
458         ui.input(|i| !i.has_touch_screen()) || ui.memory(|mem| mem.has_focus(id));
459      let sense_behavior = if self.interactive {
460         if allow_drag_to_select {
461            Sense::click_and_drag()
462         } else {
463            Sense::click()
464         }
465      } else {
466         Sense::hover()
467      };
468      let mut response = ui.interact(outer_rect, id, sense_behavior);
469      response.set_intrinsic_size(vec2(desired_width, desired_outer_size.y));
470
471      // Handle click to focus
472      if self.interactive {
473         if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
474            if response.hovered() {
475               ui.output_mut(|o| o.mutable_text_under_cursor = true);
476            }
477            let singleline_offset_vec = vec2(state.singleline_offset, 0.0);
478            let cursor_at_pointer =
479               galley.cursor_from_pos(pointer_pos - text_draw_rect.min + singleline_offset_vec);
480
481            let is_being_dragged = ui.ctx().is_being_dragged(response.id);
482            let did_interact_with_cursor = state.cursor.pointer_interaction(
483               ui,
484               &response,
485               cursor_at_pointer,
486               &galley,
487               is_being_dragged,
488            );
489
490            if did_interact_with_cursor || response.clicked() {
491               ui.memory_mut(|mem| mem.request_focus(response.id));
492               state.last_interaction_time = ui.input(|i| i.time);
493            }
494         }
495      }
496      if self.interactive && response.hovered() {
497         ui.ctx().set_cursor_icon(CursorIcon::Text);
498      }
499
500      // --- Event Handling ---
501      let mut cursor_range_after_events = None;
502      // Initial galley before any events in this frame
503      let current_frame_galley = galley.clone();
504
505      if self.interactive && ui.memory(|mem| mem.has_focus(id)) {
506         ui.memory_mut(|mem| mem.set_focus_lock_filter(id, self.event_filter));
507
508         let default_cursor_range = if self.cursor_at_end {
509            CCursorRange::one(current_frame_galley.end())
510         } else {
511            CCursorRange::default()
512         };
513
514         let (text_changed_by_event, new_cursor_range, _updated_galley_from_events) =
515            secure_text_edit_events(
516               ui,
517               &mut state,
518               self.text,
519               &current_frame_galley,
520               id,
521               self.multiline,
522               self.password,
523               default_cursor_range,
524               self.char_limit,
525               self.event_filter,
526               self.return_key,
527               &font_id,
528               text_color,
529               wrap_width,
530               self.align.0[0],
531            );
532
533         if text_changed_by_event {
534            response.mark_changed();
535         }
536         cursor_range_after_events = Some(new_cursor_range);
537
538         if !text_changed_by_event {
539            state.cursor.set_char_range(Some(new_cursor_range));
540         }
541      }
542
543      // --- Galley Positioning & Single-line Offset ---
544      let mut galley_pos = self.align.align_size_within_rect(galley.size(), text_draw_rect).min;
545      if self.clip_text && !self.multiline {
546         let current_cursor_primary_x =
547            match cursor_range_after_events.or_else(|| state.cursor.range(&galley)) {
548               Some(cr) => galley.pos_from_cursor(cr.primary).min.x,
549               None => 0.0,
550            };
551         let visible_width = text_draw_rect.width();
552         let mut offset_x = state.singleline_offset;
553         let visible_range_start = offset_x;
554         let visible_range_end = offset_x + visible_width;
555
556         if current_cursor_primary_x < visible_range_start {
557            offset_x = current_cursor_primary_x;
558         } else if current_cursor_primary_x > visible_range_end {
559            offset_x = current_cursor_primary_x - visible_width;
560         }
561         offset_x = offset_x.at_most(galley.size().x - visible_width).at_least(0.0);
562         state.singleline_offset = offset_x;
563         galley_pos.x -= offset_x;
564      } else {
565         // For multiline or non-clip singleline, capture any alignment offset
566         // state.singleline_offset = text_draw_rect.left() - galley_pos.x;
567         state.singleline_offset = 0.0;
568         // And ensure galley_pos respects it if it was aligned (e.g. center/right)
569         state.singleline_offset = text_draw_rect.left() - galley_pos.x;
570      }
571
572      // --- Painting ---
573      if ui.is_rect_visible(text_draw_rect) {
574         let is_text_empty = self.text.char_len() == 0;
575         if is_text_empty && !self.hint_text.is_empty() {
576            let hint_text_color = ui.visuals().weak_text_color();
577            let hint_font_id = FontSelection::default();
578            let hint_galley = self.hint_text.clone().into_galley(
579               ui,
580               Some(TextWrapMode::Wrap),
581               text_draw_rect.width(),
582               hint_font_id,
583            );
584            let hint_galley_pos =
585               self.align.align_size_within_rect(hint_galley.size(), text_draw_rect).min;
586            ui.painter_at(text_draw_rect)
587               .galley(hint_galley_pos, hint_galley, hint_text_color);
588         }
589
590         let mut galley_for_paint = galley.clone();
591         if ui.memory(|mem| mem.has_focus(id)) {
592            if let Some(cursor_range_for_sel) = state.cursor.range(&galley_for_paint) {
593               text_selection::visuals::paint_text_selection(
594                  &mut galley_for_paint,
595                  ui.visuals(),
596                  &cursor_range_for_sel,
597                  None,
598               );
599            }
600         }
601         ui.painter_at(text_draw_rect).galley(galley_pos, galley_for_paint, text_color);
602
603         // Paint cursor
604         if self.interactive && ui.memory(|mem| mem.has_focus(id)) {
605            if let Some(cursor_range_for_cursor_paint) = state.cursor.range(&galley) {
606               // Use original galley for metrics
607               let primary_cursor_rect_ui = text_selection::text_cursor_state::cursor_rect(
608                  &galley,
609                  &cursor_range_for_cursor_paint.primary,
610                  row_height,
611               )
612               .translate(galley_pos.to_vec2());
613
614               if response.changed() {
615                  // Could also check selection_changed
616                  ui.scroll_to_rect(
617                     primary_cursor_rect_ui.expand(self.margin.sum().y / 2.0),
618                     None,
619                  );
620               }
621
622               if ui.ctx().input(|i| i.focused) {
623                  // Viewport has focus
624                  let time_since_last_interaction =
625                     ui.input(|i| i.time) - state.last_interaction_time;
626                  text_selection::visuals::paint_text_cursor(
627                     ui,
628                     &ui.painter_at(text_draw_rect.expand(1.0)), // Expand for cursor
629                     primary_cursor_rect_ui,
630                     time_since_last_interaction,
631                  );
632               }
633               // IME output
634               let to_global =
635                  ui.ctx().layer_transform_to_global(ui.layer_id()).unwrap_or_default();
636               ui.ctx().output_mut(|o| {
637                  o.ime = Some(output::IMEOutput {
638                     purpose: if self.password {
639                        IMEPurpose::Password
640                     } else {
641                        IMEPurpose::Normal
642                     },
643                     rect: to_global * text_draw_rect,
644                     cursor_rect: to_global * primary_cursor_rect_ui,
645                     should_interrupt_composition: false,
646                  });
647               });
648            }
649         }
650      }
651
652      // IME focus state management
653      if state.ime_enabled && (response.gained_focus() || response.lost_focus()) {
654         state.ime_enabled = false;
655         if let Some(mut ccursor_range) = state.cursor.char_range() {
656            ccursor_range.secondary.index = ccursor_range.primary.index;
657            state.cursor.set_char_range(Some(ccursor_range));
658         }
659         ui.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Ime(_))));
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   char_limit: usize,
714   event_filter: EventFilter,
715   return_key: Option<KeyboardShortcut>,
716   font_id: &FontId,
717   text_color: Color32,
718   wrap_width: f32,
719   text_align_horizontal: Align,
720) -> (bool, CCursorRange, Arc<Galley>) {
721   let os = ui.ctx().os();
722   let mut current_galley = initial_galley.clone();
723   let mut cursor_range = state.cursor.range(&current_galley).unwrap_or(default_cursor_range);
724   let mut text_changed_in_total = false;
725
726   let mut events_filtered = ui.input(|i| i.filtered_events(&event_filter));
727   if state.ime_enabled {
728      events_filtered.sort_by_key(|e| !matches!(e, Event::Ime(_)));
729   }
730
731   for event in events_filtered {
732      let current_char_len_before_event = text.char_len();
733      let mut text_mutated_this_event = false;
734
735      // Pass current_galley to on_event. If it modifies cursor_range, it uses current_galley.
736      if cursor_range.on_event(os, &event, &current_galley, id) {
737         state.last_interaction_time = ui.input(|i| i.time);
738         continue;
739      }
740
741      let new_ccursor_range_opt: Option<CCursorRange> = match event {
742         // For now don't allow copy/cut on any text
743         Event::Copy => None,
744         Event::Cut => None,
745         #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
746         Event::Paste(mut text_to_paste) => {
747            if !text_to_paste.is_empty() {
748               let [min, max] = cursor_range.sorted_cursors();
749               let selection_char_len = max.index - min.index;
750               text.delete_text_char_range(min.index.0..max.index.0);
751
752               let space_available = char_limit.saturating_sub(
753                  current_char_len_before_event.saturating_sub(selection_char_len.0),
754               );
755               let mut final_text_to_paste = if text_to_paste.chars().count() > space_available {
756                  text_to_paste.chars().take(space_available).collect::<String>()
757               } else {
758                  text_to_paste.clone()
759               };
760
761               let mut current_ccursor = min;
762               let chars_inserted =
763                  text.insert_text_at_char_idx(current_ccursor.index.0, &final_text_to_paste);
764               current_ccursor.index += chars_inserted;
765               text_mutated_this_event = true; // Mark mutation
766
767               #[cfg(feature = "secure-types")]
768               {
769                  text_to_paste.zeroize();
770                  final_text_to_paste.zeroize();
771               }
772
773               Some(text::CCursorRange::one(current_ccursor))
774            } else {
775               None
776            }
777         }
778         #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
779         Event::Text(mut text_to_insert) => {
780            if !text_to_insert.is_empty() && text_to_insert != "\n" && text_to_insert != "\r" {
781               let [min, max] = cursor_range.sorted_cursors();
782               let selection_char_len = max.index - min.index;
783               text.delete_text_char_range(min.index.0..max.index.0);
784
785               let space_available = char_limit.saturating_sub(
786                  current_char_len_before_event.saturating_sub(selection_char_len.0),
787               );
788               let mut final_text_to_insert = if text_to_insert.chars().count() > space_available {
789                  text_to_insert.chars().take(space_available).collect::<String>()
790               } else {
791                  text_to_insert.clone()
792               };
793
794               let mut current_ccursor = min;
795               let chars_inserted =
796                  text.insert_text_at_char_idx(current_ccursor.index.0, &final_text_to_insert);
797               current_ccursor.index += chars_inserted;
798               text_mutated_this_event = true;
799
800               #[cfg(feature = "secure-types")]
801               {
802                  text_to_insert.zeroize();
803                  final_text_to_insert.zeroize();
804               }
805               Some(text::CCursorRange::one(current_ccursor))
806            } else {
807               None
808            }
809         }
810         Event::Key {
811            key: Key::Enter,
812            pressed: true,
813            modifiers,
814            ..
815         } if return_key.is_some_and(|rk| {
816            Key::Enter == rk.logical_key && modifiers.matches_logically(rk.modifiers)
817         }) =>
818         {
819            if multiline {
820               let [min, max] = cursor_range.sorted_cursors();
821               let selection_char_len = max.index - min.index;
822               text.delete_text_char_range(min.index.0..max.index.0);
823
824               let current_len_after_delete =
825                  current_char_len_before_event.saturating_sub(selection_char_len.0);
826               let space_available = char_limit.saturating_sub(current_len_after_delete);
827
828               if space_available > 0 {
829                  let mut current_ccursor = min;
830                  let chars_inserted = text.insert_text_at_char_idx(current_ccursor.index.0, "\n");
831                  current_ccursor.index += chars_inserted;
832                  text_mutated_this_event = true; // Mark mutation
833                  Some(text::CCursorRange::one(current_ccursor))
834               } else {
835                  None
836               }
837            } else {
838               ui.memory_mut(|mem| mem.surrender_focus(id));
839               None
840            }
841         }
842         Event::Key {
843            key: Key::Backspace,
844            pressed: true,
845            ..
846         } => {
847            // Modifiers for word/para delete later
848            let [min, max] = cursor_range.sorted_cursors();
849            let mut new_cursor_idx = min.index;
850            if min == max {
851               // No selection
852               if min.index.0 > 0 {
853                  text.delete_text_char_range(min.index.0 - 1..min.index.0);
854                  new_cursor_idx = min.index - 1;
855                  text_mutated_this_event = true;
856               }
857            } else {
858               // Selection exists
859               text.delete_text_char_range(min.index.0..max.index.0);
860               // new_cursor_idx is already min.ccursor.index
861               text_mutated_this_event = true;
862            }
863            if text_mutated_this_event {
864               Some(text::CCursorRange::one(text::CCursor::new(
865                  new_cursor_idx,
866               )))
867            } else {
868               None
869            }
870         }
871         Event::Key {
872            key: Key::Delete,
873            pressed: true,
874            ..
875         } => {
876            // Modifiers for word/para delete later
877            let [min, max] = cursor_range.sorted_cursors();
878            if min == max {
879               if min.index < current_char_len_before_event.into() {
880                  // Before deleting
881                  text.delete_text_char_range(min.index.0..min.index.0 + 1);
882                  text_mutated_this_event = true;
883               }
884            } else {
885               text.delete_text_char_range(min.index.0..max.index.0);
886               text_mutated_this_event = true;
887            }
888            if text_mutated_this_event {
889               Some(text::CCursorRange::one(min))
890            } else {
891               None
892            }
893         }
894         Event::Key {
895            key: Key::Tab,
896            pressed: true,
897            modifiers,
898            ..
899         } if multiline && event_filter.tab => {
900            let [min, _max] = cursor_range.sorted_cursors();
901            let mut current_ccursor = min;
902            if modifiers.shift {
903            } else {
904               let space_available = char_limit.saturating_sub(current_char_len_before_event);
905               if space_available > 0 {
906                  // Enough for at least '\t'
907                  let chars_inserted = text.insert_text_at_char_idx(current_ccursor.index.0, "\t");
908                  current_ccursor.index += chars_inserted;
909                  text_mutated_this_event = true;
910               }
911            }
912            if text_mutated_this_event {
913               Some(text::CCursorRange::one(current_ccursor))
914            } else {
915               None
916            }
917         }
918         Event::Ime(ime_event) => {
919            match ime_event {
920               #[allow(deprecated)]
921               ImeEvent::Enabled => {
922                  state.ime_enabled = true;
923                  state.ime_cursor_range = cursor_range;
924                  None
925               }
926               #[allow(deprecated)]
927               ImeEvent::Disabled => {
928                  state.ime_enabled = false;
929                  None
930               }
931               #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
932               ImeEvent::Preedit {
933                  text: mut preedit_text,
934                  active_range_chars: _,
935               } => {
936                  let [min_ime, max_ime] = state.ime_cursor_range.sorted_cursors(); // Use IME's original range for delete
937                  text.delete_text_char_range(min_ime.index.0..max_ime.index.0);
938                  let mut c = min_ime; // Insert at start of IME original selection
939                  let inserted = text.insert_text_at_char_idx(c.index.0, &preedit_text);
940                  c.index += inserted;
941                  text_mutated_this_event = true;
942
943                  #[cfg(feature = "secure-types")]
944                  preedit_text.zeroize();
945
946                  Some(text::CCursorRange::two(min_ime, c))
947               }
948               #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
949               ImeEvent::Commit(mut commit_text) => {
950                  state.ime_enabled = false; // IME done
951                  let [min_commit, max_commit] = cursor_range.sorted_cursors();
952                  text.delete_text_char_range(min_commit.index.0..max_commit.index.0);
953                  let mut c = min_commit;
954                  let inserted = text.insert_text_at_char_idx(c.index.0, &commit_text);
955                  c.index += inserted;
956                  text_mutated_this_event = true;
957
958                  #[cfg(feature = "secure-types")]
959                  commit_text.zeroize();
960
961                  Some(text::CCursorRange::one(c))
962               }
963               ImeEvent::DeleteSurrounding {
964                  before_chars,
965                  after_chars,
966               } => {
967                  let [min, max] = cursor_range.sorted_cursors();
968                  let mut new_range = cursor_range;
969                  let mut mutated = false;
970
971                  if after_chars > 0 {
972                     let end = (max.index.0 + after_chars).min(text.char_len());
973                     if end > max.index.0 {
974                        text.delete_text_char_range(max.index.0..end);
975                        mutated = true;
976                     }
977                  }
978                  if before_chars > 0 {
979                     let start = min.index.0.saturating_sub(before_chars);
980                     if start < min.index.0 {
981                        let deleted = min.index.0 - start;
982                        text.delete_text_char_range(start..min.index.0);
983                        new_range.primary.index =
984                           (new_range.primary.index.0.saturating_sub(deleted)).into();
985                        new_range.secondary.index =
986                           (new_range.secondary.index.0.saturating_sub(deleted)).into();
987                        mutated = true;
988                     }
989                  }
990
991                  if mutated {
992                     text_mutated_this_event = true;
993                     Some(new_range)
994                  } else {
995                     None
996                  }
997               }
998            }
999         }
1000         _ => None,
1001      };
1002
1003      if text_mutated_this_event {
1004         text_changed_in_total = true;
1005
1006         // --- Re-layout galley ---
1007
1008         let display_text_for_layout = if password {
1009            std::iter::repeat(epaint::text::PASSWORD_REPLACEMENT_CHAR)
1010               .take(text.char_len())
1011               .collect::<String>()
1012         } else {
1013            text.to_string() // !
1014         };
1015
1016         let mut job = if multiline {
1017            LayoutJob::simple(
1018               display_text_for_layout,
1019               font_id.clone(),
1020               text_color,
1021               wrap_width,
1022            )
1023         } else {
1024            LayoutJob::simple_singleline(
1025               display_text_for_layout,
1026               font_id.clone(),
1027               text_color,
1028            )
1029         };
1030
1031         job.halign = text_align_horizontal;
1032         current_galley = ui.fonts_mut(|f| f.layout_job(job));
1033      }
1034
1035      // Set the final state.cursor using the most up-to-date cursor_range
1036      state.cursor.set_char_range(new_ccursor_range_opt);
1037      if let Some(new_range) = new_ccursor_range_opt {
1038         state.last_interaction_time = ui.input(|i| i.time);
1039         cursor_range = new_range;
1040      }
1041   }
1042
1043   (
1044      text_changed_in_total,
1045      cursor_range,
1046      current_galley,
1047   )
1048}