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