Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod comparison;
pub mod heatmap;
pub mod indicator;
pub mod kline;
mod keyboard_nav;
mod scale;

use crate::style;
Expand Down Expand Up @@ -81,6 +82,14 @@ pub trait Chart: PlotConstants + canvas::Program<Message> {
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<Message> {
keyboard_nav::handle(event, self.state())
}
}

fn canvas_interaction<T: Chart>(
Expand Down Expand Up @@ -253,18 +262,22 @@ fn canvas_interaction<T: Chart>(
}
}
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())
}
keyboard::Key::Named(keyboard::key::Named::Escape) => {
*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,
}
Expand Down
61 changes: 61 additions & 0 deletions src/chart/keyboard_nav.rs
Original file line number Diff line number Diff line change
@@ -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<Message> {
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)))
}
23 changes: 19 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![
Expand Down
11 changes: 10 additions & 1 deletion src/screen/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -52,6 +52,8 @@ pub enum Message {
data: FetchedData,
},
ResolveStreams(uuid::Uuid, Vec<PersistStreamKind>),
/// Pan the focused chart via keyboard (arrow keys, PageUp/Down, Home).
ChartKeyNav(keyboard::Event),
}

pub struct Dashboard {
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 23 additions & 1 deletion src/screen/dashboard/pane.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -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<Effect> {
match msg {
Event::ShowModal(requested_modal) => {
Expand Down