rename user-facing commands and crates to gsf
This commit is contained in:
+11
@@ -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" }
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user