Feat: UI / UX fixes

This commit is contained in:
2026-02-26 21:17:53 +01:00
parent f618f47811
commit 6b56655661
20 changed files with 268 additions and 169 deletions

View File

@@ -7,6 +7,8 @@ RUN dpkg --add-architecture arm64 && \
libclang-dev \ libclang-dev \
libasound2-dev:arm64 \ libasound2-dev:arm64 \
libjack-dev:arm64 \ libjack-dev:arm64 \
libx11-dev:arm64 \
libx11-xcb-dev:arm64 \
libxcb-render0-dev:arm64 \ libxcb-render0-dev:arm64 \
libxcb-shape0-dev:arm64 \ libxcb-shape0-dev:arm64 \
libxcb-xfixes0-dev:arm64 \ libxcb-xfixes0-dev:arm64 \

View File

@@ -6,6 +6,8 @@ RUN apt-get update && \
libclang-dev \ libclang-dev \
libasound2-dev \ libasound2-dev \
libjack-dev \ libjack-dev \
libx11-dev \
libx11-xcb-dev \
libxcb-render0-dev \ libxcb-render0-dev \
libxcb-shape0-dev \ libxcb-shape0-dev \
libxcb-xfixes0-dev \ libxcb-xfixes0-dev \

View File

@@ -314,7 +314,7 @@ copy_artifacts() {
# Plugin artifacts for native targets (cross handled in bundle_plugins_cross) # Plugin artifacts for native targets (cross handled in bundle_plugins_cross)
if $build_plugins && ! is_cross_target "$platform"; then if $build_plugins && ! is_cross_target "$platform"; then
local bundle_dir="$rd/bundle" local bundle_dir="target/bundled"
# CLAP # CLAP
local clap_src="$bundle_dir/${PLUGIN_NAME}.clap" local clap_src="$bundle_dir/${PLUGIN_NAME}.clap"

View File

@@ -2,7 +2,9 @@
set -euo pipefail set -euo pipefail
# Usage: scripts/make-appimage.sh <binary-path> <arch> <output-dir> # Usage: scripts/make-appimage.sh <binary-path> <arch> <output-dir>
# 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 if [[ $# -ne 3 ]]; then
echo "Usage: $0 <binary-path> <arch> <output-dir>" echo "Usage: $0 <binary-path> <arch> <output-dir>"
@@ -16,121 +18,124 @@ OUTDIR="$3"
REPO_ROOT="$(git rev-parse --show-toplevel)" REPO_ROOT="$(git rev-parse --show-toplevel)"
CACHE_DIR="$REPO_ROOT/.cache" CACHE_DIR="$REPO_ROOT/.cache"
APP_NAME="$(basename "$BINARY")" 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_URL="https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-${ARCH}"
RUNTIME="$CACHE_DIR/runtime-${ARCH}" RUNTIME="$CACHE_DIR/runtime-${ARCH}"
# Map arch to linuxdeploy's expected values build_appdir() {
case "$ARCH" in local appdir="$1"
x86_64) export LDAI_ARCH="x86_64" ;; mkdir -p "$appdir/usr/bin"
aarch64) export LDAI_ARCH="aarch64" ;; cp "$BINARY" "$appdir/usr/bin/cagire"
*) echo "Unsupported arch: $ARCH"; exit 1 ;; chmod +x "$appdir/usr/bin/cagire"
esac
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" 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 if [[ ! -f "$RUNTIME" ]]; then
echo " Downloading AppImage runtime for $ARCH..." echo " Downloading AppImage runtime for $ARCH..."
curl -fSL "$RUNTIME_URL" -o "$RUNTIME" curl -fSL "$RUNTIME_URL" -o "$RUNTIME"
fi fi
} }
build_appdir() { run_native() {
mkdir -p "$APPDIR/usr/bin" local linuxdeploy_url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage"
cp "$BINARY" "$APPDIR/usr/bin/cagire" local linuxdeploy="$CACHE_DIR/linuxdeploy-${ARCH}.AppImage"
chmod +x "$APPDIR/usr/bin/cagire"
mkdir -p "$APPDIR/usr/share/icons/hicolor/512x512/apps" mkdir -p "$CACHE_DIR"
cp "$REPO_ROOT/assets/Cagire.png" "$APPDIR/usr/share/icons/hicolor/512x512/apps/cagire.png" 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
export ARCH="$LDAI_ARCH"
export LDAI_RUNTIME_FILE="$RUNTIME" export LDAI_RUNTIME_FILE="$RUNTIME"
"$LINUXDEPLOY" \ "$linuxdeploy" \
--appimage-extract-and-run \ --appimage-extract-and-run \
--appdir "$APPDIR" \ --appdir "$appdir" \
--desktop-file "$APPDIR/cagire.desktop" \ --desktop-file "$appdir/cagire.desktop" \
--icon-file "$APPDIR/usr/share/icons/hicolor/512x512/apps/cagire.png" \ --icon-file "$appdir/usr/share/icons/hicolor/512x512/apps/cagire.png" \
--output appimage --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 local platform
case "$ARCH" in case "$ARCH" in
x86_64) platform="linux/amd64" ;; x86_64) platform="linux/amd64" ;;
aarch64) platform="linux/arm64" ;; aarch64) platform="linux/arm64" ;;
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
esac esac
local appdir
appdir="$(mktemp -d)/AppDir"
build_appdir "$appdir"
local image_tag="cagire-appimage-${ARCH}" local image_tag="cagire-appimage-${ARCH}"
echo " Building Docker image $image_tag ($platform)..." echo " Building Docker image $image_tag ($platform)..."
docker build --platform "$platform" -q -t "$image_tag" - <<'DOCKERFILE' docker build --platform "$platform" -q -t "$image_tag" - <<'DOCKERFILE'
FROM ubuntu:22.04 FROM ubuntu:22.04
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
file \ squashfs-tools \
libasound2 \
libjack0 \
libxcb-render0 \
libxcb-shape0 \
libxcb-xfixes0 \
libxkbcommon0 \
libgl1 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
DOCKERFILE DOCKERFILE
echo " Running linuxdeploy inside Docker ($image_tag)..." echo " Creating squashfs via Docker ($image_tag)..."
docker run --rm --platform "$platform" \ docker run --rm --platform "$platform" \
-v "$REPO_ROOT:/project" \ -v "$appdir:/appdir:ro" \
-v "$APPDIR:/appdir" \
-v "$CACHE_DIR:/cache" \ -v "$CACHE_DIR:/cache" \
-e ARCH="$LDAI_ARCH" \
-e LDAI_RUNTIME_FILE="/cache/runtime-${ARCH}" \
-w /project \
"$image_tag" \ "$image_tag" \
bash -c " mksquashfs /appdir /cache/appimage-${ARCH}.squashfs \
chmod +x /cache/linuxdeploy-${ARCH}.AppImage && \ -root-owned -noappend -comp gzip -no-progress
/cache/linuxdeploy-${ARCH}.AppImage \
--appimage-extract-and-run \ mkdir -p "$OUTDIR"
--appdir /appdir \ local final="$OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage"
--desktop-file /appdir/cagire.desktop \ cat "$RUNTIME" "$CACHE_DIR/appimage-${ARCH}.squashfs" > "$final"
--icon-file /appdir/usr/share/icons/hicolor/512x512/apps/cagire.png \ chmod +x "$final"
--output appimage rm -f "$CACHE_DIR/appimage-${ARCH}.squashfs"
" echo " AppImage -> $final"
} }
HOST_ARCH="$(uname -m)" HOST_ARCH="$(uname -m)"
download_tools download_runtime
build_appdir
echo " Building AppImage for cagire ($ARCH)..." echo " Building AppImage for ${APP_NAME} ($ARCH)..."
if [[ "$HOST_ARCH" == "$ARCH" ]] && [[ "$(uname -s)" == "Linux" ]]; then if [[ "$HOST_ARCH" == "$ARCH" ]] && [[ "$(uname -s)" == "Linux" ]]; then
run_linuxdeploy_native run_native
else else
run_linuxdeploy_docker run_docker
fi 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"

