1use crate::config::KeyboardAction;
4use egui::{Context, Event, KeyboardShortcut};
5
6#[derive(Clone, Debug)]
11pub struct ShortcutMap {
12 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 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
135#[non_exhaustive]
136pub enum ShortcutRequest {
137 Action(KeyboardAction),
139 PasteDrawing(String),
141 SymbolSearch(String),
143 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 pub fn shortcuts(&self) -> &ShortcutMap {
164 &self.keyboard.map
165 }
166
167 pub fn shortcuts_mut(&mut self) -> &mut ShortcutMap {
169 &mut self.keyboard.map
170 }
171
172 pub fn take_shortcut_requests(&mut self) -> Vec<ShortcutRequest> {
174 std::mem::take(&mut self.keyboard.requests)
175 }
176
177 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 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 #[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 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 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 #[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 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 #[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 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}