rename user-facing commands and crates to gsf

This commit is contained in:
2026-03-24 12:10:31 +00:00
parent 6ef326188a
commit 3684b24c50
9 changed files with 966 additions and 0 deletions
+11
View File
@@ -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" }
+23
View File
@@ -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.
+98
View File
@@ -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
```
+22
View File
@@ -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"
+198
View File
@@ -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<CLiCommands>,
}
#[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<f64> },
}
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::<f64>())
{
println!("{}", Fpt::from(n).0);
}
}
for n in nums {
println!("{}", Fpt::from(n).0);
}
}
},
}
Ok(())
}
fn print_all_params<'p>(
params: impl Iterator<Item = &'p Param>,
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::<anyhow::Result<Vec<_>>>()
.context("failed to get all parameters")?;
for (name, value) in params {
if !quiet {
print!("{}: \t", name);
}
print!("{}", value);
print!("{}", delimiter);
}
Ok(())
}
+19
View File
@@ -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"
+150
View File
@@ -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<PS: ParamStore> {
pub current_mode: AccelMode,
parameters: Vec<Parameter>,
parameter_store: PS,
}
impl<PS: ParamStore> TuiContext<PS> {
pub fn init(parameter_store: PS, parameters: &[Param]) -> anyhow::Result<Self> {
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::<anyhow::Result<Vec<_>>>()
.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<PS: ParamStore> {
inner: Rc<RefCell<TuiContext<PS>>>,
}
impl<PS: ParamStore> Clone for ContextRef<PS> {
fn clone(&self) -> Self {
Self {
inner: Rc::clone(&self.inner),
}
}
}
impl<PS: ParamStore> ContextRef<PS> {
pub fn new(value: TuiContext<PS>) -> Self {
Self {
inner: Rc::new(RefCell::new(value)),
}
}
pub fn get(&self) -> Ref<'_, TuiContext<PS>> {
self.inner.borrow()
}
pub fn get_mut(&mut self) -> RefMut<'_, TuiContext<PS>> {
self.inner.deref().borrow_mut()
}
}
+12
View File
@@ -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 }
+433
View File
@@ -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<PS: ParamStore> {
pub accel_mode: AccelMode,
param_idx: CyclingIdx,
parameters: Vec<ParameterInput<PS>>,
preview_slot: Box<dyn TuiComponent>,
scroll_offset: usize,
visible_count: Cell<usize>,
}
impl<PS: ParamStore> Screen<PS> {
pub fn new(
mode: AccelMode,
parameters: Vec<ParameterInput<PS>>,
preview: Box<dyn TuiComponent>,
) -> 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<PS: ParamStore + Debug> TuiComponent for Screen<PS> {
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<Span> = [
("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<Span> = [
("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<PS: ParamStore> Screen<PS> {
#[cfg(test)]
pub fn with_no_preview(mode: AccelMode, parameters: Vec<ParameterInput<PS>>) -> 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"
);
}
}