Feat: lots of things, preparing for live gig
This commit is contained in:
168
src/app.rs
168
src/app.rs
@@ -20,6 +20,7 @@ use crate::page::Page;
|
||||
use crate::services::{clipboard, dict_nav, euclidean, help_nav, pattern_editor};
|
||||
use crate::settings::Settings;
|
||||
use crate::state::{
|
||||
undo::{UndoEntry, UndoHistory, UndoScope},
|
||||
AudioSettings, CyclicEnum, EditorContext, EditorTarget, FlashKind, LiveKeyState, Metrics,
|
||||
Modal, MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav,
|
||||
PlaybackState, ProjectState, SampleTree, StagedChange, StagedPropChange, UiState,
|
||||
@@ -61,6 +62,8 @@ pub struct App {
|
||||
pub copied_patterns: Option<Vec<Pattern>>,
|
||||
pub copied_banks: Option<Vec<Bank>>,
|
||||
|
||||
pub undo: UndoHistory,
|
||||
|
||||
pub audio: AudioSettings,
|
||||
pub options: OptionsState,
|
||||
pub panel: PanelState,
|
||||
@@ -103,6 +106,8 @@ impl App {
|
||||
copied_patterns: None,
|
||||
copied_banks: None,
|
||||
|
||||
undo: UndoHistory::default(),
|
||||
|
||||
audio: AudioSettings::default(),
|
||||
options: OptionsState::default(),
|
||||
panel: PanelState::default(),
|
||||
@@ -124,6 +129,7 @@ impl App {
|
||||
runtime_highlight: self.ui.runtime_highlight,
|
||||
show_scope: self.audio.config.show_scope,
|
||||
show_spectrum: self.audio.config.show_spectrum,
|
||||
show_preview: self.audio.config.show_preview,
|
||||
show_completion: self.ui.show_completion,
|
||||
color_scheme: self.ui.color_scheme,
|
||||
layout: self.audio.config.layout,
|
||||
@@ -617,6 +623,7 @@ impl App {
|
||||
link.set_tempo(tempo);
|
||||
|
||||
self.playback.clear_queues();
|
||||
self.undo.clear();
|
||||
self.variables.store(Arc::new(HashMap::new()));
|
||||
self.dict.lock().clear();
|
||||
|
||||
@@ -742,6 +749,46 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shift_patterns_up(&mut self) {
|
||||
let bank = self.patterns_nav.bank_cursor;
|
||||
let patterns = self.patterns_nav.selected_patterns();
|
||||
let start = *patterns.first().unwrap();
|
||||
let end = *patterns.last().unwrap();
|
||||
if let Some(dirty) = clipboard::shift_patterns_up(
|
||||
&mut self.project_state.project,
|
||||
bank,
|
||||
start..=end,
|
||||
) {
|
||||
for (b, p) in &dirty {
|
||||
self.project_state.mark_dirty(*b, *p);
|
||||
}
|
||||
self.patterns_nav.pattern_cursor -= 1;
|
||||
if let Some(ref mut anchor) = self.patterns_nav.pattern_anchor {
|
||||
*anchor -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shift_patterns_down(&mut self) {
|
||||
let bank = self.patterns_nav.bank_cursor;
|
||||
let patterns = self.patterns_nav.selected_patterns();
|
||||
let start = *patterns.first().unwrap();
|
||||
let end = *patterns.last().unwrap();
|
||||
if let Some(dirty) = clipboard::shift_patterns_down(
|
||||
&mut self.project_state.project,
|
||||
bank,
|
||||
start..=end,
|
||||
) {
|
||||
for (b, p) in &dirty {
|
||||
self.project_state.mark_dirty(*b, *p);
|
||||
}
|
||||
self.patterns_nav.pattern_cursor += 1;
|
||||
if let Some(ref mut anchor) = self.patterns_nav.pattern_anchor {
|
||||
*anchor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_bank(&mut self, bank: usize) {
|
||||
self.copied_banks = Some(vec![clipboard::copy_bank(
|
||||
&self.project_state.project,
|
||||
@@ -986,8 +1033,120 @@ impl App {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) {
|
||||
fn undoable_scope(&self, cmd: &AppCommand) -> Option<UndoScope> {
|
||||
match cmd {
|
||||
// Pattern-level
|
||||
AppCommand::ToggleSteps
|
||||
| AppCommand::LengthIncrease
|
||||
| AppCommand::LengthDecrease
|
||||
| AppCommand::SpeedIncrease
|
||||
| AppCommand::SpeedDecrease
|
||||
| AppCommand::SaveEditorToStep
|
||||
| AppCommand::CompileCurrentStep
|
||||
| AppCommand::HardenSteps
|
||||
| AppCommand::PasteSteps
|
||||
| AppCommand::LinkPasteSteps
|
||||
| AppCommand::DuplicateSteps
|
||||
| AppCommand::CopySteps => {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let data = self.project_state.project.pattern_at(bank, pattern).clone();
|
||||
Some(UndoScope::Pattern { bank, pattern, data })
|
||||
}
|
||||
AppCommand::SetLength { bank, pattern, .. }
|
||||
| AppCommand::SetSpeed { bank, pattern, .. }
|
||||
| AppCommand::DeleteStep { bank, pattern, .. }
|
||||
| AppCommand::DeleteSteps { bank, pattern, .. }
|
||||
| AppCommand::ResetPattern { bank, pattern }
|
||||
| AppCommand::PastePattern { bank, pattern }
|
||||
| AppCommand::RenamePattern { bank, pattern, .. } => {
|
||||
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
|
||||
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
|
||||
}
|
||||
AppCommand::RenameStep { bank, pattern, .. } => {
|
||||
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
|
||||
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
|
||||
}
|
||||
AppCommand::ApplyEuclideanDistribution { bank, pattern, .. } => {
|
||||
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
|
||||
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
|
||||
}
|
||||
// Bank-level
|
||||
AppCommand::ResetBank { bank } | AppCommand::PasteBank { bank } => {
|
||||
let data = self.project_state.project.banks[*bank].clone();
|
||||
Some(UndoScope::Bank { bank: *bank, data })
|
||||
}
|
||||
AppCommand::ShiftPatternsUp | AppCommand::ShiftPatternsDown => {
|
||||
let bank = self.patterns_nav.bank_cursor;
|
||||
let data = self.project_state.project.banks[bank].clone();
|
||||
Some(UndoScope::Bank { bank, data })
|
||||
}
|
||||
AppCommand::PastePatterns { bank, .. }
|
||||
| AppCommand::ResetPatterns { bank, .. }
|
||||
| AppCommand::RenameBank { bank, .. } => {
|
||||
let data = self.project_state.project.banks[*bank].clone();
|
||||
Some(UndoScope::Bank { bank: *bank, data })
|
||||
}
|
||||
AppCommand::PasteBanks { .. } | AppCommand::ResetBanks { .. } => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_undo_entry(&mut self, entry: UndoEntry) -> UndoEntry {
|
||||
let cursor = (self.editor_ctx.bank, self.editor_ctx.pattern, self.editor_ctx.step);
|
||||
let reverse_scope = match entry.scope {
|
||||
UndoScope::Pattern { bank, pattern, data } => {
|
||||
let current = self.project_state.project.pattern_at(bank, pattern).clone();
|
||||
*self.project_state.project.pattern_at_mut(bank, pattern) = data;
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
UndoScope::Pattern { bank, pattern, data: current }
|
||||
}
|
||||
UndoScope::Bank { bank, data } => {
|
||||
let current = self.project_state.project.banks[bank].clone();
|
||||
let pat_count = current.patterns.len();
|
||||
self.project_state.project.banks[bank] = data;
|
||||
for p in 0..pat_count {
|
||||
self.project_state.mark_dirty(bank, p);
|
||||
}
|
||||
UndoScope::Bank { bank, data: current }
|
||||
}
|
||||
};
|
||||
self.editor_ctx.bank = entry.cursor.0;
|
||||
self.editor_ctx.pattern = entry.cursor.1;
|
||||
self.editor_ctx.step = entry.cursor.2;
|
||||
self.load_step_to_editor();
|
||||
UndoEntry { scope: reverse_scope, cursor }
|
||||
}
|
||||
|
||||
pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) {
|
||||
// Handle undo/redo before the undoable snapshot
|
||||
match cmd {
|
||||
AppCommand::Undo => {
|
||||
if let Some(entry) = self.undo.pop_undo() {
|
||||
let reverse = self.apply_undo_entry(entry);
|
||||
self.undo.push_redo(reverse);
|
||||
self.ui.flash("Undo", 100, FlashKind::Info);
|
||||
}
|
||||
return;
|
||||
}
|
||||
AppCommand::Redo => {
|
||||
if let Some(entry) = self.undo.pop_redo() {
|
||||
let reverse = self.apply_undo_entry(entry);
|
||||
self.undo.undo_stack.push(reverse);
|
||||
self.ui.flash("Redo", 100, FlashKind::Info);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(scope) = self.undoable_scope(&cmd) {
|
||||
let cursor = (self.editor_ctx.bank, self.editor_ctx.pattern, self.editor_ctx.step);
|
||||
self.undo.push(UndoEntry { scope, cursor });
|
||||
}
|
||||
|
||||
match cmd {
|
||||
AppCommand::Undo | AppCommand::Redo => unreachable!(),
|
||||
|
||||
// Playback
|
||||
AppCommand::TogglePlaying => self.toggle_playing(link),
|
||||
AppCommand::TempoUp => self.tempo_up(link),
|
||||
@@ -1092,6 +1251,10 @@ impl App {
|
||||
self.reset_banks(&banks);
|
||||
}
|
||||
|
||||
// Reorder
|
||||
AppCommand::ShiftPatternsUp => self.shift_patterns_up(),
|
||||
AppCommand::ShiftPatternsDown => self.shift_patterns_down(),
|
||||
|
||||
// Clipboard
|
||||
AppCommand::HardenSteps => self.harden_steps(),
|
||||
AppCommand::CopySteps => self.copy_steps(),
|
||||
@@ -1406,6 +1569,9 @@ impl App {
|
||||
AppCommand::ToggleSpectrum => {
|
||||
self.audio.config.show_spectrum = !self.audio.config.show_spectrum;
|
||||
}
|
||||
AppCommand::TogglePreview => {
|
||||
self.audio.config.show_preview = !self.audio.config.show_preview;
|
||||
}
|
||||
|
||||
// Metrics
|
||||
AppCommand::ResetPeakVoices => {
|
||||
|
||||
@@ -5,6 +5,10 @@ use crate::page::Page;
|
||||
use crate::state::{ColorScheme, DeviceKind, EngineSection, Modal, OptionsFocus, PatternField, SettingKind};
|
||||
|
||||
pub enum AppCommand {
|
||||
// Undo/Redo
|
||||
Undo,
|
||||
Redo,
|
||||
|
||||
// Playback
|
||||
TogglePlaying,
|
||||
TempoUp,
|
||||
@@ -89,6 +93,10 @@ pub enum AppCommand {
|
||||
banks: Vec<usize>,
|
||||
},
|
||||
|
||||
// Reorder
|
||||
ShiftPatternsUp,
|
||||
ShiftPatternsDown,
|
||||
|
||||
// Clipboard
|
||||
HardenSteps,
|
||||
CopySteps,
|
||||
@@ -239,6 +247,7 @@ pub enum AppCommand {
|
||||
ToggleRefreshRate,
|
||||
ToggleScope,
|
||||
ToggleSpectrum,
|
||||
TogglePreview,
|
||||
|
||||
// Metrics
|
||||
ResetPeakVoices,
|
||||
|
||||
@@ -81,6 +81,7 @@ pub fn init(args: InitArgs) -> Init {
|
||||
app.ui.runtime_highlight = settings.display.runtime_highlight;
|
||||
app.audio.config.show_scope = settings.display.show_scope;
|
||||
app.audio.config.show_spectrum = settings.display.show_spectrum;
|
||||
app.audio.config.show_preview = settings.display.show_preview;
|
||||
app.ui.show_completion = settings.display.show_completion;
|
||||
app.ui.color_scheme = settings.display.color_scheme;
|
||||
app.ui.hue_rotation = settings.display.hue_rotation;
|
||||
|
||||
@@ -96,6 +96,12 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
|
||||
let state = FileBrowserState::new_save(initial);
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
|
||||
}
|
||||
KeyCode::Char('z') if ctrl && !shift => {
|
||||
ctx.dispatch(AppCommand::Undo);
|
||||
}
|
||||
KeyCode::Char('Z') if ctrl => {
|
||||
ctx.dispatch(AppCommand::Redo);
|
||||
}
|
||||
KeyCode::Char('c') if ctrl => {
|
||||
ctx.dispatch(AppCommand::CopySteps);
|
||||
}
|
||||
@@ -134,6 +140,10 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
|
||||
let current = format!("{:.1}", ctx.link.tempo());
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current)));
|
||||
}
|
||||
KeyCode::Char(':') => {
|
||||
let current = (ctx.app.editor_ctx.step + 1).to_string();
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::JumpToStep(current)));
|
||||
}
|
||||
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
|
||||
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
|
||||
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),
|
||||
|
||||
@@ -138,6 +138,22 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
KeyCode::Char(c) => input.push(c),
|
||||
_ => {}
|
||||
},
|
||||
Modal::JumpToStep(input) => match key.code {
|
||||
KeyCode::Enter => {
|
||||
if let Ok(step) = input.parse::<usize>() {
|
||||
if step > 0 {
|
||||
ctx.dispatch(AppCommand::GoToStep(step - 1));
|
||||
}
|
||||
}
|
||||
ctx.dispatch(AppCommand::CloseModal);
|
||||
}
|
||||
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||
KeyCode::Backspace => {
|
||||
input.pop();
|
||||
}
|
||||
KeyCode::Char(c) if c.is_ascii_digit() => input.push(c),
|
||||
_ => {}
|
||||
},
|
||||
Modal::SetTempo(input) => match key.code {
|
||||
KeyCode::Enter => {
|
||||
if let Ok(tempo) = input.parse::<f64>() {
|
||||
@@ -466,7 +482,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
EuclideanField::Rotation => rotation,
|
||||
};
|
||||
if let Ok(val) = target.parse::<usize>() {
|
||||
*target = (val + 1).min(128).to_string();
|
||||
*target = (val + 1).min(1024).to_string();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) if c.is_ascii_digit() => match field {
|
||||
|
||||
@@ -312,7 +312,8 @@ fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
||||
// Replay viz/sequencer split
|
||||
let show_scope = ctx.app.audio.config.show_scope;
|
||||
let show_spectrum = ctx.app.audio.config.show_spectrum;
|
||||
let has_viz = show_scope || show_spectrum;
|
||||
let show_preview = ctx.app.audio.config.show_preview;
|
||||
let has_viz = show_scope || show_spectrum || show_preview;
|
||||
let layout = ctx.app.audio.config.layout;
|
||||
|
||||
let sequencer_area = match layout {
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) {
|
||||
OptionsFocus::ShowScope => ctx.dispatch(AppCommand::ToggleScope),
|
||||
OptionsFocus::ShowSpectrum => ctx.dispatch(AppCommand::ToggleSpectrum),
|
||||
OptionsFocus::ShowCompletion => ctx.dispatch(AppCommand::ToggleCompletion),
|
||||
OptionsFocus::ShowPreview => ctx.dispatch(AppCommand::TogglePreview),
|
||||
OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
|
||||
OptionsFocus::StartStopSync => ctx
|
||||
.link
|
||||
|
||||
@@ -8,8 +8,15 @@ use crate::state::{ConfirmAction, Modal, PatternsColumn, RenameTarget};
|
||||
pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||
|
||||
match key.code {
|
||||
KeyCode::Up if alt && ctx.app.patterns_nav.column == PatternsColumn::Patterns => {
|
||||
ctx.dispatch(AppCommand::ShiftPatternsUp);
|
||||
}
|
||||
KeyCode::Down if alt && ctx.app.patterns_nav.column == PatternsColumn::Patterns => {
|
||||
ctx.dispatch(AppCommand::ShiftPatternsDown);
|
||||
}
|
||||
KeyCode::Up if shift => {
|
||||
match ctx.app.patterns_nav.column {
|
||||
PatternsColumn::Banks => {
|
||||
@@ -94,6 +101,12 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
KeyCode::Char('z') if ctrl && !shift => {
|
||||
ctx.dispatch(AppCommand::Undo);
|
||||
}
|
||||
KeyCode::Char('Z') if ctrl => {
|
||||
ctx.dispatch(AppCommand::Redo);
|
||||
}
|
||||
KeyCode::Char('c') if ctrl => {
|
||||
let bank = ctx.app.patterns_nav.bank_cursor;
|
||||
match ctx.app.patterns_nav.column {
|
||||
|
||||
@@ -56,6 +56,17 @@ pub const DOCS: &[DocEntry] = &[
|
||||
Topic("Introduction", include_str!("../../docs/midi_intro.md")),
|
||||
Topic("MIDI Output", include_str!("../../docs/midi_output.md")),
|
||||
Topic("MIDI Input", include_str!("../../docs/midi_input.md")),
|
||||
// Tutorials
|
||||
Section("Tutorials"),
|
||||
Topic("Randomness", include_str!("../../docs/tutorial_randomness.md")),
|
||||
Topic(
|
||||
"Notes & Harmony",
|
||||
include_str!("../../docs/tutorial_harmony.md"),
|
||||
),
|
||||
Topic(
|
||||
"Generators",
|
||||
include_str!("../../docs/tutorial_generators.md"),
|
||||
),
|
||||
];
|
||||
|
||||
pub fn topic_count() -> usize {
|
||||
|
||||
@@ -9,6 +9,34 @@ fn annotate_copy_name(name: &Option<String>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shift_patterns_up(
|
||||
project: &mut Project,
|
||||
bank: usize,
|
||||
range: std::ops::RangeInclusive<usize>,
|
||||
) -> Option<Vec<(usize, usize)>> {
|
||||
if *range.start() == 0 {
|
||||
return None;
|
||||
}
|
||||
let slice_start = range.start() - 1;
|
||||
let slice_end = *range.end();
|
||||
project.banks[bank].patterns[slice_start..=slice_end].rotate_left(1);
|
||||
Some((slice_start..=slice_end).map(|p| (bank, p)).collect())
|
||||
}
|
||||
|
||||
pub fn shift_patterns_down(
|
||||
project: &mut Project,
|
||||
bank: usize,
|
||||
range: std::ops::RangeInclusive<usize>,
|
||||
) -> Option<Vec<(usize, usize)>> {
|
||||
if *range.end() >= crate::model::MAX_PATTERNS - 1 {
|
||||
return None;
|
||||
}
|
||||
let slice_start = *range.start();
|
||||
let slice_end = range.end() + 1;
|
||||
project.banks[bank].patterns[slice_start..=slice_end].rotate_right(1);
|
||||
Some((slice_start..=slice_end).map(|p| (bank, p)).collect())
|
||||
}
|
||||
|
||||
pub fn copy_pattern(project: &Project, bank: usize, pattern: usize) -> Pattern {
|
||||
project.banks[bank].patterns[pattern].clone()
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ pub struct DisplaySettings {
|
||||
pub show_scope: bool,
|
||||
pub show_spectrum: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_preview: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_completion: bool,
|
||||
#[serde(default = "default_font")]
|
||||
pub font: String,
|
||||
@@ -83,6 +85,7 @@ impl Default for DisplaySettings {
|
||||
runtime_highlight: false,
|
||||
show_scope: true,
|
||||
show_spectrum: true,
|
||||
show_preview: true,
|
||||
show_completion: true,
|
||||
font: default_font(),
|
||||
color_scheme: ColorScheme::default(),
|
||||
|
||||
@@ -83,6 +83,7 @@ pub struct AudioConfig {
|
||||
pub refresh_rate: RefreshRate,
|
||||
pub show_scope: bool,
|
||||
pub show_spectrum: bool,
|
||||
pub show_preview: bool,
|
||||
pub layout: MainLayout,
|
||||
}
|
||||
|
||||
@@ -101,6 +102,7 @@ impl Default for AudioConfig {
|
||||
refresh_rate: RefreshRate::default(),
|
||||
show_scope: true,
|
||||
show_spectrum: true,
|
||||
show_preview: true,
|
||||
layout: MainLayout::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod patterns_nav;
|
||||
pub mod playback;
|
||||
pub mod project;
|
||||
pub mod sample_browser;
|
||||
pub mod undo;
|
||||
pub mod ui;
|
||||
|
||||
pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind};
|
||||
|
||||
@@ -64,6 +64,7 @@ pub enum Modal {
|
||||
input: String,
|
||||
},
|
||||
SetTempo(String),
|
||||
JumpToStep(String),
|
||||
AddSamplePath(Box<FileBrowserState>),
|
||||
Editor,
|
||||
Preview,
|
||||
|
||||
@@ -10,6 +10,7 @@ pub enum OptionsFocus {
|
||||
ShowScope,
|
||||
ShowSpectrum,
|
||||
ShowCompletion,
|
||||
ShowPreview,
|
||||
LinkEnabled,
|
||||
StartStopSync,
|
||||
Quantum,
|
||||
@@ -32,6 +33,7 @@ impl CyclicEnum for OptionsFocus {
|
||||
Self::ShowScope,
|
||||
Self::ShowSpectrum,
|
||||
Self::ShowCompletion,
|
||||
Self::ShowPreview,
|
||||
Self::LinkEnabled,
|
||||
Self::StartStopSync,
|
||||
Self::Quantum,
|
||||
@@ -54,17 +56,18 @@ const FOCUS_LINES: &[(OptionsFocus, usize)] = &[
|
||||
(OptionsFocus::ShowScope, 6),
|
||||
(OptionsFocus::ShowSpectrum, 7),
|
||||
(OptionsFocus::ShowCompletion, 8),
|
||||
(OptionsFocus::LinkEnabled, 12),
|
||||
(OptionsFocus::StartStopSync, 13),
|
||||
(OptionsFocus::Quantum, 14),
|
||||
(OptionsFocus::MidiOutput0, 24),
|
||||
(OptionsFocus::MidiOutput1, 25),
|
||||
(OptionsFocus::MidiOutput2, 26),
|
||||
(OptionsFocus::MidiOutput3, 27),
|
||||
(OptionsFocus::MidiInput0, 31),
|
||||
(OptionsFocus::MidiInput1, 32),
|
||||
(OptionsFocus::MidiInput2, 33),
|
||||
(OptionsFocus::MidiInput3, 34),
|
||||
(OptionsFocus::ShowPreview, 9),
|
||||
(OptionsFocus::LinkEnabled, 13),
|
||||
(OptionsFocus::StartStopSync, 14),
|
||||
(OptionsFocus::Quantum, 15),
|
||||
(OptionsFocus::MidiOutput0, 25),
|
||||
(OptionsFocus::MidiOutput1, 26),
|
||||
(OptionsFocus::MidiOutput2, 27),
|
||||
(OptionsFocus::MidiOutput3, 28),
|
||||
(OptionsFocus::MidiInput0, 32),
|
||||
(OptionsFocus::MidiInput1, 33),
|
||||
(OptionsFocus::MidiInput2, 34),
|
||||
(OptionsFocus::MidiInput3, 35),
|
||||
];
|
||||
|
||||
impl OptionsFocus {
|
||||
|
||||
53
src/state/undo.rs
Normal file
53
src/state/undo.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::model::{Bank, Pattern};
|
||||
|
||||
const MAX_UNDO: usize = 100;
|
||||
|
||||
pub enum UndoScope {
|
||||
Pattern {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
data: Pattern,
|
||||
},
|
||||
Bank {
|
||||
bank: usize,
|
||||
data: Bank,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct UndoEntry {
|
||||
pub scope: UndoScope,
|
||||
pub cursor: (usize, usize, usize),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UndoHistory {
|
||||
pub(crate) undo_stack: Vec<UndoEntry>,
|
||||
redo_stack: Vec<UndoEntry>,
|
||||
}
|
||||
|
||||
impl UndoHistory {
|
||||
pub fn push(&mut self, entry: UndoEntry) {
|
||||
self.redo_stack.clear();
|
||||
if self.undo_stack.len() >= MAX_UNDO {
|
||||
self.undo_stack.remove(0);
|
||||
}
|
||||
self.undo_stack.push(entry);
|
||||
}
|
||||
|
||||
pub fn pop_undo(&mut self) -> Option<UndoEntry> {
|
||||
self.undo_stack.pop()
|
||||
}
|
||||
|
||||
pub fn push_redo(&mut self, entry: UndoEntry) {
|
||||
self.redo_stack.push(entry);
|
||||
}
|
||||
|
||||
pub fn pop_redo(&mut self) -> Option<UndoEntry> {
|
||||
self.redo_stack.pop()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.undo_stack.clear();
|
||||
self.redo_stack.clear();
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
|
||||
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
|
||||
bindings.push(("r", "Rename", "Rename current step"));
|
||||
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
|
||||
bindings.push((":", "Jump", "Jump to step number"));
|
||||
bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm"));
|
||||
bindings.push(("m", "Mute", "Stage mute for current pattern"));
|
||||
bindings.push(("x", "Solo", "Stage solo for current pattern"));
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::engine::SequencerSnapshot;
|
||||
use crate::model::SourceSpan;
|
||||
use crate::state::MainLayout;
|
||||
use crate::theme;
|
||||
use crate::views::highlight::highlight_line_with_runtime;
|
||||
use crate::views::render::{adjust_resolved_for_line, adjust_spans_for_line};
|
||||
use crate::widgets::{ActivePatterns, Orientation, Scope, Spectrum, VuMeter};
|
||||
|
||||
pub fn layout(area: Rect) -> [Rect; 5] {
|
||||
@@ -25,7 +31,8 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area:
|
||||
|
||||
let show_scope = app.audio.config.show_scope;
|
||||
let show_spectrum = app.audio.config.show_spectrum;
|
||||
let has_viz = show_scope || show_spectrum;
|
||||
let show_preview = app.audio.config.show_preview;
|
||||
let has_viz = show_scope || show_spectrum || show_preview;
|
||||
let layout = app.audio.config.layout;
|
||||
|
||||
let (viz_area, sequencer_area) = match layout {
|
||||
@@ -70,7 +77,7 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area:
|
||||
};
|
||||
|
||||
if has_viz {
|
||||
render_viz_area(frame, app, viz_area, layout, show_scope, show_spectrum);
|
||||
render_viz_area(frame, app, snapshot, viz_area);
|
||||
}
|
||||
|
||||
render_sequencer(frame, app, snapshot, sequencer_area);
|
||||
@@ -78,43 +85,48 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area:
|
||||
render_active_patterns(frame, app, snapshot, patterns_area);
|
||||
}
|
||||
|
||||
enum VizPanel {
|
||||
Scope,
|
||||
Spectrum,
|
||||
Preview,
|
||||
}
|
||||
|
||||
fn render_viz_area(
|
||||
frame: &mut Frame,
|
||||
app: &App,
|
||||
snapshot: &SequencerSnapshot,
|
||||
area: Rect,
|
||||
layout: MainLayout,
|
||||
show_scope: bool,
|
||||
show_spectrum: bool,
|
||||
) {
|
||||
let is_vertical_layout = matches!(layout, MainLayout::Left | MainLayout::Right);
|
||||
let is_vertical_layout = matches!(app.audio.config.layout, MainLayout::Left | MainLayout::Right);
|
||||
let show_scope = app.audio.config.show_scope;
|
||||
let show_spectrum = app.audio.config.show_spectrum;
|
||||
let show_preview = app.audio.config.show_preview;
|
||||
|
||||
if show_scope && show_spectrum {
|
||||
if is_vertical_layout {
|
||||
let [scope_area, spectrum_area] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
render_scope(frame, app, scope_area, Orientation::Vertical);
|
||||
render_spectrum(frame, app, spectrum_area);
|
||||
} else {
|
||||
let [scope_area, spectrum_area] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
render_scope(frame, app, scope_area, Orientation::Horizontal);
|
||||
render_spectrum(frame, app, spectrum_area);
|
||||
let mut panels = Vec::new();
|
||||
if show_scope { panels.push(VizPanel::Scope); }
|
||||
if show_spectrum { panels.push(VizPanel::Spectrum); }
|
||||
if show_preview { panels.push(VizPanel::Preview); }
|
||||
|
||||
let constraints: Vec<Constraint> = panels.iter().map(|_| Constraint::Fill(1)).collect();
|
||||
|
||||
let areas: Vec<Rect> = if is_vertical_layout {
|
||||
Layout::vertical(&constraints).split(area).to_vec()
|
||||
} else {
|
||||
Layout::horizontal(&constraints).split(area).to_vec()
|
||||
};
|
||||
|
||||
let orientation = if is_vertical_layout {
|
||||
Orientation::Vertical
|
||||
} else {
|
||||
Orientation::Horizontal
|
||||
};
|
||||
|
||||
for (panel, panel_area) in panels.iter().zip(areas.iter()) {
|
||||
match panel {
|
||||
VizPanel::Scope => render_scope(frame, app, *panel_area, orientation),
|
||||
VizPanel::Spectrum => render_spectrum(frame, app, *panel_area),
|
||||
VizPanel::Preview => render_script_preview(frame, app, snapshot, *panel_area),
|
||||
}
|
||||
} else if show_scope {
|
||||
let orientation = if is_vertical_layout {
|
||||
Orientation::Vertical
|
||||
} else {
|
||||
Orientation::Horizontal
|
||||
};
|
||||
render_scope(frame, app, area, orientation);
|
||||
} else if show_spectrum {
|
||||
render_spectrum(frame, app, area);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +199,7 @@ fn render_tile(
|
||||
let pattern = app.current_edit_pattern();
|
||||
let step = pattern.step(step_idx);
|
||||
let is_active = step.map(|s| s.active).unwrap_or(false);
|
||||
let has_content = step.map(|s| s.has_content()).unwrap_or(false);
|
||||
let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false);
|
||||
let is_selected = step_idx == app.editor_ctx.step;
|
||||
let in_selection = app.editor_ctx.selection_range()
|
||||
@@ -217,7 +230,10 @@ fn render_tile(
|
||||
let (r, g, b) = link_color.unwrap().1;
|
||||
(Color::Rgb(r, g, b), theme.tile.active_fg)
|
||||
}
|
||||
(false, true, false, false, _) => (theme.tile.active_bg, theme.tile.active_fg),
|
||||
(false, true, false, false, _) => {
|
||||
let bg = if has_content { theme.tile.content_bg } else { theme.tile.active_bg };
|
||||
(bg, theme.tile.active_fg)
|
||||
}
|
||||
(false, false, true, _, _) => (theme.selection.selected, theme.selection.cursor_fg),
|
||||
(false, false, _, _, true) => (theme.selection.in_range, theme.selection.cursor_fg),
|
||||
(false, false, false, _, _) => (theme.tile.inactive_bg, theme.tile.inactive_fg),
|
||||
@@ -234,6 +250,8 @@ fn render_tile(
|
||||
"▶".to_string()
|
||||
} else if let Some(source) = source_idx {
|
||||
format!("→{:02}", source + 1)
|
||||
} else if has_content {
|
||||
format!("·{:02}·", step_idx + 1)
|
||||
} else {
|
||||
format!("{:02}", step_idx + 1)
|
||||
};
|
||||
@@ -314,6 +332,90 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
|
||||
frame.render_widget(spectrum, inner);
|
||||
}
|
||||
|
||||
fn render_script_preview(
|
||||
frame: &mut Frame,
|
||||
app: &App,
|
||||
snapshot: &SequencerSnapshot,
|
||||
area: Rect,
|
||||
) {
|
||||
let theme = theme::get();
|
||||
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
|
||||
|
||||
let pattern = app.current_edit_pattern();
|
||||
let step_idx = app.editor_ctx.step;
|
||||
let step = pattern.step(step_idx);
|
||||
let source_idx = step.and_then(|s| s.source);
|
||||
let step_name = step.and_then(|s| s.name.as_ref());
|
||||
|
||||
let title = match (source_idx, step_name) {
|
||||
(Some(src), Some(name)) => format!(" {:02}: {} -> {:02} ", step_idx + 1, name, src + 1),
|
||||
(None, Some(name)) => format!(" {:02}: {} ", step_idx + 1, name),
|
||||
(Some(src), None) => format!(" {:02} -> {:02} ", step_idx + 1, src + 1),
|
||||
(None, None) => format!(" Step {:02} ", step_idx + 1),
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.border_style(Style::new().fg(theme.ui.border));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let script = pattern.resolve_script(step_idx).unwrap_or("");
|
||||
if script.is_empty() {
|
||||
let empty = Paragraph::new("(empty)")
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().fg(theme.ui.text_dim));
|
||||
let centered = Rect {
|
||||
y: inner.y + inner.height / 2,
|
||||
height: 1,
|
||||
..inner
|
||||
};
|
||||
frame.render_widget(empty, centered);
|
||||
return;
|
||||
}
|
||||
|
||||
let trace = if app.ui.runtime_highlight && app.playback.playing {
|
||||
let source = pattern.resolve_source(step_idx);
|
||||
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let resolved_display: Vec<(SourceSpan, String)> = trace
|
||||
.map(|t| {
|
||||
t.resolved
|
||||
.iter()
|
||||
.map(|(s, v)| (*s, v.display()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut line_start = 0usize;
|
||||
let lines: Vec<Line> = script
|
||||
.lines()
|
||||
.take(inner.height as usize)
|
||||
.map(|line_str| {
|
||||
let tokens = if let Some(t) = trace {
|
||||
let exec = adjust_spans_for_line(&t.executed_spans, line_start, line_str.len());
|
||||
let sel = adjust_spans_for_line(&t.selected_spans, line_start, line_str.len());
|
||||
let res = adjust_resolved_for_line(&resolved_display, line_start, line_str.len());
|
||||
highlight_line_with_runtime(line_str, &exec, &sel, &res, &user_words)
|
||||
} else {
|
||||
highlight_line_with_runtime(line_str, &[], &[], &[], &user_words)
|
||||
};
|
||||
line_start += line_str.len() + 1;
|
||||
let spans: Vec<Span> = tokens
|
||||
.into_iter()
|
||||
.map(|(style, text, _)| Span::styled(text, style))
|
||||
.collect();
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect();
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let block = Block::default()
|
||||
|
||||
@@ -161,6 +161,12 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
focus == OptionsFocus::ShowCompletion,
|
||||
&theme,
|
||||
),
|
||||
render_option_line(
|
||||
"Show preview",
|
||||
if app.audio.config.show_preview { "On" } else { "Off" },
|
||||
focus == OptionsFocus::ShowPreview,
|
||||
&theme,
|
||||
),
|
||||
Line::from(""),
|
||||
link_header,
|
||||
render_divider(content_width, &theme),
|
||||
|
||||
@@ -38,7 +38,7 @@ fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option<Sou
|
||||
})
|
||||
}
|
||||
|
||||
fn adjust_spans_for_line(
|
||||
pub fn adjust_spans_for_line(
|
||||
spans: &[SourceSpan],
|
||||
line_start: usize,
|
||||
line_len: usize,
|
||||
@@ -49,7 +49,7 @@ fn adjust_spans_for_line(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn adjust_resolved_for_line(
|
||||
pub fn adjust_resolved_for_line(
|
||||
resolved: &[(SourceSpan, String)],
|
||||
line_start: usize,
|
||||
line_len: usize,
|
||||
@@ -518,7 +518,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
}
|
||||
Modal::SetPattern { field, input } => {
|
||||
let (title, hint) = match field {
|
||||
PatternField::Length => ("Set Length (1-128)", "Enter number"),
|
||||
PatternField::Length => ("Set Length (1-1024)", "Enter number"),
|
||||
PatternField::Speed => ("Set Speed", "e.g. 1/3, 2/5, 1x, 2x"),
|
||||
};
|
||||
TextInputModal::new(title, input)
|
||||
@@ -527,6 +527,15 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
.border_color(theme.modal.confirm)
|
||||
.render_centered(frame, term)
|
||||
}
|
||||
Modal::JumpToStep(input) => {
|
||||
let pattern_len = app.current_edit_pattern().length;
|
||||
let title = format!("Jump to Step (1-{})", pattern_len);
|
||||
TextInputModal::new(&title, input)
|
||||
.hint("Enter step number")
|
||||
.width(30)
|
||||
.border_color(theme.modal.confirm)
|
||||
.render_centered(frame, term)
|
||||
}
|
||||
Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input)
|
||||
.hint("Enter BPM")
|
||||
.width(30)
|
||||
|
||||
Reference in New Issue
Block a user