zeus_widgets/
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},
7   vec2,
8};
9use secure_types::SecureString;
10use std::sync::Arc;
11
12#[derive(Clone, Debug, Default)]
13pub struct SecureTextEditState {
14   pub cursor: text_selection::TextCursorState,
15   pub singleline_offset: f32,
16   pub last_interaction_time: f64,
17   pub ime_enabled: bool,
18   pub ime_cursor_range: CCursorRange,
19}
20
21impl SecureTextEditState {
22   pub fn load(ctx: &egui::Context, id: egui::Id) -> Option<Self> {
23      ctx.data_mut(|d| d.get_persisted(id))
24   }
25
26   pub fn store(self, ctx: &egui::Context, id: egui::Id) {
27      ctx.data_mut(|d| d.insert_persisted(id, self));
28   }
29}
30
31pub struct SecureTextEditOutput {
32   pub response: Response,
33   pub state: SecureTextEditState,
34   pub cursor_range: Option<CCursorRange>,
35}
36
37/// A widget for editing text that is secured by a [`SecureString`].
38///
39/// This widget is identical to [`egui::TextEdit`], but it uses a [`SecureString`] instead of a [`std::string::String`].
40///
41/// ## Notes
42///
43/// - Accessability like screen readers is disabled to avoid multiple unsecure allocations of the entered text.
44/// - 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`.
45///
46/// Otherwise egui will make copies of that text and some of the copied allocations will stay in memory.
47#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
48pub struct SecureTextEdit<'a> {
49   text: &'a mut SecureString,
50   hint_text: WidgetText,
51   id: Option<Id>,
52   id_salt: Option<Id>,
53   font_selection: FontSelection,
54   text_color: Option<Color32>,
55   password: bool,
56   frame: bool,
57   margin: Margin,
58   multiline: bool,
59   interactive: bool,
60   desired_width: Option<f32>,
61   desired_height_rows: usize,
62   event_filter: EventFilter,
63   cursor_at_end: bool,
64   min_size: Vec2,
65   align: Align2,
66   clip_text: bool,
67   char_limit: usize,
68   return_key: Option<KeyboardShortcut>,
69   background_color: Option<Color32>,
70}
71
72impl<'a> SecureTextEdit<'a> {
73   pub fn singleline(text: &'a mut SecureString) -> Self {
74      Self {
75         text,
76         hint_text: Default::default(),
77         id: None,
78         id_salt: None,
79         font_selection: FontSelection::default(),
80         text_color: None,
81         password: false,
82         frame: true,
83         margin: Margin::symmetric(4, 2),
84         multiline: false,
85         interactive: true,
86         desired_width: None,
87         desired_height_rows: 1,
88         event_filter: EventFilter {
89            horizontal_arrows: true,
90            vertical_arrows: true,
91            tab: false,
92            ..Default::default()
93         },
94         cursor_at_end: true,
95         min_size: Vec2::ZERO,
96         align: Align2::LEFT_CENTER,
97         clip_text: true,
98         char_limit: usize::MAX,
99         return_key: Some(KeyboardShortcut::new(Modifiers::NONE, Key::Enter)),
100         background_color: None,
101      }
102   }
103
104   pub fn multiline(text: &'a mut SecureString) -> Self {
105      Self {
106         text,
107         hint_text: Default::default(),
108         id: None,
109         id_salt: None,
110         font_selection: FontSelection::default(),
111         text_color: None,
112         password: false,
113         frame: true,
114         margin: Margin::symmetric(4, 2),
115         multiline: true,
116         interactive: true,
117         desired_width: None,
118         desired_height_rows: 4,
119         event_filter: EventFilter {
120            horizontal_arrows: true,
121            vertical_arrows: true,
122            tab: false,
123            ..Default::default()
124         },
125         cursor_at_end: true,
126         min_size: Vec2::ZERO,
127         align: Align2::LEFT_TOP,
128         clip_text: false,
129         char_limit: usize::MAX,
130         return_key: Some(KeyboardShortcut::new(Modifiers::NONE, Key::Enter)),
131         background_color: None,
132      }
133   }
134
135   pub fn id(mut self, id: Id) -> Self {
136      self.id = Some(id);
137      self
138   }
139
140   pub fn id_source(self, id_source: impl std::hash::Hash) -> Self {
141      self.id_salt(id_source)
142   }
143
144   pub fn id_salt(mut self, id_salt: impl std::hash::Hash) -> Self {
145      self.id_salt = Some(Id::new(id_salt));
146      self
147   }
148
149   pub fn hint_text(mut self, hint_text: impl Into<WidgetText>) -> Self {
150      self.hint_text = hint_text.into();
151      self
152   }
153
154   pub fn font(mut self, font_selection: impl Into<FontSelection>) -> Self {
155      self.font_selection = font_selection.into();
156      self
157   }
158
159   pub fn text_color(mut self, text_color: Color32) -> Self {
160      self.text_color = Some(text_color);
161      self
162   }
163
164   pub fn text_color_opt(mut self, text_color: Option<Color32>) -> Self {
165      self.text_color = text_color;
166      self
167   }
168
169   pub fn password(mut self, password: bool) -> Self {
170      self.password = password;
171      self
172   }
173
174   pub fn frame(mut self, frame: bool) -> Self {
175      self.frame = frame;
176      self
177   }
178
179   pub fn margin(mut self, margin: impl Into<Margin>) -> Self {
180      self.margin = margin.into();
181      self
182   }
183
184   pub fn interactive(mut self, interactive: bool) -> Self {
185      self.interactive = interactive;
186      self
187   }
188
189   pub fn desired_width(mut self, desired_width: f32) -> Self {
190      self.desired_width = Some(desired_width);
191      self
192   }
193
194   pub fn desired_rows(mut self, desired_height_rows: usize) -> Self {
195      self.desired_height_rows = desired_height_rows;
196      self
197   }
198
199   pub fn lock_focus(mut self, tab_will_indent: bool) -> Self {
200      self.event_filter.tab = tab_will_indent;
201      self
202   }
203
204   pub fn cursor_at_end(mut self, b: bool) -> Self {
205      self.cursor_at_end = b;
206      self
207   }
208
209   pub fn min_size(mut self, min_size: Vec2) -> Self {
210      self.min_size = min_size;
211      self
212   }
213
214   pub fn horizontal_align(mut self, align: Align) -> Self {
215      self.align.0[0] = align;
216      self
217   }
218
219   pub fn vertical_align(mut self, align: Align) -> Self {
220      self.align.0[1] = align;
221      self
222   }
223
224   pub fn clip_text(mut self, b: bool) -> Self {
225      if !self.multiline {
226         self.clip_text = b;
227      }
228      self
229   }
230
231   pub fn char_limit(mut self, limit: usize) -> Self {
232      self.char_limit = limit;
233      self
234   }
235
236   pub fn return_key(mut self, return_key: impl Into<Option<KeyboardShortcut>>) -> Self {
237      self.return_key = return_key.into();
238      self
239   }
240
241   pub fn background_color(mut self, color: Color32) -> Self {
242      self.background_color = Some(color);
243      self
244   }
245
246   pub fn show(self, ui: &mut Ui) -> SecureTextEditOutput {
247      let frame = self.frame;
248      let where_to_put_background = ui.painter().add(Shape::Noop);
249      let background_color = self.background_color.unwrap_or(ui.visuals().extreme_bg_color);
250      let is_interactive = self.interactive;
251
252      let output = self.show_content(ui);
253
254      let outer_rect_for_frame = output.response.rect;
255      if frame {
256         let visuals = ui.style().interact(&output.response);
257         let frame_rect = outer_rect_for_frame.expand(visuals.expansion);
258         let shape = if is_interactive {
259            if output.response.has_focus() {
260               epaint::RectShape::new(
261                  frame_rect,
262                  visuals.corner_radius,
263                  background_color,
264                  ui.visuals().selection.stroke,
265                  epaint::StrokeKind::Inside,
266               )
267            } else {
268               epaint::RectShape::new(
269                  frame_rect,
270                  visuals.corner_radius,
271                  background_color,
272                  visuals.bg_stroke,
273                  epaint::StrokeKind::Inside,
274               )
275            }
276         } else {
277            // Not interactive
278            let visuals = &ui.style().visuals.widgets.inactive;
279            epaint::RectShape::stroke(
280               frame_rect,
281               visuals.corner_radius,
282               visuals.bg_stroke,
283               epaint::StrokeKind::Inside,
284            )
285         };
286         ui.painter().set(where_to_put_background, shape);
287      }
288      output
289   }
290
291   #[allow(clippy::too_many_lines)]
292   fn show_content(self, ui: &mut Ui) -> SecureTextEditOutput {
293      let font_id = self.font_selection.resolve(ui.style());
294      let text_color = self
295         .text_color
296         .or(ui.visuals().override_text_color)
297         .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
298
299      let row_height = ui.fonts_mut(|f| f.row_height(&font_id));
300      let available_width = (ui.available_width() - self.margin.sum().x).at_least(24.0); // Min width
301      let desired_width = self.desired_width.unwrap_or_else(|| ui.spacing().text_edit_width);
302      let wrap_width = if ui.layout().horizontal_justify() {
303         available_width
304      } else {
305         desired_width.min(available_width)
306      };
307
308      // --- Layout Galley ---
309      let galley: Arc<Galley> = self.text.unlock_str(|text_slice| {
310         let display_text_cow = if self.password {
311            // Generate '●' string based on actual char count
312            std::borrow::Cow::<'_, str>::Owned(
313               std::iter::repeat(epaint::text::PASSWORD_REPLACEMENT_CHAR)
314                  .take(text_slice.chars().count())
315                  .collect::<String>(),
316            )
317         } else {
318            std::borrow::Cow::Owned(text_slice.to_string()) // !
319         };
320
321         let mut job = if self.multiline {
322            LayoutJob::simple(
323               (*display_text_cow).to_owned(),
324               font_id.clone(),
325               text_color,
326               wrap_width,
327            )
328         } else {
329            LayoutJob::simple_singleline(
330               (*display_text_cow).to_owned(),
331               font_id.clone(),
332               text_color,
333            )
334         };
335         job.halign = self.align.0[0];
336         ui.fonts_mut(|f| f.layout_job(job))
337      });
338
339      // --- Size & Allocation ---
340      let desired_inner_width = if self.clip_text && !self.multiline {
341         wrap_width
342      } else {
343         galley.size().x.max(wrap_width)
344      };
345      let desired_height = (self.desired_height_rows.at_least(1) as f32) * row_height;
346      let desired_inner_size = vec2(
347         desired_inner_width,
348         galley.size().y.max(desired_height),
349      );
350      let desired_outer_size = (desired_inner_size + self.margin.sum()).at_least(self.min_size);
351
352      let (auto_id, outer_rect) = ui.allocate_space(desired_outer_size);
353      let text_draw_rect = outer_rect - self.margin;
354
355      let id = self.id.unwrap_or_else(|| {
356         if let Some(id_salt) = self.id_salt {
357            ui.make_persistent_id(id_salt)
358         } else {
359            auto_id
360         }
361      });
362      let mut state = SecureTextEditState::load(ui.ctx(), id).unwrap_or_default();
363
364      // --- Interaction ---
365      let allow_drag_to_select =
366         ui.input(|i| !i.has_touch_screen()) || ui.memory(|mem| mem.has_focus(id));
367      let sense_behavior = if self.interactive {
368         if allow_drag_to_select {
369            Sense::click_and_drag()
370         } else {
371            Sense::click()
372         }
373      } else {
374         Sense::hover()
375      };
376      let mut response = ui.interact(outer_rect, id, sense_behavior);
377      response.intrinsic_size = Some(vec2(desired_width, desired_outer_size.y));
378
379      // Handle click to focus
380      if self.interactive {
381         if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
382            if response.hovered() {
383               ui.output_mut(|o| o.mutable_text_under_cursor = true);
384            }
385            let singleline_offset_vec = vec2(state.singleline_offset, 0.0);
386            let cursor_at_pointer =
387               galley.cursor_from_pos(pointer_pos - text_draw_rect.min + singleline_offset_vec);
388
389            let is_being_dragged = ui.ctx().is_being_dragged(response.id);
390            let did_interact_with_cursor = state.cursor.pointer_interaction(
391               ui,
392               &response,
393               cursor_at_pointer,
394               &galley,
395               is_being_dragged,
396            );
397
398            if did_interact_with_cursor || response.clicked() {
399               ui.memory_mut(|mem| mem.request_focus(response.id));
400               state.last_interaction_time = ui.input(|i| i.time);
401            }
402         }
403      }
404      if self.interactive && response.hovered() {
405         ui.ctx().set_cursor_icon(CursorIcon::Text);
406      }
407
408      // --- Event Handling ---
409      let mut cursor_range_after_events = None;
410      // Initial galley before any events in this frame
411      let current_frame_galley = galley.clone();
412
413      if self.interactive && ui.memory(|mem| mem.has_focus(id)) {
414         ui.memory_mut(|mem| mem.set_focus_lock_filter(id, self.event_filter));
415
416         let default_cursor_range = if self.cursor_at_end {
417            CCursorRange::one(current_frame_galley.end())
418         } else {
419            CCursorRange::default()
420         };
421
422         let (text_changed_by_event, new_cursor_range, _updated_galley_from_events) =
423            secure_text_edit_events(
424               ui,
425               &mut state,
426               self.text,
427               &current_frame_galley,
428               id,
429               self.multiline,
430               self.password,
431               default_cursor_range,
432               self.char_limit,
433               self.event_filter,
434               self.return_key,
435               &font_id,
436               text_color,
437               wrap_width,
438               self.align.0[0],
439            );
440
441         if text_changed_by_event {
442            response.mark_changed();
443         }
444         cursor_range_after_events = Some(new_cursor_range);
445
446         if !text_changed_by_event {
447            state.cursor.set_char_range(Some(new_cursor_range));
448         }
449      }
450
451      // --- Galley Positioning & Single-line Offset ---
452      let mut galley_pos = self.align.align_size_within_rect(galley.size(), text_draw_rect).min;
453      if self.clip_text && !self.multiline {
454         let current_cursor_primary_x =
455            match cursor_range_after_events.or_else(|| state.cursor.range(&galley)) {
456               Some(cr) => galley.pos_from_cursor(cr.primary).min.x,
457               None => 0.0,
458            };
459         let visible_width = text_draw_rect.width();
460         let mut offset_x = state.singleline_offset;
461         let visible_range_start = offset_x;
462         let visible_range_end = offset_x + visible_width;
463
464         if current_cursor_primary_x < visible_range_start {
465            offset_x = current_cursor_primary_x;
466         } else if current_cursor_primary_x > visible_range_end {
467            offset_x = current_cursor_primary_x - visible_width;
468         }
469         offset_x = offset_x.at_most(galley.size().x - visible_width).at_least(0.0);
470         state.singleline_offset = offset_x;
471         galley_pos.x -= offset_x;
472      } else {
473         // For multiline or non-clip singleline, capture any alignment offset
474         // state.singleline_offset = text_draw_rect.left() - galley_pos.x;
475         state.singleline_offset = 0.0;
476         // And ensure galley_pos respects it if it was aligned (e.g. center/right)
477         state.singleline_offset = text_draw_rect.left() - galley_pos.x;
478      }
479
480      // --- Painting ---
481      if ui.is_rect_visible(text_draw_rect) {
482         let is_text_empty = self.text.char_len() == 0;
483         if is_text_empty && !self.hint_text.is_empty() {
484            let hint_text_color = ui.visuals().weak_text_color();
485            let hint_font_id = FontSelection::default();
486            let hint_galley = self.hint_text.clone().into_galley(
487               ui,
488               Some(TextWrapMode::Wrap),
489               text_draw_rect.width(),
490               hint_font_id,
491            );
492            let hint_galley_pos =
493               self.align.align_size_within_rect(hint_galley.size(), text_draw_rect).min;
494            ui.painter_at(text_draw_rect)
495               .galley(hint_galley_pos, hint_galley, hint_text_color);
496         }
497
498         let mut galley_for_paint = galley.clone();
499         if ui.memory(|mem| mem.has_focus(id)) {
500            if let Some(cursor_range_for_sel) = state.cursor.range(&galley_for_paint) {
501               text_selection::visuals::paint_text_selection(
502                  &mut galley_for_paint,
503                  ui.visuals(),
504                  &cursor_range_for_sel,
505                  None,
506               );
507            }
508         }
509         ui.painter_at(text_draw_rect).galley(galley_pos, galley_for_paint, text_color);
510
511         // Paint cursor
512         if self.interactive && ui.memory(|mem| mem.has_focus(id)) {
513            if let Some(cursor_range_for_cursor_paint) = state.cursor.range(&galley) {
514               // Use original galley for metrics
515               let primary_cursor_rect_ui = text_selection::text_cursor_state::cursor_rect(
516                  &galley,
517                  &cursor_range_for_cursor_paint.primary,
518                  row_height,
519               )
520               .translate(galley_pos.to_vec2());
521
522               if response.changed() {
523                  // Could also check selection_changed
524                  ui.scroll_to_rect(
525                     primary_cursor_rect_ui.expand(self.margin.sum().y / 2.0),
526                     None,
527                  );
528               }
529
530               if ui.ctx().input(|i| i.focused) {
531                  // Viewport has focus
532                  let time_since_last_interaction =
533                     ui.input(|i| i.time) - state.last_interaction_time;
534                  text_selection::visuals::paint_text_cursor(
535                     ui,
536                     &ui.painter_at(text_draw_rect.expand(1.0)), // Expand for cursor
537                     primary_cursor_rect_ui,
538                     time_since_last_interaction,
539                  );
540               }
541               // IME output
542               let to_global =
543                  ui.ctx().layer_transform_to_global(ui.layer_id()).unwrap_or_default();
544               ui.ctx().output_mut(|o| {
545                  o.ime = Some(output::IMEOutput {
546                     rect: to_global * text_draw_rect,
547                     cursor_rect: to_global * primary_cursor_rect_ui,
548                  });
549               });
550            }
551         }
552      }
553
554      // IME focus state management
555      if state.ime_enabled && (response.gained_focus() || response.lost_focus()) {
556         state.ime_enabled = false;
557         if let Some(mut ccursor_range) = state.cursor.char_range() {
558            ccursor_range.secondary.index = ccursor_range.primary.index;
559            state.cursor.set_char_range(Some(ccursor_range));
560         }
561         ui.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Ime(_))));
562      }
563
564      state.clone().store(ui.ctx(), id);
565
566      // !
567      // This is only for accessibility, so set them to empty is fine
568      /*
569      let _ = self.text.str_scope(|s| {
570         if self.password {
571            std::iter::repeat(epaint::text::PASSWORD_REPLACEMENT_CHAR)
572               .take(s.chars().count())
573               .collect()
574         } else {
575            s.to_string()
576         }
577      });
578      */
579      response.widget_info(|| {
580         WidgetInfo::text_edit(
581            ui.is_enabled(),
582            String::new(),
583            String::new(),
584            String::new(),
585         )
586      });
587
588      SecureTextEditOutput {
589         response,
590         state,
591         cursor_range: cursor_range_after_events,
592      }
593   }
594}
595
596impl<'a> Widget for SecureTextEdit<'a> {
597   fn ui(self, ui: &mut Ui) -> Response {
598      self.show(ui).response
599   }
600}
601
602#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
603fn secure_text_edit_events(
604   ui: &Ui,
605   state: &mut SecureTextEditState,
606   secure_text: &mut SecureString,
607   initial_galley: &Arc<Galley>,
608   id: Id,
609   multiline: bool,
610   password: bool,
611   default_cursor_range: CCursorRange,
612   char_limit: usize,
613   event_filter: EventFilter,
614   return_key: Option<KeyboardShortcut>,
615   font_id: &FontId,
616   text_color: Color32,
617   wrap_width: f32,
618   text_align_horizontal: Align,
619) -> (bool, CCursorRange, Arc<Galley>) {
620   let os = ui.ctx().os();
621   let mut current_galley = initial_galley.clone();
622   let mut cursor_range = state.cursor.range(&current_galley).unwrap_or(default_cursor_range);
623   let mut text_changed_in_total = false;
624
625   let mut events_filtered = ui.input(|i| i.filtered_events(&event_filter));
626   if state.ime_enabled {
627      events_filtered.sort_by_key(|e| !matches!(e, Event::Ime(_)));
628   }
629
630   for event in &events_filtered {
631      let current_char_len_before_event = secure_text.char_len();
632      let mut text_mutated_this_event = false;
633
634      // Pass current_galley to on_event. If it modifies cursor_range, it uses current_galley.
635      if cursor_range.on_event(os, event, &current_galley, id) {
636         state.last_interaction_time = ui.input(|i| i.time);
637         continue;
638      }
639
640      let new_ccursor_range_opt: Option<CCursorRange> = match event {
641         // For now don't allow copy/cut on any text
642         Event::Copy => None,
643         Event::Cut => None,
644         Event::Paste(text_to_paste) => {
645            if !text_to_paste.is_empty() {
646               let [min, max] = cursor_range.sorted_cursors();
647               let selection_char_len = max.index - min.index;
648               secure_text.delete_text_char_range(min.index..max.index);
649
650               let space_available = char_limit
651                  .saturating_sub(current_char_len_before_event.saturating_sub(selection_char_len));
652               let final_text_to_paste = if text_to_paste.chars().count() > space_available {
653                  text_to_paste.chars().take(space_available).collect::<String>()
654               } else {
655                  text_to_paste.clone()
656               };
657
658               let mut current_ccursor = min;
659               let chars_inserted =
660                  secure_text.insert_text_at_char_idx(current_ccursor.index, &final_text_to_paste);
661               current_ccursor.index += chars_inserted;
662               text_mutated_this_event = true; // Mark mutation
663               Some(text::CCursorRange::one(current_ccursor))
664            } else {
665               None
666            }
667         }
668         Event::Text(text_to_insert) => {
669            if !text_to_insert.is_empty() && text_to_insert != "\n" && text_to_insert != "\r" {
670               let [min, max] = cursor_range.sorted_cursors();
671               let selection_char_len = max.index - min.index;
672               secure_text.delete_text_char_range(min.index..max.index);
673
674               let space_available = char_limit
675                  .saturating_sub(current_char_len_before_event.saturating_sub(selection_char_len));
676               let final_text_to_insert = if text_to_insert.chars().count() > space_available {
677                  text_to_insert.chars().take(space_available).collect::<String>()
678               } else {
679                  text_to_insert.clone()
680               };
681
682               let mut current_ccursor = min;
683               let chars_inserted =
684                  secure_text.insert_text_at_char_idx(current_ccursor.index, &final_text_to_insert);
685               current_ccursor.index += chars_inserted;
686               text_mutated_this_event = true;
687               Some(text::CCursorRange::one(current_ccursor))
688            } else {
689               None
690            }
691         }
692         Event::Key {
693            key: Key::Enter,
694            pressed: true,
695            modifiers,
696            ..
697         } if return_key.is_some_and(|rk| {
698            Key::Enter == rk.logical_key && modifiers.matches_logically(rk.modifiers)
699         }) =>
700         {
701            if multiline {
702               let [min, max] = cursor_range.sorted_cursors();
703               let selection_char_len = max.index - min.index;
704               secure_text.delete_text_char_range(min.index..max.index);
705
706               let current_len_after_delete =
707                  current_char_len_before_event.saturating_sub(selection_char_len);
708               let space_available = char_limit.saturating_sub(current_len_after_delete);
709
710               if space_available > 0 {
711                  let mut current_ccursor = min;
712                  let chars_inserted =
713                     secure_text.insert_text_at_char_idx(current_ccursor.index, "\n");
714                  current_ccursor.index += chars_inserted;
715                  text_mutated_this_event = true; // Mark mutation
716                  Some(text::CCursorRange::one(current_ccursor))
717               } else {
718                  None
719               }
720            } else {
721               ui.memory_mut(|mem| mem.surrender_focus(id));
722               None
723            }
724         }
725         Event::Key {
726            key: Key::Backspace,
727            pressed: true,
728            ..
729         } => {
730            // Modifiers for word/para delete later
731            let [min, max] = cursor_range.sorted_cursors();
732            let mut new_cursor_idx = min.index;
733            if min == max {
734               // No selection
735               if min.index > 0 {
736                  secure_text.delete_text_char_range(min.index - 1..min.index);
737                  new_cursor_idx = min.index - 1;
738                  text_mutated_this_event = true;
739               }
740            } else {
741               // Selection exists
742               secure_text.delete_text_char_range(min.index..max.index);
743               // new_cursor_idx is already min.ccursor.index
744               text_mutated_this_event = true;
745            }
746            if text_mutated_this_event {
747               Some(text::CCursorRange::one(text::CCursor::new(
748                  new_cursor_idx,
749               )))
750            } else {
751               None
752            }
753         }
754         Event::Key {
755            key: Key::Delete,
756            pressed: true,
757            ..
758         } => {
759            // Modifiers for word/para delete later
760            let [min, max] = cursor_range.sorted_cursors();
761            if min == max {
762               if min.index < current_char_len_before_event {
763                  // Before deleting
764                  secure_text.delete_text_char_range(min.index..min.index + 1);
765                  text_mutated_this_event = true;
766               }
767            } else {
768               secure_text.delete_text_char_range(min.index..max.index);
769               text_mutated_this_event = true;
770            }
771            if text_mutated_this_event {
772               Some(text::CCursorRange::one(min))
773            } else {
774               None
775            }
776         }
777         Event::Key {
778            key: Key::Tab,
779            pressed: true,
780            modifiers,
781            ..
782         } if multiline && event_filter.tab => {
783            let [min, _max] = cursor_range.sorted_cursors();
784            let mut current_ccursor = min;
785            if modifiers.shift {
786            } else {
787               let space_available = char_limit.saturating_sub(current_char_len_before_event);
788               if space_available > 0 {
789                  // Enough for at least '\t'
790                  let chars_inserted =
791                     secure_text.insert_text_at_char_idx(current_ccursor.index, "\t");
792                  current_ccursor.index += chars_inserted;
793                  text_mutated_this_event = true;
794               }
795            }
796            if text_mutated_this_event {
797               Some(text::CCursorRange::one(current_ccursor))
798            } else {
799               None
800            }
801         }
802         Event::Ime(ime_event) => {
803            match ime_event {
804               ImeEvent::Enabled => {
805                  state.ime_enabled = true;
806                  state.ime_cursor_range = cursor_range;
807                  None
808               }
809               ImeEvent::Preedit(preedit_text) => {
810                  let [min_ime, max_ime] = state.ime_cursor_range.sorted_cursors(); // Use IME's original range for delete
811                  secure_text.delete_text_char_range(min_ime.index..max_ime.index);
812                  let mut c = min_ime; // Insert at start of IME original selection
813                  let inserted = secure_text.insert_text_at_char_idx(c.index, preedit_text);
814                  c.index += inserted;
815                  text_mutated_this_event = true;
816                  Some(text::CCursorRange::two(min_ime, c))
817               }
818               ImeEvent::Commit(commit_text) => {
819                  state.ime_enabled = false; // IME done
820                  let [min_commit, max_commit] = cursor_range.sorted_cursors();
821                  secure_text.delete_text_char_range(min_commit.index..max_commit.index);
822                  let mut c = min_commit;
823                  let inserted = secure_text.insert_text_at_char_idx(c.index, commit_text);
824                  c.index += inserted;
825                  text_mutated_this_event = true;
826                  Some(text::CCursorRange::one(c))
827               }
828               ImeEvent::Disabled => {
829                  state.ime_enabled = false;
830                  None
831               }
832            }
833         }
834         _ => None,
835      };
836
837      if text_mutated_this_event {
838         text_changed_in_total = true;
839
840         // --- Re-layout galley ---
841         current_galley = secure_text.unlock_str(|text_slice| {
842            let display_text_for_layout = if password {
843               std::iter::repeat(epaint::text::PASSWORD_REPLACEMENT_CHAR)
844                  .take(text_slice.chars().count())
845                  .collect::<String>()
846            } else {
847               text_slice.to_owned() // !
848            };
849            let mut job = if multiline {
850               LayoutJob::simple(
851                  display_text_for_layout,
852                  font_id.clone(),
853                  text_color,
854                  wrap_width,
855               )
856            } else {
857               LayoutJob::simple_singleline(
858                  display_text_for_layout,
859                  font_id.clone(),
860                  text_color,
861               )
862            };
863            job.halign = text_align_horizontal;
864            ui.fonts_mut(|f| f.layout_job(job))
865         });
866      }
867
868      // Set the final state.cursor using the most up-to-date cursor_range
869      state.cursor.set_char_range(new_ccursor_range_opt);
870      if let Some(new_range) = new_ccursor_range_opt {
871         state.last_interaction_time = ui.input(|i| i.time);
872         cursor_range = new_range;
873      }
874   }
875
876   (
877      text_changed_in_total,
878      cursor_range,
879      current_galley,
880   )
881}