diff --git a/src/chart.rs b/src/chart.rs index a844c8c2..ff6d3743 100644 --- a/src/chart.rs +++ b/src/chart.rs @@ -2,6 +2,7 @@ pub mod comparison; pub mod heatmap; pub mod indicator; pub mod kline; +mod keyboard_nav; mod scale; use crate::style; @@ -81,6 +82,14 @@ pub trait Chart: PlotConstants + canvas::Program { fn supports_fit_autoscaling(&self) -> bool; fn is_empty(&self) -> bool; + + /// Compute a pan [`Message`] from a keyboard event using this chart's current state. + /// + /// Default implementation delegates to [`keyboard_nav::handle`]; chart types do not + /// need to override this unless they have custom navigation behaviour. + fn keyboard_nav_msg(&self, event: &iced::keyboard::Event) -> Option { + keyboard_nav::handle(event, self.state()) + } } fn canvas_interaction( @@ -253,10 +262,13 @@ fn canvas_interaction( } } Event::Keyboard(keyboard_event) => { - cursor_position?; + // Note: cursor guard is only needed for Shift (ruler requires a + // cursor position to start from). Navigation keys must work even + // when the pointer is outside the chart area. match keyboard_event { iced::keyboard::Event::KeyPressed { key, .. } => match key.as_ref() { keyboard::Key::Named(keyboard::key::Named::Shift) => { + cursor_position?; *interaction = Interaction::Ruler { start: None }; Some(canvas::Action::request_redraw().and_capture()) } @@ -264,7 +276,8 @@ fn canvas_interaction( *interaction = Interaction::None; Some(canvas::Action::request_redraw().and_capture()) } - _ => None, + _ => keyboard_nav::handle(keyboard_event, chart.state()) + .map(|msg| canvas::Action::publish(msg).and_capture()), }, _ => None, } diff --git a/src/chart/keyboard_nav.rs b/src/chart/keyboard_nav.rs new file mode 100644 index 00000000..6bf55c35 --- /dev/null +++ b/src/chart/keyboard_nav.rs @@ -0,0 +1,61 @@ +//! Keyboard-driven chart panning. +//! +//! Computes a [`Message::Translated`] pan step from a keyboard event, +//! using the chart's current [`ViewState`] (translation, cell_width, scaling, +//! bounds). Returns `None` for any key that is not a navigation key. +//! +//! ## Key bindings +//! +//! | Key | Action | +//! |----------------|------------------------------------------------| +//! | `←` | Scroll left 10 bars (towards history) | +//! | `→` | Scroll right 10 bars (towards present) | +//! | `Shift + ←` | Scroll left 50 bars (fast) | +//! | `Shift + →` | Scroll right 50 bars (fast) | +//! | `PageUp` | Scroll left one full viewport width | +//! | `PageDown` | Scroll right one full viewport width | +//! | `Home` | Jump to latest bar (reset translation.x = 0) | +//! +//! ## Sign convention +//! +//! `interval_to_x` returns a negative value for older bars, so the x-axis +//! grows *leftward* in chart coordinates. Increasing `translation.x` shifts +//! content to the right, revealing older history — so `ArrowLeft` (backwards +//! in time) *increases* `translation.x`, mirroring a rightward mouse drag. + +use super::{Message, ViewState}; +use iced::{Vector, keyboard}; + +const BARS_SMALL: f32 = 10.0; +const BARS_LARGE: f32 = 50.0; + +/// Compute a [`Message::Translated`] pan step from a keyboard event. +/// +/// Returns `None` when `event` is not a navigation key, so callers can +/// fall through to other handlers. +pub fn handle(event: &keyboard::Event, state: &ViewState) -> Option { + let keyboard::Event::KeyPressed { key, modifiers, .. } = event else { + return None; + }; + + let shift = modifiers.shift(); + let bars = if shift { BARS_LARGE } else { BARS_SMALL }; + // Convert bar count → chart-coordinate step (screen pixels ÷ scaling) + let step = bars * state.cell_width / state.scaling; + + let new_x = match key.as_ref() { + keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => state.translation.x + step, + keyboard::Key::Named(keyboard::key::Named::ArrowRight) => state.translation.x - step, + keyboard::Key::Named(keyboard::key::Named::PageUp) => { + state.translation.x + state.bounds.width / state.scaling + } + keyboard::Key::Named(keyboard::key::Named::PageDown) => { + state.translation.x - state.bounds.width / state.scaling + } + // Jump to the latest bar (reset pan) + keyboard::Key::Named(keyboard::key::Named::Home) => 0.0, + _ => return None, + }; + + Some(Message::Translated(Vector::new(new_x, state.translation.y))) +} diff --git a/src/main.rs b/src/main.rs index d5acdaa5..9a0bcd00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -688,13 +688,28 @@ impl Flowsurface { let tick = iced::time::every(std::time::Duration::from_millis(100)).map(Message::Tick); let hotkeys = keyboard::listen().filter_map(|event| { - let keyboard::Event::KeyPressed { key, .. } = event else { + let keyboard::Event::KeyPressed { key, .. } = &event else { return None; }; - match key { - keyboard::Key::Named(keyboard::key::Named::Escape) => Some(Message::GoBack), - _ => None, + if matches!(key, keyboard::Key::Named(keyboard::key::Named::Escape)) { + return Some(Message::GoBack); } + if matches!( + key, + keyboard::Key::Named( + keyboard::key::Named::ArrowLeft + | keyboard::key::Named::ArrowRight + | keyboard::key::Named::PageUp + | keyboard::key::Named::PageDown + | keyboard::key::Named::Home, + ) + ) { + return Some(Message::Dashboard { + layout_id: None, + event: dashboard::Message::ChartKeyNav(event), + }); + } + None }); Subscription::batch(vec![ diff --git a/src/screen/dashboard.rs b/src/screen/dashboard.rs index db08df98..b518b7be 100644 --- a/src/screen/dashboard.rs +++ b/src/screen/dashboard.rs @@ -28,7 +28,7 @@ use exchange::{ }; use iced::{ - Element, Length, Subscription, Task, Vector, + Element, Length, Subscription, Task, Vector, keyboard, task::{Straw, sipper}, widget::{ PaneGrid, center, container, @@ -52,6 +52,8 @@ pub enum Message { data: FetchedData, }, ResolveStreams(uuid::Uuid, Vec), + /// Pan the focused chart via keyboard (arrow keys, PageUp/Down, Home). + ChartKeyNav(keyboard::Event), } pub struct Dashboard { @@ -408,6 +410,13 @@ impl Dashboard { Message::Notification(toast) => { return (Task::none(), Some(Event::Notification(toast))); } + Message::ChartKeyNav(event) => { + if let Some((window, pane)) = self.focus + && let Some(state) = self.get_mut_pane(main_window.id, window, pane) + { + state.apply_keyboard_nav(&event); + } + } } (Task::none(), None) diff --git a/src/screen/dashboard/pane.rs b/src/screen/dashboard/pane.rs index 9b30e69c..d7e11152 100644 --- a/src/screen/dashboard/pane.rs +++ b/src/screen/dashboard/pane.rs @@ -1,5 +1,5 @@ use crate::{ - chart::{self, comparison::ComparisonChart, heatmap::HeatmapChart, kline::KlineChart}, + chart::{self, Chart, comparison::ComparisonChart, heatmap::HeatmapChart, kline::KlineChart}, modal::{ self, ModifierKind, pane::{ @@ -925,6 +925,28 @@ impl State { }) } + /// Pan the chart in this pane in response to a keyboard navigation event. + pub fn apply_keyboard_nav(&mut self, event: &iced::keyboard::Event) { + let msg = match &self.content { + Content::Kline { chart: Some(c), .. } => c.keyboard_nav_msg(event), + Content::Heatmap { chart: Some(c), .. } => c.keyboard_nav_msg(event), + _ => None, + }; + if let Some(msg) = msg { + match &mut self.content { + Content::Kline { chart: Some(c), .. } => { + super::chart::update(c, &msg); + let _ = c.invalidate(None); + } + Content::Heatmap { chart: Some(c), .. } => { + super::chart::update(c, &msg); + let _ = c.invalidate(None); + } + _ => {} + } + } + } + pub fn update(&mut self, msg: Event) -> Option { match msg { Event::ShowModal(requested_modal) => {