From 859c5c8cc9a73a28d4c999b5022c7e6ae7535325 Mon Sep 17 00:00:00 2001 From: terrylica Date: Fri, 20 Feb 2026 20:49:21 -0800 Subject: [PATCH] feat(chart): keyboard navigation for chart panning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds keyboard-driven chart panning so users can navigate history without touching the mouse or trackpad. Key bindings: ←/→ scroll 10 bars left/right Shift+←/→ scroll 50 bars (fast) PageUp/Down scroll one full visible window Home jump to latest bar (reset pan) macOS equivalents: Fn+↑ = PageUp, Fn+↓ = PageDown, Fn+← = Home Navigation works whenever the app window is focused; the cursor does not need to be inside the chart canvas. Implementation -------------- All pan logic lives in a new src/chart/keyboard_nav.rs module. keyboard_nav_msg() is added as a default method on the Chart trait (using the already-required state() accessor), so every current and future chart type automatically gets keyboard navigation with no per-type boilerplate. Minimal changes to existing files: - src/chart.rs mod declaration + default trait method + delegate _ => arm in canvas_interaction; cursor_position? guard moved inside Shift arm (ruler tool needs it, nav keys do not) - src/screen/dashboard.rs ChartKeyNav message + handler - src/screen/dashboard/pane.rs apply_keyboard_nav() + Chart import - src/main.rs extend keyboard::listen() to forward nav keys app-wide (canvas delivery requires cursor focus) Tested on: Apple M3 Max, macOS 15.7.5 Sequoia (Build 24G607) --- src/chart.rs | 17 ++++++++-- src/chart/keyboard_nav.rs | 61 ++++++++++++++++++++++++++++++++++++ src/main.rs | 23 +++++++++++--- src/screen/dashboard.rs | 11 ++++++- src/screen/dashboard/pane.rs | 24 +++++++++++++- 5 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 src/chart/keyboard_nav.rs 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) => {