From 6b56655661849034c1cce489dd587185ca53eb6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Thu, 26 Feb 2026 21:17:53 +0100 Subject: [PATCH] Feat: UI / UX fixes --- cross/aarch64-linux.Dockerfile | 2 + cross/x86_64-linux.Dockerfile | 2 + scripts/build-all.sh | 2 +- scripts/make-appimage.sh | 149 +++++++++++++++++---------------- src/app/clipboard.rs | 10 +-- src/app/dispatch.rs | 4 + src/app/mod.rs | 2 - src/app/navigation.rs | 21 +++++ src/commands.rs | 4 + src/engine/sequencer.rs | 18 ++++ src/input/main_page.rs | 5 +- src/input/mod.rs | 35 +++++++- src/input/modal.rs | 14 +--- src/input/mouse.rs | 5 +- src/main.rs | 3 +- src/state/effects.rs | 9 ++ src/state/modal.rs | 1 - src/state/ui.rs | 19 ++++- src/views/keybindings.rs | 8 +- src/views/render.rs | 124 +++++++++++++-------------- 20 files changed, 268 insertions(+), 169 deletions(-) diff --git a/cross/aarch64-linux.Dockerfile b/cross/aarch64-linux.Dockerfile index cc3188f..95d0cc8 100644 --- a/cross/aarch64-linux.Dockerfile +++ b/cross/aarch64-linux.Dockerfile @@ -7,6 +7,8 @@ RUN dpkg --add-architecture arm64 && \ libclang-dev \ libasound2-dev:arm64 \ libjack-dev:arm64 \ + libx11-dev:arm64 \ + libx11-xcb-dev:arm64 \ libxcb-render0-dev:arm64 \ libxcb-shape0-dev:arm64 \ libxcb-xfixes0-dev:arm64 \ diff --git a/cross/x86_64-linux.Dockerfile b/cross/x86_64-linux.Dockerfile index 45847cd..75c4fb1 100644 --- a/cross/x86_64-linux.Dockerfile +++ b/cross/x86_64-linux.Dockerfile @@ -6,6 +6,8 @@ RUN apt-get update && \ libclang-dev \ libasound2-dev \ libjack-dev \ + libx11-dev \ + libx11-xcb-dev \ libxcb-render0-dev \ libxcb-shape0-dev \ libxcb-xfixes0-dev \ diff --git a/scripts/build-all.sh b/scripts/build-all.sh index 9c3f974..088e232 100755 --- a/scripts/build-all.sh +++ b/scripts/build-all.sh @@ -314,7 +314,7 @@ copy_artifacts() { # Plugin artifacts for native targets (cross handled in bundle_plugins_cross) if $build_plugins && ! is_cross_target "$platform"; then - local bundle_dir="$rd/bundle" + local bundle_dir="target/bundled" # CLAP local clap_src="$bundle_dir/${PLUGIN_NAME}.clap" diff --git a/scripts/make-appimage.sh b/scripts/make-appimage.sh index e496e0b..c2949fc 100755 --- a/scripts/make-appimage.sh +++ b/scripts/make-appimage.sh @@ -2,7 +2,9 @@ set -euo pipefail # Usage: scripts/make-appimage.sh -# Produces an AppImage from a Linux binary using linuxdeploy. +# Produces an AppImage from a Linux binary. +# On native Linux with matching arch: uses linuxdeploy. +# Otherwise (cross-compilation): builds AppImage via mksquashfs in Docker. if [[ $# -ne 3 ]]; then echo "Usage: $0 " @@ -16,121 +18,124 @@ OUTDIR="$3" REPO_ROOT="$(git rev-parse --show-toplevel)" CACHE_DIR="$REPO_ROOT/.cache" APP_NAME="$(basename "$BINARY")" -APPDIR="$(mktemp -d)/AppDir" -LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage" -LINUXDEPLOY="$CACHE_DIR/linuxdeploy-${ARCH}.AppImage" RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-${ARCH}" RUNTIME="$CACHE_DIR/runtime-${ARCH}" -# Map arch to linuxdeploy's expected values -case "$ARCH" in - x86_64) export LDAI_ARCH="x86_64" ;; - aarch64) export LDAI_ARCH="aarch64" ;; - *) echo "Unsupported arch: $ARCH"; exit 1 ;; -esac +build_appdir() { + local appdir="$1" + mkdir -p "$appdir/usr/bin" + cp "$BINARY" "$appdir/usr/bin/cagire" + chmod +x "$appdir/usr/bin/cagire" -download_tools() { + mkdir -p "$appdir/usr/share/icons/hicolor/512x512/apps" + cp "$REPO_ROOT/assets/Cagire.png" "$appdir/usr/share/icons/hicolor/512x512/apps/cagire.png" + + cp "$REPO_ROOT/assets/cagire.desktop" "$appdir/cagire.desktop" + + # AppRun entry point + cat > "$appdir/AppRun" <<'APPRUN' +#!/bin/sh +SELF="$(readlink -f "$0")" +HERE="$(dirname "$SELF")" +exec "$HERE/usr/bin/cagire" "$@" +APPRUN + chmod +x "$appdir/AppRun" + + # Symlink icon at root for AppImage spec + ln -sf usr/share/icons/hicolor/512x512/apps/cagire.png "$appdir/cagire.png" + ln -sf cagire.desktop "$appdir/.DirIcon" 2>/dev/null || true +} + +download_runtime() { mkdir -p "$CACHE_DIR" - if [[ ! -f "$LINUXDEPLOY" ]]; then - echo " Downloading linuxdeploy for $ARCH..." - curl -fSL "$LINUXDEPLOY_URL" -o "$LINUXDEPLOY" - chmod +x "$LINUXDEPLOY" - fi if [[ ! -f "$RUNTIME" ]]; then echo " Downloading AppImage runtime for $ARCH..." curl -fSL "$RUNTIME_URL" -o "$RUNTIME" fi } -build_appdir() { - mkdir -p "$APPDIR/usr/bin" - cp "$BINARY" "$APPDIR/usr/bin/cagire" - chmod +x "$APPDIR/usr/bin/cagire" +run_native() { + local linuxdeploy_url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage" + local linuxdeploy="$CACHE_DIR/linuxdeploy-${ARCH}.AppImage" - mkdir -p "$APPDIR/usr/share/icons/hicolor/512x512/apps" - cp "$REPO_ROOT/assets/Cagire.png" "$APPDIR/usr/share/icons/hicolor/512x512/apps/cagire.png" + mkdir -p "$CACHE_DIR" + if [[ ! -f "$linuxdeploy" ]]; then + echo " Downloading linuxdeploy for $ARCH..." + curl -fSL "$linuxdeploy_url" -o "$linuxdeploy" + chmod +x "$linuxdeploy" + fi - cp "$REPO_ROOT/assets/cagire.desktop" "$APPDIR/cagire.desktop" -} + local appdir + appdir="$(mktemp -d)/AppDir" + build_appdir "$appdir" -run_linuxdeploy_native() { - export ARCH="$LDAI_ARCH" + export ARCH export LDAI_RUNTIME_FILE="$RUNTIME" - "$LINUXDEPLOY" \ + "$linuxdeploy" \ --appimage-extract-and-run \ - --appdir "$APPDIR" \ - --desktop-file "$APPDIR/cagire.desktop" \ - --icon-file "$APPDIR/usr/share/icons/hicolor/512x512/apps/cagire.png" \ + --appdir "$appdir" \ + --desktop-file "$appdir/cagire.desktop" \ + --icon-file "$appdir/usr/share/icons/hicolor/512x512/apps/cagire.png" \ --output appimage + + local appimage + appimage=$(ls -1t ./*.AppImage 2>/dev/null | head -1 || true) + if [[ -z "$appimage" ]]; then + echo " ERROR: No AppImage produced" + exit 1 + fi + mkdir -p "$OUTDIR" + mv "$appimage" "$OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage" + echo " AppImage -> $OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage" } -run_linuxdeploy_docker() { +run_docker() { local platform case "$ARCH" in x86_64) platform="linux/amd64" ;; aarch64) platform="linux/arm64" ;; + *) echo "Unsupported arch: $ARCH"; exit 1 ;; esac + local appdir + appdir="$(mktemp -d)/AppDir" + build_appdir "$appdir" + local image_tag="cagire-appimage-${ARCH}" echo " Building Docker image $image_tag ($platform)..." docker build --platform "$platform" -q -t "$image_tag" - <<'DOCKERFILE' FROM ubuntu:22.04 RUN apt-get update && apt-get install -y --no-install-recommends \ - file \ - libasound2 \ - libjack0 \ - libxcb-render0 \ - libxcb-shape0 \ - libxcb-xfixes0 \ - libxkbcommon0 \ - libgl1 \ + squashfs-tools \ && rm -rf /var/lib/apt/lists/* DOCKERFILE - echo " Running linuxdeploy inside Docker ($image_tag)..." + echo " Creating squashfs via Docker ($image_tag)..." docker run --rm --platform "$platform" \ - -v "$REPO_ROOT:/project" \ - -v "$APPDIR:/appdir" \ + -v "$appdir:/appdir:ro" \ -v "$CACHE_DIR:/cache" \ - -e ARCH="$LDAI_ARCH" \ - -e LDAI_RUNTIME_FILE="/cache/runtime-${ARCH}" \ - -w /project \ "$image_tag" \ - bash -c " - chmod +x /cache/linuxdeploy-${ARCH}.AppImage && \ - /cache/linuxdeploy-${ARCH}.AppImage \ - --appimage-extract-and-run \ - --appdir /appdir \ - --desktop-file /appdir/cagire.desktop \ - --icon-file /appdir/usr/share/icons/hicolor/512x512/apps/cagire.png \ - --output appimage - " + mksquashfs /appdir /cache/appimage-${ARCH}.squashfs \ + -root-owned -noappend -comp gzip -no-progress + + mkdir -p "$OUTDIR" + local final="$OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage" + cat "$RUNTIME" "$CACHE_DIR/appimage-${ARCH}.squashfs" > "$final" + chmod +x "$final" + rm -f "$CACHE_DIR/appimage-${ARCH}.squashfs" + echo " AppImage -> $final" } HOST_ARCH="$(uname -m)" -download_tools -build_appdir +download_runtime -echo " Building AppImage for cagire ($ARCH)..." +echo " Building AppImage for ${APP_NAME} ($ARCH)..." if [[ "$HOST_ARCH" == "$ARCH" ]] && [[ "$(uname -s)" == "Linux" ]]; then - run_linuxdeploy_native + run_native else - run_linuxdeploy_docker + run_docker fi - -mkdir -p "$OUTDIR" - -# linuxdeploy outputs to cwd; find and move the AppImage -APPIMAGE=$(ls -1t ./*.AppImage 2>/dev/null | head -1 || true) -if [[ -z "$APPIMAGE" ]]; then - echo " ERROR: No AppImage produced" - exit 1 -fi - -FINAL_NAME="${APP_NAME}-linux-${ARCH}.AppImage" -mv "$APPIMAGE" "$OUTDIR/$FINAL_NAME" -echo " AppImage -> $OUTDIR/$FINAL_NAME" diff --git a/src/app/clipboard.rs b/src/app/clipboard.rs index 84d83db..02f6ff6 100644 --- a/src/app/clipboard.rs +++ b/src/app/clipboard.rs @@ -175,7 +175,7 @@ impl App { match model::share::export(pattern_data) { Ok(encoded) => { let len = encoded.len(); - if let Some(clip) = &mut self.clipboard { + if let Ok(mut clip) = arboard::Clipboard::new() { let _ = clip.set_text(encoded); } if len > 2000 { @@ -201,7 +201,7 @@ impl App { match model::share::export_bank(bank_data) { Ok(encoded) => { let len = encoded.len(); - if let Some(clip) = &mut self.clipboard { + if let Ok(mut clip) = arboard::Clipboard::new() { let _ = clip.set_text(encoded); } if len > 2000 { @@ -223,7 +223,7 @@ impl App { } pub fn import_bank(&mut self, bank: usize) { - let text = match self.clipboard.as_mut().and_then(|c| c.get_text().ok()) { + let text = match arboard::Clipboard::new().ok().and_then(|mut c| c.get_text().ok()) { Some(t) => t, None => { self.ui.flash("Clipboard empty", 150, FlashKind::Error); @@ -250,7 +250,7 @@ impl App { } pub fn import_pattern(&mut self, bank: usize, pattern: usize) { - let text = match self.clipboard.as_mut().and_then(|c| c.get_text().ok()) { + let text = match arboard::Clipboard::new().ok().and_then(|mut c| c.get_text().ok()) { Some(t) => t, None => { self.ui @@ -305,7 +305,7 @@ impl App { &indices, ); let count = copied.steps.len(); - if let Some(clip) = &mut self.clipboard { + if let Ok(mut clip) = arboard::Clipboard::new() { let text: String = copied.steps.iter().map(|s| s.script.as_str()).collect::>().join("\n"); let _ = clip.set_text(text); } diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index 916b598..a72e748 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -49,6 +49,10 @@ impl App { AppCommand::PrevStep => self.prev_step(), AppCommand::StepUp => self.step_up(), AppCommand::StepDown => self.step_down(), + AppCommand::NextPattern => self.navigate_pattern(1), + AppCommand::PrevPattern => self.navigate_pattern(-1), + AppCommand::NextBank => self.navigate_bank(1), + AppCommand::PrevBank => self.navigate_bank(-1), // Pattern editing AppCommand::ToggleSteps => self.toggle_steps(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 93a7297..e8ada44 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -60,7 +60,6 @@ pub struct App { // Held to keep the Arc alive (shared with ScriptEngine). pub _rng: Rng, pub live_keys: Arc, - pub clipboard: Option, pub copied_patterns: Option>, pub copied_banks: Option>, @@ -115,7 +114,6 @@ impl App { _rng: rng, live_keys, script_engine, - clipboard: arboard::Clipboard::new().ok(), copied_patterns: None, copied_banks: None, diff --git a/src/app/navigation.rs b/src/app/navigation.rs index 3f042a2..5d5631c 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -1,5 +1,8 @@ //! Step and bank/pattern cursor navigation. +use cagire_project::{MAX_BANKS, MAX_PATTERNS}; +use tachyonfx::Motion; + use super::App; impl App { @@ -55,4 +58,22 @@ impl App { self.editor_ctx.step = 0; self.load_step_to_editor(); } + + pub fn navigate_pattern(&mut self, delta: i32) { + let cur = self.editor_ctx.pattern as i32; + self.editor_ctx.pattern = (cur + delta).rem_euclid(MAX_PATTERNS as i32) as usize; + self.editor_ctx.step = 0; + self.load_step_to_editor(); + let direction = if delta > 0 { Motion::UpToDown } else { Motion::DownToUp }; + self.ui.show_nav_indicator(500, direction); + } + + pub fn navigate_bank(&mut self, delta: i32) { + let cur = self.editor_ctx.bank as i32; + self.editor_ctx.bank = (cur + delta).rem_euclid(MAX_BANKS as i32) as usize; + self.editor_ctx.step = 0; + self.load_step_to_editor(); + let direction = if delta > 0 { Motion::LeftToRight } else { Motion::RightToLeft }; + self.ui.show_nav_indicator(500, direction); + } } diff --git a/src/commands.rs b/src/commands.rs index 1ebe793..e4b31f1 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -21,6 +21,10 @@ pub enum AppCommand { PrevStep, StepUp, StepDown, + NextPattern, + PrevPattern, + NextBank, + PrevBank, // Pattern editing ToggleSteps, diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index da53494..ddd6a77 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -131,6 +131,7 @@ pub enum SeqCommand { length: usize, }, StopAll, + RestartAll, ResetScriptState, Shutdown, } @@ -712,6 +713,23 @@ impl SequencerState { self.runs_counter.counts.clear(); self.audio_state.flush_midi_notes = true; } + SeqCommand::RestartAll => { + for active in self.audio_state.active_patterns.values_mut() { + active.step_index = 0; + active.iter = 0; + } + self.audio_state.prev_beat = -1.0; + self.script_frontier = -1.0; + self.script_step = 0; + self.script_trace = None; + self.variables.store(Arc::new(HashMap::new())); + self.dict.lock().clear(); + self.speed_overrides.clear(); + self.script_engine.clear_global_params(); + self.runs_counter.counts.clear(); + Arc::make_mut(&mut self.step_traces).clear(); + self.audio_state.flush_midi_notes = true; + } SeqCommand::ResetScriptState => { // Clear shared state instead of replacing - preserves sharing with app self.variables.store(Arc::new(HashMap::new())); diff --git a/src/input/main_page.rs b/src/input/main_page.rs index 040ed75..fa3d5ed 100644 --- a/src/input/main_page.rs +++ b/src/input/main_page.rs @@ -120,7 +120,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease), KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)), KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)), - KeyCode::Char('p') => ctx.dispatch(AppCommand::OpenModal(Modal::Preview)), + KeyCode::Char('p') => ctx.dispatch(AppCommand::OpenPreludeEditor), KeyCode::Delete | KeyCode::Backspace => { let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); if let Some(range) = ctx.app.editor_ctx.selection_range() { @@ -231,9 +231,6 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool ctx.app.send_mute_state(ctx.seq_cmd_tx); } KeyCode::Char('d') => { - ctx.dispatch(AppCommand::OpenPreludeEditor); - } - KeyCode::Char('D') => { ctx.dispatch(AppCommand::EvaluatePrelude); } KeyCode::Char('g') => { diff --git a/src/input/mod.rs b/src/input/mod.rs index a52fbb0..d2171fa 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -14,7 +14,7 @@ use arc_swap::ArcSwap; use crossbeam_channel::Sender; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent}; use ratatui::layout::Rect; -use std::sync::atomic::{AtomicBool, AtomicI64}; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -87,7 +87,7 @@ fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool { match (key.code, key.kind) { _ if !matches!(ctx.app.ui.modal, Modal::None) => false, _ if ctx.app.page == Page::Script && ctx.app.script_editor.focused => false, - (KeyCode::Char('f'), KeyEventKind::Press) => { + (KeyCode::Char('f'), KeyEventKind::Press) if !key.modifiers.contains(KeyModifiers::ALT) => { ctx.dispatch(AppCommand::ToggleLiveKeysFill); true } @@ -97,11 +97,42 @@ fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool { fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + if key.code == KeyCode::F(12) && !ctx.app.plugin_mode { + if !ctx.app.playback.playing { + ctx.dispatch(AppCommand::TogglePlaying); + ctx.playing.store(true, Ordering::Relaxed); + } + let _ = ctx.seq_cmd_tx.send(SeqCommand::RestartAll); + return InputResult::Continue; + } if ctx.app.panel.visible && ctx.app.panel.focus == PanelFocus::Side { return panel::handle_panel_input(ctx, key); } + if alt { + match key.code { + KeyCode::Up => { + ctx.dispatch(AppCommand::PrevPattern); + return InputResult::Continue; + } + KeyCode::Down => { + ctx.dispatch(AppCommand::NextPattern); + return InputResult::Continue; + } + KeyCode::Left | KeyCode::Char('b') => { + ctx.dispatch(AppCommand::PrevBank); + return InputResult::Continue; + } + KeyCode::Right | KeyCode::Char('f') => { + ctx.dispatch(AppCommand::NextBank); + return InputResult::Continue; + } + _ => {} + } + } + if ctrl { let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(250)); match key.code { diff --git a/src/input/modal.rs b/src/input/modal.rs index b281577..3758b0a 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -383,19 +383,19 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input KeyCode::Char('c') if ctrl => { ctx.app.editor_ctx.editor.copy(); let text = ctx.app.editor_ctx.editor.yank_text(); - if let Some(clip) = &mut ctx.app.clipboard { + if let Ok(mut clip) = arboard::Clipboard::new() { let _ = clip.set_text(text); } } KeyCode::Char('x') if ctrl => { ctx.app.editor_ctx.editor.cut(); let text = ctx.app.editor_ctx.editor.yank_text(); - if let Some(clip) = &mut ctx.app.clipboard { + if let Ok(mut clip) = arboard::Clipboard::new() { let _ = clip.set_text(text); } } KeyCode::Char('v') if ctrl => { - if let Some(clip) = &mut ctx.app.clipboard { + if let Ok(mut clip) = arboard::Clipboard::new() { if let Ok(text) = clip.get_text() { ctx.app.editor_ctx.editor.set_yank_text(text); } @@ -417,14 +417,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input crate::services::stack_preview::update_cache(&ctx.app.editor_ctx); } } - Modal::Preview => match key.code { - KeyCode::Esc | KeyCode::Char('p') => ctx.dispatch(AppCommand::CloseModal), - KeyCode::Left => ctx.dispatch(AppCommand::PrevStep), - KeyCode::Right => ctx.dispatch(AppCommand::NextStep), - KeyCode::Up => ctx.dispatch(AppCommand::StepUp), - KeyCode::Down => ctx.dispatch(AppCommand::StepDown), - _ => {} - }, Modal::PatternProps { bank, pattern, diff --git a/src/input/mouse.rs b/src/input/mouse.rs index 22238cd..892c5c4 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -130,7 +130,7 @@ fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) { ctx.dispatch(AppCommand::ClearStatus); - // If a modal is active, clicks outside dismiss it (except Editor/Preview) + // If a modal is active, clicks outside dismiss it (except Editor) if !matches!(ctx.app.ui.modal, Modal::None) { handle_modal_click(ctx, col, row, term); return; @@ -893,9 +893,6 @@ fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) { Modal::Editor => { handle_editor_mouse(ctx, col, row, term, false); } - Modal::Preview => { - // Don't dismiss preview on click - } Modal::Confirm { .. } => { handle_confirm_click(ctx, col, row, term); } diff --git a/src/main.rs b/src/main.rs index 9e07a95..bc06054 100644 --- a/src/main.rs +++ b/src/main.rs @@ -338,7 +338,8 @@ fn main() -> io::Result<()> { let effects_active = app.ui.effects.borrow().is_running() || app.ui.modal_fx.borrow().is_some() - || app.ui.title_fx.borrow().is_some(); + || app.ui.title_fx.borrow().is_some() + || app.ui.nav_fx.borrow().is_some(); if app.playback.playing || had_event || app.ui.show_title || effects_active { if app.ui.show_title { app.ui.sparkles.tick(terminal.get_frame().area()); diff --git a/src/state/effects.rs b/src/state/effects.rs index d858749..e27f12c 100644 --- a/src/state/effects.rs +++ b/src/state/effects.rs @@ -9,6 +9,7 @@ use crate::theme; pub enum FxId { #[default] PageTransition, + NavSwitch, } pub fn tick_effects(ui: &mut UiState, page: Page) { @@ -39,6 +40,14 @@ pub fn tick_effects(ui: &mut UiState, page: Page) { } } +pub fn nav_sweep(ui: &UiState, direction: Motion) { + let bg = theme::get().ui.bg; + ui.effects.borrow_mut().add_unique_effect( + FxId::NavSwitch, + fx::sweep_in(direction, 10, 0, bg, (300, Interpolation::QuadOut)), + ); +} + fn page_direction(from: Page, to: Page) -> Motion { let (fc, fr) = from.grid_pos(); let (tc, tr) = to.grid_pos(); diff --git a/src/state/modal.rs b/src/state/modal.rs index d99fd1d..e6a7c7d 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -77,7 +77,6 @@ pub enum Modal { JumpToStep(String), AddSamplePath(Box), Editor, - Preview, PatternProps { bank: usize, pattern: usize, diff --git a/src/state/ui.rs b/src/state/ui.rs index 93831ff..1bdcefe 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -3,7 +3,7 @@ use std::time::{Duration, Instant}; use cagire_markdown::ParsedMarkdown; use cagire_ratatui::Sparkles; -use tachyonfx::{fx, Effect, EffectManager, Interpolation}; +use tachyonfx::{fx, Effect, EffectManager, Interpolation, Motion}; use crate::page::Page; use crate::state::effects::FxId; @@ -83,6 +83,8 @@ pub struct UiState { pub window_height: u32, pub load_demo_on_startup: bool, pub demo_index: usize, + pub nav_indicator_until: Option, + pub nav_fx: RefCell>, } impl Default for UiState { @@ -135,6 +137,8 @@ impl Default for UiState { window_height: 800, load_demo_on_startup: true, demo_index: 0, + nav_indicator_until: None, + nav_fx: RefCell::new(None), } } } @@ -196,6 +200,19 @@ impl UiState { self.minimap = MinimapMode::Hidden; } + pub fn show_nav_indicator(&mut self, duration_ms: u64, direction: Motion) { + self.nav_indicator_until = Some(Instant::now() + Duration::from_millis(duration_ms)); + let bg = crate::theme::get().ui.bg; + *self.nav_fx.borrow_mut() = Some(fx::fade_from_fg(bg, (150, Interpolation::QuadOut))); + crate::state::effects::nav_sweep(self, direction); + } + + pub fn nav_indicator_visible(&self) -> bool { + self.nav_indicator_until + .map(|t| Instant::now() < t) + .unwrap_or(false) + } + pub fn invalidate_help_cache(&self) { self.help_parsed .borrow_mut() diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs index da632ab..19515bd 100644 --- a/src/views/keybindings.rs +++ b/src/views/keybindings.rs @@ -12,6 +12,7 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati ("s", "Save", "Save project"), ("l", "Load", "Load project"), ("?", "Keybindings", "Show this help"), + ("F12", "Restart", "Full restart from step 0"), ]); // Page-specific bindings @@ -20,12 +21,14 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati if !plugin_mode { bindings.push(("Space", "Play/Stop", "Toggle playback")); } + bindings.push(("Alt+↑↓", "Pattern", "Previous/next pattern")); + bindings.push(("Alt+←→", "Bank", "Previous/next bank")); bindings.push(("←→↑↓", "Navigate", "Move cursor between steps")); bindings.push(("Shift+←→↑↓", "Select", "Extend selection")); bindings.push(("Esc", "Clear", "Clear selection")); bindings.push(("Enter", "Edit", "Open step editor")); bindings.push(("t", "Toggle", "Toggle selected steps")); - bindings.push(("p", "Preview", "Preview step script")); + bindings.push(("p", "Prelude", "Edit prelude script")); bindings.push(("Tab", "Samples", "Toggle sample browser")); bindings.push(("Ctrl+C", "Copy", "Copy selected steps")); bindings.push(("Ctrl+V", "Paste", "Paste steps")); @@ -50,8 +53,7 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati bindings.push(("x", "Solo", "Stage solo for current pattern")); bindings.push(("M", "Clear mutes", "Clear all mutes")); bindings.push(("X", "Clear solos", "Clear all solos")); - bindings.push(("d", "Prelude", "Edit prelude script")); - bindings.push(("D", "Eval prelude", "Re-evaluate prelude without editing")); + bindings.push(("d", "Eval prelude", "Re-evaluate prelude without editing")); bindings.push(("g", "Share", "Export pattern to clipboard")); bindings.push(("G", "Import", "Import pattern from clipboard")); } diff --git a/src/views/render.rs b/src/views/render.rs index 96e3520..120006e 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -201,6 +201,17 @@ pub fn render( } let modal_area = render_modal(frame, app, snapshot, term); + if app.ui.nav_indicator_visible() { + let nav_area = render_nav_indicator(frame, app, term); + let mut fx = app.ui.nav_fx.borrow_mut(); + if let Some(effect) = fx.as_mut() { + effect.process(elapsed, frame.buffer_mut(), nav_area); + if !effect.running() { + *fx = None; + } + } + } + if app.ui.show_minimap() { let tiles: Vec = Page::ALL .iter() @@ -234,6 +245,57 @@ pub fn render( } } +fn render_nav_indicator(frame: &mut Frame, app: &App, term: Rect) -> Rect { + let theme = theme::get(); + let bank = &app.project_state.project.banks[app.editor_ctx.bank]; + let pattern = &bank.patterns[app.editor_ctx.pattern]; + + let bank_num = format!("{:02}", app.editor_ctx.bank + 1); + let pattern_num = format!("{:02}", app.editor_ctx.pattern + 1); + let bank_name = bank.name.as_deref().unwrap_or(""); + let pattern_name = pattern.name.as_deref().unwrap_or(""); + + let inner = ModalFrame::new("") + .width(34) + .height(5) + .border_color(theme.modal.border_accent) + .render_centered(frame, term); + + let bank_style = Style::new().fg(theme.header.bank_fg).bold(); + let pattern_style = Style::new().fg(theme.header.pattern_fg).bold(); + let dim = Style::new().fg(theme.ui.text_dim); + let divider = Style::new().fg(theme.ui.border); + + let line1 = Line::from(vec![ + Span::styled(" BANK ", bank_style), + Span::styled("│", divider), + Span::styled(" PATTERN ", pattern_style), + ]); + let line2 = Line::from(vec![ + Span::styled(format!(" {bank_num} "), bank_style), + Span::styled(format!("{bank_name:<10}"), dim), + Span::styled("│", divider), + Span::styled(format!(" {pattern_num} "), pattern_style), + Span::styled(format!("{pattern_name:<11}"), dim), + ]); + + frame.render_widget( + Paragraph::new(line1), + Rect::new(inner.x, inner.y, inner.width, 1), + ); + frame.render_widget( + Paragraph::new(line2), + Rect::new(inner.x, inner.y + 1, inner.width, 1), + ); + + Rect::new( + inner.x.saturating_sub(1), + inner.y.saturating_sub(1), + inner.width + 2, + inner.height + 2, + ) +} + fn header_height(_width: u16) -> u16 { 3 } @@ -660,10 +722,6 @@ fn render_modal( .height(18) .render_centered(frame, term) } - Modal::Preview => { - let user_words: HashSet = app.dict.lock().keys().cloned().collect(); - render_modal_preview(frame, app, snapshot, &user_words, term) - } Modal::Editor => { let user_words: HashSet = app.dict.lock().keys().cloned().collect(); render_modal_editor(frame, app, snapshot, &user_words, term) @@ -878,64 +936,6 @@ fn render_modal( )) } -fn render_modal_preview( - frame: &mut Frame, - app: &App, - snapshot: &SequencerSnapshot, - user_words: &HashSet, - term: Rect, -) -> Rect { - let theme = theme::get(); - let width = (term.width * 80 / 100).max(40); - let height = (term.height * 80 / 100).max(10); - - 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!("Step {:02}: {} → {:02}", step_idx + 1, name, src + 1) - } - (None, Some(name)) => format!("Step {:02}: {}", step_idx + 1, name), - (Some(src), None) => format!("Step {:02} → {:02}", step_idx + 1, src + 1), - (None, None) => format!("Step {:02}", step_idx + 1), - }; - - let inner = ModalFrame::new(&title) - .width(width) - .height(height) - .border_color(theme.modal.preview) - .render_centered(frame, term); - - 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_area = Rect { - y: inner.y + inner.height / 2, - height: 1, - ..inner - }; - frame.render_widget(empty, centered_area); - } else { - 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 lines = highlight_script_lines(script, trace, user_words, usize::MAX); - frame.render_widget(Paragraph::new(lines), inner); - } - - inner -} - fn render_modal_editor( frame: &mut Frame, app: &App,