use crate::ChartRequest;
use crate::app::feedback::Confirm;
use crate::app::form::FormKey;
use crate::chart::chart_export::{ChartExportFormat, ChartExportRequest};
use crate::chart::chart_export_modal::{ChartExportFocus, ExportDefaults};
use crate::chart::chart_modal::{ChartFocus, Mark};
use crate::chart::chart_plot::PlotData;
use crate::export::output_file::Overwrite;
use crate::logging::LogFailure;
use crate::widgets::crosshair::{self, Move};
use crate::{App, AppEvent, Overlay, home};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
impl App {
pub(crate) fn chart_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
let out = self.chart_key_inner(event);
if self.chart.modal.focus != ChartFocus::LimitRows || self.chart.modal.plot_focus {
self.chart.modal.leave_rows();
}
out
}
fn chart_key_inner(&mut self, event: &KeyEvent) -> Option<AppEvent> {
if !event.is_press() {
return None;
}
if self.overlay == Overlay::ChartExport {
return self.chart_export_key(event);
}
if crate::app::form::picker_form_key(&mut self.chart.modal, event) {
return None;
}
if self.chart.modal.plot_focus {
let to = match event.code {
KeyCode::Left | KeyCode::Char('h') => Some(Move::Left),
KeyCode::Right | KeyCode::Char('l') => Some(Move::Right),
KeyCode::Home => Some(Move::First),
KeyCode::End => Some(Move::Last),
_ => None,
};
if let Some(to) = to {
self.move_crosshair(to);
return None;
}
match event.code {
KeyCode::Char('x') | KeyCode::Esc | KeyCode::Tab | KeyCode::BackTab => {
self.chart.modal.plot_focus = false;
return None;
}
KeyCode::Char('1'..='7' | '[' | ']' | 'g' | 'e' | 't' | '?') => {}
_ => return None,
}
}
if self.chart.modal.focus == ChartFocus::LimitRows
&& !self.chart.modal.plot_focus
&& self.chart_rows_key(event)
{
return None;
}
match crate::app::form::key(&mut self.chart.modal, event) {
FormKey::Cancel => {
self.close_overlay();
return None;
}
FormKey::Submit | FormKey::Act(_) => {
self.chart_act(self.chart.modal.focus);
return None;
}
FormKey::Step(row, delta) => {
self.chart.modal.step(row, delta);
return None;
}
FormKey::Moved | FormKey::Text(_) => return None,
FormKey::Other => {}
}
match event.code {
KeyCode::Char('x') if self.chart.modal.has_crosshair() => {
self.chart.modal.plot_focus = true;
self.move_crosshair_to(None);
}
KeyCode::Char(c @ '1'..='7') => {
let idx = c as usize - '1' as usize;
self.chart.modal.set_mark(Mark::ALL[idx]);
}
KeyCode::Char('[') => self.chart.modal.step_mark(-1),
KeyCode::Char(']') => self.chart.modal.step_mark(1),
KeyCode::Char('g') if self.chart.modal.has_grid() => {
self.chart.modal.toggle_grid();
}
KeyCode::Char('e') => {
if self.data_table_state.is_some() && self.chart.modal.can_export() {
self.open_chart_export();
}
}
KeyCode::Char('t') if self.follow_rows_waiting() => {
self.take_follow_rows(false);
self.chart.cache.clear();
}
KeyCode::Char('?') => self.open_help_overlay(),
KeyCode::Char('+') | KeyCode::Char('=') => self.chart.modal.adjust_number_row(1),
KeyCode::Char('-') => self.chart.modal.adjust_number_row(-1),
_ => {}
}
None
}
fn chart_rows_key(&mut self, event: &KeyEvent) -> bool {
let plain = !event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);
let modal = &mut self.chart.modal;
let typing = modal.typing_rows();
match event.code {
KeyCode::Char(c)
if plain
&& (c.is_ascii_digit()
|| (typing && matches!(c, 'k' | 'K' | 'm' | 'M' | ',' | '_' | '.'))) =>
{
modal.type_rows(c);
}
KeyCode::Backspace if typing => modal.backspace_rows(),
KeyCode::Esc if modal.rows_draft.is_some() => {
let pending = modal.rows_pending();
modal.discard_rows();
if !pending {
return false;
}
}
KeyCode::Enter => {
modal.commit_rows();
}
_ => return false,
}
true
}
fn chart_act(&mut self, focus: ChartFocus) {
if self.chart.modal.picker_for(focus).is_some() {
self.chart.modal.open_picker();
} else {
self.chart.modal.step(focus, 1);
}
}
fn open_chart_export(&mut self) {
let description = sentence_case(&self.chart.modal.how());
self.chart.export_modal.open(
&self.theme,
self.display.history_limit,
ExportDefaults {
description,
source: self
.info
.catalog_entry
.as_ref()
.map(|(_, entry)| entry.credit())
.unwrap_or_default(),
legend: self.chart.modal.show_legend,
mark: self.chart.modal.mark(),
y_from_zero: self.chart.modal.y_starts_at_zero,
},
);
self.open_overlay(Overlay::ChartExport);
}
fn chart_export_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
match crate::app::form::key(&mut self.chart.export_modal, event) {
FormKey::Cancel => self.close_overlay(),
FormKey::Submit => return self.submit_chart_export(),
FormKey::Step(field, delta) => self.chart.export_modal.step(field, delta),
FormKey::Text(field) => {
let size = matches!(
field,
ChartExportFocus::WidthInput | ChartExportFocus::HeightInput
);
let allowed = !size
|| match event.code {
KeyCode::Char(c) => c.is_ascii_digit(),
KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Left
| KeyCode::Right
| KeyCode::Home
| KeyCode::End => true,
_ => false,
};
if field == ChartExportFocus::PathInput {
self.chart.export_modal.error = None;
}
if allowed && let Some(input) = self.chart.export_modal.input_mut(field) {
let _ = input.handle_key(event, Some(&self.cache));
if size {
self.chart.export_modal.size_typed();
}
}
}
_ => {}
}
None
}
fn submit_chart_export(&mut self) -> Option<AppEvent> {
let modal = &self.chart.export_modal;
let path_str = modal.path_input.value().trim();
if path_str.is_empty() {
self.chart.export_modal.error = Some("Enter a file path.".to_string());
crate::app::form::Form::focus(
&mut self.chart.export_modal,
ChartExportFocus::PathInput,
);
return None;
}
let mut path = home::expand_user_path(path_str);
let format = match ChartExportFormat::from_extension(&path) {
Some(format) => format,
None => {
let format = modal.format;
let mut name = path.into_os_string();
name.push(".");
name.push(format.extension());
path = name.into();
format
}
};
let (width, height) = modal.export_dimensions();
let recipe = modal.recipe;
let options = crate::chart::chart_export::ExportOptions {
width,
height,
dpi: modal.dpi,
palette: crate::chart::chart_export::Palette::for_style(
modal.style,
&self.app_config.theme.colors,
),
legend: modal.legend,
title: modal.title_input.value().trim().to_string(),
description: modal.description_input.value().trim().to_string(),
notes: modal.notes_input.value().trim().to_string(),
source: modal.source_input.value().trim().to_string(),
byline: modal.byline_input.value().trim().to_string(),
point_opacity: modal.point_opacity,
point_size: modal.point_size,
line_width: modal.line_width,
y_from_zero: modal.y_from_zero_option(),
recipe: None,
};
self.chart
.export_modal
.path_input
.save_to_history(&self.cache)
.or_log("save the chart export path");
let path_display = path.display().to_string();
let request = ChartExportRequest {
path,
format,
options,
overwrite: Overwrite::Forbid,
recipe,
};
if request.path.exists() {
self.step_back();
self.confirmation_modal.show_destructive(
format!("File already exists:\n{path_display}\n\nOverwrite it?"),
"Overwrite",
Confirm::ChartExport(Box::new(request)),
);
return None;
}
self.step_back();
Some(AppEvent::Applied(crate::Applied::ChartExport(request)))
}
fn chart_xs(&self) -> Option<&[f64]> {
let request = ChartRequest::from_modal(&self.chart.modal)?;
match self.chart.cache.prepared(&request)? {
PlotData::Lines(xy) => Some(&xy.xs),
_ => None,
}
}
fn move_crosshair(&mut self, to: Move) {
let (Some(place), Some(xs)) = (self.chart.modal.plot, self.chart_xs()) else {
return;
};
let from = self
.chart
.modal
.cursor_x
.or_else(|| crosshair::at_column(xs, &place, middle(place.graph)));
let to = from.and_then(|from| crosshair::step(xs, &place, from, to));
if to.is_some() {
self.chart.modal.cursor_x = to;
}
}
pub(crate) fn move_crosshair_to(&mut self, column: Option<u16>) {
let (Some(place), Some(xs)) = (self.chart.modal.plot, self.chart_xs()) else {
return;
};
let at = match (column, self.chart.modal.cursor_x) {
(Some(column), _) => crosshair::at_column(xs, &place, column),
(None, Some(x)) => crosshair::nearest(xs, x),
(None, None) => crosshair::at_column(xs, &place, middle(place.graph)),
};
if at.is_some() {
self.chart.modal.cursor_x = at;
}
}
}
fn sentence_case(phrase: &str) -> String {
let mut chars = phrase.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect(),
None => String::new(),
}
}
fn middle(area: ratatui::layout::Rect) -> u16 {
area.left() + area.width / 2
}