egui 0.37.0

An easy-to-use immediate mode GUI that runs on both web and native
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
use core::ops::Range;

use emath::RectAlign;

use crate::{
    Atom, Atoms, Button, Event, EventFilter, Id, InputState, IntoAtoms, Key, LayerId, Popup,
    PopupKind, ScrollArea, SurrenderFocusOn, TextEdit, Ui, WidgetText,
    class::{ClassName, HasClasses as _},
    text::{CCursor, CCursorRange, CharIndex},
    vec2,
};

use super::{TextEditOutput, TextEditState};

/// One item in a [`CompletionPopup`].
#[derive(Clone)]
pub struct Suggestion {
    /// The text that replaces the current word when this suggestion is accepted.
    pub insert: String,

    /// What to show in the popup. Defaults to [`Self::insert`].
    pub content: Atoms<'static>,
}

impl Suggestion {
    /// A suggestion that inserts `insert`, showing it in the popup as-is.
    pub fn new(insert: impl Into<String>) -> Self {
        let insert = insert.into();
        Self {
            content: insert.clone().into_atoms(),
            insert,
        }
    }

    /// What to show in the popup instead of [`Self::insert`].
    #[inline]
    pub fn content(mut self, content: impl IntoAtoms<'static>) -> Self {
        self.content = content.into_atoms();
        self
    }

    /// Add weak text to the far right of the suggestion.
    #[inline]
    pub fn description(mut self, description: impl Into<WidgetText>) -> Self {
        self.content.push_right(Atom::grow());
        self.content.push_right(description.into().weak());
        self
    }
}

/// What the [`CompletionPopup`] asks for suggestions for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompletionQuery<'a> {
    /// The full text of the [`TextEdit`].
    pub text: &'a str,

    /// The word that ends at the cursor (see [`CompletionPopup::word_boundary`]).
    pub word: &'a str,

    /// Where in [`Self::text`] the word is, as char indices.
    pub word_range: Range<CharIndex>,
}

impl CompletionQuery<'_> {
    /// Is the word at the very start of the text?
    pub fn is_at_start(&self) -> bool {
        self.word_range.start == CharIndex(0)
    }
}

/// The result of [`CompletionPopup::show`].
pub struct CompletionOutput {
    /// The output of the wrapped [`TextEdit`].
    pub text_edit: TextEditOutput,

    /// The suggestion accepted this frame (with Enter, Tab, or a click), if any.
    pub accepted: Option<Suggestion>,

    /// Is the popup visible?
    pub is_open: bool,
}

/// State stored between frames.
#[derive(Clone, Default)]
struct CompletionState {
    /// Index of the selected suggestion.
    selected: usize,

    /// The word under the cursor when the selection was last used.
    ///
    /// When the user types, the word and thus the suggestions change,
    /// and the old selection index would point at an unrelated suggestion.
    /// So when the word differs from this, the selection resets to the first suggestion.
    selected_word: String,

    /// Is the popup open? Updated at the end of each frame.
    open: bool,

    /// The word for which the user dismissed the popup with Escape.
    /// The popup stays hidden until the word changes.
    dismissed_word: Option<String>,
}

impl CompletionState {
    fn load(ui: &Ui, id: Id) -> Self {
        ui.data(|data| data.get_temp(id)).unwrap_or_default()
    }

    fn store(self, ui: &Ui, id: Id) {
        ui.data_mut(|data| data.insert_temp(id, self));
    }
}

/// Keys pressed while the popup was open, consumed before the [`TextEdit`] sees them.
#[derive(Default)]
struct PopupKeys {
    /// How many times `ArrowDown` was pressed.
    down: usize,

    /// How many times `ArrowUp` was pressed.
    up: usize,

    accept: bool,
    dismiss: bool,
}

impl PopupKeys {
    fn moved_selection(&self) -> bool {
        self.down != self.up
    }
}

/// A code completion popup over a [`TextEdit`].
///
/// Shows a list of suggestions for the word under the cursor.
/// Use the arrow keys to select one, and Enter or Tab to accept it.
/// Escape closes the popup until the word changes.
/// Clicking a suggestion also accepts it.
///
/// Keyboard focus stays in the [`TextEdit`] the whole time.
///
/// The popup starts intercepting keys the frame after it opens,
/// so an accept key pressed in the same frame as the popup appears goes to the [`TextEdit`].
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// # let mut text = String::new();
/// let commands = ["/help", "/clear", "/quit"];
/// let output = egui::CompletionPopup::new(ui.make_persistent_id("prompt")).show(
///     ui,
///     egui::TextEdit::singleline(&mut text).hint_text("Type / for commands"),
///     |query| {
///         if !query.is_at_start() {
///             return vec![];
///         }
///         commands
///             .iter()
///             .filter(|command| command.starts_with(query.word))
///             .map(|command| egui::Suggestion::new(format!("{command} ")))
///             .collect()
///     },
/// );
/// if let Some(accepted) = output.accepted {
///     println!("Accepted {}", accepted.insert);
/// }
/// # });
/// ```
#[must_use = "You should call .show()"]
pub struct CompletionPopup<'a> {
    id: Id,
    is_word_boundary: Box<dyn Fn(char) -> bool + 'a>,
    align: RectAlign,
    max_height: f32,
    accept_keys: Vec<Key>,
}

