diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..51596bd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[workspace] +resolver = "2" +members = ["cli", "tui", "daemon", "crates/*"] + +[workspace.dependencies] +anyhow = "1.0.79" +tracing = "0.1.41" +tracing-subscriber = "0.3.19" +clap = { version = "4.4.18", features = ["derive"] } +paste = "1.0.15" +gsf-core = { path = "./crates/core" } diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..bbc232f --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,23 @@ +# notice and attribution + +This repository includes substantial code and project structure derived from: + +- **maccel**: https://github.com/Gnarus-G/maccel +- Copyright (C) maccel contributors +- Licensed under GNU GPL v2 or later + +## what is reused + +- Workspace/crate layout and CLI/TUI structure +- Core parameter model and sensitivity curve plumbing +- Build and packaging scaffolding imported from upstream + +## what is being changed here + +- Target behavior shifted from pointer acceleration kernel module control to host-side scroll acceleration +- Added user-space scroll daemon prototype (`daemon/`) +- Replaced sysfs kernel-parameter persistence with local config persistence for host-side workflow + +## licensing intent + +This fork/reimplementation remains under **GPL-2.0-or-later** and keeps proper attribution to upstream maccel. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2181344 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# gotta-scroll-fast + +Host-side **scroll acceleration** toolkit for Linux/Wayland. + +This project is a fork/reimplementation inspired by [maccel](https://github.com/Gnarus-G/maccel), but it is a **separate tool** focused on scroll acceleration. + +## credit and licensing + +- Original inspiration/source: https://github.com/Gnarus-G/maccel +- Attribution details: `NOTICE.md` +- License: **GPL-2.0-or-later** (`LICENSE`) + +## components + +- `gsf` - parameter CLI + TUI +- `gsfd` - evdev -> uinput scroll acceleration daemon + - applies acceleration curves to wheel/hwheel (including hi-res wheel events) + - suppresses low-res wheel duplicates when hi-res events are present in the same frame + +## development + +```bash +nix develop +cargo build +``` + +## quick start (functional) + +1) List devices: + +```bash +cargo run -p gsf-daemon -- --list-devices +``` + +2) Pick your trackball device and run dry-run first: + +```bash +cargo run -p gsf-daemon -- --device /dev/input/eventX --dry-run +``` + +3) Tune parameters (separate terminal): + +```bash +cargo run -p gsf-cli -- set mode natural +cargo run -p gsf-cli -- set param sens-mult 1.0 +cargo run -p gsf-cli -- set param decay-rate 0.1 +cargo run -p gsf-cli -- set param limit 1.5 +``` + +or launch TUI: + +```bash +cargo run -p gsf-cli -- tui +``` + +4) Run live daemon: + +```bash +cargo run -p gsf-daemon -- --device /dev/input/eventX +``` + +Tip: auto-pick by name substring: + +```bash +cargo run -p gsf-daemon -- --match-name ploopy +``` + +## config + state paths + +Default config path: +- `~/.config/gsf/config.json` +- override with `GSF_CONFIG_PATH` + +Default speed stats path: +- `$XDG_RUNTIME_DIR/gsf-speed.txt` (if available) +- fallback `~/.local/state/gsf/speed.txt` +- override with `GSF_STATS_PATH` + +## runtime requirements + +- Linux with evdev/uinput +- `/dev/uinput` available +- permissions to read source event device and write uinput + +For NixOS notes, see `DEV_SETUP.md`. + +## systemd user service + +A starting unit file exists at: + +- `packaging/systemd/user/gsfd.service` + +Copy to `~/.config/systemd/user/gsfd.service`, adjust device selection args, then: + +```bash +systemctl --user daemon-reload +systemctl --user enable --now gsfd +``` diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..bdb1c3d --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "gsf-cli" +version = "0.6.0" +edition = "2021" + +[[bin]] +name = "gsf" +path = "src/main.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = { workspace = true } +clap_complete = "4.4.9" +clap = { workspace = true } +gsf-core = { path = "../crates/core/", features = ["clap"] } +gsf-tui = { path = "./../tui/" } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[build-dependencies] +cc = "1.2.3" diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..4e7c747 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,198 @@ +use anyhow::Context; +use clap::{CommandFactory, Parser}; +use gsf_core::{ + fixedptc::Fpt, + persist::{ParamStore, SysFsStore}, + subcommads::*, + AccelMode, NoAccelParamArgs, Param, ALL_COMMON_PARAMS, ALL_LINEAR_PARAMS, ALL_NATURAL_PARAMS, + ALL_SYNCHRONOUS_PARAMS, +}; +use gsf_tui::run_tui; + +#[derive(Parser)] +#[clap(author, about, version)] +/// CLI to control acceleration parameters (used by gotta-scroll-fast daemon). +struct Cli { + #[clap(subcommand)] + command: Option, +} + +#[derive(clap::Subcommand, Default)] +enum CLiCommands { + /// Open the Terminal UI to manage the parameters + /// and see a graph of the sensitivity + #[default] + Tui, + /// Set the value for a parameter + Set { + #[clap(subcommand)] + command: CliSubcommandSetParams, + }, + /// Get parameter values + Get { + #[clap(subcommand)] + command: CliSubcommandGetParams, + }, + /// Generate a completions file for a specified shell + Completion { + // The shell for which to generate completions + shell: clap_complete::Shell, + }, + + #[cfg(debug_assertions)] + Debug { + #[clap(subcommand)] + command: DebugCommands, + }, +} + +#[cfg(debug_assertions)] +#[derive(Debug, clap::Subcommand)] +enum DebugCommands { + Print { nums: Vec }, +} + +fn main() -> anyhow::Result<()> { + let args = Cli::parse(); + + // tracing_subscriber::fmt() + // .with_max_level(Level::DEBUG) + // .with_writer(File::create("./gsf.log")?) + // .init(); + + let mut param_store = SysFsStore; + + match args.command.unwrap_or_default() { + CLiCommands::Set { command } => match command { + CliSubcommandSetParams::Param { name, value } => param_store.set(name, value)?, + CliSubcommandSetParams::All { command } => match command { + SetParamByModesSubcommands::Linear(param_args) => { + param_store.set_all_linear(param_args)? + } + SetParamByModesSubcommands::Natural(param_args) => { + param_store.set_all_natural(param_args)? + } + SetParamByModesSubcommands::Common(param_args) => { + param_store.set_all_common(param_args)? + } + SetParamByModesSubcommands::Synchronous(param_args) => { + param_store.set_all_synchronous(param_args)? + } + SetParamByModesSubcommands::NoAccel(NoAccelParamArgs {}) => { + eprintln!( + "NOTE: There are no parameters specific here except for the common ones." + ); + eprintln!(); + } + }, + CliSubcommandSetParams::Mode { mode } => SysFsStore.set_current_accel_mode(mode)?, + }, + CLiCommands::Get { command } => match command { + CliSubcommandGetParams::Param { name } => { + let value = param_store.get(name)?; + let string_value: &str = (&value).try_into()?; + println!("{}", string_value); + } + CliSubcommandGetParams::All { + oneline, + quiet, + command, + } => match command { + GetParamsByModesSubcommands::Linear => { + print_all_params(ALL_LINEAR_PARAMS.iter(), oneline, quiet)?; + } + GetParamsByModesSubcommands::Natural => { + print_all_params(ALL_NATURAL_PARAMS.iter(), oneline, quiet)?; + } + GetParamsByModesSubcommands::Common => { + print_all_params(ALL_COMMON_PARAMS.iter(), oneline, quiet)?; + } + GetParamsByModesSubcommands::Synchronous => { + print_all_params(ALL_SYNCHRONOUS_PARAMS.iter(), oneline, quiet)?; + } + GetParamsByModesSubcommands::NoAccel => { + eprintln!( + "NOTE: There are no parameters specific here except for the common ones." + ); + eprintln!(); + print_all_params(ALL_COMMON_PARAMS.iter(), oneline, quiet)?; + } + }, + CliSubcommandGetParams::Mode => { + let mode = SysFsStore.get_current_accel_mode()?; + println!("{}\n", mode.as_title()); + match mode { + AccelMode::Linear => { + print_all_params(ALL_LINEAR_PARAMS.iter(), false, false)?; + } + AccelMode::Natural => { + print_all_params(ALL_NATURAL_PARAMS.iter(), false, false)?; + } + AccelMode::Synchronous => { + print_all_params(ALL_SYNCHRONOUS_PARAMS.iter(), false, false)?; + } + AccelMode::NoAccel => { + eprintln!( + "NOTE: There are no parameters specific here except for the common ones." + ); + eprintln!(); + print_all_params(ALL_COMMON_PARAMS.iter(), false, false)?; + } + } + } + }, + CLiCommands::Tui => run_tui()?, + CLiCommands::Completion { shell } => { + clap_complete::generate(shell, &mut Cli::command(), "gsf", &mut std::io::stdout()) + } + #[cfg(debug_assertions)] + CLiCommands::Debug { command } => match command { + DebugCommands::Print { nums } => { + if nums.is_empty() { + for n in std::io::stdin() + .lines() + .map_while(Result::ok) + .flat_map(|s| s.parse::()) + { + println!("{}", Fpt::from(n).0); + } + } + + for n in nums { + println!("{}", Fpt::from(n).0); + } + } + }, + } + + Ok(()) +} + +fn print_all_params<'p>( + params: impl Iterator, + oneline: bool, + quiet: bool, +) -> anyhow::Result<()> { + let delimiter = if oneline { " " } else { "\n" }; + + let params = params + .map(|&p| { + SysFsStore.get(p).and_then(|_p: Fpt| { + let value: &str = (&_p).try_into()?; + Ok((p.display_name(), value.to_string())) + }) + }) + .collect::>>() + .context("failed to get all parameters")?; + + for (name, value) in params { + if !quiet { + print!("{}: \t", name); + } + print!("{}", value); + + print!("{}", delimiter); + } + + Ok(()) +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..88debaf --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "gsf-core" +version = "0.0.0" +edition = "2024" + +[dependencies] +anyhow.workspace = true +clap = { workspace = true, optional = true } +paste = { workspace = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[features] +dbg = [] +long_bit_32 = [] +clap = ["dep:clap"] + +[build-dependencies] +cc = "1.2.3" diff --git a/crates/core/src/context.rs b/crates/core/src/context.rs new file mode 100644 index 0000000..3854df9 --- /dev/null +++ b/crates/core/src/context.rs @@ -0,0 +1,150 @@ +use std::{ + cell::{Ref, RefCell, RefMut}, + ops::Deref, + rc::Rc, +}; + +use anyhow::Context; + +use crate::{ + AccelMode, + libmaccel::fixedptc::Fpt, + params::{AllParamArgs, Param}, + persist::ParamStore, +}; + +#[derive(Debug, Clone, Copy)] +pub struct Parameter { + pub tag: Param, + pub value: Fpt, +} + +impl Parameter { + pub fn new(param: Param, value: Fpt) -> Self { + Self { tag: param, value } + } +} + +#[derive(Debug)] +pub struct TuiContext { + pub current_mode: AccelMode, + parameters: Vec, + parameter_store: PS, +} + +impl TuiContext { + pub fn init(parameter_store: PS, parameters: &[Param]) -> anyhow::Result { + let s = Self { + current_mode: parameter_store + .get_current_accel_mode() + .context("failed to get the current acceleration mode")?, + parameters: parameters + .iter() + .map(|&p| { + let value = parameter_store.get(p)?; + Ok(Parameter::new(p, value)) + }) + .collect::>>() + .context("failed to get a necessary parameter")?, + parameter_store, + }; + Ok(s) + } + + pub fn parameter(&self, param: Param) -> Option<&Parameter> { + self.parameters.iter().find(|p| p.tag == param) + } + + pub fn update_param_value(&mut self, param_id: Param, value: f64) -> anyhow::Result<()> { + let param = self + .parameters + .iter_mut() + .find(|p| p.tag == param_id) + .context("Unknown parameter, cannot update")?; + + self.parameter_store.set(param.tag, value)?; + param.value = value.into(); + Ok(()) + } + + pub fn reset_current_parameters(&mut self) { + for p in self.parameters.iter_mut() { + p.value = self + .parameter_store + .get(p.tag) + .expect("failed to read and initialize a parameter's value"); + } + } + + pub fn params_snapshot(&self) -> AllParamArgs { + macro_rules! get { + ($param_tag:tt) => {{ + use $crate::params::Param; + let x = self + .parameter(Param::$param_tag) + .expect(concat!("failed to get param ", stringify!($param_tag))) + .value; + x + }}; + } + + AllParamArgs { + sens_mult: get!(SensMult), + yx_ratio: get!(YxRatio), + input_dpi: get!(InputDpi), + accel: get!(Accel), + offset_linear: get!(OffsetLinear), + offset_natural: get!(OffsetNatural), + output_cap: get!(OutputCap), + decay_rate: get!(DecayRate), + limit: get!(Limit), + gamma: get!(Gamma), + smooth: get!(Smooth), + motivity: get!(Motivity), + sync_speed: get!(SyncSpeed), + angle_rotation: get!(AngleRotation), + } + } +} + +#[macro_export] +macro_rules! get_param_value_from_ctx { + ($ctx:expr, $param_tag:tt) => {{ + use gsf_core::Param; + let x = $ctx + .get() + .parameter(Param::$param_tag) + .expect(concat!("failed to get param ", stringify!($param_tag))) + .value; + x + }}; +} + +#[derive(Debug)] +pub struct ContextRef { + inner: Rc>>, +} + +impl Clone for ContextRef { + fn clone(&self) -> Self { + Self { + inner: Rc::clone(&self.inner), + } + } +} + +impl ContextRef { + pub fn new(value: TuiContext) -> Self { + Self { + inner: Rc::new(RefCell::new(value)), + } + } + + pub fn get(&self) -> Ref<'_, TuiContext> { + self.inner.borrow() + } + + pub fn get_mut(&mut self) -> RefMut<'_, TuiContext> { + self.inner.deref().borrow_mut() + } +} diff --git a/tui/Cargo.toml b/tui/Cargo.toml new file mode 100644 index 0000000..89136c3 --- /dev/null +++ b/tui/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "gsf-tui" +version = "0.0.0" +edition = "2024" + +[dependencies] +ratatui = "0.29.0" +crossterm = "0.28.1" +tui-input = "*" +anyhow = { workspace = true } +tracing = { workspace = true } +gsf-core = { workspace = true } diff --git a/tui/src/screen.rs b/tui/src/screen.rs new file mode 100644 index 0000000..727bc72 --- /dev/null +++ b/tui/src/screen.rs @@ -0,0 +1,433 @@ +use std::cell::Cell; + +use std::fmt::Debug; + +use crossterm::event; +use gsf_core::persist::ParamStore; +use ratatui::layout::Rect; +use ratatui::{prelude::*, widgets::*}; + +use crate::action::{Action, Actions}; +use crate::component::TuiComponent; +use crate::param_input::ParameterInput; +use crate::utils::CyclingIdx; +use crate::widgets::scrollbar::CustomScrollbar; +use gsf_core::AccelMode; + +use super::param_input::InputMode; + +const PARAM_HEIGHT: u16 = 5; + +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub enum HelpTextMode { + EditMode, + #[default] + NormalMode, +} + +pub struct Screen { + pub accel_mode: AccelMode, + param_idx: CyclingIdx, + parameters: Vec>, + preview_slot: Box, + scroll_offset: usize, + visible_count: Cell, +} + +impl Screen { + pub fn new( + mode: AccelMode, + parameters: Vec>, + preview: Box, + ) -> Self { + let mut s = Self { + param_idx: CyclingIdx::new(parameters.len()), + accel_mode: mode, + parameters, + preview_slot: preview, + scroll_offset: 0, + visible_count: Cell::new(0), + }; + + s.parameters[0].is_selected = true; + + s + } + + fn selected_parameter_index(&self) -> usize { + self.param_idx.current() + } + + fn scroll_to_selected(&mut self) { + let visible = self.visible_count.get().max(1); + let selected = self.selected_parameter_index(); + + if selected < self.scroll_offset { + self.scroll_offset = selected; + } else if visible > 0 && selected >= self.scroll_offset + visible { + self.scroll_offset = selected - visible + 1; + } + } + + pub fn is_in_editing_mode(&self) -> bool { + self.parameters + .iter() + .any(|p| p.input_mode == InputMode::Editing) + } + + fn help_text_mode(&self) -> HelpTextMode { + if self.is_in_editing_mode() { + HelpTextMode::EditMode + } else { + HelpTextMode::NormalMode + } + } +} + +impl TuiComponent for Screen { + fn handle_key_event(&mut self, event: &event::KeyEvent, actions: &mut Actions) { + let selected_param_idx = self.selected_parameter_index(); + for (idx, param) in self.parameters.iter_mut().enumerate() { + param.is_selected = selected_param_idx == idx; + param.handle_key_event(event, actions); + } + self.preview_slot.handle_key_event(event, actions); + } + + fn handle_mouse_event(&mut self, event: &crossterm::event::MouseEvent, actions: &mut Actions) { + use crossterm::event::MouseEventKind; + match event.kind { + MouseEventKind::ScrollDown => actions.push(Action::ScrollDown), + MouseEventKind::ScrollUp => actions.push(Action::ScrollUp), + _ => {} + } + } + + fn update(&mut self, action: &Action) { + match action { + Action::SelectNextInput => { + self.param_idx.forward(); + self.scroll_to_selected(); + } + Action::SelectPreviousInput => { + self.param_idx.back(); + self.scroll_to_selected(); + } + Action::ScrollDown => { + let max_scroll = self.parameters.len().saturating_sub(1); + if self.scroll_offset < max_scroll { + self.scroll_offset += 1; + } + } + Action::ScrollUp => { + if self.scroll_offset > 0 { + self.scroll_offset -= 1; + } + } + Action::ScrollPageDown => { + let visible = self.visible_count.get().max(1); + let max_scroll = self.parameters.len().saturating_sub(visible); + self.scroll_offset = (self.scroll_offset + visible).min(max_scroll); + } + Action::ScrollPageUp => { + let visible = self.visible_count.get().max(1); + self.scroll_offset = self.scroll_offset.saturating_sub(visible); + } + _ => {} + } + + let selected_param_idx = self.selected_parameter_index(); + // debug!( + // "selected parameter {} on current screen, tick = {}", + // selected_param_idx, self.param_idx + // ); + for (idx, param) in self.parameters.iter_mut().enumerate() { + param.is_selected = selected_param_idx == idx; + param.update(action); + } + self.preview_slot.update(action); + } + + fn draw(&self, frame: &mut ratatui::Frame, area: Rect) { + let root_layout = Layout::new( + Direction::Vertical, + [ + Constraint::Length(1), + Constraint::Min(5), + Constraint::Length(3), + ], + ) + .horizontal_margin(2) + .split(area); + + frame.render_widget( + Paragraph::new(Text::from(vec![Line::from(vec![ + "gsf".blue(), + " (press 'q' to quit)".into(), + ])])), + root_layout[0], + ); + + let main_layout = Layout::new( + Direction::Horizontal, + [Constraint::Percentage(25), Constraint::Percentage(75)], + ) + .split(root_layout[1]); + + let selected_param = &self.parameters[self.selected_parameter_index()]; + let description = selected_param.param().description(); + + // Split the bottom panel horizontally - description on left, keybind help on right + let bottom_layout = Layout::new( + Direction::Horizontal, + [Constraint::Percentage(60), Constraint::Percentage(40)], + ) + .split(root_layout[2]); + + // Render description on the left + let description_text = Paragraph::new(description.italic()) + .wrap(ratatui::widgets::Wrap { trim: true }) + .block(Block::new().borders(Borders::ALL).title("Description")); + frame.render_widget(description_text, bottom_layout[0]); + + // Render keybind help on the right + let help = match self.help_text_mode() { + HelpTextMode::EditMode => { + let commands: Vec = [ + ("Enter", "commit"), + ("Esc", "cancel"), + ("Left/Right", "change mode"), + ] + .into_iter() + .flat_map(|(cmd, desc)| vec![cmd.bold(), ": ".into(), desc.into(), " ".into()]) + .collect(); + Text::from(Line::from(commands)) + } + HelpTextMode::NormalMode => { + let commands: Vec = [ + ("Tab/Up/Down", "navigate"), + ("i/Enter", "edit"), + ("Left/Right", "mode"), + ] + .into_iter() + .flat_map(|(cmd, desc)| vec![cmd.bold(), " ".into(), desc.into(), " ".into()]) + .collect(); + Text::from(Line::from(commands)) + } + }; + + frame.render_widget( + Paragraph::new(help).block(Block::new().borders(Borders::ALL)), + bottom_layout[1], + ); + + // Done with main layout, now to layout the parameters inputs + + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .title(self.accel_mode.as_title()) + .border_style(Style::new().blue().bold()), + main_layout[0], + ); + + let params_area = main_layout[0].inner(Margin { + vertical: 1, + horizontal: 1, + }); + + let available_height = params_area.height as usize; + let param_count = self.parameters.len(); + let params_fit = param_count * (PARAM_HEIGHT as usize) <= available_height; + + if params_fit || param_count == 0 { + let constraints: Vec<_> = self + .parameters + .iter() + .map(|_| Constraint::Length(PARAM_HEIGHT)) + .collect(); + + let params_layout = Layout::new(Direction::Vertical, constraints) + .margin(1) + .split(params_area); + + for (idx, param) in self.parameters.iter().enumerate() { + param.draw(frame, params_layout[idx]); + } + } else { + let margin_height = 2; + let content_height = available_height.saturating_sub(margin_height); + let visible = content_height / (PARAM_HEIGHT as usize); + self.visible_count.set(visible); + let start = self + .scroll_offset + .min(param_count.saturating_sub(visible.max(1))); + let end = (start + visible.max(1)).min(param_count); + + let constraints: Vec<_> = self.parameters[start..end] + .iter() + .map(|_| Constraint::Length(PARAM_HEIGHT)) + .collect(); + + let params_layout = Layout::new(Direction::Vertical, constraints) + .margin(1) + .split(params_area); + + for (i, param) in self.parameters[start..end].iter().enumerate() { + param.draw(frame, params_layout[i]); + } + + // Calculate scrollbar area based on rendered params + let first_rect = params_layout.first().copied().unwrap_or(params_area); + let last_rect = params_layout.last().copied().unwrap_or(params_area); + let visible_items = end - start; + + // Area includes arrows above and below the content + let scrollbar_area = Rect { + x: params_area.x + params_area.width.saturating_sub(1), + y: first_rect.y.saturating_sub(1), + width: 1, + height: (last_rect.y + last_rect.height - first_rect.y + 1) + 2, + }; + + let scrollbar = CustomScrollbar::new(param_count, visible_items, start); + frame.render_widget(scrollbar, scrollbar_area); + } + // Done with parameter inputs, now on to the graph + + self.preview_slot.draw(frame, main_layout[1]); + } +} + +#[cfg(test)] +mod test { + use gsf_core::{AccelMode, persist::ParamStore}; + + use crossterm::event::KeyCode; + + use crate::{ + action::Action, component::TuiComponent, param_input::ParameterInput, screen::HelpTextMode, + utils::test_utils::new_context, + }; + + use super::Screen; + + impl Screen { + #[cfg(test)] + pub fn with_no_preview(mode: AccelMode, parameters: Vec>) -> Self { + use crate::component::NoopComponent; + Self::new(mode, parameters, Box::new(NoopComponent)) + } + } + + #[test] + fn can_select_input() { + let (context, parameters) = new_context(); + let inputs = parameters + .iter() + .map(|p| ParameterInput::new(p, context.clone())) + .collect(); + + let mut screen = Screen::with_no_preview(AccelMode::Linear, inputs); + assert_eq!(screen.selected_parameter_index(), 0); + assert!(screen.parameters[0].is_selected); + + let mut actions = vec![]; + + screen.handle_event( + &crate::event::Event::Key(KeyCode::Down.into()), + &mut actions, + ); + + assert_eq!(actions, vec![Action::SelectNextInput]); + assert_eq!(screen.selected_parameter_index(), 0); + assert!(screen.parameters[0].is_selected); + + screen.update(&actions[0]); + assert_eq!(screen.selected_parameter_index(), 1); + assert!(screen.parameters[1].is_selected); + + actions.clear(); + + screen.handle_event(&crate::event::Event::Key(KeyCode::Up.into()), &mut actions); + + assert_eq!(actions, vec![Action::SelectPreviousInput]); + + screen.update(&actions[0]); + assert_eq!(screen.selected_parameter_index(), 0); + assert!(screen.parameters[0].is_selected); + } + + #[test] + fn can_show_edit_mode_help_text() { + let (context, parameters) = new_context(); + + let inputs = parameters + .iter() + .map(|p| ParameterInput::new(p, context.clone())) + .collect(); + + let mut screen = Screen::with_no_preview(AccelMode::Linear, inputs); + + assert_eq!(screen.help_text_mode(), HelpTextMode::NormalMode); + + let mut actions = vec![]; + screen.handle_event( + &crate::event::Event::Key(KeyCode::Char('i').into()), + &mut actions, + ); + + assert_eq!( + actions, + vec![Action::Input(crate::action::InputAction::Focus)] + ); + + screen.update(&actions[0]); + assert_eq!(screen.help_text_mode(), HelpTextMode::EditMode); + } + + #[test] + fn can_edit_input() { + let (context, parameters) = new_context(); + + let inputs = parameters + .iter() + .map(|p| ParameterInput::new(p, context.clone())) + .collect(); + + let mut screen = Screen::with_no_preview(AccelMode::Linear, inputs); + let mut actions = vec![]; + + screen.handle_event( + &crate::event::Event::Key(KeyCode::Char('i').into()), + &mut actions, + ); + assert_eq!( + actions, + vec![Action::Input(crate::action::InputAction::Focus)] + ); + screen.update(&actions[0]); + actions.clear(); + + screen.handle_event( + &crate::event::Event::Key(KeyCode::Char('1').into()), + &mut actions, + ); + assert_eq!(actions, vec![]); + + assert_eq!( + screen.parameters[screen.selected_parameter_index()].value(), + "11" // 0's are to be trimmed from the right + ); + + screen.handle_event( + &crate::event::Event::Key(KeyCode::Char('9').into()), + &mut actions, + ); + + assert_eq!( + screen.parameters[screen.selected_parameter_index()].value(), + "119" + ); + } +}