diff --git a/Cargo.toml b/Cargo.toml index 7010736..5110242 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter", "serde"] } clap = { version = "4.4.5", features = ["derive", "cargo", "wrap_help", "unicode", "string", "unstable-styles"] } # Config +bounded-integer = { version = "0.5.7", features = ["types"] } config = "0.13.3" derive_deref = "1.1.1" directories = "5.0.1" @@ -31,6 +32,7 @@ hostname = "0.3.1" json5 = "0.4.1" serde = { version = "1.0.188", features = ["derive"] } serde_json = "1.0.107" +serde_path_to_error = "0.1.14" # TODO: switch to toml_edit to preserve (and write) doc comments # Error handling diff --git a/src/components/backlog.rs b/src/components/backlog.rs index 495d0b7..6c3cd95 100644 --- a/src/components/backlog.rs +++ b/src/components/backlog.rs @@ -22,6 +22,7 @@ use enum_dispatch::enum_dispatch; use ratatui::{prelude::*, widgets::*}; use crate::components::Action; +use crate::config::{Config, ScrollAmount}; use crate::widgets::prerender::{PrerenderInner, PrerenderValue}; use crate::widgets::{ BacklogItemWidget, BottomAlignedParagraph, Divider, EmptyWidget, OverlappableWidget, @@ -38,8 +39,11 @@ struct ScrollPosition { relative_scroll: i64, } -#[derive(Default, Debug)] +#[derive(Debug)] pub struct Backlog { + config: Config, + /// Used to compute scroll on PageUp/PageDown when configured to a percentage. + last_height: u16, scroll_position: Option, /// Fallback used if the scroll_position is missing or unusable absolute_scroll: u64, @@ -58,16 +62,22 @@ impl Component for Backlog { modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, - } => { - self.scroll_up(20); // TODO: use the component height + } => match self.config.keyboard.scroll_page { + ScrollAmount::Absolute(n) => self.scroll_up(n.into()), + ScrollAmount::Percentage(n) => { + self.scroll_up(u64::from(n) * u64::from(self.last_height) / 100) + }, }, KeyEvent { code: KeyCode::PageDown, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, - } => { - self.scroll_down(20) // TODO: use the component height + } => match self.config.keyboard.scroll_page { + ScrollAmount::Absolute(n) => self.scroll_down(n.into()), + ScrollAmount::Percentage(n) => { + self.scroll_down(u64::from(n) * u64::from(self.last_height) / 100) + }, }, _ => {}, } @@ -96,6 +106,16 @@ impl Component for Backlog { } impl Backlog { + pub fn new(config: Config) -> Self { + Backlog { + config, + last_height: 30, // Arbitrary default, only useful when user scrolls before first render + scroll_position: None, + absolute_scroll: 0, + pending_scroll: 0, + } + } + pub fn scroll_up(&mut self, lines: u64) { self.pending_scroll = self.pending_scroll.saturating_add(lines as i64); } @@ -156,6 +176,7 @@ impl Backlog { let block = Block::new().borders(Borders::ALL); let mut text_area = block.inner(area); block.render(area, frame_buffer); + self.last_height = text_area.height; // Recompute absolute scroll position if we are not at the bottom of the backlog if self.absolute_scroll != 0 || self.pending_scroll != 0 { @@ -206,7 +227,7 @@ impl Backlog { }; let expected_height = self.get_item_height(&item, text_area.width); - if scroll.saturating_sub(expected_height) > text_area.height.into() { + if scroll.saturating_sub(expected_height) > u64::from(text_area.height) { // Paragraph is too far down, not displayed scroll -= expected_height; continue; diff --git a/src/components/home.rs b/src/components/home.rs index 5b26e83..b0849c2 100644 --- a/src/components/home.rs +++ b/src/components/home.rs @@ -43,7 +43,7 @@ impl Home { let mut self_ = Home { command_tx: None, buflist: Buflist::new(config.clone()), - backlog: Backlog::default(), + backlog: Backlog::new(config.clone()), textarea: TextArea::default(), config, }; diff --git a/src/config.rs b/src/config.rs index 231366d..a28eb96 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, fmt, path::PathBuf}; +use bounded_integer::BoundedU8; use color_eyre::eyre::Result; use config::Value; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -40,11 +41,77 @@ fn default_device_name() -> String { } } +#[derive(Clone, Debug)] +pub enum ScrollAmount { + Percentage(BoundedU8<1, 100>), + Absolute(u16), +} + +impl<'de> Deserialize<'de> for ScrollAmount { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(ScrollAmountVisitor) + } +} + +struct ScrollAmountVisitor; + +impl<'de> Visitor<'de> for ScrollAmountVisitor { + type Value = ScrollAmount; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a positive integer or a percentage") + } + + fn visit_u16(self, value: u16) -> Result + where + E: de::Error, + { + Ok(ScrollAmount::Absolute(value)) + } + + fn visit_str(self, s: &str) -> Result + where + E: de::Error, + { + let s: String = s.chars().filter(|c| !c.is_whitespace()).collect(); // strip whitespaces + match s.strip_suffix('%') { + Some(percent_str) => match percent_str.parse() { + Ok(percent) => { + if 0 < percent && percent <= 100 { + Ok(ScrollAmount::Percentage(percent)) + } else { + Err(E::invalid_value( + de::Unexpected::Unsigned(percent.into()), + &"integer between 1 and 100 (inclusive)", + )) + } + }, + Err(_) => Err(E::invalid_value( + de::Unexpected::Other(percent_str), + &"integer between 1 and 100 (inclusive)", + )), + }, + None => Err(E::invalid_value( + de::Unexpected::Str(&s), + &"integer or quoted percentage (ending with '%')", + )), + } + } +} + #[derive(Clone, Debug, Deserialize)] pub struct MouseConfig { pub enable: bool, } +#[derive(Clone, Debug, Deserialize)] +pub struct KeyboardConfig { + pub scroll_page: ScrollAmount, +} + #[derive(Clone, Debug, Deserialize)] pub struct BuflistLayoutConfig { pub column_width: u16, @@ -84,13 +151,14 @@ pub struct Config { pub accounts: nonempty::NonEmpty, #[serde(default)] pub keybindings: KeyBindings, + pub keyboard: KeyboardConfig, pub mouse: MouseConfig, pub style: StylesConfig, pub layout: LayoutConfig, } impl Config { - pub fn new() -> Result { + pub fn new() -> Result { let data_dir = crate::utils::get_data_dir(); let config_dir = crate::utils::get_config_dir(); let mut builder = config::Config::builder() @@ -123,7 +191,7 @@ impl Config { log::error!("No configuration file found. Application may not behave as expected"); } - builder.build()?.try_deserialize() + Ok(serde_path_to_error::deserialize(builder.build()?)?) } } diff --git a/src/default_config.toml b/src/default_config.toml index 0f0dfe0..9947270 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -6,6 +6,11 @@ "" = "/previous" "" = "/next" +[keyboard] +# How much to scroll when pressing PageUp/PageDown. This can be either a number of lines +# eg. 42) or a percentage wrapped in quotes (eg. "100%") of the screen size. +scroll_page = "50%" + [mouse] enable = true