impl<'a> CompletionPopup<'a> {
    /// Present on every suggestion button in the popup, so they can be styled.
    pub const CLASS_SUGGESTION: ClassName = ClassName::from_static("egui::button::completion");

    /// The `id` is given to the [`TextEdit`], and is also used to store the popup state.
    ///
    /// It must be unique, so if you show several completion popups (e.g. one per tab),
    /// use e.g. [`Ui::make_persistent_id`] rather than a constant [`Id`].
    pub fn new(id: Id) -> Self {
        Self {
            id,
            is_word_boundary: Box::new(char::is_whitespace),
            align: RectAlign::TOP_START,
            max_height: 200.0,
            accept_keys: vec![Key::Enter, Key::Tab],
        }
    }

    /// Which characters separate words? The current word is the text
    /// between the last such character and the cursor.
    ///
    /// Default: [`char::is_whitespace`].
    #[inline]
    pub fn word_boundary(mut self, is_word_boundary: impl Fn(char) -> bool + 'a) -> Self {
        self.is_word_boundary = Box::new(is_word_boundary);
        self
    }

    /// Where to place the popup relative to the [`TextEdit`].
    /// If there is no room, the vertically flipped alignment is used instead.
    ///
    /// Default: [`RectAlign::TOP_START`].
    #[inline]
    pub fn align(mut self, align: RectAlign) -> Self {
        self.align = align;
        self
    }

    /// Maximum height of the popup before it starts to scroll.
    ///
    /// Default: 200.0.
    #[inline]
    pub fn max_height(mut self, max_height: f32) -> Self {
        self.max_height = max_height;
        self
    }

    /// Which keys accept the selected suggestion?
    ///
    /// Default: Enter and Tab.
    #[inline]
    pub fn accept_keys(mut self, accept_keys: impl Into<Vec<Key>>) -> Self {
        self.accept_keys = accept_keys.into();
        self
    }

