Skip to main content

zeus_widgets/
secure_text_edit.rs

1use egui::{
2   Align, Align2, Color32, CursorIcon, Event, EventFilter, FontId, FontSelection, Galley, Id,
3   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                     rect: to_global * text_draw_rect,
639                     cursor_rect: to_global * primary_cursor_rect_ui,
640                     should_interrupt_composition: false,
641                  });
642               });
643            }
644         }
645      }
646
647      // IME focus state management
648      if state.ime_enabled && (response.gained_focus() || response.lost_focus()) {
649         state.ime_enabled = false;
650         if let Some(mut ccursor_range) = state.cursor.char_range() {
651            ccursor_range.secondary.index = ccursor_range.primary.index;
652            state.cursor.set_char_range(Some(ccursor_range));
653         }
654         ui.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Ime(_))));
655      }
656
657      state.clone().store(ui.ctx(), id);
658
659      // !
660      // This is only for accessibility, so set them to empty is fine
661      /*
662      let _ = self.text.str_scope(|s| {
663         if self.password {
664            std::iter::repeat(epaint::text::PASSWORD_REPLACEMENT_CHAR)
665               .take(s.chars().count())
666               .collect()
667         } else {
668            s.to_string()
669         }
670      });
671      */
672      response.widget_info(|| {
673         WidgetInfo::text_edit(
674            ui.is_enabled(),
675            String::new(),
676            String::new(),
677            String::new(),
678         )
679      });
680
681      let output = SecureTextEditOutput {
682         response,
683         state,
684         cursor_range: cursor_range_after_events,
685      };
686
687      let visuals = self.visuals.take();
688      (output, visuals)
689   }
690}
691
692impl<'a> Widget for SecureTextEdit<'a> {
693   fn ui(self, ui: &mut Ui) -> Response {
694      self.show(ui).response
695   }
696}
697
698#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
699fn secure_text_edit_events(
700   ui: &Ui,
701   state: &mut SecureTextEditState,
702   text: &mut dyn TextBuffer,
703   initial_galley: &Arc<Galley>,
704   id: Id,
705   multiline: bool,
706   password: bool,
707   default_cursor_range: CCursorRange,
708   char_limit: usize,
709   event_filter: EventFilter,
710   return_key: Option<KeyboardShortcut>,
711   font_id: &FontId,
712   text_color: Color32,
713   wrap_width: f32,
714   text_align_horizontal: Align,
715) -> (bool, CCursorRange, Arc<Galley>) {
716   let os = ui.ctx().os();
717   let mut current_galley = initial_galley.clone();
718   let mut cursor_range = state.cursor.range(&current_galley).unwrap_or(default_cursor_range);
719   let mut text_changed_in_total = false;
720
721   let mut events_filtered = ui.input(|i| i.filtered_events(&event_filter));
722   if state.ime_enabled {
723      events_filtered.sort_by_key(|e| !matches!(e, Event::Ime(_)));
724   }
725
726   for event in events_filtered {
727      let current_char_len_before_event = text.char_len();
728      let mut text_mutated_this_event = false;
729
730      // Pass current_galley to on_event. If it modifies cursor_range, it uses current_galley.
731      if cursor_range.on_event(os, &event, &current_galley, id) {
732         state.last_interaction_time = ui.input(|i| i.time);
733         continue;
734      }
735
736      let new_ccursor_range_opt: Option<CCursorRange> = match event {
737         // For now don't allow copy/cut on any text
738         Event::Copy => None,
739         Event::Cut => None,
740         #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
741         Event::Paste(mut text_to_paste) => {
742            if !text_to_paste.is_empty() {
743               let [min, max] = cursor_range.sorted_cursors();
744               let selection_char_len = max.index - min.index;
745               text.delete_text_char_range(min.index.0..max.index.0);
746
747               let space_available = char_limit.saturating_sub(
748                  current_char_len_before_event.saturating_sub(selection_char_len.0),
749               );
750               let mut final_text_to_paste = if text_to_paste.chars().count() > space_available {
751                  text_to_paste.chars().take(space_available).collect::<String>()
752               } else {
753                  text_to_paste.clone()
754               };
755
756               let mut current_ccursor = min;
757               let chars_inserted =
758                  text.insert_text_at_char_idx(current_ccursor.index.0, &final_text_to_paste);
759               current_ccursor.index += chars_inserted;
760               text_mutated_this_event = true; // Mark mutation
761
762               #[cfg(feature = "secure-types")]
763               {
764                  text_to_paste.zeroize();
765                  final_text_to_paste.zeroize();
766               }
767
768               Some(text::CCursorRange::one(current_ccursor))
769            } else {
770               None
771            }
772         }
773         #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
774         Event::Text(mut text_to_insert) => {
775            if !text_to_insert.is_empty() && text_to_insert != "\n" && text_to_insert != "\r" {
776               let [min, max] = cursor_range.sorted_cursors();
777               let selection_char_len = max.index - min.index;
778               text.delete_text_char_range(min.index.0..max.index.0);
779
780               let space_available = char_limit.saturating_sub(
781                  current_char_len_before_event.saturating_sub(selection_char_len.0),
782               );
783               let mut final_text_to_insert = if text_to_insert.chars().count() > space_available {
784                  text_to_insert.chars().take(space_available).collect::<String>()
785               } else {
786                  text_to_insert.clone()
787               };
788
789               let mut current_ccursor = min;
790               let chars_inserted =
791                  text.insert_text_at_char_idx(current_ccursor.index.0, &final_text_to_insert);
792               current_ccursor.index += chars_inserted;
793               text_mutated_this_event = true;
794
795               #[cfg(feature = "secure-types")]
796               {
797                  text_to_insert.zeroize();
798                  final_text_to_insert.zeroize();
799               }
800               Some(text::CCursorRange::one(current_ccursor))
801            } else {
802               None
803            }
804         }
805         Event::Key {
806            key: Key::Enter,
807            pressed: true,
808            modifiers,
809            ..
810         } if return_key.is_some_and(|rk| {
811            Key::Enter == rk.logical_key && modifiers.matches_logically(rk.modifiers)
812         }) =>
813         {
814            if multiline {
815               let [min, max] = cursor_range.sorted_cursors();
816               let selection_char_len = max.index - min.index;
817               text.delete_text_char_range(min.index.0..max.index.0);
818
819               let current_len_after_delete =
820                  current_char_len_before_event.saturating_sub(selection_char_len.0);
821               let space_available = char_limit.saturating_sub(current_len_after_delete);
822
823               if space_available > 0 {
824                  let mut current_ccursor = min;
825                  let chars_inserted = text.insert_text_at_char_idx(current_ccursor.index.0, "\n");
826                  current_ccursor.index += chars_inserted;
827                  text_mutated_this_event = true; // Mark mutation
828                  Some(text::CCursorRange::one(current_ccursor))
829               } else {
830                  None
831               }
832            } else {
833               ui.memory_mut(|mem| mem.surrender_focus(id));
834               None
835            }
836         }
837         Event::Key {
838            key: Key::Backspace,
839            pressed: true,
840            ..
841         } => {
842            // Modifiers for word/para delete later
843            let [min, max] = cursor_range.sorted_cursors();
844            let mut new_cursor_idx = min.index;
845            if min == max {
846               // No selection
847               if min.index.0 > 0 {
848                  text.delete_text_char_range(min.index.0 - 1..min.index.0);
849                  new_cursor_idx = min.index - 1;
850                  text_mutated_this_event = true;
851               }
852            } else {
853               // Selection exists
854               text.delete_text_char_range(min.index.0..max.index.0);
855               // new_cursor_idx is already min.ccursor.index
856               text_mutated_this_event = true;
857            }
858            if text_mutated_this_event {
859               Some(text::CCursorRange::one(text::CCursor::new(
860                  new_cursor_idx,
861               )))
862            } else {
863               None
864            }
865         }
866         Event::Key {
867            key: Key::Delete,
868            pressed: true,
869            ..
870         } => {
871            // Modifiers for word/para delete later
872            let [min, max] = cursor_range.sorted_cursors();
873            if min == max {
874               if min.index < current_char_len_before_event.into() {
875                  // Before deleting
876                  text.delete_text_char_range(min.index.0..min.index.0 + 1);
877                  text_mutated_this_event = true;
878               }
879            } else {
880               text.delete_text_char_range(min.index.0..max.index.0);
881               text_mutated_this_event = true;
882            }
883            if text_mutated_this_event {
884               Some(text::CCursorRange::one(min))
885            } else {
886               None
887            }
888         }
889         Event::Key {
890            key: Key::Tab,
891            pressed: true,
892            modifiers,
893            ..
894         } if multiline && event_filter.tab => {
895            let [min, _max] = cursor_range.sorted_cursors();
896            let mut current_ccursor = min;
897            if modifiers.shift {
898            } else {
899               let space_available = char_limit.saturating_sub(current_char_len_before_event);
900               if space_available > 0 {
901                  // Enough for at least '\t'
902                  let chars_inserted = text.insert_text_at_char_idx(current_ccursor.index.0, "\t");
903                  current_ccursor.index += chars_inserted;
904                  text_mutated_this_event = true;
905               }
906            }
907            if text_mutated_this_event {
908               Some(text::CCursorRange::one(current_ccursor))
909            } else {
910               None
911            }
912         }
913         Event::Ime(ime_event) => {
914            match ime_event {
915               #[allow(deprecated)]
916               ImeEvent::Enabled => {
917                  state.ime_enabled = true;
918                  state.ime_cursor_range = cursor_range;
919                  None
920               }
921               #[allow(deprecated)]
922               ImeEvent::Disabled => {
923                  state.ime_enabled = false;
924                  None
925               }
926               #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
927               ImeEvent::Preedit {
928                  text: mut preedit_text,
929                  active_range_chars: _,
930               } => {
931                  let [min_ime, max_ime] = state.ime_cursor_range.sorted_cursors(); // Use IME's original range for delete
932                  text.delete_text_char_range(min_ime.index.0..max_ime.index.0);
933                  let mut c = min_ime; // Insert at start of IME original selection
934                  let inserted = text.insert_text_at_char_idx(c.index.0, &preedit_text);
935                  c.index += inserted;
936                  text_mutated_this_event = true;
937
938                  #[cfg(feature = "secure-types")]
939                  preedit_text.zeroize();
940
941                  Some(text::CCursorRange::two(min_ime, c))
942               }
943               #[cfg_attr(not(feature = "secure-types"), allow(unused_mut))]
944               ImeEvent::Commit(mut commit_text) => {
945                  state.ime_enabled = false; // IME done
946                  let [min_commit, max_commit] = cursor_range.sorted_cursors();
947                  text.delete_text_char_range(min_commit.index.0..max_commit.index.0);
948                  let mut c = min_commit;
949                  let inserted = text.insert_text_at_char_idx(c.index.0, &commit_text);
950                  c.index += inserted;
951                  text_mutated_this_event = true;
952
953                  #[cfg(feature = "secure-types")]
954                  commit_text.zeroize();
955
956                  Some(text::CCursorRange::one(c))
957               }
958            }
959         }
960         _ => None,
961      };
962
963      if text_mutated_this_event {
964         text_changed_in_total = true;
965
966         // --- Re-layout galley ---
967
968         let display_text_for_layout = if password {
969            std::iter::repeat(epaint::text::PASSWORD_REPLACEMENT_CHAR)
970               .take(text.char_len())
971               .collect::<String>()
972         } else {
973            text.to_string() // !
974         };
975
976         let mut job = if multiline {
977            LayoutJob::simple(
978               display_text_for_layout,
979               font_id.clone(),
980               text_color,
981               wrap_width,
982            )
983         } else {
984            LayoutJob::simple_singleline(
985               display_text_for_layout,
986               font_id.clone(),
987               text_color,
988            )
989         };
990
991         job.halign = text_align_horizontal;
992         current_galley = ui.fonts_mut(|f| f.layout_job(job));
993      }
994
995      // Set the final state.cursor using the most up-to-date cursor_range
996      state.cursor.set_char_range(new_ccursor_range_opt);
997      if let Some(new_range) = new_ccursor_range_opt {
998         state.last_interaction_time = ui.input(|i| i.time);
999         cursor_range = new_range;
1000      }
1001   }
1002
1003   (
1004      text_changed_in_total,
1005      cursor_range,
1006      current_galley,
1007   )
1008}