View File

@@ -175,7 +175,7 @@ impl App {
match model::share::export(pattern_data) { match model::share::export(pattern_data) {
Ok(encoded) => { Ok(encoded) => {
let len = encoded.len(); let len = encoded.len();
if let Some(clip) = &mut self.clipboard { if let Ok(mut clip) = arboard::Clipboard::new() {
let _ = clip.set_text(encoded); let _ = clip.set_text(encoded);
} }
if len > 2000 { if len > 2000 {
@@ -201,7 +201,7 @@ impl App {
match model::share::export_bank(bank_data) { match model::share::export_bank(bank_data) {
Ok(encoded) => { Ok(encoded) => {
let len = encoded.len(); let len = encoded.len();
if let Some(clip) = &mut self.clipboard { if let Ok(mut clip) = arboard::Clipboard::new() {
let _ = clip.set_text(encoded); let _ = clip.set_text(encoded);
} }
if len > 2000 { if len > 2000 {
@@ -223,7 +223,7 @@ impl App {
} }
pub fn import_bank(&mut self, bank: usize) { 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, Some(t) => t,
None => { None => {
self.ui.flash("Clipboard empty", 150, FlashKind::Error); self.ui.flash("Clipboard empty", 150, FlashKind::Error);
@@ -250,7 +250,7 @@ impl App {
} }
pub fn import_pattern(&mut self, bank: usize, pattern: usize) { 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, Some(t) => t,
None => { None => {
self.ui self.ui
@@ -305,7 +305,7 @@ impl App {
&indices, &indices,
); );
let count = copied.steps.len(); 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::<Vec<_>>().join("\n"); let text: String = copied.steps.iter().map(|s| s.script.as_str()).collect::<Vec<_>>().join("\n");
let _ = clip.set_text(text); let _ = clip.set_text(text);
} }

View File

@@ -49,6 +49,10 @@ impl App {
AppCommand::PrevStep => self.prev_step(), AppCommand::PrevStep => self.prev_step(),
AppCommand::StepUp => self.step_up(), AppCommand::StepUp => self.step_up(),
AppCommand::StepDown => self.step_down(), 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 // Pattern editing
AppCommand::ToggleSteps => self.toggle_steps(), AppCommand::ToggleSteps => self.toggle_steps(),

View File

@@ -60,7 +60,6 @@ pub struct App {
// Held to keep the Arc alive (shared with ScriptEngine). // Held to keep the Arc alive (shared with ScriptEngine).
pub _rng: Rng, pub _rng: Rng,
pub live_keys: Arc<LiveKeyState>, pub live_keys: Arc<LiveKeyState>,
pub clipboard: Option<arboard::Clipboard>,
pub copied_patterns: Option<Vec<Pattern>>, pub copied_patterns: Option<Vec<Pattern>>,
pub copied_banks: Option<Vec<Bank>>, pub copied_banks: Option<Vec<Bank>>,
@@ -115,7 +114,6 @@ impl App {
_rng: rng, _rng: rng,
live_keys, live_keys,
script_engine, script_engine,
clipboard: arboard::Clipboard::new().ok(),
copied_patterns: None, copied_patterns: None,
copied_banks: None, copied_banks: None,

View File

@@ -1,5 +1,8 @@
//! Step and bank/pattern cursor navigation. //! Step and bank/pattern cursor navigation.
use cagire_project::{MAX_BANKS, MAX_PATTERNS};
use tachyonfx::Motion;
use super::App; use super::App;
impl App { impl App {
@@ -55,4 +58,22 @@ impl App {
self.editor_ctx.step = 0; self.editor_ctx.step = 0;
self.load_step_to_editor(); 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);
}
} }

View File

@@ -21,6 +21,10 @@ pub enum AppCommand {
PrevStep, PrevStep,
StepUp, StepUp,
StepDown, StepDown,
NextPattern,
PrevPattern,
NextBank,
PrevBank,
// Pattern editing // Pattern editing
ToggleSteps, ToggleSteps,

View File

@@ -131,6 +131,7 @@ pub enum SeqCommand {
length: usize, length: usize,
}, },
StopAll, StopAll,
RestartAll,
ResetScriptState, ResetScriptState,
Shutdown, Shutdown,
} }
@@ -712,6 +713,23 @@ impl SequencerState {
self.runs_counter.counts.clear(); self.runs_counter.counts.clear();
self.audio_state.flush_midi_notes = true; 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 => { SeqCommand::ResetScriptState => {
// Clear shared state instead of replacing - preserves sharing with app // Clear shared state instead of replacing - preserves sharing with app
self.variables.store(Arc::new(HashMap::new())); self.variables.store(Arc::new(HashMap::new()));

View File

@@ -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(']') => ctx.dispatch(AppCommand::SpeedIncrease),
KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)), KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)),
KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)), 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 => { KeyCode::Delete | KeyCode::Backspace => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
if let Some(range) = ctx.app.editor_ctx.selection_range() { 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); ctx.app.send_mute_state(ctx.seq_cmd_tx);
} }
KeyCode::Char('d') => { KeyCode::Char('d') => {
ctx.dispatch(AppCommand::OpenPreludeEditor);
}
KeyCode::Char('D') => {
ctx.dispatch(AppCommand::EvaluatePrelude); ctx.dispatch(AppCommand::EvaluatePrelude);
} }
KeyCode::Char('g') => { KeyCode::Char('g') => {

View File

@@ -14,7 +14,7 @@ use arc_swap::ArcSwap;
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent};
use ratatui::layout::Rect; use ratatui::layout::Rect;
use std::sync::atomic::{AtomicBool, AtomicI64}; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -87,7 +87,7 @@ fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool {
match (key.code, key.kind) { match (key.code, key.kind) {
_ if !matches!(ctx.app.ui.modal, Modal::None) => false, _ if !matches!(ctx.app.ui.modal, Modal::None) => false,
_ if ctx.app.page == Page::Script && ctx.app.script_editor.focused => 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); ctx.dispatch(AppCommand::ToggleLiveKeysFill);
true 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 { fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); 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 { if ctx.app.panel.visible && ctx.app.panel.focus == PanelFocus::Side {
return panel::handle_panel_input(ctx, key); 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 { if ctrl {
let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(250)); let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(250));
match key.code { match key.code {

View File

@@ -383,19 +383,19 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
KeyCode::Char('c') if ctrl => { KeyCode::Char('c') if ctrl => {
ctx.app.editor_ctx.editor.copy(); ctx.app.editor_ctx.editor.copy();
let text = ctx.app.editor_ctx.editor.yank_text(); 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); let _ = clip.set_text(text);
} }
} }
KeyCode::Char('x') if ctrl => { KeyCode::Char('x') if ctrl => {
ctx.app.editor_ctx.editor.cut(); ctx.app.editor_ctx.editor.cut();
let text = ctx.app.editor_ctx.editor.yank_text(); 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); let _ = clip.set_text(text);
} }
} }
KeyCode::Char('v') if ctrl => { 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() { if let Ok(text) = clip.get_text() {
ctx.app.editor_ctx.editor.set_yank_text(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); 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 { Modal::PatternProps {
bank, bank,
pattern, pattern,

View File

@@ -130,7 +130,7 @@ fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
ctx.dispatch(AppCommand::ClearStatus); 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) { if !matches!(ctx.app.ui.modal, Modal::None) {
handle_modal_click(ctx, col, row, term); handle_modal_click(ctx, col, row, term);
return; return;
@@ -893,9 +893,6 @@ fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
Modal::Editor => { Modal::Editor => {
handle_editor_mouse(ctx, col, row, term, false); handle_editor_mouse(ctx, col, row, term, false);
} }
Modal::Preview => {
// Don't dismiss preview on click
}
Modal::Confirm { .. } => { Modal::Confirm { .. } => {
handle_confirm_click(ctx, col, row, term); handle_confirm_click(ctx, col, row, term);
} }

View File

@@ -338,7 +338,8 @@ fn main() -> io::Result<()> {
let effects_active = app.ui.effects.borrow().is_running() let effects_active = app.ui.effects.borrow().is_running()
|| app.ui.modal_fx.borrow().is_some() || 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.playback.playing || had_event || app.ui.show_title || effects_active {
if app.ui.show_title { if app.ui.show_title {
app.ui.sparkles.tick(terminal.get_frame().area()); app.ui.sparkles.tick(terminal.get_frame().area());

View File

@@ -9,6 +9,7 @@ use crate::theme;
pub enum FxId { pub enum FxId {
#[default] #[default]
PageTransition, PageTransition,
NavSwitch,
} }
pub fn tick_effects(ui: &mut UiState, page: Page) { 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 { fn page_direction(from: Page, to: Page) -> Motion {
let (fc, fr) = from.grid_pos(); let (fc, fr) = from.grid_pos();
let (tc, tr) = to.grid_pos(); let (tc, tr) = to.grid_pos();

View File

@@ -77,7 +77,6 @@ pub enum Modal {
JumpToStep(String), JumpToStep(String),
AddSamplePath(Box<FileBrowserState>), AddSamplePath(Box<FileBrowserState>),
Editor, Editor,
Preview,
PatternProps { PatternProps {
bank: usize, bank: usize,
pattern: usize, pattern: usize,

View File

@@ -3,7 +3,7 @@ use std::time::{Duration, Instant};
use cagire_markdown::ParsedMarkdown; use cagire_markdown::ParsedMarkdown;
use cagire_ratatui::Sparkles; use cagire_ratatui::Sparkles;
use tachyonfx::{fx, Effect, EffectManager, Interpolation}; use tachyonfx::{fx, Effect, EffectManager, Interpolation, Motion};
use crate::page::Page; use crate::page::Page;
use crate::state::effects::FxId; use crate::state::effects::FxId;
@@ -83,6 +83,8 @@ pub struct UiState {
pub window_height: u32, pub window_height: u32,
pub load_demo_on_startup: bool, pub load_demo_on_startup: bool,
pub demo_index: usize, pub demo_index: usize,
pub nav_indicator_until: Option<Instant>,
pub nav_fx: RefCell<Option<Effect>>,
} }
impl Default for UiState { impl Default for UiState {
@@ -135,6 +137,8 @@ impl Default for UiState {
window_height: 800, window_height: 800,
load_demo_on_startup: true, load_demo_on_startup: true,
demo_index: 0, demo_index: 0,
nav_indicator_until: None,
nav_fx: RefCell::new(None),
} }
} }
} }
@@ -196,6 +200,19 @@ impl UiState {
self.minimap = MinimapMode::Hidden; 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) { pub fn invalidate_help_cache(&self) {
self.help_parsed self.help_parsed
.borrow_mut() .borrow_mut()

View File

@@ -12,6 +12,7 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati
("s", "Save", "Save project"), ("s", "Save", "Save project"),
("l", "Load", "Load project"), ("l", "Load", "Load project"),
("?", "Keybindings", "Show this help"), ("?", "Keybindings", "Show this help"),
("F12", "Restart", "Full restart from step 0"),
]); ]);
// Page-specific bindings // Page-specific bindings
@@ -20,12 +21,14 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati
if !plugin_mode { if !plugin_mode {
bindings.push(("Space", "Play/Stop", "Toggle playback")); 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(("←→↑↓", "Navigate", "Move cursor between steps"));
bindings.push(("Shift+←→↑↓", "Select", "Extend selection")); bindings.push(("Shift+←→↑↓", "Select", "Extend selection"));
bindings.push(("Esc", "Clear", "Clear selection")); bindings.push(("Esc", "Clear", "Clear selection"));
bindings.push(("Enter", "Edit", "Open step editor")); bindings.push(("Enter", "Edit", "Open step editor"));
bindings.push(("t", "Toggle", "Toggle selected steps")); 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(("Tab", "Samples", "Toggle sample browser"));
bindings.push(("Ctrl+C", "Copy", "Copy selected steps")); bindings.push(("Ctrl+C", "Copy", "Copy selected steps"));
bindings.push(("Ctrl+V", "Paste", "Paste 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(("x", "Solo", "Stage solo for current pattern"));
bindings.push(("M", "Clear mutes", "Clear all mutes")); bindings.push(("M", "Clear mutes", "Clear all mutes"));
bindings.push(("X", "Clear solos", "Clear all solos")); 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", "Share", "Export pattern to clipboard"));
bindings.push(("G", "Import", "Import pattern from clipboard")); bindings.push(("G", "Import", "Import pattern from clipboard"));
} }

View File

@@ -201,6 +201,17 @@ pub fn render(
} }
let modal_area = render_modal(frame, app, snapshot, term); 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() { if app.ui.show_minimap() {
let tiles: Vec<NavTile> = Page::ALL let tiles: Vec<NavTile> = Page::ALL
.iter() .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 { fn header_height(_width: u16) -> u16 {
3 3
} }
@@ -660,10 +722,6 @@ fn render_modal(
.height(18) .height(18)
.render_centered(frame, term) .render_centered(frame, term)
} }
Modal::Preview => {
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
render_modal_preview(frame, app, snapshot, &user_words, term)
}
Modal::Editor => { Modal::Editor => {
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect(); let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
render_modal_editor(frame, app, snapshot, &user_words, term) 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<String>,
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( fn render_modal_editor(
frame: &mut Frame, frame: &mut Frame,
app: &App, app: &App,