Feat: UI / UX fixes
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 9m42s
Deploy Website / deploy (push) Failing after 32s
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 9m42s
Deploy Website / deploy (push) Failing after 32s
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
set -euo pipefail
|
||||
|
||||
# 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
|
||||
echo "Usage: $0 <binary-path> <arch> <output-dir>"
|
||||
@@ -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"
|
||||
|
||||
@@ -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::<Vec<_>>().join("\n");
|
||||
let _ = clip.set_text(text);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -60,7 +60,6 @@ pub struct App {
|
||||
// Held to keep the Arc alive (shared with ScriptEngine).
|
||||
pub _rng: Rng,
|
||||
pub live_keys: Arc<LiveKeyState>,
|
||||
pub clipboard: Option<arboard::Clipboard>,
|
||||
pub copied_patterns: Option<Vec<Pattern>>,
|
||||
pub copied_banks: Option<Vec<Bank>>,
|
||||
|
||||
@@ -115,7 +114,6 @@ impl App {
|
||||
_rng: rng,
|
||||
live_keys,
|
||||
script_engine,
|
||||
clipboard: arboard::Clipboard::new().ok(),
|
||||
copied_patterns: None,
|
||||
copied_banks: None,
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ pub enum AppCommand {
|
||||
PrevStep,
|
||||
StepUp,
|
||||
StepDown,
|
||||
NextPattern,
|
||||
PrevPattern,
|
||||
NextBank,
|
||||
PrevBank,
|
||||
|
||||
// Pattern editing
|
||||
ToggleSteps,
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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') => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -77,7 +77,6 @@ pub enum Modal {
|
||||
JumpToStep(String),
|
||||
AddSamplePath(Box<FileBrowserState>),
|
||||
Editor,
|
||||
Preview,
|
||||
PatternProps {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
|
||||
@@ -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<Instant>,
|
||||
pub nav_fx: RefCell<Option<Effect>>,
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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<NavTile> = 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<String> = app.dict.lock().keys().cloned().collect();
|
||||
render_modal_preview(frame, app, snapshot, &user_words, term)
|
||||
}
|
||||
Modal::Editor => {
|
||||
let user_words: HashSet<String> = 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<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(
|
||||
frame: &mut Frame,
|
||||
app: &App,
|
||||
|
||||
Reference in New Issue
Block a user