    /// Show the [`TextEdit`] and, when `suggest` returns anything for the word under the cursor, the popup.
    ///
    /// The [`TextEdit`] is given the [`Id`] of this popup.
    /// Its [`TextEdit::event_filter`] is respected, except that Tab and Escape
    /// are also captured while the popup is open.
    ///
    /// `suggest` is called once per frame, after the [`TextEdit`] has handled input.
    pub fn show(
        self,
        ui: &mut Ui,
        text_edit: TextEdit<'_>,
        suggest: impl FnOnce(&CompletionQuery<'_>) -> Vec<Suggestion>,
    ) -> CompletionOutput {
        let Self {
            id,
            is_word_boundary,
            align,
            max_height,
            accept_keys,
        } = self;

        let mut state = CompletionState::load(ui, id);

        // Consume the popup keys before the `TextEdit` sees them.
        // We act on them after the `TextEdit` has run, when we know the current text and cursor.
        let mut keys = PopupKeys::default();
        if state.open {
            ui.input_mut(|input| {
                keys.down = consume_unmodified_key(input, Key::ArrowDown);
                keys.up = consume_unmodified_key(input, Key::ArrowUp);
                for &key in &accept_keys {
                    if 0 < consume_unmodified_key(input, key) {
                        keys.accept = true;
                    }
                }
                keys.dismiss = 0 < consume_unmodified_key(input, Key::Escape);
            });
        }

        let event_filter = text_edit.get_event_filter();
        let text_edit = text_edit.id(id).event_filter(EventFilter {
            // Keep focus on Tab and Escape while the popup is open, so they can act on the popup:
            tab: event_filter.tab || state.open,
            escape: event_filter.escape || state.open,
            ..event_filter
        });

        // Clicking on the popup shouldn't remove the focus from the textedit,
        // so we disable surrendering while hovering the popup
        let popup_id = id.with("completion_popup");
        let popup_layer = LayerId::new(PopupKind::Popup.order(), popup_id);
        let pointer_on_popup = state.open
            && ui
                .ctx()
                .pointer_interact_pos()
                .and_then(|pos| ui.ctx().layer_id_at(pos))
                == Some(popup_layer);
        let surrender_focus_on = ui
            .ctx()
            .options(|options| options.input_options.surrender_focus_on);
        if pointer_on_popup {
            ui.ctx().options_mut(|options| {
                options.input_options.surrender_focus_on = SurrenderFocusOn::Never;
            });
        }
        let (output, text) = text_edit.show_returning_text(ui);
        ui.ctx()
            .options_mut(|options| options.input_options.surrender_focus_on = surrender_focus_on);
        let response = output.response.response.clone();

        let cursor = output.cursor_range.map_or_else(
            || text.as_str().chars().count(),
            |range| range.primary.index.0,
        );
        let word_range = word_range_before_cursor(text.as_str(), cursor, &*is_word_boundary);
        let word = text.char_range(word_range.clone()).to_owned();

        if keys.dismiss {
            state.dismissed_word = Some(word.clone());
        }

        let mut suggestions = if state.dismissed_word.as_deref() == Some(word.as_str()) {
            vec![]
        } else {
            suggest(&CompletionQuery {
                text: text.as_str(),
                word: &word,
                word_range: word_range.clone(),
            })
        };

        if state.selected_word != word {
            state.selected = 0;
            state.selected_word = word.clone();
        }
        if !suggestions.is_empty() {
            let len = suggestions.len();
            state.selected = (state.selected + keys.down + (len - keys.up % len)) % len;
        }

        let mut accepted_index = (keys.accept && !suggestions.is_empty()).then_some(state.selected);

        // Don't show the popup in the frame we accept, to avoid a one-frame flash of stale suggestions:
        let is_open = response.has_focus() && !suggestions.is_empty() && accepted_index.is_none();

        let clicked = Popup::from_response(&response)
            .id(popup_id)
            .kind(PopupKind::Popup)
            .open(is_open)
            .align(align)
            .align_alternatives(&[align.flipped_y()])
            .width(response.rect.width())
            .show(|ui| {
                ScrollArea::vertical()
                    .max_height(max_height)
                    .show(ui, |ui| {
                        let pointer_moved = ui.input(|input| input.pointer.is_moving());
                        let mut clicked = None;
                        for (i, suggestion) in suggestions.iter().enumerate() {
                            let is_selected = i == state.selected;
                            let response = ui.add(
                                Button::selectable(is_selected, suggestion.content.clone())
                                    .with_class(Self::CLASS_SUGGESTION)
                                    .min_size(vec2(ui.available_width(), 0.0)),
                            );
                            if is_selected && keys.moved_selection() {
                                response.scroll_to_me(None);
                            }
                            if response.hovered() && pointer_moved {
                                state.selected = i;
                            }
                            if response.clicked() {
                                clicked = Some(i);
                            }
                        }
                        clicked
                    })
                    .inner
            })
            .and_then(|inner| inner.inner);

        if clicked.is_some() {
            accepted_index = clicked;
        }

        let accepted = accepted_index
            .filter(|&i| i < suggestions.len())
            .map(|i| suggestions.swap_remove(i));
        if let Some(accepted) = &accepted {
            text.delete_char_range(word_range.clone());
            let mut ccursor = CCursor::new(word_range.start);
            text.insert_text_at(&mut ccursor, &accepted.insert, usize::MAX);

            let mut text_edit_state = TextEditState::load(ui.ctx(), id).unwrap_or_default();
            text_edit_state
                .cursor
                .set_char_range(Some(CCursorRange::one(ccursor)));
            text_edit_state.store(ui.ctx(), id);

            state.dismissed_word = None;
            state.selected = 0;
        }

        state.open = is_open;
        state.store(ui, id);

        CompletionOutput {
            text_edit: output,
            accepted,
            is_open,
        }
    }
}

/// Consume presses of `key` with no modifiers held, returning how many there were.
///
/// Unlike [`InputState::consume_key`], this leaves e.g. Shift+Enter and Shift+ArrowDown alone,
/// so they still reach the [`TextEdit`].
fn consume_unmodified_key(input: &mut InputState, key: Key) -> usize {
    let mut count = 0;
    input.events.retain(|event| {
        let is_match = matches!(
            event,
            Event::Key {
                key: event_key,
                modifiers,
                pressed: true,
                ..
            } if *event_key == key && modifiers.is_none()
        );
        count += usize::from(is_match);
        !is_match
    });
    count
}

/// The char range of the word that ends at the cursor (a char index).
fn word_range_before_cursor(
    text: &str,
    cursor: usize,
    is_word_boundary: &dyn Fn(char) -> bool,
) -> Range<CharIndex> {
    let start = text
        .chars()
        .take(cursor)
        .enumerate()
        .filter(|&(_, c)| is_word_boundary(c))
        .last()
        .map_or(0, |(i, _)| i + 1);
    CharIndex(start)..CharIndex(cursor)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn word_range_before_cursor_works() {
        let range = |text, cursor| {
            let range = word_range_before_cursor(text, cursor, &char::is_whitespace);
            range.start.0..range.end.0
        };
        assert_eq!(range("", 0), 0..0);
        assert_eq!(range("/gr", 3), 0..3);
        assert_eq!(range("hello /gr", 9), 6..9);
        assert_eq!(range("hello /gr", 6), 6..6);
        assert_eq!(range("hello /gr", 8), 6..8);
        assert_eq!(range("héllo wörld", 11), 6..11);

        let range = word_range_before_cursor("a.b", 3, &|c| c == '.');
        assert_eq!(range.start.0..range.end.0, 2..3);
    }
}