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