Skip to main content

egui_elements/widgets/
secure_text_edit.rs

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