Skip to main content

egui_charts/widget/
keyboard.rs

1//! Shared shortcut bindings and chart command dispatch.
2
3use crate::config::KeyboardAction;
4use egui::{Context, Event, KeyboardShortcut};
5
6/// Editable bindings shared by input dispatch, menus and shortcut help.
7///
8/// Remove a binding to disable it, or replace its key or action to rebind it.
9/// The first binding wins if several actions use the same key combination.
10#[derive(Clone, Debug)]
11pub struct ShortcutMap {
12    /// Key combinations and their actions, in precedence order.
13    pub bindings: Vec<(KeyboardShortcut, KeyboardAction)>,
14}
15
16impl Default for ShortcutMap {
17    fn default() -> Self {
18        Self {
19            bindings: KeyboardAction::all_by_category()
20                .into_iter()
21                .flat_map(|(_, actions)| actions)
22                .flat_map(|action| action.shortcuts().iter().map(move |key| (*key, action)))
23                .collect(),
24        }
25    }
26}
27
28impl ShortcutMap {
29    /// The first binding for an action, for a platform-formatted menu hint.
30    pub fn shortcut(&self, action: KeyboardAction) -> Option<KeyboardShortcut> {
31        self.keys_for(action).next()
32    }
33
34    pub(crate) fn keys_for(
35        &self,
36        action: KeyboardAction,
37    ) -> impl Iterator<Item = KeyboardShortcut> + '_ {
38        self.bindings
39            .iter()
40            .enumerate()
41            .filter_map(move |(index, (key, bound))| {
42                (*bound == action
43                    && !self.bindings[..index]
44                        .iter()
45                        .any(|(earlier, _)| earlier == key))
46                .then_some(*key)
47            })
48    }
49
50    /// Consume the first bound key press, leaving other input for its owner.
51    pub(crate) fn consume(
52        &self,
53        ctx: &Context,
54        stop_at_text: bool,
55        accepts: impl Fn(KeyboardAction, KeyboardShortcut) -> bool,
56    ) -> Option<(KeyboardAction, Event)> {
57        ctx.input_mut(|input| {
58            let (index, action) = input
59                .events
60                .iter()
61                .enumerate()
62                .take_while(|(_, event)| {
63                    !stop_at_text || !matches!(event, Event::Text(text) if starts_entry(text))
64                })
65                .find_map(|(index, event)| {
66                    let (key, modifiers) = match event {
67                        Event::Key {
68                            key,
69                            pressed: true,
70                            modifiers,
71                            ..
72                        } => (*key, *modifiers),
73                        Event::Copy => (egui::Key::C, egui::Modifiers::COMMAND),
74                        Event::Paste(_) => (egui::Key::V, egui::Modifiers::COMMAND),
75                        _ => return None,
76                    };
77                    let (shortcut, action) = self.bindings.iter().find(|(shortcut, _)| {
78                        let matches = modifiers.matches_exact(shortcut.modifiers)
79                            || (matches!(key, egui::Key::Plus | egui::Key::Questionmark)
80                                && modifiers.matches_logically(shortcut.modifiers)
81                                && modifiers.alt == shortcut.modifiers.alt);
82                        key == shortcut.logical_key && matches
83                    })?;
84                    accepts(*action, *shortcut).then_some((index, *action))
85                })?;
86            let event = input.events.remove(index);
87            if let Event::Key { key, .. } = &event {
88                let symbol = key.symbol_or_name();
89                if symbol.len() == 1
90                    && input
91                        .events
92                        .get(index)
93                        .is_some_and(|next| matches!(next, Event::Text(text) if text.eq_ignore_ascii_case(symbol)))
94                {
95                    input.events.remove(index);
96                }
97            }
98            Some((action, event))
99        })
100    }
101
102    /// Bound actions grouped for help, including bindings added by the host.
103    pub fn by_category(&self) -> Vec<(&'static str, Vec<KeyboardAction>)> {
104        let mut categories = KeyboardAction::all_by_category();
105        for (_, actions) in &mut categories {
106            actions.retain(|action| self.shortcut(*action).is_some());
107        }
108        let mut custom = Vec::new();
109        for (_, action) in &self.bindings {
110            if self.shortcut(*action).is_some()
111                && !categories
112                    .iter()
113                    .any(|(_, actions)| actions.contains(action))
114                && !custom.contains(action)
115            {
116                custom.push(*action);
117            }
118        }
119        if !custom.is_empty() {
120            categories.push(("Custom", custom));
121        }
122        categories.retain(|(_, actions)| !actions.is_empty());
123        categories
124    }
125}
126
127fn starts_entry(text: &str) -> bool {
128    text.chars()
129        .next()
130        .is_some_and(|c| !c.is_control() && !c.is_whitespace())
131}
132
133/// A shortcut that needs the host's dialogs, layout or clipboard.
134#[derive(Clone, Debug, PartialEq, Eq)]
135#[non_exhaustive]
136pub enum ShortcutRequest {
137    /// Execute an application command.
138    Action(KeyboardAction),
139    /// Paste clipboard text. The host recognizes its drawing format before inserting anything.
140    PasteDrawing(String),
141    /// Open symbol search with the typed text.
142    SymbolSearch(String),
143    /// Open interval entry with the typed digits; a comma opens it empty.
144    IntervalEntry(String),
145}
146
147#[derive(Default)]
148pub(crate) struct KeyboardState {
149    pub map: ShortcutMap,
150    pub requests: Vec<ShortcutRequest>,
151    pub chart_id: Option<egui::Id>,
152    pub last_pass: Option<u64>,
153    pub focus_elsewhere: bool,
154}
155
156pub(crate) fn shortcuts_suppressed(ctx: &Context) -> bool {
157    ctx.data(|data| data.get_temp::<u64>(egui::Id::NULL.with("chart_keyboard_capture")))
158        == Some(ctx.cumulative_pass_nr())
159}
160
161impl super::Chart {
162    /// Bindings used by this chart. Hosts pass this same map to help and menus.
163    pub fn shortcuts(&self) -> &ShortcutMap {
164        &self.keyboard.map
165    }
166
167    /// Edit this chart's keyboard bindings.
168    pub fn shortcuts_mut(&mut self) -> &mut ShortcutMap {
169        &mut self.keyboard.map
170    }
171
172    /// Drain commands that need host UI or a host-owned service.
173    pub fn take_shortcut_requests(&mut self) -> Vec<ShortcutRequest> {
174        std::mem::take(&mut self.keyboard.requests)
175    }
176
177    /// Give host dialogs keyboard priority over every chart and drawing toolbar this pass.
178    pub fn suppress_shortcuts(&mut self, ctx: &Context) {
179        let pass = ctx.cumulative_pass_nr();
180        ctx.data_mut(|data| data.insert_temp(egui::Id::NULL.with("chart_keyboard_capture"), pass));
181        self.keyboard.focus_elsewhere = true;
182    }
183
184    /// Dispatch keys before other application controls, after dialogs have had their turn.
185    ///
186    /// `show` also calls this for standalone charts. Each pass dispatches at most once.
187    /// Text fields, other focused widgets and open popups keep their input.
188    pub fn dispatch_shortcuts(&mut self, ctx: &Context) {
189        let pass = ctx.cumulative_pass_nr();
190        if self.keyboard.last_pass == Some(pass) {
191            return;
192        }
193        self.keyboard.last_pass = Some(pass);
194        // A line shortcut places only in the frame of its key press.
195        #[cfg(feature = "drawings")]
196        {
197            self.drawings.place_at_pointer = false;
198        }
199        ctx.data_mut(|data| {
200            data.insert_temp(
201                egui::Id::NULL.with("chart_shortcuts"),
202                self.keyboard.map.clone(),
203            )
204        });
205        if self.menu_open {
206            self.suppress_shortcuts(ctx);
207        }
208        if !self.chart_options.keyboard.enabled || shortcuts_suppressed(ctx) {
209            return;
210        }
211        let focused = ctx.memory(|memory| memory.focused());
212        let elsewhere = focused.is_some() && focused != self.keyboard.chart_id;
213        if elsewhere && !egui::Popup::is_any_open(ctx) {
214            let text_focused = ctx.text_edit_focused();
215            while let Some((action, _)) =
216                self.keyboard.map.consume(ctx, false, |action, shortcut| {
217                    matches!(
218                        action,
219                        KeyboardAction::OpenQuickSearch | KeyboardAction::ShowKeyboardHelp
220                    ) && (!text_focused
221                        || shortcut.modifiers.command
222                        || shortcut.modifiers.ctrl
223                        || shortcut.modifiers.alt
224                        || shortcut.logical_key == egui::Key::F1)
225                })
226            {
227                self.keyboard.requests.push(ShortcutRequest::Action(action));
228            }
229        }
230        if elsewhere
231            && self
232                .keyboard
233                .chart_id
234                .is_some_and(|id| ctx.memory(|memory| memory.had_focus_last_frame(id)))
235        {
236            while let Some((action, _)) = self.keyboard.map.consume(ctx, false, |action, _| {
237                matches!(
238                    action,
239                    KeyboardAction::CycleChartFocus | KeyboardAction::CycleChartFocusBack
240                )
241            }) {
242                self.keyboard.requests.push(ShortcutRequest::Action(action));
243            }
244        }
245        let was_elsewhere = std::mem::replace(&mut self.keyboard.focus_elsewhere, elsewhere);
246        if elsewhere
247            || egui::Popup::is_any_open(ctx)
248            || (was_elsewhere
249                && ctx
250                    .input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)))
251        {
252            return;
253        }
254        while let Some((action, event)) = {
255            #[cfg(feature = "drawings")]
256            while self.nudge_selected_drawing(ctx) {}
257            self.keyboard.map.consume(ctx, true, |_, _| true)
258        } {
259            if action == KeyboardAction::PasteDrawing
260                && let Event::Paste(text) = event
261            {
262                self.keyboard
263                    .requests
264                    .push(ShortcutRequest::PasteDrawing(text));
265                continue;
266            }
267            let rect = if self.last_rendered_price_rect.width().is_finite()
268                && self.last_rendered_price_rect.width() > 0.0
269            {
270                self.last_rendered_price_rect
271            } else {
272                ctx.content_rect()
273            };
274            let pending = self.keyboard.requests.len();
275            self.apply_keyboard_action(action, rect);
276            if self.keyboard.requests.len() != pending {
277                return;
278            }
279        }
280        let text = ctx.input_mut(|input| {
281            let Some(index) = input
282                .events
283                .iter()
284                .position(|event| matches!(event, Event::Text(text) if starts_entry(text)))
285            else {
286                return String::new();
287            };
288            let Event::Text(text) = input.events.remove(index) else {
289                unreachable!()
290            };
291            text
292        });
293        if !text.is_empty() {
294            // A comma opens interval entry without becoming part of it, as leading digits do.
295            self.keyboard
296                .requests
297                .push(if let Some(rest) = text.strip_prefix(',') {
298                    ShortcutRequest::IntervalEntry(rest.to_owned())
299                } else if text.starts_with(|c: char| c.is_ascii_digit()) {
300                    ShortcutRequest::IntervalEntry(text)
301                } else {
302                    ShortcutRequest::SymbolSearch(text)
303                });
304        }
305    }
306
307    #[cfg(feature = "drawings")]
308    fn nudge_selected_drawing(&mut self, ctx: &Context) -> bool {
309        use egui::{Key, Modifiers};
310        if !self.drawings.selected_ids().any(|id| {
311            self.drawings
312                .drawings
313                .iter()
314                .any(|d| d.id == id && !d.locked)
315        }) {
316            return false;
317        }
318        let key = ctx.input_mut(|input| {
319            for (index, event) in input.events.iter().enumerate() {
320                match event {
321                    Event::Text(text) if starts_entry(text) => return None,
322                    Event::Copy | Event::Paste(_) => return None,
323                    Event::Key {
324                        key,
325                        modifiers,
326                        pressed: true,
327                        ..
328                    } => {
329                        if *modifiers == Modifiers::NONE
330                            && matches!(
331                                key,
332                                Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown
333                            )
334                        {
335                            let key = *key;
336                            input.events.remove(index);
337                            return Some(key);
338                        }
339                        if self.keyboard.map.bindings.iter().any(|(shortcut, _)| {
340                            shortcut.logical_key == *key
341                                && modifiers.matches_exact(shortcut.modifiers)
342                        }) {
343                            return None;
344                        }
345                    }
346                    _ => {}
347                }
348            }
349            None
350        });
351        let Some(key) = key else {
352            return false;
353        };
354        let step = self
355            .instrument()
356            .and_then(|i| i.tick_size.filter(|s| s.is_finite() && *s > 0.0))
357            .unwrap_or_else(|| {
358                let decimals = self
359                    .instrument()
360                    .and_then(crate::scales::instrument_price_precision)
361                    .unwrap_or(2);
362                10.0_f64.powi(-i32::from(decimals))
363            });
364        let (bars, price) = match key {
365            Key::ArrowLeft => (-1.0, 0.0),
366            Key::ArrowRight => (1.0, 0.0),
367            Key::ArrowUp => (0.0, step),
368            _ => (0.0, -step),
369        };
370        let main_map = self.get_chart_mapping();
371        let pane_maps: Vec<_> = self
372            .last_rendered_indicator_panes
373            .iter()
374            .filter_map(|pane| Some((self.study_ids.get(pane.indicator_idx)?.0, pane.mapping)))
375            .collect();
376        self.user_action(|chart| {
377            chart.drawings.edit_selected(|drawing| {
378                if let Some([x, y]) = drawing.screen_anchor
379                    && let Some(anchor) = drawing.chart_points.first()
380                {
381                    let map = pane_maps
382                        .iter()
383                        .find(|(id, _)| Some(*id) == drawing.pane)
384                        .map_or(&main_map, |(_, map)| map);
385                    drawing.screen_anchor = Some([
386                        x + bars * map.bar_spacing / map.rect.width(),
387                        y + (map.price_to_y(anchor.price + price) - map.price_to_y(anchor.price))
388                            / map.rect.height(),
389                    ]);
390                }
391                let first = drawing.first_moved_anchor();
392                for point in drawing.chart_points.iter_mut().skip(first) {
393                    point.bar_idx += bars;
394                    point.price += price;
395                }
396            });
397        });
398        true
399    }
400
401    fn apply_keyboard_action(&mut self, action: KeyboardAction, chart_rect: egui::Rect) {
402        match action {
403            KeyboardAction::Undo => {
404                self.undo();
405            }
406            KeyboardAction::Redo => {
407                self.redo();
408            }
409            _ => self.user_action(|chart| chart.apply_keyboard_edit(action, chart_rect)),
410        }
411    }
412
413    fn apply_keyboard_edit(&mut self, action: KeyboardAction, chart_rect: egui::Rect) {
414        use crate::scales::PriceScaleMode;
415        use KeyboardAction as A;
416        match action {
417            A::PanLeft | A::PanRight | A::PanLeftFast | A::PanRightFast => {
418                self.pending_scroll_time = None;
419                self.tracking_mode_active = false;
420                let amount = if matches!(action, A::PanLeftFast | A::PanRightFast) {
421                    self.state
422                        .time_scale()
423                        .visible_logical_range()
424                        .length()
425                        .ceil()
426                } else {
427                    self.chart_options.keyboard.pan_amount
428                };
429                self.state.time_scale_mut().scroll_bars(
430                    if matches!(action, A::PanLeft | A::PanLeftFast) {
431                        -amount
432                    } else {
433                        amount
434                    },
435                );
436            }
437            A::ZoomIn | A::ZoomOut | A::PageUp | A::PageDown => {
438                self.pending_scroll_time = None;
439                self.tracking_mode_active = false;
440                let step = self.chart_options.keyboard.zoom_step;
441                let delta = match action {
442                    A::ZoomIn => step,
443                    A::ZoomOut => -step,
444                    A::PageUp => step * 3.0,
445                    _ => -step * 3.0,
446                };
447                let rect = chart_rect;
448                let anchor = self.zoom_anchor(rect.width() / 2.0, rect.width());
449                self.state
450                    .time_scale_mut()
451                    .zoom(delta, anchor, rect.left(), rect.width());
452            }
453            A::ScrollToRealTime => self.scroll_to_real_time(true),
454            A::ScrollToFirstBar => {
455                // A range spans `length() + 1` bars, so its left index becomes 0.
456                let scale = self.state.time_scale();
457                let position = scale.visible_logical_range().length() - scale.base_idx() as f32;
458                self.scroll_to_position(position, true);
459            }
460            A::FitContent => {
461                self.pending_scroll_time = None;
462                self.state.time_scale_mut().fit_content();
463            }
464            A::ResetChart | A::ResetZoom => self.reset_chart_view(),
465            A::ToggleLogScale | A::TogglePercentageScale => {
466                let mode = if action == A::ToggleLogScale {
467                    PriceScaleMode::Logarithmic
468                } else {
469                    PriceScaleMode::Percentage
470                };
471                let scale = self.state.price_scale_mut();
472                scale.options_mut().mode = if scale.options().mode == mode {
473                    PriceScaleMode::Normal
474                } else {
475                    mode
476                };
477                scale.reset();
478            }
479            A::InvertScale => {
480                let options = self.state.price_scale_mut().options_mut();
481                options.invert_scale = !options.invert_scale;
482            }
483            A::ToggleAutoScale => {
484                let scale = self.state.price_scale_mut();
485                if scale.is_manual() {
486                    scale.reset();
487                } else {
488                    let range = scale.range();
489                    scale.set_range(range.min, range.max);
490                }
491            }
492            A::ToggleGrid => self.config.show_grid = !self.config.show_grid,
493            A::ToggleVolume => self.config.show_volume = !self.config.show_volume,
494            #[cfg(feature = "drawings")]
495            A::DrawingTool(tool) => {
496                self.drawings.set_active_tool(Some(tool));
497                self.drawings.place_at_pointer = self.drawings.active_tool == Some(tool)
498                    && matches!(
499                        tool,
500                        crate::drawings::DrawingToolType::HorizontalLine
501                            | crate::drawings::DrawingToolType::VerticalLine
502                            | crate::drawings::DrawingToolType::CrossLine
503                    );
504            }
505            #[cfg(feature = "drawings")]
506            A::ToggleDrawings => self.drawings.toggle_visibility_all(),
507            #[cfg(feature = "drawings")]
508            A::CompleteDrawing => {
509                if self.drawings.active_tool.is_some_and(|tool| {
510                    tool.interaction_mode() == crate::drawings::DrawingInteractionMode::MultiPoint
511                }) && self.drawings.curr_drawing.is_some()
512                {
513                    self.drawings.complete_curr_drawing();
514                }
515            }
516            // Without a selected drawing, the key removes the selected study.
517            #[cfg(feature = "drawings")]
518            A::DeleteSelectedDrawing => match self.selection.selected_id() {
519                Some(super::ChartElementId::Indicator(id))
520                    if self.drawings.selected_ids().next().is_none() =>
521                {
522                    self.remove_series(id);
523                }
524                _ => self.drawings.delete_selected(),
525            },
526            #[cfg(feature = "drawings")]
527            A::Cancel => {
528                // Keep placed path anchors; completion drops the pointer preview.
529                if self.drawings.curr_drawing.as_ref().is_some_and(|d| {
530                    matches!(
531                        d.tool_type,
532                        crate::drawings::DrawingToolType::Path
533                            | crate::drawings::DrawingToolType::Polyline
534                    )
535                }) {
536                    self.drawings.complete_curr_drawing();
537                }
538                self.drawings.cancel_curr_drawing();
539                self.drawings.deselect();
540                self.drawings.set_active_tool(None);
541                self.drawings.set_eraser_mode(false);
542            }
543            #[cfg(not(feature = "drawings"))]
544            A::DeleteSelectedDrawing => {
545                if let Some(super::ChartElementId::Indicator(id)) = self.selection.selected_id() {
546                    self.remove_series(id);
547                }
548            }
549            #[cfg(not(feature = "drawings"))]
550            A::DrawingTool(_) | A::ToggleDrawings | A::CompleteDrawing | A::Cancel => {}
551            _ => self.keyboard.requests.push(ShortcutRequest::Action(action)),
552        }
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::scales::PriceScaleMode;
560    use crate::widget::test_frames::chart;
561    use egui::{Key, Modifiers, RawInput};
562
563    /// Line shortcuts place once at the pointer; a missing pointer only arms the tool.
564    #[cfg(feature = "drawings")]
565    #[test]
566    fn line_shortcuts_place_their_line_at_the_pointer() {
567        use crate::drawings::DrawingToolType::{
568            CrossLine, HorizontalLine, HorizontalRay, VerticalLine,
569        };
570        use crate::widget::test_frames::frame;
571        for (key, over_chart, tool) in [
572            (Key::H, true, HorizontalLine),
573            (Key::V, true, VerticalLine),
574            (Key::C, true, CrossLine),
575            (Key::J, true, HorizontalRay),
576            (Key::H, false, HorizontalLine),
577        ] {
578            let ctx = Context::default();
579            let mut chart = chart(&[10.0], &[20.0]);
580            frame(&ctx, &mut chart, 0.0, vec![]);
581            let map = chart.get_chart_mapping();
582            let a = egui::pos2(map.idx_to_x(30), map.price_to_y(16.0));
583            frame(
584                &ctx,
585                &mut chart,
586                0.1,
587                vec![
588                    if over_chart {
589                        Event::PointerMoved(a)
590                    } else {
591                        Event::PointerGone
592                    },
593                    Event::Key {
594                        key,
595                        physical_key: None,
596                        pressed: true,
597                        repeat: false,
598                        modifiers: Modifiers::ALT,
599                    },
600                ],
601            );
602            if over_chart && tool != HorizontalRay {
603                assert_eq!(chart.drawings().drawings.len(), 1, "{key:?}");
604                let drawing = &chart.drawings().drawings[0];
605                assert_eq!(drawing.tool_type, tool);
606                assert_eq!(drawing.chart_points[0].bar_idx, 30.0);
607                assert!((drawing.chart_points[0].price - 16.0).abs() < 1e-3);
608                assert_eq!(chart.drawings().active_tool, None);
609                assert!(chart.undo());
610                assert!(chart.drawings().drawings.is_empty());
611                assert!(!chart.can_undo());
612            } else {
613                assert!(chart.drawings().drawings.is_empty());
614                assert_eq!(chart.drawings().active_tool, Some(tool));
615                frame(&ctx, &mut chart, 0.2, vec![Event::PointerMoved(a)]);
616                assert!(chart.drawings().drawings.is_empty());
617            }
618        }
619    }
620
621    #[cfg(feature = "drawings")]
622    #[test]
623    fn arrows_nudge_selected_drawings_with_one_undo_per_press() {
624        use crate::drawings::{ChartPoint, Drawing, DrawingToolType};
625        use crate::widget::test_frames::frame;
626        for (locked, selected) in [(false, true), (true, true), (false, false)] {
627            let ctx = Context::default();
628            let mut chart = chart(&[10.0], &[20.0]);
629            chart.set_instrument(Some(
630                crate::model::Instrument::new("TEST").with_tick_size(0.25),
631            ));
632            let mut drawing = Drawing::new(7, DrawingToolType::TrendLine);
633            drawing.chart_points = vec![ChartPoint::new(30.0, 14.0), ChartPoint::new(70.0, 17.0)];
634            drawing.completed = true;
635            drawing.locked = locked;
636            chart.drawings_mut().drawings.push(drawing);
637            if selected {
638                chart.drawings_mut().select(7);
639            }
640            frame(&ctx, &mut chart, 0.0, vec![]);
641            let view = chart.get_time_scale_state();
642            for (i, (key, bars, price)) in [
643                (Key::ArrowLeft, -1.0, 0.0),
644                (Key::ArrowRight, 1.0, 0.0),
645                (Key::ArrowUp, 0.0, 0.25),
646                (Key::ArrowDown, 0.0, -0.25),
647            ]
648            .into_iter()
649            .enumerate()
650            {
651                let before = chart.drawings().drawings[0].chart_points.clone();
652                frame(
653                    &ctx,
654                    &mut chart,
655                    1.0 + i as f64,
656                    vec![Event::Key {
657                        key,
658                        physical_key: None,
659                        pressed: true,
660                        repeat: false,
661                        modifiers: Modifiers::NONE,
662                    }],
663                );
664                for (old, point) in before
665                    .iter()
666                    .zip(&chart.drawings().drawings[0].chart_points)
667                {
668                    assert_eq!(
669                        point.bar_idx,
670                        old.bar_idx + if selected && !locked { bars } else { 0.0 }
671                    );
672                    assert_eq!(
673                        point.price,
674                        old.price + if selected && !locked { price } else { 0.0 }
675                    );
676                }
677                if selected && !locked {
678                    assert_eq!(chart.get_time_scale_state(), view);
679                    assert!(chart.undo());
680                    assert_eq!(chart.drawings().drawings[0].chart_points, before);
681                    assert!(!chart.can_undo());
682                    assert!(chart.redo());
683                    assert!(chart.undo());
684                } else if i < 2 {
685                    assert_ne!(chart.get_time_scale_state(), view);
686                    assert!(chart.undo());
687                }
688            }
689        }
690    }
691
692    #[test]
693    fn reset_chart_restores_time_and_every_price_scale() {
694        use crate::model::SeriesScale;
695        use crate::widget::test_frames::frame;
696        for (spacing, margin, pixels, expected_offset) in [
697            (6.0, 2.5, None, 2.5),
698            (9.0, 4.0, None, 4.0),
699            (8.0, 2.5, Some(64.0), 8.0),
700        ] {
701            for action in [
702                Some(KeyboardAction::ResetChart),
703                Some(KeyboardAction::ResetZoom),
704                None,
705            ] {
706                let ctx = Context::default();
707                let mut chart = chart(&[100.0], &[200.0]);
708                let mut options = chart.chart_options().clone();
709                options.time_scale.bar_spacing = spacing;
710                options.time_scale.right_offset = margin;
711                options.time_scale.right_offset_pixels = pixels;
712                chart = chart.with_chart_options(options);
713                let id = chart.add_series(crate::widget::SeriesOptions {
714                    scale: SeriesScale::Left,
715                    ..Default::default()
716                });
717                let pane = chart.add_pane();
718                chart.set_preserve_empty_pane(pane, true);
719                frame(&ctx, &mut chart, 0.0, vec![]);
720                chart.state.time_scale_mut().set_bar_spacing(20.0);
721                chart.state.time_scale_mut().set_right_offset(-30.0);
722                chart.price_scale_mut().set_range(150.0, 150.4);
723                chart
724                    .series_price_scale_mut(id)
725                    .unwrap()
726                    .set_range(2.0, 3.0);
727                chart
728                    .pane_layout
729                    .panes
730                    .last_mut()
731                    .unwrap()
732                    .scale
733                    .set_range(40.0, 50.0);
734                chart.set_visible_bars(5);
735                chart.scroll_to_position(-50.0, true);
736                chart.scroll_to_time(chart.state.shown()[10].time);
737                if let Some(action) = action {
738                    chart.apply_keyboard_action(action, chart.get_rendered_price_rect());
739                } else {
740                    chart.user_action(|chart| chart.reset_chart_view());
741                }
742                for step in 0..3 {
743                    if step == 1 {
744                        assert!(chart.undo());
745                    } else if step == 2 {
746                        assert!(chart.redo());
747                    }
748                    frame(&ctx, &mut chart, 0.1 + step as f64 * 0.1, vec![]);
749                    let manual = step == 1;
750                    if step == 0 {
751                        assert_eq!(chart.get_time_scale_state(), (spacing, expected_offset));
752                    }
753                    assert_eq!(chart.state.price_scale().is_manual(), manual);
754                    assert_eq!(chart.series_price_scale(id).unwrap().is_manual(), manual);
755                    assert!(
756                        chart
757                            .pane_layout
758                            .panes
759                            .iter()
760                            .skip(1)
761                            .all(|pane| pane.scale.is_manual() == manual)
762                    );
763                }
764            }
765        }
766    }
767
768    #[test]
769    fn navigation_and_panel_keys_follow_the_shared_action_map() {
770        use KeyboardAction as A;
771        let alt_shift = Modifiers::ALT.plus(Modifiers::SHIFT);
772        for (key, modifiers, expected) in [
773            (Key::ArrowLeft, Modifiers::NONE, Some(A::PanLeft)),
774            (Key::ArrowLeft, Modifiers::ALT, None),
775            (Key::ArrowRight, Modifiers::COMMAND, Some(A::PanRightFast)),
776            (Key::ArrowUp, Modifiers::COMMAND, Some(A::ZoomIn)),
777            (Key::ArrowLeft, alt_shift, Some(A::ScrollToFirstBar)),
778            (Key::Home, Modifiers::NONE, Some(A::ScrollToFirstBar)),
779            (Key::ArrowRight, alt_shift, Some(A::ScrollToRealTime)),
780            (Key::End, Modifiers::NONE, Some(A::ScrollToRealTime)),
781            (Key::Period, Modifiers::NONE, Some(A::OpenLayout)),
782            (Key::D, Modifiers::ALT, Some(A::OpenDataWindow)),
783            (Key::E, alt_shift, Some(A::ToggleSession)),
784        ] {
785            let ctx = Context::default();
786            let mut chart = chart(&[10.0], &[20.0]);
787            let latest = chart.state.time_scale().live_edge_offset();
788            let mut before_offset = 0.0;
789            let mut before_spacing = 0.0;
790            let mut page = 0.0;
791            for (pass, time) in [0.0, 0.1, 0.2, 0.3, 2.0, 2.1].into_iter().enumerate() {
792                if pass == 2 {
793                    // Zoom into history so neither end is already in view.
794                    chart.state.time_scale_mut().set_bar_spacing(20.0);
795                    chart.scroll_to_position(-40.0, false);
796                    before_offset = chart.state.time_scale().right_offset();
797                    before_spacing = chart.state.time_scale().bar_spacing();
798                    page = chart
799                        .state
800                        .time_scale()
801                        .visible_logical_range()
802                        .length()
803                        .ceil();
804                }
805                ctx.run_ui(
806                    RawInput {
807                        screen_rect: Some(egui::Rect::from_min_size(
808                            egui::Pos2::ZERO,
809                            egui::vec2(800.0, 600.0),
810                        )),
811                        time: Some(time),
812                        events: if pass == 2 {
813                            vec![Event::Key {
814                                key,
815                                modifiers,
816                                physical_key: None,
817                                pressed: true,
818                                repeat: false,
819                            }]
820                        } else {
821                            vec![]
822                        },
823                        ..Default::default()
824                    },
825                    |ui| {
826                        let response = chart.show(ui);
827                        if pass < 2 {
828                            response.request_focus();
829                        }
830                    },
831                )
832                .drop_without_applying_deltas();
833            }
834            let requests = chart.take_shortcut_requests();
835            let offset = chart.state.time_scale().right_offset();
836            let spacing = chart.state.time_scale().bar_spacing();
837            match expected {
838                Some(A::PanLeft) => assert_eq!(offset, before_offset + 1.0),
839                Some(A::PanRightFast) => assert_eq!(offset, before_offset - page),
840                Some(A::ZoomIn) => assert!(spacing > before_spacing),
841                Some(A::ScrollToFirstBar) => {
842                    let left = chart.visible_logical_range().unwrap().left;
843                    assert!(left.abs() < 1e-3, "{key:?}: left edge at bar {left}");
844                }
845                Some(A::ScrollToRealTime) => assert_eq!(offset, latest, "{key:?}"),
846                Some(action) => {
847                    assert_eq!(requests, vec![ShortcutRequest::Action(action)], "{key:?}")
848                }
849                None => assert_eq!(offset, before_offset),
850            }
851            if expected != Some(A::ZoomIn) {
852                assert_eq!(spacing, before_spacing, "{key:?} keeps the zoom");
853            }
854        }
855    }
856
857    #[test]
858    fn rebinding_changes_chart_input_and_bound_characters_do_not_start_entry() {
859        let ctx = Context::default();
860        let mut chart = chart(&[10.0], &[20.0]);
861        chart
862            .shortcuts_mut()
863            .bindings
864            .retain(|(_, action)| *action != KeyboardAction::PanLeft);
865        chart.shortcuts_mut().bindings.push((
866            KeyboardShortcut::new(Modifiers::NONE, Key::F2),
867            KeyboardAction::ToggleLogScale,
868        ));
869        for (event, expected) in [
870            (
871                Event::Key {
872                    key: Key::ArrowLeft,
873                    physical_key: None,
874                    pressed: true,
875                    repeat: false,
876                    modifiers: Modifiers::NONE,
877                },
878                PriceScaleMode::Normal,
879            ),
880            (
881                Event::Key {
882                    key: Key::F2,
883                    physical_key: None,
884                    pressed: true,
885                    repeat: false,
886                    modifiers: Modifiers::NONE,
887                },
888                PriceScaleMode::Logarithmic,
889            ),
890            (Event::Text("IBM".into()), PriceScaleMode::Logarithmic),
891            (Event::Text("15".into()), PriceScaleMode::Logarithmic),
892            (Event::Text(",".into()), PriceScaleMode::Logarithmic),
893        ] {
894            let before = chart.state.time_scale().right_offset();
895            ctx.run_ui(
896                RawInput {
897                    events: vec![event.clone()],
898                    ..Default::default()
899                },
900                |ui| {
901                    chart.show(ui).request_focus();
902                },
903            )
904            .drop_without_applying_deltas();
905            assert_eq!(chart.state.price_scale().options().mode, expected);
906            assert_eq!(chart.state.time_scale().right_offset(), before);
907            if let Event::Text(text) = event {
908                let request = if text == "15" {
909                    ShortcutRequest::IntervalEntry(text)
910                } else if text == "," {
911                    ShortcutRequest::IntervalEntry(String::new())
912                } else {
913                    ShortcutRequest::SymbolSearch(text)
914                };
915                assert_eq!(chart.take_shortcut_requests(), vec![request]);
916            }
917        }
918        chart.shortcuts_mut().bindings.push((
919            KeyboardShortcut::new(Modifiers::NONE, Key::F),
920            KeyboardAction::FitContent,
921        ));
922        ctx.run_ui(
923            RawInput {
924                events: vec![
925                    Event::Key {
926                        key: Key::F,
927                        physical_key: None,
928                        pressed: true,
929                        repeat: false,
930                        modifiers: Modifiers::NONE,
931                    },
932                    Event::Text("f".into()),
933                ],
934                ..Default::default()
935            },
936            |ui| {
937                chart.show(ui);
938            },
939        )
940        .drop_without_applying_deltas();
941        assert!(
942            chart.take_shortcut_requests().is_empty(),
943            "a bound character must not also open symbol search"
944        );
945    }
946
947    #[test]
948    fn text_fields_keep_typing_and_panel_keys_but_command_search_still_opens() {
949        let ctx = Context::default();
950        let mut chart = chart(&[10.0], &[20.0]);
951        let mut text = "label".to_owned();
952        for pass in 0..8 {
953            if pass == 7 {
954                chart.shortcuts_mut().bindings.insert(
955                    0,
956                    (
957                        KeyboardShortcut::new(Modifiers::COMMAND, Key::K),
958                        KeyboardAction::FitContent,
959                    ),
960                );
961            }
962            let map = chart.shortcuts();
963            let visible = map
964                .by_category()
965                .into_iter()
966                .flat_map(|(_, actions)| actions)
967                .any(|action| action == KeyboardAction::OpenQuickSearch);
968            assert_eq!(
969                visible,
970                pass != 7,
971                "help must not advertise a shadowed binding"
972            );
973            assert_eq!(
974                map.shortcut(KeyboardAction::OpenQuickSearch).is_some(),
975                pass != 7
976            );
977            let events = match pass {
978                2 => vec![
979                    Event::Key {
980                        key: Key::Questionmark,
981                        physical_key: None,
982                        pressed: true,
983                        repeat: false,
984                        modifiers: Modifiers::SHIFT,
985                    },
986                    Event::Text("?".into()),
987                ],
988                3 | 7 => vec![Event::Key {
989                    key: Key::K,
990                    physical_key: None,
991                    pressed: true,
992                    repeat: false,
993                    modifiers: Modifiers::COMMAND,
994                }],
995                4 => vec![
996                    Event::Key {
997                        key: Key::Period,
998                        physical_key: None,
999                        pressed: true,
1000                        repeat: false,
1001                        modifiers: Modifiers::NONE,
1002                    },
1003                    Event::Text(".".into()),
1004                ],
1005                5 | 6 => vec![Event::Key {
1006                    key: if pass == 5 { Key::D } else { Key::E },
1007                    physical_key: None,
1008                    pressed: true,
1009                    repeat: false,
1010                    modifiers: if pass == 5 {
1011                        Modifiers::ALT
1012                    } else {
1013                        Modifiers::ALT.plus(Modifiers::SHIFT)
1014                    },
1015                }],
1016                _ => vec![],
1017            };
1018            ctx.run_ui(
1019                RawInput {
1020                    events,
1021                    ..Default::default()
1022                },
1023                |ui| {
1024                    chart.dispatch_shortcuts(ui.ctx());
1025                    let field = ui.text_edit_singleline(&mut text);
1026                    chart.show(ui);
1027                    if pass < 2 {
1028                        field.request_focus();
1029                    }
1030                },
1031            )
1032            .drop_without_applying_deltas();
1033            let expected = if pass == 3 {
1034                vec![ShortcutRequest::Action(KeyboardAction::OpenQuickSearch)]
1035            } else {
1036                vec![]
1037            };
1038            assert_eq!(
1039                chart.take_shortcut_requests(),
1040                expected,
1041                "field pass {pass}"
1042            );
1043        }
1044        assert_eq!(text, "label?.");
1045    }
1046
1047    #[test]
1048    #[cfg(feature = "drawings")]
1049    fn typed_entry_keeps_following_edit_keys() {
1050        let ctx = Context::default();
1051        let mut chart = crate::widget::test_frames::chart(&[10.0], &[20.0]);
1052        chart
1053            .drawings_mut()
1054            .drawings
1055            .push(crate::drawings::Drawing::new(
1056                7,
1057                crate::drawings::DrawingToolType::TrendLine,
1058            ));
1059        chart.drawings_mut().select(7);
1060        for pass in 0..2 {
1061            ctx.run_ui(
1062                RawInput {
1063                    events: if pass == 1 {
1064                        vec![
1065                            Event::Text("AB".into()),
1066                            Event::Key {
1067                                key: Key::Backspace,
1068                                physical_key: None,
1069                                pressed: true,
1070                                repeat: false,
1071                                modifiers: Modifiers::NONE,
1072                            },
1073                        ]
1074                    } else {
1075                        vec![]
1076                    },
1077                    ..Default::default()
1078                },
1079                |ui| {
1080                    chart.show(ui).request_focus();
1081                },
1082            )
1083            .drop_without_applying_deltas();
1084        }
1085        assert_eq!(
1086            chart.drawings().drawings.len(),
1087            1,
1088            "text entry owns subsequent keys in the same input batch"
1089        );
1090        assert_eq!(
1091            chart.take_shortcut_requests(),
1092            vec![ShortcutRequest::SymbolSearch("AB".into())]
1093        );
1094        assert!(
1095            ctx.input(|input| input.key_pressed(Key::Backspace)),
1096            "the host entry field keeps the following edit key"
1097        );
1098    }
1099
1100    #[test]
1101    fn opening_search_keeps_following_text() {
1102        let ctx = Context::default();
1103        let mut chart = chart(&[10.0], &[20.0]);
1104        ctx.run_ui(
1105            RawInput {
1106                events: vec![
1107                    Event::Key {
1108                        key: Key::K,
1109                        physical_key: None,
1110                        pressed: true,
1111                        repeat: false,
1112                        modifiers: Modifiers::COMMAND,
1113                    },
1114                    Event::Text("RSI".into()),
1115                ],
1116                ..Default::default()
1117            },
1118            |ui| chart.dispatch_shortcuts(ui.ctx()),
1119        )
1120        .drop_without_applying_deltas();
1121        assert_eq!(
1122            chart.take_shortcut_requests(),
1123            vec![ShortcutRequest::Action(KeyboardAction::OpenQuickSearch)]
1124        );
1125        assert!(
1126            ctx.input(|input| input.events.contains(&Event::Text("RSI".into()))),
1127            "opening a host dialog hands the remaining text to it"
1128        );
1129    }
1130}