egui_keybind/
keybind.rs

1use crate::Bind;
2use egui::epaint::StrokeKind;
3use egui::{
4    pos2, vec2, Event, Id, Key, KeyboardShortcut, ModifierNames, PointerButton, RichText, Sense,
5    TextStyle, Ui, Widget, WidgetInfo, WidgetText, WidgetType,
6};
7
8/// A keybind (hotkey) widget for [egui].
9pub struct Keybind<'a, B: Bind> {
10    bind: &'a mut B,
11    reset: B,
12    text: &'a str,
13    id: Id,
14    reset_key: Option<Key>,
15    modifier_names: &'a ModifierNames<'a>,
16}
17
18impl<'a, B: Bind> Keybind<'a, B> {
19    /// Create a new [Keybind] for a given [Bind].
20    ///
21    /// # Arguments
22    ///
23    /// * `bind` - The bind to use for the [Keybind].
24    /// * `id` - ID for the [Keybind] in [egui]'s memory.
25    pub fn new(bind: &'a mut B, id: impl Into<Id>) -> Self {
26        let prev_bind = bind.clone();
27        Self {
28            bind,
29            reset: prev_bind,
30            text: "",
31            id: id.into(),
32            reset_key: None,
33            modifier_names: &ModifierNames::NAMES,
34        }
35    }
36
37    /// Set the text of the [Keybind]. This will be displayed next to the
38    /// keybind widget (and used for accessibility).
39    ///
40    /// You can remove the text by setting it to an empty string.
41    /// By default there is no text.
42    pub fn with_text(mut self, text: &'a str) -> Self {
43        self.text = text;
44        self
45    }
46
47    /// Set the bind of the [Keybind].
48    ///
49    /// By default this is the bind that was passed to `new`.
50    pub fn with_bind(mut self, bind: &'a mut B) -> Self {
51        self.bind = bind;
52        self
53    }
54
55    /// Set the ID of the [Keybind] in [egui]'s memory.
56    ///
57    /// By default this is the ID that was passed in `new`.
58    pub fn with_id(mut self, id: impl Into<Id>) -> Self {
59        self.id = id.into();
60        self
61    }
62
63    /// Set the key that resets the [Keybind]. If [None], the [Keybind] will
64    /// never reset to its' previous value.
65    ///
66    /// By default this is [None].
67    pub fn with_reset_key(mut self, key: Option<Key>) -> Self {
68        self.reset_key = key;
69        self
70    }
71
72    /// Set the bind that the [Keybind] will reset to after the reset key gets pressed.
73    ///
74    /// By default this is the same as the bind passed to `new`.
75    pub fn with_reset(mut self, prev_bind: B) -> Self {
76        self.reset = prev_bind;
77        self
78    }
79
80    /// Set the modifier names to use for the [Keybind]. By default this is [`ModifierNames::NAMES`].
81    pub fn with_modifier_names(mut self, modifier_names: &'a ModifierNames<'a>) -> Self {
82        self.modifier_names = modifier_names;
83        self
84    }
85}
86
87/// Get the widget expecting value from egui's memory.
88fn get_expecting(ui: &Ui, id: Id) -> bool {
89    let expecting = ui.ctx().memory_mut(|memory| {
90        *memory
91            .data
92            .get_temp_mut_or_default::<bool>(ui.make_persistent_id(id))
93    });
94    expecting
95}
96
97/// Set the widget expecting value in egui's memory.
98fn set_expecting(ui: &Ui, id: Id, expecting: bool) {
99    ui.ctx().memory_mut(|memory| {
100        *memory
101            .data
102            .get_temp_mut_or_default(ui.make_persistent_id(id)) = expecting;
103    });
104}
105
106impl<B: Bind> Widget for Keybind<'_, B> {
107    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
108        let text = self.bind.format(self.modifier_names, false);
109
110        let galley = WidgetText::RichText(RichText::new(text.clone()).into()).into_galley(
111            ui,
112            Some(egui::TextWrapMode::Extend),
113            0.0,
114            TextStyle::Button,
115        );
116
117        let size = ui.spacing().interact_size.max(galley.size());
118        let button_padding = ui.spacing().button_padding;
119        let mut widget_size = size + button_padding * vec2(2.0, 1.0);
120
121        // compute the text galley next to the widget (set by with_text), expand
122        // widget appropriately
123        let text_galley = if !self.text.is_empty() {
124            let galley = WidgetText::RichText(RichText::new(self.text).into()).into_galley(
125                ui,
126                None,
127                ui.available_width() - widget_size.x, // not exactly right
128                TextStyle::Button,
129            );
130            Some(galley)
131        } else {
132            None
133        };
134
135        let custom_text_width = text_galley.clone().map_or(0.0, |text_galley| {
136            ui.spacing().icon_spacing + text_galley.size().x
137        });
138        widget_size.x += custom_text_width;
139
140        let (rect, mut response) = ui.allocate_exact_size(widget_size, Sense::click());
141
142        // calculate size of the widget without the custom text
143        let mut hotkey_rect = rect;
144        *hotkey_rect.right_mut() -= custom_text_width;
145
146        // see if we're currently waiting for any key (pull from egui's memory)
147        let mut expecting = get_expecting(ui, self.id);
148        let prev_expecting = expecting;
149        if response.clicked() {
150            expecting = !expecting;
151        }
152
153        // add widget info for accessibility. this generates a string like "Ctrl+T. Open the terminal"
154        // if the keybind was created with `with_text`
155        response.widget_info(|| {
156            WidgetInfo::selected(
157                WidgetType::Button,
158                expecting,
159                expecting,
160                if self.text.is_empty() {
161                    text.clone() // just read out the hotkey
162                } else {
163                    text.clone() + ". " + self.text
164                },
165            )
166        });
167
168        if expecting {
169            if response.clicked_elsewhere() {
170                // the user has clicked somewhere else, stop capturing input
171                expecting = false;
172            } else {
173                // everything ok, capture keyboard input
174                let kb = ui.input(|i| {
175                    i.events.iter().find_map(|e| match e {
176                        Event::Key {
177                            key,
178                            pressed: true,
179                            modifiers,
180                            repeat: false,
181                            ..
182                        } => Some((*key, *modifiers)),
183                        _ => None,
184                    })
185                });
186
187                // capture mouse input
188                let pointer = ui.input(|i| {
189                    i.events.iter().find_map(|e| match e {
190                        Event::PointerButton {
191                            button,
192                            pressed: true,
193                            ..
194                        } if *button != PointerButton::Primary
195                            && *button != PointerButton::Secondary =>
196                        {
197                            Some(*button)
198                        }
199                        _ => None,
200                    })
201                });
202
203                // set keybind
204                if kb.is_some() || pointer.is_some() {
205                    self.bind
206                        .set(kb.map(|kb| KeyboardShortcut::new(kb.1, kb.0)), pointer);
207                    response.mark_changed();
208                    expecting = false;
209                }
210            }
211
212            if let Some(reset_key) = self.reset_key {
213                // the reset key was pressed
214                if ui.input(|i| i.key_pressed(reset_key)) {
215                    *self.bind = self.reset;
216                    expecting = false;
217                    response.mark_changed();
218                }
219            }
220        }
221
222        // paint
223        if ui.is_rect_visible(rect) {
224            // paint bg rect
225            let visuals = ui.style().interact_selectable(&response, expecting);
226            ui.painter().rect(
227                hotkey_rect.expand(visuals.expansion),
228                visuals.corner_radius,
229                visuals.bg_fill,
230                visuals.bg_stroke,
231                StrokeKind::Inside,
232            );
233
234            // align text to center in rect that is shrinked to match button padding
235            let mut text_pos = ui
236                .layout()
237                .align_size_within_rect(galley.size(), hotkey_rect.shrink2(button_padding))
238                .min;
239
240            // align text to center of the button if it doesn't expand the rect
241            if text_pos.x + galley.size().x + button_padding.x < hotkey_rect.right() {
242                text_pos.x += hotkey_rect.size().x / 2.0 - galley.size().x / 2.0 - button_padding.x;
243            }
244
245            // paint text inside button
246            ui.painter().galley(text_pos, galley, visuals.text_color());
247
248            // paint galley for text outside on the left, if any
249            if let Some(text_galley) = text_galley {
250                let text_pos = pos2(
251                    hotkey_rect.right() + ui.spacing().icon_spacing,
252                    hotkey_rect.center().y - 0.5 * text_galley.size().y,
253                );
254                ui.painter().galley(
255                    text_pos,
256                    text_galley,
257                    ui.style().noninteractive().text_color(),
258                );
259            }
260        }
261
262        if prev_expecting != expecting {
263            set_expecting(ui, self.id, expecting);
264        }
265        response
266    }
267}