rename user-facing commands and crates to gsf
This commit is contained in:
@@ -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