This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
use gsf_core::AccelMode;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InputAction {
|
||||
Enter,
|
||||
Focus,
|
||||
Reset,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
Tick,
|
||||
Input(InputAction),
|
||||
SelectNextInput,
|
||||
SelectPreviousInput,
|
||||
SetMode(AccelMode),
|
||||
ScrollDown,
|
||||
ScrollUp,
|
||||
ScrollPageDown,
|
||||
ScrollPageUp,
|
||||
}
|
||||
|
||||
pub type Actions = Vec<Action>;
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use gsf_core::ALL_COMMON_PARAMS;
|
||||
use gsf_core::ALL_LINEAR_PARAMS;
|
||||
use gsf_core::ALL_MODES;
|
||||
use gsf_core::ALL_NATURAL_PARAMS;
|
||||
use gsf_core::ALL_NOACCEL_PARAMS;
|
||||
use gsf_core::ALL_SYNCHRONOUS_PARAMS;
|
||||
use gsf_core::Param;
|
||||
use gsf_core::get_param_value_from_ctx;
|
||||
use gsf_core::persist::ParamStore;
|
||||
use gsf_core::persist::SysFsStore;
|
||||
use gsf_core::{ALL_PARAMS, AccelMode, ContextRef, TuiContext};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::Backend;
|
||||
use ratatui::crossterm::event::{DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEventKind};
|
||||
use std::{io, time::Instant};
|
||||
use std::{panic, time::Duration};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::action::{Action, Actions};
|
||||
use crate::component::TuiComponent;
|
||||
use crate::event::{Event, EventHandler};
|
||||
use crate::graph::SensitivityGraph;
|
||||
use crate::param_input::ParameterInput;
|
||||
use crate::screen::Screen;
|
||||
use crate::utils::CyclingIdx;
|
||||
|
||||
pub struct App {
|
||||
context: ContextRef<SysFsStore>,
|
||||
screens: Vec<Screen<SysFsStore>>,
|
||||
screen_idx: CyclingIdx,
|
||||
pub(crate) is_running: bool,
|
||||
|
||||
last_tick_at: Instant,
|
||||
}
|
||||
|
||||
pub fn collect_inputs_for_params(
|
||||
params: &[Param],
|
||||
context: ContextRef<SysFsStore>,
|
||||
) -> Vec<ParameterInput<SysFsStore>> {
|
||||
ALL_COMMON_PARAMS
|
||||
.iter()
|
||||
.chain(params)
|
||||
.filter_map(|&p| context.get().parameter(p).copied())
|
||||
.map(|param| ParameterInput::new(¶m, context.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
let context = ContextRef::new(
|
||||
TuiContext::init(SysFsStore, ALL_PARAMS).expect("Failed to initialize the Tui Context"),
|
||||
);
|
||||
|
||||
Self {
|
||||
screens: vec![
|
||||
Screen::new(
|
||||
AccelMode::Linear,
|
||||
collect_inputs_for_params(ALL_LINEAR_PARAMS, context.clone()),
|
||||
Box::new(
|
||||
SensitivityGraph::new(context.clone()).on_y_axix_bounds_update(|ctx| {
|
||||
// Appropriate dynamic bounds for the Linear sens graph
|
||||
let upper_bound = f64::from(get_param_value_from_ctx!(ctx, SensMult))
|
||||
* f64::from(get_param_value_from_ctx!(ctx, OutputCap)).max(1.0)
|
||||
* 2.0;
|
||||
|
||||
[0.0, upper_bound]
|
||||
}),
|
||||
),
|
||||
),
|
||||
Screen::new(
|
||||
AccelMode::Natural,
|
||||
collect_inputs_for_params(ALL_NATURAL_PARAMS, context.clone()),
|
||||
Box::new(
|
||||
SensitivityGraph::new(context.clone()).on_y_axix_bounds_update(|ctx| {
|
||||
// Appropriate dynamic bounds for the Natural sens graph
|
||||
let upper_bound = f64::from(get_param_value_from_ctx!(ctx, SensMult))
|
||||
* f64::from(get_param_value_from_ctx!(ctx, Limit)).max(1.0)
|
||||
* 2.0;
|
||||
|
||||
[0.0, upper_bound]
|
||||
}),
|
||||
),
|
||||
),
|
||||
Screen::new(
|
||||
AccelMode::Synchronous,
|
||||
collect_inputs_for_params(ALL_SYNCHRONOUS_PARAMS, context.clone()),
|
||||
Box::new(
|
||||
SensitivityGraph::new(context.clone()).on_y_axix_bounds_update(|ctx| {
|
||||
// Appropriate dynamic bounds for the Synchronous sens graph
|
||||
let upper_bound = f64::from(get_param_value_from_ctx!(ctx, SensMult))
|
||||
* f64::from(get_param_value_from_ctx!(ctx, Motivity)).max(1.0)
|
||||
* 2.0;
|
||||
|
||||
[0.0, upper_bound]
|
||||
}),
|
||||
),
|
||||
),
|
||||
Screen::new(
|
||||
AccelMode::NoAccel,
|
||||
collect_inputs_for_params(ALL_NOACCEL_PARAMS, context.clone()),
|
||||
Box::new(
|
||||
SensitivityGraph::new(context.clone()).on_y_axix_bounds_update(|ctx| {
|
||||
// Appropriate dynamic bounds for the NoAccel sens graph
|
||||
let upper_bound =
|
||||
f64::from(get_param_value_from_ctx!(ctx, SensMult)) * 2.0; // No other factor, sens is 1.0
|
||||
|
||||
[0.0, upper_bound.max(1.0)] // Ensure at least a small visible range
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
screen_idx: CyclingIdx::new_starting_at(
|
||||
ALL_MODES.len(),
|
||||
context.clone().get().current_mode.ordinal() as usize,
|
||||
),
|
||||
context,
|
||||
is_running: true,
|
||||
last_tick_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tick(&mut self) -> bool {
|
||||
#[cfg(not(debug_assertions))]
|
||||
let tick_rate = 16;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let tick_rate = 100;
|
||||
|
||||
let do_tick = self.last_tick_at.elapsed() >= Duration::from_millis(tick_rate);
|
||||
do_tick.then(|| {
|
||||
self.last_tick_at = Instant::now();
|
||||
});
|
||||
do_tick
|
||||
}
|
||||
|
||||
fn current_screen_mut(&mut self) -> &mut Screen<SysFsStore> {
|
||||
let screen_idx = self.screen_idx.current();
|
||||
self.screens.get_mut(screen_idx).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Failed to get a Screen for mode id {}: {:?}",
|
||||
screen_idx, ALL_MODES[screen_idx]
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
fn current_screen(&self) -> &Screen<SysFsStore> {
|
||||
let screen_idx = self.screen_idx.current();
|
||||
self.screens.get(screen_idx).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Failed to get a Screen for mode id {}: {:?}",
|
||||
screen_idx, ALL_MODES[screen_idx]
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
fn can_switch_screens(&self) -> bool {
|
||||
self.screens.len() > 1 && !self.current_screen().is_in_editing_mode()
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn handle_event(&mut self, event: &Event, actions: &mut Actions) {
|
||||
debug!("received event: {:?}", event);
|
||||
if let Event::Key(crossterm::event::KeyEvent {
|
||||
kind: KeyEventKind::Press,
|
||||
code,
|
||||
..
|
||||
}) = event
|
||||
{
|
||||
match code {
|
||||
KeyCode::Char('q') => {
|
||||
self.is_running = false;
|
||||
return;
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.can_switch_screens() {
|
||||
self.screen_idx.forward();
|
||||
actions.push(Action::SetMode(
|
||||
self.screens[self.screen_idx.current()].accel_mode,
|
||||
));
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if self.can_switch_screens() {
|
||||
self.screen_idx.back();
|
||||
actions.push(Action::SetMode(
|
||||
self.screens[self.screen_idx.current()].accel_mode,
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.current_screen_mut().handle_event(event, actions);
|
||||
}
|
||||
|
||||
pub(crate) fn update(&mut self, actions: &mut Vec<Action>) {
|
||||
debug!("performing actions: {actions:?}");
|
||||
|
||||
for action in actions.drain(..) {
|
||||
if let Action::SetMode(accel_mode) = action {
|
||||
self.context.get_mut().current_mode = accel_mode;
|
||||
SysFsStore
|
||||
.set_current_accel_mode(accel_mode)
|
||||
.expect("Failed to set accel mode in TUI");
|
||||
self.context.get_mut().reset_current_parameters();
|
||||
}
|
||||
|
||||
self.current_screen_mut().update(&action);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn draw(&self, frame: &mut ratatui::Frame, area: ratatui::prelude::Rect) {
|
||||
self.current_screen().draw(frame, area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Representation of a terminal user interface.
|
||||
///
|
||||
/// It is responsible for setting up the terminal,
|
||||
/// initializing the interface and handling the draw events.
|
||||
#[derive(Debug)]
|
||||
pub struct Tui<B: Backend> {
|
||||
/// Interface to the Terminal.
|
||||
terminal: Terminal<B>,
|
||||
/// Terminal event handler.
|
||||
pub events: EventHandler,
|
||||
}
|
||||
|
||||
impl<B: Backend> Tui<B> {
|
||||
/// Constructs a new instance of [`Tui`].
|
||||
pub fn new(terminal: Terminal<B>, events: EventHandler) -> Self {
|
||||
Self { terminal, events }
|
||||
}
|
||||
|
||||
/// Initializes the terminal interface.
|
||||
///
|
||||
/// It enables the raw mode and sets terminal properties.
|
||||
pub fn init(&mut self) -> anyhow::Result<()> {
|
||||
crossterm::terminal::enable_raw_mode()?;
|
||||
crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
|
||||
|
||||
// Define a custom panic hook to reset the terminal properties.
|
||||
// This way, you won't have your terminal messed up if an unexpected error happens.
|
||||
let panic_hook = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |panic| {
|
||||
Self::reset().expect("failed to reset the terminal");
|
||||
panic_hook(panic);
|
||||
}));
|
||||
|
||||
self.terminal.hide_cursor()?;
|
||||
self.terminal.clear()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Draw`] the terminal interface by [`rendering`] the widgets.
|
||||
///
|
||||
/// [`Draw`]: ratatui::Terminal::draw
|
||||
/// [`rendering`]: crate::ui::render
|
||||
pub fn draw(&mut self, app: &mut App) -> anyhow::Result<()> {
|
||||
self.terminal.draw(|frame| app.draw(frame, frame.area()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resets the terminal interface.
|
||||
///
|
||||
/// This function is also used for the panic hook to revert
|
||||
/// the terminal properties if unexpected errors occur.
|
||||
fn reset() -> anyhow::Result<()> {
|
||||
crossterm::terminal::disable_raw_mode()?;
|
||||
crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exits the terminal interface.
|
||||
///
|
||||
/// It disables the raw mode and reverts back the terminal properties.
|
||||
pub fn exit(&mut self) -> anyhow::Result<()> {
|
||||
Self::reset()?;
|
||||
self.terminal.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crossterm::event::{KeyEvent, MouseEvent};
|
||||
use ratatui::{Frame, layout::Rect};
|
||||
|
||||
use super::{
|
||||
action::{Action, Actions},
|
||||
event::Event,
|
||||
};
|
||||
|
||||
pub trait TuiComponent {
|
||||
fn handle_event(&mut self, event: &Event, actions: &mut Actions) {
|
||||
match event {
|
||||
Event::Key(key_event) => self.handle_key_event(key_event, actions),
|
||||
Event::Mouse(mouse_event) => self.handle_mouse_event(mouse_event, actions),
|
||||
}
|
||||
}
|
||||
fn handle_key_event(&mut self, event: &KeyEvent, actions: &mut Actions);
|
||||
fn handle_mouse_event(&mut self, event: &MouseEvent, actions: &mut Actions);
|
||||
|
||||
/// Stuff to do on any action derived from an event
|
||||
fn update(&mut self, action: &Action);
|
||||
|
||||
fn draw(&self, frame: &mut Frame, area: Rect);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NoopComponent;
|
||||
|
||||
impl TuiComponent for NoopComponent {
|
||||
fn handle_key_event(&mut self, _event: &KeyEvent, _actions: &mut Actions) {}
|
||||
|
||||
fn handle_mouse_event(&mut self, _event: &MouseEvent, _actions: &mut Actions) {}
|
||||
|
||||
fn update(&mut self, _action: &Action) {}
|
||||
|
||||
fn draw(&self, _frame: &mut Frame, _area: Rect) {}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::sync::mpsc::TryRecvError;
|
||||
|
||||
use crossterm::event::Event as CrosstermEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Event {
|
||||
Key(crossterm::event::KeyEvent),
|
||||
Mouse(crossterm::event::MouseEvent),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EventHandler {
|
||||
rx: std::sync::mpsc::Receiver<Event>,
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
pub fn new() -> Self {
|
||||
let tick_rate = std::time::Duration::from_millis(33);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || -> anyhow::Result<()> {
|
||||
loop {
|
||||
if crossterm::event::poll(tick_rate)? {
|
||||
match crossterm::event::read()? {
|
||||
CrosstermEvent::Key(e) => tx.send(Event::Key(e)),
|
||||
CrosstermEvent::Mouse(e) => {
|
||||
if e.kind == MouseEventKind::Moved {
|
||||
// These are annoying because they get buffered
|
||||
// while you move your mouse a bunch and delay
|
||||
// other events, e.g 'q' quitting could much longer than it should.
|
||||
continue;
|
||||
}
|
||||
tx.send(Event::Mouse(e))
|
||||
}
|
||||
CrosstermEvent::Resize(_col, _row) => continue,
|
||||
event => unimplemented!("event {event:?}"),
|
||||
}?
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
EventHandler { rx }
|
||||
}
|
||||
|
||||
pub fn next(&self) -> anyhow::Result<Option<Event>> {
|
||||
Ok(match self.rx.try_recv() {
|
||||
Ok(e) => Some(e),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(err) => return Err(err.into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use gsf_core::inputspeed;
|
||||
|
||||
use event::EventHandler;
|
||||
use ratatui::{Terminal, prelude::CrosstermBackend};
|
||||
|
||||
mod event;
|
||||
|
||||
mod action;
|
||||
mod app;
|
||||
mod component;
|
||||
mod graph;
|
||||
mod param_input;
|
||||
mod screen;
|
||||
mod utils;
|
||||
mod widgets;
|
||||
|
||||
pub fn run_tui() -> anyhow::Result<()> {
|
||||
let mut app = app::App::new();
|
||||
|
||||
let backend = CrosstermBackend::new(std::io::stdout());
|
||||
let terminal = Terminal::new(backend)?;
|
||||
let events = EventHandler::new();
|
||||
|
||||
let mut tui = app::Tui::new(terminal, events);
|
||||
tui.init()?;
|
||||
|
||||
let input_speed_thread_handle = inputspeed::setup_input_speed_reader();
|
||||
|
||||
let mut actions = vec![];
|
||||
while app.is_running {
|
||||
if let Some(event) = tui.events.next()? {
|
||||
app.handle_event(&event, &mut actions);
|
||||
app.update(&mut actions);
|
||||
}
|
||||
|
||||
if app.tick() {
|
||||
app.update(&mut vec![action::Action::Tick]);
|
||||
tui.draw(&mut app)?;
|
||||
}
|
||||
}
|
||||
|
||||
tui.exit()?;
|
||||
|
||||
if input_speed_thread_handle.is_finished() {
|
||||
input_speed_thread_handle
|
||||
.join()
|
||||
.expect("couldn't join on (finished) inputspeed reader thread")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use anyhow::Context;
|
||||
use gsf_core::persist::ParamStore;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::{prelude::*, widgets::*};
|
||||
use tui_input::Input;
|
||||
use tui_input::backend::crossterm::EventHandler;
|
||||
|
||||
use crate::action::{Action, Actions, InputAction};
|
||||
use crate::component::TuiComponent;
|
||||
use gsf_core::{ContextRef, Param, Parameter};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InputMode {
|
||||
Normal,
|
||||
Editing,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ParameterInput<PS: ParamStore> {
|
||||
context: ContextRef<PS>,
|
||||
param_tag: Param,
|
||||
input: Input,
|
||||
pub input_mode: InputMode,
|
||||
error: Option<String>,
|
||||
pub is_selected: bool,
|
||||
}
|
||||
|
||||
impl<PS: ParamStore> ParameterInput<PS> {
|
||||
pub fn new(param: &Parameter, context: ContextRef<PS>) -> Self {
|
||||
Self {
|
||||
context,
|
||||
param_tag: param.tag,
|
||||
input_mode: InputMode::Normal,
|
||||
input: format!("{}", param.value).into(),
|
||||
error: None,
|
||||
is_selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn this_param(&self) -> Parameter {
|
||||
*self
|
||||
.context
|
||||
.get()
|
||||
.parameter(self.param_tag)
|
||||
.expect("Failed to get param from context")
|
||||
}
|
||||
|
||||
pub fn param(&self) -> Param {
|
||||
self.param_tag
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
self.input.value()
|
||||
}
|
||||
|
||||
fn update_value(&mut self) {
|
||||
let value = self
|
||||
.value()
|
||||
.parse()
|
||||
.context("should be a number")
|
||||
.and_then(|value| {
|
||||
self.context
|
||||
.get_mut()
|
||||
.update_param_value(self.param_tag, value)
|
||||
});
|
||||
|
||||
match value {
|
||||
Ok(_) => {
|
||||
self.error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
self.reset();
|
||||
self.error = Some(format!("{err:#}"));
|
||||
}
|
||||
}
|
||||
|
||||
self.input_mode = InputMode::Normal;
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.error = None;
|
||||
self.input = format!("{}", self.this_param().value).into();
|
||||
self.input_mode = InputMode::Normal;
|
||||
}
|
||||
}
|
||||
|
||||
impl<PS: ParamStore + Debug> TuiComponent for ParameterInput<PS> {
|
||||
fn handle_key_event(&mut self, key: &KeyEvent, actions: &mut Actions) {
|
||||
if !self.is_selected {
|
||||
return;
|
||||
}
|
||||
|
||||
let action = match self.input_mode {
|
||||
InputMode::Normal => match key.code {
|
||||
KeyCode::Char('i') | KeyCode::Enter => InputAction::Focus,
|
||||
KeyCode::BackTab | KeyCode::Up => {
|
||||
actions.push(Action::SelectPreviousInput);
|
||||
return;
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Down => {
|
||||
actions.push(Action::SelectNextInput);
|
||||
return;
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
actions.push(Action::ScrollPageDown);
|
||||
return;
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
actions.push(Action::ScrollPageUp);
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
},
|
||||
InputMode::Editing => match key.code {
|
||||
KeyCode::Enter => InputAction::Enter,
|
||||
KeyCode::Esc => InputAction::Reset,
|
||||
_ => {
|
||||
let _ = self
|
||||
.input
|
||||
.handle_event(&::crossterm::event::Event::Key(*key));
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
actions.push(Action::Input(action));
|
||||
}
|
||||
|
||||
fn handle_mouse_event(
|
||||
&mut self,
|
||||
_event: &::crossterm::event::MouseEvent,
|
||||
_actions: &mut Actions,
|
||||
) {
|
||||
}
|
||||
|
||||
fn update(&mut self, action: &Action) {
|
||||
if let Action::SetMode(_) = action {
|
||||
self.reset();
|
||||
}
|
||||
|
||||
if !self.is_selected {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Action::Input(action) = action {
|
||||
match action {
|
||||
InputAction::Enter => {
|
||||
self.update_value();
|
||||
}
|
||||
InputAction::Reset => self.reset(),
|
||||
InputAction::Focus => self.input_mode = InputMode::Editing,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(&self, frame: &mut ratatui::Frame, area: Rect) {
|
||||
let input_group_layout = Layout::new(
|
||||
Direction::Vertical,
|
||||
[Constraint::Min(0), Constraint::Length(2)],
|
||||
)
|
||||
.split(area);
|
||||
|
||||
let input_layout = input_group_layout[0];
|
||||
|
||||
let input_width = area.width.max(3) - 3; // keep 2 for borders and 1 for cursor
|
||||
let input_scroll_position = self.input.visual_scroll(input_width as usize);
|
||||
|
||||
let mut input = Paragraph::new(self.input.value())
|
||||
.style(match self.input_mode {
|
||||
InputMode::Normal => match self.is_selected {
|
||||
true => Style::default().light_blue(),
|
||||
false => Style::default(),
|
||||
},
|
||||
InputMode::Editing => Style::default().bold().light_blue(),
|
||||
})
|
||||
.scroll((0, input_scroll_position as u16))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(self.this_param().tag.display_name()),
|
||||
);
|
||||
|
||||
match self.input_mode {
|
||||
InputMode::Normal =>
|
||||
// Hide the cursor. `Frame` does this by default, so we don't need to do anything here
|
||||
{}
|
||||
|
||||
InputMode::Editing => {
|
||||
// Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering
|
||||
frame.set_cursor_position((
|
||||
// Put cursor past the end of the input text
|
||||
input_layout.x
|
||||
+ ((self.input.visual_cursor()).max(input_scroll_position)
|
||||
- input_scroll_position) as u16
|
||||
+ 1,
|
||||
// Move one line down, from the border to the input line
|
||||
input_layout.y + 1,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let helper_text_layout = input_group_layout[1];
|
||||
|
||||
if let Some(error) = &self.error {
|
||||
let helper_text = Paragraph::new(error.as_str())
|
||||
.red()
|
||||
.wrap(ratatui::widgets::Wrap { trim: true });
|
||||
|
||||
frame.render_widget(helper_text, helper_text_layout);
|
||||
|
||||
input = input.red();
|
||||
}
|
||||
|
||||
frame.render_widget(input, input_layout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#[derive(Debug)]
|
||||
pub struct CyclingIdx {
|
||||
idx: usize,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl CyclingIdx {
|
||||
pub fn new(n: usize) -> Self {
|
||||
Self::new_starting_at(n, 0)
|
||||
}
|
||||
|
||||
pub fn new_starting_at(n: usize, idx: usize) -> Self {
|
||||
Self { idx, n }
|
||||
}
|
||||
|
||||
pub fn current(&self) -> usize {
|
||||
debug_assert!(self.idx < self.n);
|
||||
self.idx
|
||||
}
|
||||
|
||||
pub fn forward(&mut self) {
|
||||
self.idx += 1;
|
||||
if self.idx >= self.n {
|
||||
self.idx = 0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn back(&mut self) {
|
||||
if self.idx == 0 {
|
||||
self.idx = self.n
|
||||
}
|
||||
self.idx -= 1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_utils {
|
||||
use gsf_core::{ContextRef, Parameter, fixedptc::Fpt};
|
||||
use mocks::MockStore;
|
||||
|
||||
pub fn new_context() -> (ContextRef<MockStore>, Vec<Parameter>) {
|
||||
let params = [
|
||||
(gsf_core::Param::SensMult, 1.0),
|
||||
(gsf_core::Param::Accel, 1.0),
|
||||
];
|
||||
|
||||
let params = params.map(|(p, v)| (p, Fpt::from(v)));
|
||||
let context = ContextRef::new(
|
||||
gsf_core::TuiContext::init(
|
||||
MockStore {
|
||||
list: params.to_vec(),
|
||||
},
|
||||
¶ms.map(|(p, _)| p),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
(context, params.map(|(p, v)| Parameter::new(p, v)).to_vec())
|
||||
}
|
||||
|
||||
mod mocks {
|
||||
use anyhow::Context;
|
||||
use gsf_core::{AccelMode, Param, fixedptc::Fpt, persist::ParamStore};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MockStore {
|
||||
pub list: Vec<(Param, Fpt)>,
|
||||
}
|
||||
|
||||
impl ParamStore for MockStore {
|
||||
fn set(&mut self, param: Param, value: f64) -> anyhow::Result<()> {
|
||||
if !self.list.iter().any(|(p, _)| p == ¶m) {
|
||||
self.list.push((param, value.into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, param: Param) -> anyhow::Result<Fpt> {
|
||||
self.list
|
||||
.iter()
|
||||
.find(|(p, _)| p == ¶m)
|
||||
.map(|(_, v)| v)
|
||||
.copied()
|
||||
.context("failed to get param")
|
||||
}
|
||||
|
||||
fn set_current_accel_mode(
|
||||
&mut self,
|
||||
_mode: gsf_core::AccelMode,
|
||||
) -> anyhow::Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
fn get_current_accel_mode(&self) -> anyhow::Result<gsf_core::AccelMode> {
|
||||
Ok(AccelMode::Linear)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod scrollbar;
|
||||
@@ -0,0 +1,70 @@
|
||||
use ratatui::{prelude::*, widgets::Widget};
|
||||
|
||||
pub struct CustomScrollbar {
|
||||
total_items: usize,
|
||||
visible_items: usize,
|
||||
scroll_position: usize,
|
||||
}
|
||||
|
||||
impl CustomScrollbar {
|
||||
pub fn new(total: usize, visible: usize, position: usize) -> Self {
|
||||
Self {
|
||||
total_items: total,
|
||||
visible_items: visible,
|
||||
scroll_position: position,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_position(self, position: usize) -> Self {
|
||||
Self {
|
||||
scroll_position: position,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for CustomScrollbar {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height < 3 || self.total_items == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let track_height = area.height.saturating_sub(2);
|
||||
let track_top = area.y + 1;
|
||||
let track_bottom = track_top + track_height.saturating_sub(1);
|
||||
let x = area.x;
|
||||
|
||||
let max_scroll = self.total_items.saturating_sub(self.visible_items);
|
||||
let scroll_ratio = if max_scroll > 0 {
|
||||
(self.scroll_position as f64) / (max_scroll as f64)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let thumb_ratio = (self.visible_items as f64) / (self.total_items as f64);
|
||||
let thumb_height = ((track_height as f64) * thumb_ratio).max(1.0).ceil() as u16;
|
||||
let thumb_height = thumb_height.min(track_height);
|
||||
|
||||
let thumb_start = if self.scroll_position >= max_scroll {
|
||||
track_bottom.saturating_sub(thumb_height - 1)
|
||||
} else {
|
||||
let available_space = track_height.saturating_sub(thumb_height);
|
||||
let thumb_offset = (scroll_ratio * (available_space as f64)).round() as u16;
|
||||
track_top + thumb_offset
|
||||
};
|
||||
|
||||
buf.set_string(x, track_top.saturating_sub(1), "▲", Style::default());
|
||||
buf.set_string(x, track_bottom + 1, "▼", Style::default());
|
||||
|
||||
for row in track_top..=track_bottom {
|
||||
let is_thumb = row >= thumb_start && row < thumb_start + thumb_height;
|
||||
let symbol = if is_thumb { "█" } else { "│" };
|
||||
let style = if is_thumb {
|
||||
Style::default().fg(Color::White)
|
||||
} else {
|
||||
Style::default().fg(Color::Gray)
|
||||
};
|
||||
buf.set_string(x, row, symbol, style);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user