/*
 * Beat Rat -- Stage 2: track/clip model + progressive UI shell
 *
 * Layout (Audacity-inspired, XP styling):
 *   - Top panel: app/data controls (currently just Import).
 *   - Center: a stack of track rows, each showing its name and a
 *     peak-based waveform.
 *   - Bottom panel: transport controls (Record/Stop, Play/Pause).
 *
 * Scope notes (deliberate, not oversights):
 *   - Import loads a fixed filename (import_sample.wav) from the
 *     working directory -- no file picker yet.
 *   - Play/Pause is really Play/Stop: no position-preserving resume yet.
 *   - No scrolling if tracks overflow the window, no per-track timeline
 *     placement (every track starts at sample 0) -- both are tied to
 *     the scrub/seek work still to come.
 *   - Playback mixes all tracks simultaneously with basic int32 clamping
 *     as a safety limiter; no per-track gain/pan yet (that's Phase 5).
 */

#include "audio_device.h"
#include "confirm.h"
#include "export.h"
#include "file_picker.h"
#include "keybinds.h"
#include "keybinds_menu.h"
#include "log.h"
#include "register.h"
#include "settings.h"
#include "track.h"
#include "ui.h"
#include "version.h"
#include "wav.h"

#include <X11/Xlib.h>
#include <X11/Xutil.h>
#include <X11/keysym.h>
#include <ctype.h>
#include <errno.h>
#include <math.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>

/* Same rationale as settings.c: these snprintf calls build paths from
 * user-typed names that are already capped well under the destination
 * buffer sizes by the append helpers -- GCC can't see that guarantee
 * across translation units, so it warns about a truncation that can't
 * actually happen in practice. */
#pragma GCC diagnostic ignored "-Wformat-truncation"

#define WIN_W 900
#define WIN_H 600
#define MIN_WIN_W 820
#define MIN_WIN_H 500
#define TOP_PANEL_H 40
#define RULER_H 18
/* Horizontal scrollbar for the timeline -- sandwiched between the
 * bottom time ruler's tick marks and the transport panel below it, per
 * the user's request for a mouse-drivable horizontal scroll (the wheel
 * and drag-to-pan already existed, but nothing you could just grab).
 * Carves its own strip out of the bottom, same way RULER_H's second
 * ruler does, rather than sharing either one's space. */
#define HSCROLL_H 12
/* Room for the transport panel itself PLUS a second time ruler drawn
 * just above it (see redraw()) -- so there's always a time reference
 * visible at whichever end of the track area you're looking at, not
 * just the one at the top which can scroll out of view once you're
 * zoomed in on a track far from its start. */
#define BOTTOM_PANEL_H (50 + RULER_H)
/* A dedicated strip for the interaction-mode/nav-context indicator
 * badges (see redraw()), between the transport buttons and the ruler.
 * The bottom transport row is already packed edge to edge at
 * MIN_WIN_W, so these get their own row instead of fighting for pixels
 * in it -- TRACK_AREA_TOP (and everything derived from it, both here
 * and in handle_click()) shifts down to make room. */
#define MODE_STRIP_H 22
#define TRACK_AREA_TOP (TOP_PANEL_H + MODE_STRIP_H + RULER_H)
#define TRACK_ROW_H 90
#define TRACK_ROW_PAD 6

/* Thin draggable scroll indicator to the left of the track label strip
 * -- shows where the current vertical scroll position sits within the
 * whole track list. TRACK_FIELD_X is the one true x-origin for the
 * label+waveform field, shared by handle_click(), redraw(), and the
 * drag-select MotionNotify handling, so all three agree on where the
 * track rows actually start now that the scrollbar takes some of the
 * left margin that used to be TRACK_ROW_PAD alone. */
#define SCROLLBAR_W 10
#define SCROLLBAR_GAP 4
#define TRACK_FIELD_X (TRACK_ROW_PAD + SCROLLBAR_W + SCROLLBAR_GAP)

/* Vertical gap between the normal scrolling track rows and a pinned
 * mini-panel block above/below them (see compute_track_layout()). */
#define TRACK_PIN_GAP 6

/* The small arrow button on a pinned row that opens the "other pinned
 * tracks" dropdown when more than one track is pinned in that
 * direction, and the row height of each entry in that dropdown list. */
#define PIN_DROPDOWN_BTN_SIZE 16
#define PIN_DROPDOWN_ITEM_H 20

#define ZOOM_BTN_W 20
#define ZOOM_BTN_H 18
#define ZOOM_OUT_BTN_X 720
#define ZOOM_IN_BTN_X 744

#define BTN_W 80
#define BTN_H 26
#define IMPORT_BTN_X 90
#define IMPORT_BTN_Y 7
#define SAVE_BTN_X 180
#define EXPORT_BTN_X 270
#define SETTINGS_BTN_X 360
#define NEW_BTN_X 450
#define OPEN_BTN_X 540
#define AUDIO_BTN_X 630

#define AUDIO_BOX_W 480
#define AUDIO_BOX_H 200
#define AUDIO_OUTPUT_Y 46
#define AUDIO_INPUT_Y 86
#define AUDIO_VOLUME_Y 126
#define AUDIO_SHOW_HIDDEN_Y 158
#define AUDIO_PREV_X 8
#define AUDIO_PREV_W 24
#define AUDIO_ROW_H 22
#define AUDIO_HIDE_X 340
#define AUDIO_HIDE_W 60
#define AUDIO_NEXT_X 408
#define AUDIO_NEXT_W 24
#define AUDIO_VOL_MINUS_X 8
#define AUDIO_VOL_PLUS_X 408
#define REC_BTN_X 10
#define PLAY_BTN_X 100
#define RESTART_BTN_X 190
#define LOOP_REGION_BTN_X 280
#define LOOP_ALL_BTN_X 370
/* Whole-project mute -- right next to Loop All, the last of the
 * transport icon buttons, rather than off by itself past the time
 * readout. Narrower than the other transport buttons (BTN_W) since
 * it's a single icon with no "R"/"A"-style corner label to make room
 * for. TIME_READOUT_X shifts right by the same amount so the two
 * never collide, including when the readout grows its "(+N)"
 * insertion-point-count suffix. */
#define MUTE_ALL_BTN_X (LOOP_ALL_BTN_X + BTN_W + 10)
#define MUTE_ALL_BTN_W 50
#define BOTTOM_BTN_YOFF 12
#define TIME_READOUT_X (MUTE_ALL_BTN_X + MUTE_ALL_BTN_W + 20)

/* Mode/context indicator badges -- deliberately two SEPARATE boxes
 * (not one combined string) so interaction mode (NORMAL/INSERT/FX/
 * BEAT) and track-vs-region nav context are each readable at a glance
 * without parsing text. Drawn in their own strip (MODE_STRIP_H) below
 * the transport buttons, not crammed into the already-packed bottom
 * row. */
#define MODE_BADGE_X   8
#define NAVCTX_BADGE_X 86
#define MODE_BADGE_W   70
#define MODE_BADGE_H   18

#define SAVE_BOX_W 420
#define SAVE_BOX_H 130
#define SAVE_FIELD_Y 46
#define SAVE_FIELD_H 22

#define EXPORT_BOX_W 460
#define EXPORT_BOX_H 170
#define EXPORT_FIELD_Y 46
#define EXPORT_FIELD_H 22
#define EXPORT_FORMAT_BTN_Y 82
#define EXPORT_FORMAT_BTN_W 70
#define EXPORT_FORMAT_BTN_H 24
#define EXPORT_WAV_BTN_X 8
#define EXPORT_FLAC_BTN_X 86
#define EXPORT_MP3_BTN_X 164

#define LOG_PATH "beatrat.log"
#define SAMPLE_RATE 44100
#define CHANNELS 1

typedef enum {
    STATE_IDLE,
    STATE_RECORDING,
    STATE_PLAYING,
    STATE_IMPORTING,
    STATE_SAVING,
    STATE_EXPORTING,
    STATE_LOADING
} AppState;

#define NAV_CONTEXT_TRACKS 0
#define NAV_CONTEXT_PLAYBACK 1

/* Top-level interaction mode -- separate from nav_context (which of the
 * two SELECTIONS is active). This is which set of keys the keyboard
 * currently means, LazyVim/vim-style. Starts in NORMAL, like vim.
 * FX and BEAT are scaffolding only for now: you can switch into them
 * and back out, and the mode indicator shows it, but neither one does
 * anything special yet -- functionality lands in a later round. */
typedef enum {
    MODE_NORMAL = 0, /* default -- transport, navigation; gates the leader key and ':' */
    MODE_INSERT,     /* copy/paste "placement" mode -- vim-style hjkl (see KeyPress handling) */
    MODE_FX,         /* effects -- backbone only, no functionality yet */
    MODE_BEAT        /* MPC-style beat controls -- backbone only, no functionality yet */
} AppMode;

#define MAX_INSERTION_POINTS 64

/* One cut insertion point -- see AppContext's insertion_points comment
 * for the full picture. track_mask is a bitmask over track indices
 * (bit i set = this point is a boundary for track i); a point whose
 * mask covers every track that existed when it was dropped behaves as
 * "global" for all practical purposes without needing a separate
 * sentinel value. */
typedef struct {
    size_t frame;
    uint32_t track_mask;
} InsertionPoint;

typedef struct {
    Display *dpy;
    Window win;
    GC gc;
    int win_w, win_h; /* current window size -- updated on ConfigureNotify (resize) */

    /* Off-screen backing pixmap for redraw() -- everything gets drawn
     * here first, then blitted onto the real window in one XCopyArea
     * at the very end, instead of every XFillRectangle/XDrawString
     * landing straight on screen one at a time. Without this, redraw()
     * at playback/record's ~43Hz dirty-every-chunk rate visibly
     * flickered (background wipe, then each layer painted back in over
     * it, all independently visible mid-frame). Recreated whenever the
     * window is resized (see redraw()'s matching check). None (0)
     * until the first redraw() call creates it. */
    Pixmap backbuf;
    int backbuf_w, backbuf_h;
    TrackList tracklist;
    atomic_int state;
    atomic_bool dirty;
    atomic_bool stop_requested;
    FilePicker picker;
    atomic_bool picker_active;
    Settings settings;
    atomic_bool settings_active;
    atomic_int settings_active_field; /* 0 = import dir, 1 = export dir, 2 = add-alt-dir entry */
    char altdir_input_buf[512];
    /* Settings > "Keybinds..." -- see keybinds_menu.h. selected/scroll
     * are row indices into the flat KEYBINDS_MENU_NUM_ROWS list;
     * awaiting_key is true between pressing Enter (or clicking the
     * already-selected row) and the next keypress, which becomes the
     * new binding. status isn't atomic -- like altdir_input_buf above,
     * it's only ever touched from the main thread's own event loop. */
    atomic_bool keybinds_menu_active;
    atomic_int keybinds_menu_selected;
    atomic_int keybinds_menu_scroll;
    atomic_bool keybinds_menu_awaiting_key;
    char keybinds_menu_status[96];
    atomic_size_t playhead_frame; /* shared position across the whole project timeline, in frames */
    atomic_size_t seek_target;    /* where to jump to, when seek_pending is set */
    atomic_bool seek_pending;     /* true = a scrub happened mid-playback; thread should jump to seek_target */
    atomic_int selected_track_index; /* -1 = none selected */
    /* Monotonic "Track N" counter for freshly recorded/imported tracks --
     * deliberately NOT derived from tracklist.num_tracks (that count
     * goes back down on delete, so "Track %d" using it could hand out
     * the same number twice in one session). Only these two creation
     * paths touch it; re-recording, pasting, and loading a saved
     * project all keep an existing/explicit name instead. */
    atomic_int next_track_number;

    atomic_bool save_active;
    char save_name_buf[256];

    atomic_bool export_active;
    char export_name_buf[256];

    ConfirmDialog confirm;
    atomic_bool confirm_active;
    int confirm_pending_action; /* 0 = none, 1 = resume export after creating its directory */
    char confirm_pending_dir[512];
    char pending_export_path[768];
    ExportFormat pending_export_format;

    int picker_purpose; /* 0 = Import (add a WAV as a track), 1 = Open Project */
    char pending_open_project_path[900];

    long last_label_click_ms;
    int last_label_click_idx;

    atomic_bool rename_active;
    char rename_buf[64];
    int rename_track_idx;

    /* "+New Track" (the virtual row just past the real track list,
     * main.c's handle_new_track_click()) -- only needs to ask for a
     * length when there's nothing else yet to size it against; with
     * tracks already present it just matches the current project
     * runtime with no prompt at all. Two small fields ("Length: {m}m
     * {s}s") rather than one seconds field -- new_track_field picks
     * which one Tab/typing is currently going into (0 = minutes,
     * 1 = seconds). */
    atomic_bool new_track_prompt_active;
    char new_track_min_buf[8];
    char new_track_sec_buf[8];
    atomic_int new_track_field;

    size_t view_start_frame;      /* leftmost visible frame */
    double samples_per_pixel;     /* zoom level: lower = more zoomed in */
    int zoom_manually_set;        /* once true, stop auto-fitting the view to new tracks */
    KeyBindMap keybinds;

    atomic_bool has_selection;
    size_t selection_start_frame;
    size_t selection_end_frame;
    int selection_track_idx;      /* -1 = all tracks (global region), else one specific track */

    /* Cut insertion points: STICKY markers 'cut' drops on the
     * timeline, separate from both the playhead and any selection --
     * they don't move when you scrub or play, and the nearest one
     * applying to a given track is what paste targets there (in
     * place of the playhead) once it exists. Generalized from a
     * single global marker into a bounded LIST, each entry carrying
     * its own track_mask: cutting with nothing selected (no track or
     * group active) drops a point scoped to every CURRENT track --
     * "global", the whole-project splice; cutting with a track or
     * group selected scopes the point to just that track/group
     * instead. Multiple points can coexist, including more than one
     * at the very same frame scoped to different tracks -- that's
     * what lets a track build up several of its OWN snippet
     * boundaries (see track_snippet_bounds()) independently of
     * what's going on elsewhere on the timeline. A real region-cut
     * sets its point at the seam the cut left behind, in addition to
     * removing that audio as usual; cutting with nothing SELECTED as
     * a region sets it at the current playhead position WITHOUT
     * touching any audio at all -- a pure "split point". Pressing cut
     * again exactly on an existing point (same frame, same scope)
     * removes it instead of adding a duplicate. Cleared via Escape
     * (nearest one to the playhead) or by a snippet-move operation
     * that needs to fork/relocate one -- see do_action()'s
     * ACTION_TOGGLE_TRACK_MOVE (PLAYBACK-context branch). Guarded by
     * its own mutex since list MEMBERSHIP can change, not just one
     * value -- that can't be expressed as a single atomic op the way
     * the old single marker was. */
    pthread_mutex_t insertion_points_mutex;
    InsertionPoint insertion_points[MAX_INSERTION_POINTS];
    int num_insertion_points;

    /* Whitespace insertion (INSERT-mode space, space -- see
     * do_whitespace_mark()/do_confirm_whitespace()). The first press
     * drops whitespace_mark_frame at the playhead and sets
     * whitespace_pending; while pending, do_scrub() diverts h/l/
     * Left/Right/Ctrl+Left/Right to move whitespace_cursor_frame
     * instead of the real playhead, and that cursor is deliberately a
     * signed `long` rather than size_t -- it's allowed to go negative
     * ("pre-roll" scrolling past the project's frame 0), which the
     * confirm step turns into actual runtime by shifting the playhead
     * and insertion point forward. The second press confirms and
     * clears whitespace_pending; Escape (ACTION_CANCEL_SELECTION)
     * cancels it instead, before that action's normal mode-exit check. */
    atomic_bool whitespace_pending;
    atomic_long whitespace_mark_frame;
    atomic_long whitespace_cursor_frame;

    /* Visual-mode resize anchor/cursor: selection_start/end_frame are
     * always derived as min/max(anchor, cursor) so the selection can
     * extend in either direction from where 'v' was pressed, the way
     * vim's visual mode does -- see ACTION_SCRUB_LEFT/RIGHT's visual
     * resize branch. Without a separate anchor, only the "end" edge
     * could move, which meant a selection could only ever grow forward
     * in time from its start, never extend backward past it. */
    size_t selection_anchor_frame;
    size_t selection_cursor_frame;
    atomic_bool visual_mode_active; /* true while actively resizing via 'v' + arrows */
    atomic_bool loop_region;       /* loop the selected region during playback */
    atomic_bool loop_all;         /* loop the whole project when there's no selection */
    atomic_int highlighted_track_idx; /* keyboard nav cursor, -1 = none; separate from selected_track_index until confirmed */

    atomic_int nav_context;      /* 0 = NAV_CONTEXT_TRACKS, 1 = NAV_CONTEXT_PLAYBACK -- what v/Shift+v/Escape act on */
    atomic_int group_start;      /* track-group selection anchor, -1 = no group */
    atomic_int group_end;        /* track-group selection cursor (may be < start; normalize at use) */
    atomic_bool leader_active;   /* true right after pressing the leader key, waiting for the chord key */

    /* <leader>m "track move" (ACTION_TOGGLE_TRACK_MOVE) -- only ever
     * touched from the main thread's own KeyPress handling, so plain
     * ints are fine here, same as e.g. confirm_pending_action. lo/hi
     * track the picked-up block's CURRENT position as it's reordered;
     * orig_lo/orig_hi are the position it started at, so Escape can
     * restore it exactly by walking the same number of steps back.
     * source records which of track_target_range()'s three fallbacks
     * (group/single-select/highlight-only) it came from, so each
     * Up/Down step can keep that same kind of state in sync with the
     * block's new position: 0 = group, 1 = single selection, 2 =
     * highlight cursor only. */
    atomic_bool track_move_active;
    int track_move_lo, track_move_hi;
    int track_move_orig_lo, track_move_orig_hi;
    int track_move_source;

    /* <leader>m's OTHER half: ACTION_TOGGLE_TRACK_MOVE in
     * NAV_CONTEXT_PLAYBACK picks up a single SNIPPET (a region of one
     * track's audio between two of its own insertion points) instead
     * of a whole track -- see do_action()'s PLAYBACK branch there and
     * track_snippet_bounds()/track_clip_swap_adjacent(). snippet_idx
     * is which snippet (0-based, into that track's current bounds
     * array) is picked up; Up/Down there swaps it with its immediate
     * neighbor rather than reordering a block like track-move does.
     * undo[]/undo_count is a stack of the exact (a0, mid, b1) swaps
     * performed, oldest first -- a swap is its own inverse, so Escape
     * just replays them back-to-front; dropping (<leader>m again or
     * Enter) just clears the stack and keeps the new arrangement. */
    atomic_bool snippet_move_active;
    int snippet_move_track;
    int snippet_move_idx;
    struct { size_t a0, mid, b1; } snippet_move_undo[MAX_INSERTION_POINTS];
    int snippet_move_undo_count;

    /* Shift+V ('V', ACTION_VISUAL_LINE_SELECT) in NAV_CONTEXT_PLAYBACK
     * on a single track that has real snippet boundaries: rather than
     * selecting the whole track in one shot, it starts on just the
     * snippet under the playhead and h/l (via do_scrub()'s visual-
     * resize branch) extend/shrink the selection one WHOLE SNIPPET at
     * a time instead of by frames. Cleared alongside visual_mode_active
     * whenever that selection is confirmed or cancelled; a track with
     * no boundaries at all keeps the old immediate whole-track
     * behavior and never sets this. */
    atomic_bool visual_line_snippet_stepping;

    atomic_bool audio_io_active;
    AudioDeviceInfo audio_output_devices[AUDIO_MAX_DEVICES];
    int audio_num_output_devices;
    int audio_output_idx; /* -1 = auto (PipeWire-preferred/ALSA-fallback), else index into audio_output_devices */
    AudioDeviceInfo audio_input_devices[AUDIO_MAX_DEVICES];
    int audio_num_input_devices;
    int audio_input_idx; /* -1 = auto */
    atomic_bool audio_show_hidden; /* session-only, not persisted -- reveals hidden devices for management */

    Register beatrat_register;   /* default copy/cut/paste register */
    Register system_register;    /* Shift (ctrl-style binds) / Super (vim-style binds) -- also offered to X11's CLIPBOARD, best-effort */
    Atom clipboard_atom;
    Atom targets_atom;
    Atom beatrat_audio_atom;      /* our custom clipboard target -- see register.c */

    /* Last copy/cut/void/paste result, shown in the bottom-panel status
     * readout in place of LOG_WARN/LOG_INFO's log-only output, which is
     * invisible during normal play. Just the latest message, no queue
     * or fade timer -- see set_reg_status(). */
    char last_reg_msg[80];

    /* Click-and-drag region selection over the waveform (mouse-driven
     * alternative to the keyboard v/Shift+v flow). mouse_button_down
     * tracks button state between events; the drag only actually
     * *commits* a selection (mouse_dragging_selection) once the pointer
     * has moved a few pixels from mouse_down_x/y, so a plain click still
     * just seeks like before. drag_anchor_frame/drag_track_idx capture
     * where the drag started so both directions (dragging left or
     * right of the anchor) resolve into a normalized [start,end). */
    atomic_bool mouse_button_down;
    atomic_bool mouse_dragging_selection;
    int mouse_down_x, mouse_down_y;
    size_t drag_anchor_frame;
    int drag_track_idx; /* -1 = whole-project (clicked between rows or global) */

    /* Vertical scroll position of the track list, in whole rows (index
     * of the first track drawn in the normal, non-pinned area) -- see
     * compute_track_layout(). scrollbar_dragging tracks a drag on the
     * thin scroll indicator the same way mouse_dragging_selection
     * tracks a waveform drag, just for a different mouse target. */
    int track_scroll_top;
    atomic_bool scrollbar_dragging;

    /* Same idea as scrollbar_dragging just above, for the new
     * horizontal scrollbar under the bottom time ruler -- drags
     * view_start_frame instead of track_scroll_top. */
    atomic_bool hscrollbar_dragging;

    /* Which pinned track gets the one visible row per direction, when
     * more than one track is pinned that way -- see TrackLayout's
     * active_top/active_bottom in compute_track_layout(). -1 means "no
     * explicit choice yet, use whichever is closest to the view". The
     * dropdown-open flags are which direction's "other pinned tracks"
     * list is currently expanded (see handle_click()); at most one
     * direction's dropdown is meaningfully open at a time in practice,
     * but there's no harm keeping them independent. */
    int pinned_active_top;
    int pinned_active_bottom;
    atomic_bool pin_top_dropdown_open;
    atomic_bool pin_bottom_dropdown_open;

    /* Hardcoded vim-style "gg" double-tap detection for ACTION_JUMP_START
     * (see the KeyPress handling in main()) -- deliberately not part of
     * the generic keybinds.conf system, since a bound action there is
     * one key(+mods) -> one action, not a same-key-twice sequence. Only
     * ever triggers for bare 'g' (no modifiers); everything else about
     * 'g' (or any rebinding of it) still goes through the normal lookup
     * untouched. */
    long last_g_press_ms;

    /* Interaction mode (NORMAL/INSERT/FX/BEAT -- see AppMode). Entirely
     * separate from nav_context (which of the two SELECTIONS is
     * active) -- this is which set of keys the keyboard currently
     * means. Starts in MODE_NORMAL, like vim starts in normal mode. */
    atomic_int current_mode;

    /* Barebones ':' command-line overlay, NORMAL-mode only (nvim-style,
     * opened with a literal colon -- XLookupKeysym never reports a
     * shifted keysym, so that's detected as XK_semicolon+ShiftMask, see
     * the KeyPress handling). cli_active is true while typing a
     * command; cli_result holds the last command's outcome (or the
     * built-in help text), shown in the status area until the next
     * command replaces it -- no fade timer, same "latest thing that
     * happened" approach as last_reg_msg. Only 'w' (open the Save
     * dialog) and 'h'/'help' exist right now -- a real command set can
     * grow here later without changing how the overlay itself opens,
     * closes, or captures typing. */
    atomic_bool cli_active;
    char cli_input[64];
    char cli_result[96];
} AppContext;

typedef struct {
    AppContext *app;
    char path[512]; /* only used by import_thread_fn */
    int replace_index; /* -1 = normal record (append new track); >=0 = re-record into this existing track */
} ThreadArg;

static int point_in_rect(int px, int py, int x, int y, int w, int h) {
    return px >= x && px < x + w && py >= y && py < y + h;
}

static int dir_exists(const char *path) {
    struct stat st;
    return (stat(path, &st) == 0 && S_ISDIR(st.st_mode));
}

/* Creates every path component in turn, ignoring EEXIST -- the
 * standard "mkdir -p" pattern. Used for user-configured export
 * directories, which may be several levels deep and not exist at all
 * yet (e.g. the default ~/Audio/Saved Beats). */
static void mkdir_p(const char *path) {
    char tmp[768];
    snprintf(tmp, sizeof(tmp), "%s", path);
    size_t len = strlen(tmp);
    if (len > 0 && tmp[len - 1] == '/')
        tmp[len - 1] = '\0';
    for (char *p = tmp + 1; *p; p++) {
        if (*p == '/') {
            *p = '\0';
            mkdir(tmp, 0755);
            *p = '/';
        }
    }
    mkdir(tmp, 0755);
}

static void default_timestamp_name(char *buf, size_t buf_size, const char *prefix) {
    time_t now = time(NULL);
    struct tm tmv;
    localtime_r(&now, &tmv);
    char stamp[32];
    strftime(stamp, sizeof(stamp), "%Y%m%d_%H%M%S", &tmv);
    snprintf(buf, buf_size, "%s_%s", prefix, stamp);
}

static long current_time_ms(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1000L + ts.tv_nsec / 1000000L;
}

/* Human-readable label for the leader popup -- only needs to cover
 * actions actually reachable via a leader chord, not the whole enum. */
static const char *action_display_name(AppAction action) {
    switch (action) {
        case ACTION_TOGGLE_NAV_CONTEXT: return "Toggle track/playback selector";
        default: return "?";
    }
}

/* Maps a handful of common modifier-key keysyms to their X11 modifier
 * mask, used only to tell "leader tapped and released, then a key
 * pressed" apart from "leader held down through the next keypress"
 * (see the KeyPress/leader_active handling in main()). Returns 0 for
 * anything not in this short list, which just means that disambiguation
 * always takes the tap-then-release (chord-menu) path for such a
 * leader key -- the only behavior that existed before this distinction
 * was added, so an unusual leader choice is never worse off than
 * before. */
static unsigned int keysym_to_modmask(KeySym ks) {
    switch (ks) {
        case XK_Super_L: case XK_Super_R: return Mod4Mask;
        case XK_Control_L: case XK_Control_R: return ControlMask;
        case XK_Shift_L: case XK_Shift_R: return ShiftMask;
        case XK_Alt_L: case XK_Alt_R: return Mod1Mask;
        default: return 0;
    }
}

/* Forward declarations: do_action (below) needs these, but they're
 * defined later in the file alongside the other playback/threading
 * code they logically belong with. */
static size_t compute_timeline_total(AppContext *app);
static void request_seek(AppContext *app, size_t new_frame);
static void spawn_detached(void *(*fn)(void *), AppContext *app);
static void *playback_thread_fn(void *arg);

#define ZOOM_MIN_SPP 1.0
#define ZOOM_MAX_SPP 20000.0
#define ZOOM_FACTOR 1.3

/* Whichever waveform pixel width the track rows actually use --
 * shared by the viewport math and the click-to-seek/scrub code so they
 * agree on what a "pixel" means. */
static int waveform_area_width(AppContext *app) {
    return (app->win_w - 2 * TRACK_ROW_PAD) - UI_TRACK_LABEL_W;
}

/* Until the user manually zooms, the view auto-fits the whole project
 * (matching the old always-fit behavior) so existing projects still
 * open looking the same as before this feature existed. Widened to
 * fit the playhead too, not just the real audio -- otherwise scrubbing
 * past the end of the audio (see ACTION_SCRUB_RIGHT, for marking a
 * paste point past the current end) pushed the playhead past the
 * right edge of this auto-fit view, which this function would then
 * immediately recompute right back to [0, total) on the very next
 * redraw, undoing ensure_frame_visible()'s work and making the
 * playhead vanish off-screen with no way to scroll to it. Fitting the
 * wider of the two keeps the normal auto-fit look whenever the
 * playhead is within the real audio (the overwhelmingly common case)
 * and only grows the view when it's actually needed. */
static void update_viewport_for_tracks(AppContext *app) {
    if (app->zoom_manually_set)
        return;
    size_t total = compute_timeline_total(app);
    size_t ph = atomic_load(&app->playhead_frame);
    if (ph > total) total = ph;
    int w = waveform_area_width(app);
    if (total == 0 || w <= 0) {
        app->samples_per_pixel = 1.0;
        app->view_start_frame = 0;
        return;
    }
    app->samples_per_pixel = (double)total / (double)w;
    if (app->samples_per_pixel < ZOOM_MIN_SPP) app->samples_per_pixel = ZOOM_MIN_SPP;
    app->view_start_frame = 0;
}

static void clamp_view_start(AppContext *app) {
    size_t total = compute_timeline_total(app);
    /* The playhead can now sit past the end of the actual audio (see
     * ACTION_SCRUB_RIGHT's uncapped step below, for positioning a paste
     * at "the end of things") -- widen what counts as "the timeline"
     * here too, so that doesn't get immediately clamped back by this
     * function on the very next redraw. */
    size_t ph = atomic_load(&app->playhead_frame);
    if (ph > total) total = ph;
    int w = waveform_area_width(app);
    double visible = app->samples_per_pixel * w;
    if ((double)total <= visible) {
        app->view_start_frame = 0;
        return;
    }
    size_t max_start = (size_t)((double)total - visible);
    if (app->view_start_frame > max_start)
        app->view_start_frame = max_start;
}

/* Keeps `frame` inside the current viewport, scrolling the minimum
 * amount necessary -- used by scrub so that, once zoomed in, running
 * the playhead past either edge of the visible window follows it
 * instead of leaving it to run off-screen (see ACTION_SCRUB_LEFT/
 * ACTION_SCRUB_RIGHT below). Only slides the view; never changes zoom. */
static void ensure_frame_visible(AppContext *app, size_t frame) {
    int w = waveform_area_width(app);
    if (w <= 0)
        return;
    double visible = app->samples_per_pixel * (double)w;
    if ((double)frame < (double)app->view_start_frame) {
        app->view_start_frame = frame;
    } else if ((double)frame > (double)app->view_start_frame + visible) {
        double new_start = (double)frame - visible;
        app->view_start_frame = new_start < 0 ? 0 : (size_t)new_start;
    }
}

/* Row equivalent of ensure_frame_visible() above, for the track list's
 * own vertical scroll -- keeps track index `idx` inside the currently
 * visible window by scrolling the minimum amount, used after Up/Down/
 * Page_Up/Page_Down move the highlight cursor. */
static void ensure_track_visible(AppContext *app, int idx, int visible_rows) {
    if (idx < app->track_scroll_top)
        app->track_scroll_top = idx;
    else if (idx >= app->track_scroll_top + visible_rows)
        app->track_scroll_top = idx - visible_rows + 1;
    if (app->track_scroll_top < 0)
        app->track_scroll_top = 0;
}


static void do_zoom(AppContext *app, int zoom_in) {
    app->zoom_manually_set = 1;
    /* Zoom around the center of the current view, so the thing you're
     * looking at stays roughly under the mouse/cursor rather than the
     * view jumping back to frame 0 on every zoom step. */
    int w = waveform_area_width(app);
    double center_frame = (double)app->view_start_frame + app->samples_per_pixel * w / 2.0;

    if (zoom_in) app->samples_per_pixel /= ZOOM_FACTOR;
    else app->samples_per_pixel *= ZOOM_FACTOR;
    if (app->samples_per_pixel < ZOOM_MIN_SPP) app->samples_per_pixel = ZOOM_MIN_SPP;
    if (app->samples_per_pixel > ZOOM_MAX_SPP) app->samples_per_pixel = ZOOM_MAX_SPP;

    double new_start = center_frame - app->samples_per_pixel * w / 2.0;
    app->view_start_frame = new_start < 0 ? 0 : (size_t)new_start;
    clamp_view_start(app);
    LOG_INFO("view: zoom %s -> %.2f samples/px", zoom_in ? "in" : "out", app->samples_per_pixel);
}

static void do_pan(AppContext *app, int forward) {
    int w = waveform_area_width(app);
    /* A step proportional to the current view width, same idea as the
     * adaptive scrub step below -- panning by a fixed sample count
     * would feel huge when zoomed in and invisible when zoomed out. */
    double step = app->samples_per_pixel * w * 0.2;
    double new_start = (double)app->view_start_frame + (forward ? step : -step);
    app->view_start_frame = new_start < 0 ? 0 : (size_t)new_start;
    clamp_view_start(app);
    LOG_INFO("view: pan %s -> view_start_frame %zu", forward ? "forward" : "backward", app->view_start_frame);
}

/* Arrow-key/action scrub step: a fraction of the CURRENTLY VISIBLE
 * width, so zooming in automatically makes scrubbing finer and zooming
 * out makes it coarser, proportional to what's on screen. Was 0.05 --
 * at anything but a tight zoom, 5% of the visible width per keypress
 * made repeated scrubbing feel like it was jumping rather than
 * tracking, since the step grows with the SAME view width scrubbing
 * itself doesn't shrink. 0.02 keeps the same zoom-proportional
 * behavior but at roughly 1/3 the previous finished effective
 * step -- about what Mod1Mask's `fine` parameter below starts from. */
static size_t adaptive_scrub_step(AppContext *app) {
    int w = waveform_area_width(app);
    double visible = app->samples_per_pixel * w;
    size_t step = (size_t)(visible * 0.02);
    return step < 1 ? 1 : step;
}

/* Ctrl+Left/Ctrl+Right (see main()'s KeyPress handling) -- a
 * deliberately finer step than plain scrubbing, for lining the
 * playhead/cursor up precisely once you're in the right neighborhood.
 * A flat fraction of adaptive_scrub_step() rather than its own
 * zoom-independent constant, so it stays "fine relative to current
 * zoom" instead of becoming either a no-op when zoomed way in or
 * still-too-coarse when zoomed way out. */
static size_t fine_scrub_step(AppContext *app) {
    size_t step = adaptive_scrub_step(app) / 4;
    return step < 1 ? 1 : step;
}

static void try_play_pause(AppContext *app) {
    int state = atomic_load(&app->state);
    if (state == STATE_IDLE) {
        LOG_INFO("UI: Play (action)");
        atomic_store(&app->state, STATE_PLAYING);
        atomic_store(&app->dirty, true);
        spawn_detached(playback_thread_fn, app);
    } else if (state == STATE_PLAYING) {
        LOG_INFO("UI: Pause (action)");
        atomic_store(&app->stop_requested, true);
    } else {
        LOG_WARN("UI: Play/Pause action ignored, app busy");
    }
}

static void try_restart(AppContext *app) {
    if (atomic_load(&app->state) == STATE_IDLE) {
        size_t target = atomic_load(&app->has_selection) ? app->selection_start_frame : 0;
        LOG_INFO("UI: Restart (action) -> frame %zu", target);
        atomic_store(&app->playhead_frame, target);
        /* Jumping the playhead used to leave the viewport wherever it
         * was -- fine at the default auto-fit zoom (the whole project
         * is always in view there), but once zoomed in, restarting
         * could jump the cursor somewhere off-screen with no visual
         * sign it had moved at all. Same fix ACTION_JUMP_START already
         * has below. */
        ensure_frame_visible(app, target);
        atomic_store(&app->dirty, true);
    } else {
        LOG_WARN("UI: Restart action ignored, app busy (pause first)");
    }
}

/* ---------------------------------------------------------------- */
/* Copy/cut/void/paste registers                                     */
/*                                                                    */
/* What these act on depends entirely on nav_context (set via the    */
/* leader chord toggle_nav_context):                                 */
/*   NAV_CONTEXT_TRACKS   -- the track-group selection (group_start/  */
/*                           group_end) if one is active, else the    */
/*                           single selected_track_index, else the    */
/*                           keyboard highlight cursor.                */
/*   NAV_CONTEXT_PLAYBACK -- the time-region selection (has_selection/ */
/*                           selection_start_frame/selection_end_frame,*/
/*                           on selection_track_idx, or every track    */
/*                           if that's -1).                            */
/*                                                                      */
/* All four are gated on STATE_IDLE, same as every other track-        */
/* mutating action in this file (Delete, Re-record, reorder, ...) --   */
/* the playback/export/save threads snapshot Track structs by value    */
/* WITHOUT deep-copying the underlying sample buffer, so mutating a     */
/* clip's buffer out from under a thread that's still reading it would  */
/* be a real use-after-free/race if these ran outside STATE_IDLE.       */
/* ---------------------------------------------------------------- */

static int16_t *dup_int16_buf(const int16_t *src, size_t n) {
    if (n == 0 || !src) return NULL;
    int16_t *buf = malloc(n * sizeof(int16_t));
    if (buf) memcpy(buf, src, n * sizeof(int16_t));
    return buf;
}

/* Track target for NAV_CONTEXT_TRACKS operations -- an inclusive
 * [lo, hi] range. Caller must hold tracklist's lock (num_tracks is
 * read here only to log a sane range; not strictly required for
 * correctness but keeps this self-contained). */
static int track_target_range(AppContext *app, int *lo, int *hi) {
    int grp_lo = atomic_load(&app->group_start);
    int grp_hi = atomic_load(&app->group_end);
    if (grp_lo >= 0) {
        *lo = grp_lo < grp_hi ? grp_lo : grp_hi;
        *hi = grp_lo < grp_hi ? grp_hi : grp_lo;
        return 1;
    }
    int sel = atomic_load(&app->selected_track_index);
    if (sel >= 0) { *lo = *hi = sel; return 1; }
    int hl = atomic_load(&app->highlighted_track_idx);
    if (hl >= 0) { *lo = *hi = hl; return 1; }
    return 0;
}

/* Like track_target_range() above, but deliberately WITHOUT the
 * highlighted-cursor fallback -- used only to decide which tracks get
 * pinned while scrolling (compute_track_layout()), where "you have a
 * selection" should mean an actual group/single selection, not just
 * wherever the keyboard cursor happens to be sitting right now. */
static int compute_selected_track_range(AppContext *app, int *lo, int *hi) {
    int grp_lo = atomic_load(&app->group_start);
    int grp_hi = atomic_load(&app->group_end);
    if (grp_lo >= 0) {
        *lo = grp_lo < grp_hi ? grp_lo : grp_hi;
        *hi = grp_lo < grp_hi ? grp_hi : grp_lo;
        return 1;
    }
    int sel = atomic_load(&app->selected_track_index);
    if (sel >= 0) { *lo = *hi = sel; return 1; }
    return 0;
}

/* ---------------------------------------------------------------- */
/* Insertion points -- see AppContext's insertion_points comment.    */
/* All of these lock/unlock app->insertion_points_mutex internally,  */
/* so none should be called while already holding it.                */
/* ---------------------------------------------------------------- */

/* Bitmask covering every track that EXISTS right now -- what a
 * "global" (nothing selected) cut point is scoped to. Deliberately a
 * snapshot, not tracked dynamically: a track added later doesn't
 * retroactively gain a splice point that was dropped before it
 * existed. */
static uint32_t all_tracks_mask(AppContext *app) {
    tracklist_lock(&app->tracklist);
    int n = app->tracklist.num_tracks;
    tracklist_unlock(&app->tracklist);
    if (n <= 0) return 0;
    if (n >= 32) return 0xFFFFFFFFu;
    return (1u << n) - 1u;
}

static uint32_t track_mask_range(int lo, int hi) {
    uint32_t m = 0;
    for (int i = lo; i <= hi && i < 32; i++) m |= (1u << i);
    return m;
}

/* Adds a point at `frame` scoped to `mask` -- merges (ORs `mask` in)
 * if a point already sits at the exact same frame rather than
 * creating a visually-identical duplicate. Silently a no-op if the
 * table is already full of DISTINCT frames (MAX_INSERTION_POINTS);
 * callers already log/set status either way. */
static void insertion_points_add(AppContext *app, size_t frame, uint32_t mask) {
    if (mask == 0) return;
    pthread_mutex_lock(&app->insertion_points_mutex);
    for (int i = 0; i < app->num_insertion_points; i++) {
        if (app->insertion_points[i].frame == frame) {
            app->insertion_points[i].track_mask |= mask;
            pthread_mutex_unlock(&app->insertion_points_mutex);
            return;
        }
    }
    if (app->num_insertion_points < MAX_INSERTION_POINTS) {
        app->insertion_points[app->num_insertion_points].frame = frame;
        app->insertion_points[app->num_insertion_points].track_mask = mask;
        app->num_insertion_points++;
    }
    pthread_mutex_unlock(&app->insertion_points_mutex);
}

/* Clears `mask`'s bits from whatever point sits exactly at `frame`;
 * the point is dropped entirely once its mask reaches 0 (other
 * tracks it still applies to are untouched). Returns 1 if a point
 * existed at that exact frame at all (whether or not `mask` actually
 * overlapped it), 0 if nothing is there. */
static int insertion_points_remove_at(AppContext *app, size_t frame, uint32_t mask) {
    int found = 0;
    pthread_mutex_lock(&app->insertion_points_mutex);
    for (int i = 0; i < app->num_insertion_points; i++) {
        if (app->insertion_points[i].frame == frame) {
            found = 1;
            app->insertion_points[i].track_mask &= ~mask;
            if (app->insertion_points[i].track_mask == 0) {
                for (int j = i; j < app->num_insertion_points - 1; j++)
                    app->insertion_points[j] = app->insertion_points[j + 1];
                app->num_insertion_points--;
            }
            break;
        }
    }
    pthread_mutex_unlock(&app->insertion_points_mutex);
    return found;
}

/* True if some point sits exactly at `frame` with a mask overlapping
 * `mask` at all -- do_cut() uses this to decide remove-vs-add when
 * cut is pressed again with nothing selected. */
static int insertion_points_has_at(AppContext *app, size_t frame, uint32_t mask) {
    int hit = 0;
    pthread_mutex_lock(&app->insertion_points_mutex);
    for (int i = 0; i < app->num_insertion_points; i++) {
        if (app->insertion_points[i].frame == frame && (app->insertion_points[i].track_mask & mask)) {
            hit = 1;
            break;
        }
    }
    pthread_mutex_unlock(&app->insertion_points_mutex);
    return hit;
}

/* Shifts every point whose mask overlaps `mask` forward by `delta`
 * frames -- do_confirm_whitespace()'s pre-roll handling uses this so
 * only points that apply to a track which actually got pre-roll
 * silence move; points scoped to untouched tracks stay put. */
static void insertion_points_shift(AppContext *app, uint32_t mask, size_t delta) {
    pthread_mutex_lock(&app->insertion_points_mutex);
    for (int i = 0; i < app->num_insertion_points; i++)
        if (app->insertion_points[i].track_mask & mask)
            app->insertion_points[i].frame += delta;
    pthread_mutex_unlock(&app->insertion_points_mutex);
}

/* The generalized magnetic scrub-lock: the nearest point whose mask
 * overlaps `mask` that sits strictly between `from` and `to` in the
 * direction of travel (`dir`), i.e. the first one actually crossed.
 * Returns 1 and fills *out_frame on a hit, 0 otherwise -- `from`
 * sitting exactly ON a point is deliberately excluded, same as
 * before, so leaving it takes one unclamped press. */
static int insertion_points_find_crossing(AppContext *app, size_t from, size_t to, int dir,
                                            uint32_t mask, size_t *out_frame) {
    int found = 0;
    size_t best = 0;
    pthread_mutex_lock(&app->insertion_points_mutex);
    for (int i = 0; i < app->num_insertion_points; i++) {
        if (!(app->insertion_points[i].track_mask & mask)) continue;
        size_t ip = app->insertion_points[i].frame;
        if (ip == from) continue;
        int crosses = (dir < 0) ? (to < ip && ip < from) : (to > ip && ip > from);
        if (!crosses) continue;
        if (!found || (dir < 0 ? ip > best : ip < best)) {
            best = ip;
            found = 1;
        }
    }
    pthread_mutex_unlock(&app->insertion_points_mutex);
    if (found) *out_frame = best;
    return found;
}

/* The generalized paste target: the point whose mask is a SUPERSET of
 * `required_mask` (applies to every track about to be pasted into)
 * that's closest to `playhead` -- ties favor proximity since that's
 * most likely "the thing I just cut". Returns 1 and fills *out_frame
 * on a hit, 0 if no point covers all of `required_mask`. */
static int insertion_points_nearest_for_paste(AppContext *app, uint32_t required_mask,
                                                size_t playhead, size_t *out_frame) {
    int found = 0;
    size_t best = 0, best_dist = 0;
    pthread_mutex_lock(&app->insertion_points_mutex);
    for (int i = 0; i < app->num_insertion_points; i++) {
        if ((app->insertion_points[i].track_mask & required_mask) != required_mask) continue;
        size_t ip = app->insertion_points[i].frame;
        size_t dist = ip > playhead ? ip - playhead : playhead - ip;
        if (!found || dist < best_dist) {
            best = ip;
            best_dist = dist;
            found = 1;
        }
    }
    pthread_mutex_unlock(&app->insertion_points_mutex);
    if (found) *out_frame = best;
    return found;
}

/* True if ANY point at all currently exists -- used wherever the old
 * code just checked has_insertion_point for a yes/no (e.g. the time
 * readout). */
static int insertion_points_any(AppContext *app) {
    pthread_mutex_lock(&app->insertion_points_mutex);
    int any = app->num_insertion_points > 0;
    pthread_mutex_unlock(&app->insertion_points_mutex);
    return any;
}

/* The point nearest `from` (any scope at all) -- Escape's "clear the
 * nearest insertion point" behavior, now that there can be more than
 * one. Returns 1 and fills *out_frame and *out_mask on a hit. */
static int insertion_points_nearest_any(AppContext *app, size_t from, size_t *out_frame, uint32_t *out_mask) {
    int found = 0;
    size_t best = 0, best_dist = 0;
    uint32_t best_mask = 0;
    pthread_mutex_lock(&app->insertion_points_mutex);
    for (int i = 0; i < app->num_insertion_points; i++) {
        size_t ip = app->insertion_points[i].frame;
        size_t dist = ip > from ? ip - from : from - ip;
        if (!found || dist < best_dist) {
            best = ip;
            best_dist = dist;
            best_mask = app->insertion_points[i].track_mask;
            found = 1;
        }
    }
    pthread_mutex_unlock(&app->insertion_points_mutex);
    if (found) { *out_frame = best; *out_mask = best_mask; }
    return found;
}

/* Fixes up every point's mask after tracklist_delete_track(idx) --
 * every later track's index just shifted down by one, so a point
 * scoped to specific tracks needs the same shift: idx's own bit is
 * dropped (that track no longer exists) and every bit above it moves
 * down by one. A point left with an empty mask is removed outright. */
static void insertion_points_on_track_deleted(AppContext *app, int idx) {
    pthread_mutex_lock(&app->insertion_points_mutex);
    int i = 0;
    while (i < app->num_insertion_points) {
        uint32_t m = app->insertion_points[i].track_mask;
        uint32_t below = (idx > 0) ? (m & ((1u << idx) - 1u)) : 0;
        uint32_t above = (m >> (idx + 1)) << idx;
        uint32_t new_mask = below | above;
        if (new_mask == 0) {
            for (int j = i; j < app->num_insertion_points - 1; j++)
                app->insertion_points[j] = app->insertion_points[j + 1];
            app->num_insertion_points--;
            continue;
        }
        app->insertion_points[i].track_mask = new_mask;
        i++;
    }
    pthread_mutex_unlock(&app->insertion_points_mutex);
}

/* Every snippet boundary that applies to `track_idx`, sorted
 * ascending and bracketed by the clip's own edges (0 and `track_len`,
 * which are never stored as real points themselves) -- consecutive
 * pairs [out[k], out[k+1]) are that track's snippets. `max_out` should
 * be at least MAX_INSERTION_POINTS + 2. Returns how many entries were
 * written (always >= 2: just [0, track_len] if the track has no real
 * boundaries of its own). */
static int track_snippet_bounds(AppContext *app, int track_idx, size_t track_len,
                                  size_t *out, int max_out) {
    int n = 0;
    uint32_t bit = (track_idx >= 0 && track_idx < 32) ? (1u << track_idx) : 0;
    pthread_mutex_lock(&app->insertion_points_mutex);
    for (int i = 0; i < app->num_insertion_points && n < max_out - 2; i++) {
        if (!(app->insertion_points[i].track_mask & bit)) continue;
        size_t f = app->insertion_points[i].frame;
        if (f > 0 && f < track_len)
            out[n++] = f;
    }
    pthread_mutex_unlock(&app->insertion_points_mutex);
    for (int i = 1; i < n; i++) {
        size_t key = out[i];
        int j = i - 1;
        while (j >= 0 && out[j] > key) { out[j + 1] = out[j]; j--; }
        out[j + 1] = key;
    }
    for (int i = n; i > 0; i--) out[i] = out[i - 1];
    out[0] = 0;
    n++;
    out[n++] = track_len;
    return n;
}

/* One frame's worth of track-list layout: which selected track
 * indices (if any) have scrolled out of the normal visible window and
 * need to be pinned to the top or bottom edge instead, plus the
 * resulting vertical bounds of the normal (scrolling) row area once
 * any pinned mini-panels have carved their space out of it. Shared
 * between redraw() and handle_click() so the mouse hit-tests exactly
 * what got drawn this frame, rather than two independently-maintained
 * copies of the same layout math drifting apart. `track_area_bottom`
 * is the y coordinate the track area ends at (the top of the second
 * ruler strip, just above the transport panel). */
typedef struct {
    int pin_top[MAX_TRACKS], num_pin_top;       /* every selected track pinned above the view */
    int pin_bottom[MAX_TRACKS], num_pin_bottom; /* every selected track pinned below the view */
    int active_top;    /* which one of pin_top[] actually gets a row -- see compute_track_layout() */
    int active_bottom; /* same, for pin_bottom[] */
    int normal_top, normal_bottom; /* y bounds of the normal scrolling area */
    int visible_rows;              /* rows the normal area fits, ignoring pins (used for scroll clamping/paging) */
} TrackLayout;

/* True if `val` appears in the first `n` entries of `arr` -- just
 * avoids drawing/hit-testing a pinned track a second time in the
 * normal scroll loop, and (below) picking an active pin that's no
 * longer actually pinned. */
static int int_in_array(int val, const int *arr, int n) {
    for (int i = 0; i < n; i++)
        if (arr[i] == val) return 1;
    return 0;
}

/* Whether track `idx` is in scope of the current time-region selection
 * (see redraw()'s sel_hl_x0/sel_hl_x1/sel_hl_all_tracks) -- a global
 * (all-tracks) selection covers every track, a single-track one only
 * that one track. Returns the pixel range to pass as ui_draw_track_row's
 * hl_x0/hl_x1 (both 0, i.e. "none", when idx isn't in scope or there's
 * no selection at all). */
static void row_selection_highlight(int sel_x0, int sel_x1, int sel_all_tracks,
                                     int selection_track_idx, int idx,
                                     int *out_x0, int *out_x1) {
    if (sel_x1 > sel_x0 && (sel_all_tracks || selection_track_idx == idx)) {
        *out_x0 = sel_x0;
        *out_x1 = sel_x1;
    } else {
        *out_x0 = 0;
        *out_x1 = 0;
    }
}

static void compute_track_layout(AppContext *app, int n, int track_area_bottom, TrackLayout *tl) {
    tl->num_pin_top = 0;
    tl->num_pin_bottom = 0;

    int visible_rows = (track_area_bottom - TRACK_AREA_TOP - TRACK_ROW_PAD) / (TRACK_ROW_H + TRACK_ROW_PAD);
    if (visible_rows < 1) visible_rows = 1;
    tl->visible_rows = visible_rows;

    int lo, hi;
    if (compute_selected_track_range(app, &lo, &hi)) {
        for (int i = lo; i <= hi && i < n; i++) {
            if (i < app->track_scroll_top) {
                if (tl->num_pin_top < MAX_TRACKS) tl->pin_top[tl->num_pin_top++] = i;
            } else if (i >= app->track_scroll_top + visible_rows) {
                if (tl->num_pin_bottom < MAX_TRACKS) tl->pin_bottom[tl->num_pin_bottom++] = i;
            }
        }
    }

    /* Only ONE track per direction actually gets a row -- "the active
     * pin" -- rather than stacking every pinned track's own full row
     * (which ate a lot of vertical space with even a modest group
     * selected). app->pinned_active_top/bottom remembers an explicit
     * choice made via the dropdown (see handle_click()); it's used as
     * long as that track is still actually in this direction's pinned
     * set, and falls back to whichever pinned track sits closest to
     * the visible window otherwise -- the largest index for pin_top
     * (nearest the top of the normal area) and the smallest for
     * pin_bottom (nearest the bottom of it), which since pin_top/
     * pin_bottom are filled in ascending order are simply the last and
     * first entries, respectively. */
    tl->active_top = -1;
    if (tl->num_pin_top > 0) {
        int want = app->pinned_active_top;
        tl->active_top = (want >= 0 && int_in_array(want, tl->pin_top, tl->num_pin_top))
                         ? want : tl->pin_top[tl->num_pin_top - 1];
    }
    tl->active_bottom = -1;
    if (tl->num_pin_bottom > 0) {
        int want = app->pinned_active_bottom;
        tl->active_bottom = (want >= 0 && int_in_array(want, tl->pin_bottom, tl->num_pin_bottom))
                            ? want : tl->pin_bottom[0];
    }

    tl->normal_top = TRACK_AREA_TOP + TRACK_ROW_PAD;
    tl->normal_bottom = track_area_bottom;
    if (tl->active_top >= 0)
        tl->normal_top += (TRACK_ROW_H + TRACK_ROW_PAD) + TRACK_PIN_GAP;
    if (tl->active_bottom >= 0)
        tl->normal_bottom -= (TRACK_ROW_H + TRACK_ROW_PAD) + TRACK_PIN_GAP;
}

/* Where (if anywhere) track `idx` is actually drawn this frame, given
 * `tl` -- the active pinned-top row, the active pinned-bottom row, or
 * the normal scrolling area, in that order of preference. Returns -1
 * if `idx` isn't on screen at all right now: either it's scrolled out
 * of view and not pinned (pinning only ever applies to the current
 * track-mode selection, so anything else can genuinely be off-screen),
 * or it's pinned but sitting collapsed in a dropdown rather than being
 * the active one shown in a row (see compute_track_layout()). Used by
 * both the group/region-selection border drawing in redraw() and can
 * be reused anywhere else that needs "where is track i right now". */
static int track_row_y_if_visible(AppContext *app, const TrackLayout *tl, int idx) {
    if (idx == tl->active_top)
        return TRACK_AREA_TOP + TRACK_ROW_PAD;
    if (idx == tl->active_bottom)
        return tl->normal_bottom + TRACK_PIN_GAP;
    int local = idx - app->track_scroll_top;
    if (local < 0) return -1;
    int y = tl->normal_top + local * (TRACK_ROW_H + TRACK_ROW_PAD);
    if (y + TRACK_ROW_H > tl->normal_bottom) return -1;
    return y;
}

/* How many track rows the list currently has room to show at once --
 * shared by Page_Up/Page_Down (the step size) and the scroll-follow
 * call after any highlight move (see ensure_track_visible() above).
 * Deliberately recomputed on demand rather than cached: it depends on
 * window height, which can change any time via ConfigureNotify. */
static int track_visible_rows(AppContext *app) {
    int by2 = app->win_h - BOTTOM_PANEL_H;
    int track_area_bottom2 = by2 - RULER_H - HSCROLL_H;
    tracklist_lock(&app->tracklist);
    int n = app->tracklist.num_tracks;
    tracklist_unlock(&app->tracklist);
    TrackLayout tl;
    compute_track_layout(app, n, track_area_bottom2, &tl);
    return tl.visible_rows;
}

/* Single-track target for NAV_CONTEXT_PLAYBACK paste when the region
 * being pasted into needs exactly one track (rather than "every
 * track", which the region-selection's own selection_track_idx==-1
 * already expresses directly and doesn't need this). */
static int resolve_single_target_track(AppContext *app, int num_tracks) {
    if (atomic_load(&app->has_selection) && app->selection_track_idx >= 0)
        return app->selection_track_idx;
    int sel = atomic_load(&app->selected_track_index);
    if (sel >= 0) return sel;
    int hl = atomic_load(&app->highlighted_track_idx);
    if (hl >= 0) return hl;
    if (num_tracks == 1) return 0;
    return -1;
}

static Register *pick_register(AppContext *app, int use_system) {
    return use_system ? &app->system_register : &app->beatrat_register;
}

/* Durable on-screen echo of the LOG_WARN/LOG_INFO calls in do_copy/
 * do_cut/do_void/do_paste below -- those only ever went to stderr/the
 * log file, so a failed copy/paste (e.g. "no region selected") looked
 * like nothing happened at all. Shown in the bottom-panel status
 * readout (redraw()) until the next register op replaces it; no fade
 * timer, just "the latest thing that happened". */
static void set_reg_status(AppContext *app, const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);
    vsnprintf(app->last_reg_msg, sizeof(app->last_reg_msg), fmt, ap);
    va_end(ap);
    atomic_store(&app->dirty, true);
}

/* Shared tail of do_copy/do_cut: hands the just-filled register to the
 * X11 clipboard (best-effort, system register only -- see register.h). */
static void publish_register(AppContext *app, Register *r, int use_system) {
    if (use_system)
        register_claim_clipboard(app->dpy, app->win, app->clipboard_atom, app->beatrat_audio_atom, r);
}

static void do_copy(AppContext *app, int use_system) {
    if (atomic_load(&app->state) != STATE_IDLE) {
        LOG_WARN("copy: ignored, app busy");
        set_reg_status(app, "Copy ignored: app busy");
        return;
    }
    Register *r = pick_register(app, use_system);
    int ctx = atomic_load(&app->nav_context);

    tracklist_lock(&app->tracklist);
    if (ctx == NAV_CONTEXT_TRACKS) {
        int lo, hi;
        if (!track_target_range(app, &lo, &hi)) {
            tracklist_unlock(&app->tracklist);
            LOG_WARN("copy: no track selected");
            set_reg_status(app, "Copy failed: no track selected");
            return;
        }
        register_begin(r, 1);
        for (int i = lo; i <= hi; i++) {
            Clip *c = &app->tracklist.tracks[i].clip;
            int16_t *buf; size_t n;
            track_clip_copy_region(c, 0, c->num_samples, &buf, &n);
            register_add_clip(r, app->tracklist.tracks[i].name, buf, n, c->sample_rate);
        }
        LOG_INFO("copy: track(s) [%d,%d] -> %s register", lo, hi, use_system ? "system" : "beatrat");
        set_reg_status(app, "Copied track(s) [%d-%d] -> %s", lo, hi, use_system ? "system" : "beatrat");
    } else {
        if (!atomic_load(&app->has_selection)) {
            tracklist_unlock(&app->tracklist);
            LOG_WARN("copy: no region selected");
            set_reg_status(app, "Copy failed: no region selected");
            return;
        }
        size_t s = app->selection_start_frame, e = app->selection_end_frame;
        if (e <= s) {
            /* A zero-length selection used to "succeed" here -- register_begin()
             * + a copy of 0 samples still marks the register valid, so copy
             * reported success while paste afterward was silently a no-op
             * (track_clip_insert_region() with count==0 does nothing). Catch
             * it here instead so the failure shows up where it happens. */
            tracklist_unlock(&app->tracklist);
            LOG_WARN("copy: region is empty (zero length), nothing copied");
            set_reg_status(app, "Copy failed: region is empty");
            return;
        }
        register_begin(r, 0);
        if (app->selection_track_idx >= 0) {
            Track *t = &app->tracklist.tracks[app->selection_track_idx];
            int16_t *buf; size_t n;
            track_clip_copy_region(&t->clip, s, e, &buf, &n);
            register_add_clip(r, t->name, buf, n, t->clip.sample_rate);
        } else {
            for (int i = 0; i < app->tracklist.num_tracks; i++) {
                Track *t = &app->tracklist.tracks[i];
                int16_t *buf; size_t n;
                track_clip_copy_region(&t->clip, s, e, &buf, &n);
                register_add_clip(r, t->name, buf, n, t->clip.sample_rate);
            }
        }
        LOG_INFO("copy: region [%zu,%zu) -> %s register", s, e, use_system ? "system" : "beatrat");
        set_reg_status(app, "Copied region -> %s (%.1fs)", use_system ? "system" : "beatrat",
                        (double)(e - s) / SAMPLE_RATE);
        /* Settings > "Auto-deselect region after copy/cut/void" (on by
         * default). Copy is the only one of the three where the region
         * survives afterward on its own merits (cut/void always clear
         * it below -- there's nothing left to keep selected once the
         * audio itself is gone), so this is the only place that's
         * actually conditional on the setting. */
        if (app->settings.auto_deselect_region) {
            atomic_store(&app->has_selection, false);
            atomic_store(&app->visual_mode_active, false);
        }
    }
    tracklist_unlock(&app->tracklist);
    publish_register(app, r, use_system);
    atomic_store(&app->dirty, true);
}

static void do_cut(AppContext *app, int use_system) {
    if (atomic_load(&app->state) != STATE_IDLE) {
        LOG_WARN("cut: ignored, app busy");
        set_reg_status(app, "Cut ignored: app busy");
        return;
    }
    Register *r = pick_register(app, use_system);
    int ctx = atomic_load(&app->nav_context);

    if (ctx == NAV_CONTEXT_TRACKS) {
        int lo, hi;
        if (!track_target_range(app, &lo, &hi)) {
            LOG_WARN("cut: no track selected");
            set_reg_status(app, "Cut failed: no track selected");
            return;
        }
        /* Gather the copies first, all under one lock/unlock scope of
         * OUR OWN -- but tracklist_delete_track() below takes the same
         * mutex itself, so it must be called AFTER we've released it,
         * not from inside it (pthread mutexes here are non-recursive;
         * re-locking from the same thread would deadlock). */
        tracklist_lock(&app->tracklist);
        register_begin(r, 1);
        for (int i = lo; i <= hi; i++) {
            Clip *c = &app->tracklist.tracks[i].clip;
            int16_t *buf; size_t n;
            track_clip_copy_region(c, 0, c->num_samples, &buf, &n);
            register_add_clip(r, app->tracklist.tracks[i].name, buf, n, c->sample_rate);
        }
        tracklist_unlock(&app->tracklist);

        /* Delete highest index first so earlier indices in [lo,hi]
         * stay valid as the list shifts down. Each call locks/unlocks
         * on its own. */
        for (int i = hi; i >= lo; i--) {
            tracklist_delete_track(&app->tracklist, i);
            insertion_points_on_track_deleted(app, i);
        }
        atomic_store(&app->selected_track_index, -1);
        atomic_store(&app->highlighted_track_idx, -1);
        atomic_store(&app->group_start, -1);
        atomic_store(&app->group_end, -1);
        LOG_INFO("cut: track(s) [%d,%d] -> %s register, removed", lo, hi, use_system ? "system" : "beatrat");
        set_reg_status(app, "Cut track(s) [%d-%d] -> %s", lo, hi, use_system ? "system" : "beatrat");
        publish_register(app, r, use_system);
        atomic_store(&app->dirty, true);
        return;
    }

    tracklist_lock(&app->tracklist);
    {
        int have_region = atomic_load(&app->has_selection) &&
                           app->selection_end_frame > app->selection_start_frame;
        if (!have_region) {
            /* No region (or a degenerate zero-length one) -- rather
             * than failing, 'cut' with nothing selected drops a POINT
             * insertion point at the current playhead position. No
             * audio is touched and neither register is written (there
             * is nothing to cut); it's purely a sticky split marker for
             * a later paste to splice into. See AppContext's
             * insertion_points comment. Scope follows whatever track
             * selection currently exists (track_target_range -- group,
             * single-select, or highlight), same as a real cut below:
             * all tracks selected (or none at all) makes a global
             * point, a track or group selected scopes it to just that. */
            tracklist_unlock(&app->tracklist);
            size_t at = atomic_load(&app->playhead_frame);
            int sel_lo, sel_hi;
            uint32_t mask = track_target_range(app, &sel_lo, &sel_hi)
                                 ? track_mask_range(sel_lo, sel_hi)
                                 : all_tracks_mask(app);
            /* Pressing cut again with nothing selected, while sitting
             * exactly on an existing insertion point OF THE SAME SCOPE,
             * removes it instead of adding a duplicate -- the
             * persistent remove gesture this needed, since Escape
             * clearing it was easy to trigger by accident (it also
             * clears whatever else Escape would have cancelled first).
             * A different scope at the same frame just coexists. */
            if (insertion_points_has_at(app, at, mask)) {
                insertion_points_remove_at(app, at, mask);
                LOG_INFO("cut: pressed again exactly on insertion point %zu -- removed", at);
                set_reg_status(app, "Insertion point removed");
                atomic_store(&app->dirty, true);
                return;
            }
            insertion_points_add(app, at, mask);
            LOG_INFO("cut: no region selected -- insertion point set at %zu (no audio removed)", at);
            set_reg_status(app, "Insertion point set at %.1fs", (double)at / SAMPLE_RATE);
            atomic_store(&app->dirty, true);
            return;
        }
        size_t s = app->selection_start_frame, e = app->selection_end_frame;
        register_begin(r, 0);
        if (app->selection_track_idx >= 0) {
            Track *t = &app->tracklist.tracks[app->selection_track_idx];
            int16_t *buf; size_t n;
            track_clip_copy_region(&t->clip, s, e, &buf, &n);
            register_add_clip(r, t->name, buf, n, t->clip.sample_rate);
            track_clip_cut_region(&t->clip, s, e);
        } else {
            for (int i = 0; i < app->tracklist.num_tracks; i++) {
                Track *t = &app->tracklist.tracks[i];
                int16_t *buf; size_t n;
                track_clip_copy_region(&t->clip, s, e, &buf, &n);
                register_add_clip(r, t->name, buf, n, t->clip.sample_rate);
                track_clip_cut_region(&t->clip, s, e);
            }
        }
        /* Settings > "Auto-deselect region after copy/cut/void" (on by
         * default) -- see do_copy's comment on the same setting. Off,
         * the selection stays at the same [s,e) frame range, which now
         * marks the gap the cut left behind (handy for immediately
         * pasting something back into exactly that spot). */
        if (app->settings.auto_deselect_region) {
            atomic_store(&app->has_selection, false);
            atomic_store(&app->visual_mode_active, false);
        }
        /* Insertion point: a STICKY marker at the seam the cut left
         * behind (s -- everything after it just shifted down to meet
         * that spot), separate from the playhead, which is left alone.
         * A later paste targets this instead of the playhead once it
         * exists -- see do_paste and AppContext's comment. Scoped to
         * whichever track(s) the cut region itself actually touched
         * (selection_track_idx -- -1 means every track, same "global"
         * convention as the no-region branch above). */
        /* Computed inline rather than via all_tracks_mask() -- that
         * helper takes tracklist's lock itself, and we're already
         * holding it here (non-recursive mutex, see do_cut's own
         * comment above on why tracklist_delete_track() must be
         * called outside it). */
        uint32_t cut_mask;
        if (app->selection_track_idx >= 0) {
            cut_mask = track_mask_range(app->selection_track_idx, app->selection_track_idx);
        } else {
            int n = app->tracklist.num_tracks;
            cut_mask = (n <= 0) ? 0 : (n >= 32 ? 0xFFFFFFFFu : (1u << n) - 1u);
        }
        insertion_points_add(app, s, cut_mask);
        LOG_INFO("cut: region [%zu,%zu) -> %s register, removed, insertion point at %zu",
                  s, e, use_system ? "system" : "beatrat", s);
        set_reg_status(app, "Cut region -> %s (%.1fs)", use_system ? "system" : "beatrat",
                        (double)(e - s) / SAMPLE_RATE);
    }
    tracklist_unlock(&app->tracklist);
    publish_register(app, r, use_system);
    atomic_store(&app->dirty, true);
}

/* Void ('x'): destroys the target exactly like cut, but never touches
 * either register -- the vim-flavored "black hole register" idea. */
static void do_void(AppContext *app) {
    if (atomic_load(&app->state) != STATE_IDLE) {
        LOG_WARN("void: ignored, app busy");
        set_reg_status(app, "Void ignored: app busy");
        return;
    }
    int ctx = atomic_load(&app->nav_context);

    if (ctx == NAV_CONTEXT_TRACKS) {
        /* No lock needed here at all -- track_target_range only reads
         * atomics, and tracklist_delete_track() locks/unlocks on its
         * own each call (see do_cut's comment on why it must NOT be
         * called from inside our own tracklist_lock). */
        int lo, hi;
        if (!track_target_range(app, &lo, &hi)) {
            LOG_WARN("void: no track selected");
            set_reg_status(app, "Void failed: no track selected");
            return;
        }
        for (int i = hi; i >= lo; i--) {
            tracklist_delete_track(&app->tracklist, i);
            insertion_points_on_track_deleted(app, i);
        }
        atomic_store(&app->selected_track_index, -1);
        atomic_store(&app->highlighted_track_idx, -1);
        atomic_store(&app->group_start, -1);
        atomic_store(&app->group_end, -1);
        LOG_INFO("void: track(s) [%d,%d] destroyed, no register touched", lo, hi);
        set_reg_status(app, "Voided track(s) [%d-%d]", lo, hi);
        atomic_store(&app->dirty, true);
        return;
    }

    tracklist_lock(&app->tracklist);
    {
        if (!atomic_load(&app->has_selection)) {
            tracklist_unlock(&app->tracklist);
            LOG_WARN("void: no region selected");
            set_reg_status(app, "Void failed: no region selected");
            return;
        }
        size_t s = app->selection_start_frame, e = app->selection_end_frame;
        if (e <= s) {
            tracklist_unlock(&app->tracklist);
            LOG_WARN("void: region is empty (zero length), nothing to void");
            set_reg_status(app, "Void failed: region is empty");
            return;
        }
        if (app->selection_track_idx >= 0) {
            track_clip_cut_region(&app->tracklist.tracks[app->selection_track_idx].clip, s, e);
        } else {
            for (int i = 0; i < app->tracklist.num_tracks; i++)
                track_clip_cut_region(&app->tracklist.tracks[i].clip, s, e);
        }
        /* Settings > "Auto-deselect region after copy/cut/void" (on by
         * default) -- see do_copy's comment on the same setting. */
        if (app->settings.auto_deselect_region) {
            atomic_store(&app->has_selection, false);
            atomic_store(&app->visual_mode_active, false);
        }
        LOG_INFO("void: region [%zu,%zu) destroyed, no register touched", s, e);
        set_reg_status(app, "Voided region (%.1fs)", (double)(e - s) / SAMPLE_RATE);
    }
    tracklist_unlock(&app->tracklist);
    atomic_store(&app->dirty, true);
}

static void do_paste(AppContext *app, int use_system) {
    if (atomic_load(&app->state) != STATE_IDLE) {
        LOG_WARN("paste: ignored, app busy");
        set_reg_status(app, "Paste ignored: app busy");
        return;
    }
    Register *r = pick_register(app, use_system);
    if (!r->valid || r->num_clips == 0) {
        LOG_WARN("paste: %s register is empty", use_system ? "system" : "beatrat");
        set_reg_status(app, "Paste failed: %s register is empty", use_system ? "system" : "beatrat");
        return;
    }
    int ctx = atomic_load(&app->nav_context);

    if (ctx == NAV_CONTEXT_TRACKS) {
        /* Always appends new track(s), regardless of what's currently
         * selected -- matches how Import/Record always append rather
         * than trying to overwrite in place. A region-audio register
         * (is_track_copy==0) still works here: each clip just becomes
         * its own new track. No lock held here -- tracklist_add_track()
         * locks/unlocks on its own each call (see do_cut's comment on
         * why it must not be called from inside our own lock). */
        for (int i = 0; i < r->num_clips; i++) {
            RegisterClip *rc = &r->clips[i];
            int16_t *buf = dup_int16_buf(rc->samples, rc->num_samples);
            const char *name = rc->name[0] ? rc->name : "Pasted";
            tracklist_add_track(&app->tracklist, name, buf, rc->num_samples, rc->sample_rate);
        }
        LOG_INFO("paste: %d clip(s) from %s register -> new track(s)", r->num_clips, use_system ? "system" : "beatrat");
        set_reg_status(app, "Pasted %d clip(s) from %s -> new track(s)", r->num_clips, use_system ? "system" : "beatrat");
        atomic_store(&app->dirty, true);
        return;
    }

    tracklist_lock(&app->tracklist);
    {
        int n = app->tracklist.num_tracks;
        int paste_all_tracks = r->num_clips > 1 && atomic_load(&app->has_selection) && app->selection_track_idx < 0;
        /* Which track(s) this paste will actually land in, resolved up
         * front so the insertion-point lookup below can require a
         * point that covers ALL of them -- a point scoped to just one
         * track shouldn't catch an all-tracks paste, and vice versa.
         * resolve_single_target_track() is pure/side-effect-free, so
         * calling it here and reusing `target` in the non-all-tracks
         * branch below is just one lookup, not two. */
        int target = paste_all_tracks ? -1 : resolve_single_target_track(app, n);
        uint32_t required_mask;
        if (paste_all_tracks) {
            required_mask = (n <= 0) ? 0 : (n >= 32 ? 0xFFFFFFFFu : (1u << n) - 1u);
        } else {
            required_mask = (target >= 0) ? (1u << target) : 0;
        }
        /* A sticky insertion point (see AppContext's comment) takes
         * priority over the playhead as the paste target, exactly the
         * scenario it exists for: 'cut' dropped a split marker, and
         * this splices new content in right at that seam regardless of
         * wherever the playhead has since wandered off to. It's NOT
         * consumed here -- pasting again lands at the same spot again,
         * on purpose, until it's explicitly cleared (Escape) or moved
         * by another cut. Only a point whose scope covers every track
         * about to be pasted into counts -- see required_mask above. */
        size_t at;
        if (!insertion_points_nearest_for_paste(app, required_mask, atomic_load(&app->playhead_frame), &at))
            at = atomic_load(&app->playhead_frame);
        if (paste_all_tracks) {
            /* A multi-clip register (a whole-project region copy, or a
             * track-group copy used in the wrong context) pastes clip i
             * into track i, matching order -- the closest thing to
             * "restore what was copied" without a stronger per-track
             * identity than position. */
            for (int i = 0; i < r->num_clips && i < n; i++) {
                RegisterClip *rc = &r->clips[i];
                track_clip_insert_region(&app->tracklist.tracks[i].clip, at, rc->samples, rc->num_samples);
            }
            LOG_INFO("paste: %d clip(s) from %s register -> tracks at frame %zu", r->num_clips, use_system ? "system" : "beatrat", at);
            set_reg_status(app, "Pasted %d clip(s) from %s @ %.1fs", r->num_clips, use_system ? "system" : "beatrat",
                            (double)at / SAMPLE_RATE);
        } else {
            if (target < 0) {
                tracklist_unlock(&app->tracklist);
                LOG_WARN("paste: no target track (select one first)");
                set_reg_status(app, "Paste failed: no target track (select one first)");
                return;
            }
            if (r->num_clips > 1)
                LOG_WARN("paste: register holds %d clips but no all-tracks region is active -- only the first is pasted", r->num_clips);
            RegisterClip *rc = &r->clips[0];
            track_clip_insert_region(&app->tracklist.tracks[target].clip, at, rc->samples, rc->num_samples);
            LOG_INFO("paste: clip from %s register -> track %d at frame %zu", use_system ? "system" : "beatrat", target, at);
            set_reg_status(app, "Pasted from %s -> track %d @ %.1fs", use_system ? "system" : "beatrat",
                            target, (double)at / SAMPLE_RATE);
        }
    }
    tracklist_unlock(&app->tracklist);
    atomic_store(&app->dirty, true);
}

/* Whitespace insertion, part 2/2 (see do_whitespace_mark() below for
 * part 1): confirms a pending mark by turning the marked [lo, hi)
 * span into that many frames of real silence, inserted into whichever
 * track(s) track_target_range() currently resolves to -- a single
 * selected/highlighted track, or every track in a group selection
 * (switch to a single-track selection first to target just one).
 * Insertion (not overwrite) means audio already in that span is never
 * destroyed, just shifted later, same as a normal paste.
 *
 * If any of the span sat before frame 0 (the "pre-roll" case --
 * scrubbing the whitespace cursor past the start instead of forward),
 * that portion lands as silence at frame 0 of the target track(s),
 * pushing their existing content later in time. The playhead and the
 * sticky insertion point (both project-wide frame numbers) are then
 * nudged forward by that same pre-roll amount, so they keep pointing
 * at the same actual spot on the waveform that didn't move -- only
 * the abstract runtime grew to make room for it. */
static void do_confirm_whitespace(AppContext *app) {
    long mark = atomic_load(&app->whitespace_mark_frame);
    long cur = atomic_load(&app->whitespace_cursor_frame);
    atomic_store(&app->whitespace_pending, false);

    long lo = mark < cur ? mark : cur;
    long hi = mark < cur ? cur : mark;
    if (hi <= lo) {
        LOG_WARN("whitespace: empty region, nothing inserted");
        set_reg_status(app, "Whitespace insertion cancelled: empty region");
        atomic_store(&app->dirty, true);
        return;
    }

    int lo_track, hi_track;
    if (!track_target_range(app, &lo_track, &hi_track)) {
        LOG_WARN("whitespace: no track selected");
        set_reg_status(app, "Whitespace failed: select a track first");
        atomic_store(&app->dirty, true);
        return;
    }

    long preroll = (lo < 0) ? -lo : 0;
    size_t insert_at = (size_t)(lo < 0 ? 0 : lo);
    size_t count = (size_t)(hi - lo);

    int16_t *silence = calloc(count, sizeof(int16_t));
    if (!silence) {
        LOG_WARN("whitespace: allocation failed, nothing inserted");
        set_reg_status(app, "Whitespace failed: out of memory");
        atomic_store(&app->dirty, true);
        return;
    }

    tracklist_lock(&app->tracklist);
    for (int i = lo_track; i <= hi_track; i++) {
        Track *t = &app->tracklist.tracks[i];
        track_clip_insert_region(&t->clip, insert_at, silence, count);
    }
    tracklist_unlock(&app->tracklist);
    free(silence);

    if (preroll > 0) {
        /* Only the tracks that actually got pre-roll silence (and
         * whatever insertion points apply to them) shift -- a point
         * scoped to some OTHER track that wasn't touched stays right
         * where it is. See insertion_points_shift()'s comment. */
        size_t old_playhead = atomic_load(&app->playhead_frame);
        atomic_store(&app->playhead_frame, old_playhead + (size_t)preroll);
        insertion_points_shift(app, track_mask_range(lo_track, hi_track), (size_t)preroll);
    }

    LOG_INFO("whitespace: inserted %zu silent frame(s) at %zu into track(s) [%d,%d]%s",
              count, insert_at, lo_track, hi_track, preroll > 0 ? " (pre-roll)" : "");
    set_reg_status(app, "Inserted %.2fs of silence%s", (double)count / SAMPLE_RATE,
                    preroll > 0 ? " (pre-roll -- runtime shifted)" : "");
    atomic_store(&app->dirty, true);
}

/* Whitespace insertion, part 1/2: bound to a bare space in INSERT mode
 * only (hardcoded in main()'s KeyPress handling, same way as the
 * insert-mode h/l override below -- NORMAL mode's space is untouched
 * and still plays/pauses). The first press drops a pending mark at
 * the current playhead; the second confirms it via
 * do_confirm_whitespace() above. */
static void do_whitespace_mark(AppContext *app) {
    if (atomic_load(&app->state) != STATE_IDLE) {
        LOG_WARN("whitespace: ignored, app busy");
        set_reg_status(app, "Whitespace ignored: app busy");
        return;
    }
    if (atomic_load(&app->whitespace_pending)) {
        do_confirm_whitespace(app);
        return;
    }
    long start = (long)atomic_load(&app->playhead_frame);
    atomic_store(&app->whitespace_mark_frame, start);
    atomic_store(&app->whitespace_cursor_frame, start);
    atomic_store(&app->whitespace_pending, true);
    LOG_INFO("whitespace: region marked at %ld", start);
    set_reg_status(app, "Whitespace region started -- space again to confirm, Esc to cancel");
    atomic_store(&app->dirty, true);
}

/* The "sticky scrub point" half of the insertion-point feature: a
 * scrub step that would cross over an existing insertion point stops
 * exactly on it instead, the same way a magnet catches something
 * passing near it. Checks EVERY point regardless of scope (0xFFFFFFFF
 * mask) -- the playhead isn't track-specific, so any splice point on
 * the timeline is worth catching on. Only kicks in when `from` isn't
 * already ON a point -- once you're sitting there, the very next
 * press in either direction is an ordinary, un-clamped step, which is
 * what makes leaving take one extra keypress rather than the point
 * blocking movement entirely. dir < 0 is left/backward, dir > 0 is
 * right/forward, matching do_scrub()'s own convention below. */
static size_t magnetic_insertion_clamp(AppContext *app, size_t from, size_t to, int dir) {
    size_t hit;
    if (insertion_points_find_crossing(app, from, to, dir, 0xFFFFFFFFu, &hit))
        return hit;
    return to;
}

/* Shared by ACTION_SCRUB_LEFT/RIGHT below AND the hardcoded Ctrl+Left/
 * Ctrl+Right KeyPress handling in main() (see fine_scrub_step()'s
 * comment) -- same scrub, just parameterized on the step size instead
 * of always calling adaptive_scrub_step() itself, so "fine" scrubbing
 * is a different step through the exact same logic rather than a
 * second near-copy of it. dir < 0 is left/backward, dir > 0 is
 * right/forward. */
/* Shared by do_scrub()'s visual-resize branches below: while Shift+V's
 * snippet-stepping mode is active (see visual_line_snippet_stepping's
 * comment), h/l (and Left/Right) move the selection cursor to the
 * ADJACENT snippet boundary instead of by a frame step. Returns 1 if
 * it handled the move, 0 if snippet-stepping isn't active right now
 * -- the caller falls through to its normal per-frame resize then. */
static int snippet_step_resize(AppContext *app, int dir) {
    if (!atomic_load(&app->visual_line_snippet_stepping) || app->selection_track_idx < 0)
        return 0;
    tracklist_lock(&app->tracklist);
    int valid = app->selection_track_idx < app->tracklist.num_tracks;
    size_t track_len = valid ? app->tracklist.tracks[app->selection_track_idx].clip.num_samples : 0;
    tracklist_unlock(&app->tracklist);
    if (!valid)
        return 0;

    size_t bounds[MAX_INSERTION_POINTS + 2];
    int nb = track_snippet_bounds(app, app->selection_track_idx, track_len, bounds, MAX_INSERTION_POINTS + 2);
    size_t cur = app->selection_cursor_frame;
    int idx = 0;
    for (int i = 0; i < nb; i++) {
        if (bounds[i] == cur) { idx = i; break; }
    }
    int target = dir < 0 ? idx - 1 : idx + 1;
    if (target < 0) target = 0;
    if (target > nb - 1) target = nb - 1;
    app->selection_cursor_frame = bounds[target];
    size_t a = app->selection_anchor_frame, c = app->selection_cursor_frame;
    app->selection_start_frame = a < c ? a : c;
    app->selection_end_frame = a < c ? c : a;
    LOG_INFO("selection: snippet-step resize -> [%zu, %zu)", app->selection_start_frame, app->selection_end_frame);
    ensure_frame_visible(app, app->selection_cursor_frame);
    atomic_store(&app->dirty, true);
    return 1;
}

static void do_scrub(AppContext *app, int dir, size_t step) {
    if (atomic_load(&app->whitespace_pending)) {
        /* While a whitespace mark is pending, every scrub path (plain
         * h/l, Left/Right, and the Ctrl-modified fine step) moves the
         * WHITESPACE cursor instead of the real playhead -- see
         * do_whitespace_mark()'s comment. Deliberately allowed to go
         * negative (pre-roll); ensure_frame_visible() takes a size_t,
         * so a negative cursor is passed as 0 to it rather than
         * wrapping to a huge unsigned value. */
        long step_signed = (long)step * (dir < 0 ? -1 : 1);
        long moved = atomic_load(&app->whitespace_cursor_frame) + step_signed;
        atomic_store(&app->whitespace_cursor_frame, moved);
        long mark = atomic_load(&app->whitespace_mark_frame);
        long lo = mark < moved ? mark : moved;
        long hi = mark < moved ? moved : mark;
        LOG_INFO("whitespace: cursor -> %ld (region [%ld, %ld))", moved, lo, hi);
        set_reg_status(app, "Whitespace region: %.2fs (space to confirm, Esc to cancel)",
                        (double)(hi - lo) / SAMPLE_RATE);
        ensure_frame_visible(app, moved < 0 ? 0 : (size_t)moved);
        atomic_store(&app->dirty, true);
        return;
    }
    if (dir < 0) {
        if (atomic_load(&app->visual_mode_active) && atomic_load(&app->nav_context) == NAV_CONTEXT_PLAYBACK) {
            if (snippet_step_resize(app, dir))
                return;
            /* Moves the CURSOR left, not "the end" -- start/end are
             * then re-derived as min/max(anchor, cursor), which is
             * what lets the selection extend backward past the
             * anchor (where 'v' was pressed) instead of only ever
             * growing forward. See selection_anchor_frame's comment. */
            size_t cursor = app->selection_cursor_frame;
            app->selection_cursor_frame = (cursor > step) ? cursor - step : 0;
            size_t a = app->selection_anchor_frame, c = app->selection_cursor_frame;
            app->selection_start_frame = a < c ? a : c;
            app->selection_end_frame = a < c ? c : a;
            LOG_INFO("selection: resize -> [%zu, %zu)", app->selection_start_frame, app->selection_end_frame);
            ensure_frame_visible(app, app->selection_cursor_frame);
            atomic_store(&app->dirty, true);
            return;
        }
        size_t cur = atomic_load(&app->playhead_frame);
        size_t new_frame = (cur > step) ? cur - step : 0;
        new_frame = magnetic_insertion_clamp(app, cur, new_frame, -1);
        LOG_INFO("UI: scrub left (step %zu) -> frame %zu", step, new_frame);
        request_seek(app, new_frame);
        ensure_frame_visible(app, new_frame);
        return;
    }

    size_t total = compute_timeline_total(app);
    if (atomic_load(&app->visual_mode_active) && atomic_load(&app->nav_context) == NAV_CONTEXT_PLAYBACK) {
        if (snippet_step_resize(app, dir))
            return;
        /* Resizing a selection stays clamped to the real audio -- unlike
         * plain scrubbing below, there's nothing meaningful to select
         * past the end of it. Moves the cursor, not "the end" -- see
         * the dir<0 branch's comment above. */
        size_t cursor = app->selection_cursor_frame + step;
        if (cursor > total) cursor = total;
        app->selection_cursor_frame = cursor;
        size_t a = app->selection_anchor_frame, c = app->selection_cursor_frame;
        app->selection_start_frame = a < c ? a : c;
        app->selection_end_frame = a < c ? c : a;
        LOG_INFO("selection: resize -> [%zu, %zu)", app->selection_start_frame, app->selection_end_frame);
        ensure_frame_visible(app, app->selection_cursor_frame);
        atomic_store(&app->dirty, true);
        return;
    }
    size_t cur = atomic_load(&app->playhead_frame);
    size_t new_frame = cur + step;
    /* Deliberately NOT clamped to `total` while idle/paused -- this is
     * what lets the playhead scrub arbitrarily far past the end of
     * the current audio to paste (or whitespace-insert) anywhere the
     * cursor sits. track_clip_insert_region() now auto-extends each
     * target track with real silence out to that frame instead of
     * clamping back to its true end -- see its own comment. Clamped
     * while actually playing, since there's nothing to play out there
     * and the playback thread doesn't expect it. */
    if (atomic_load(&app->state) == STATE_PLAYING && new_frame > total)
        new_frame = total;
    new_frame = magnetic_insertion_clamp(app, cur, new_frame, 1);
    LOG_INFO("UI: scrub right (step %zu) -> frame %zu", step, new_frame);
    request_seek(app, new_frame);
    ensure_frame_visible(app, new_frame);
}

/* Central dispatcher for every input path that can trigger a global
 * action -- keybinds, the small on-screen zoom buttons, and (later)
 * any other trigger -- so there's one place that knows what each
 * action actually does, not three copies of the same logic. */
static void do_action(AppContext *app, AppAction action) {
    switch (action) {
        case ACTION_SCRUB_LEFT:
            do_scrub(app, -1, adaptive_scrub_step(app));
            break;
        case ACTION_SCRUB_RIGHT:
            do_scrub(app, 1, adaptive_scrub_step(app));
            break;
        case ACTION_ZOOM_IN:
            do_zoom(app, 1);
            atomic_store(&app->dirty, true);
            break;
        case ACTION_ZOOM_OUT:
            do_zoom(app, 0);
            atomic_store(&app->dirty, true);
            break;
        case ACTION_PLAY_PAUSE:
            try_play_pause(app);
            break;
        case ACTION_RESTART:
            try_restart(app);
            break;
        case ACTION_VISUAL_SELECT: {
            if (atomic_load(&app->nav_context) == NAV_CONTEXT_TRACKS) {
                tracklist_lock(&app->tracklist);
                int n = app->tracklist.num_tracks;
                tracklist_unlock(&app->tracklist);
                if (n == 0) break;
                int h = atomic_load(&app->highlighted_track_idx);
                if (h < 0) h = 0;
                atomic_store(&app->group_start, h);
                atomic_store(&app->group_end, h);
                atomic_store(&app->highlighted_track_idx, h);
                atomic_store(&app->visual_mode_active, true);
                LOG_INFO("track group: started at track %d", h);
                atomic_store(&app->dirty, true);
                break;
            }
            size_t pf = atomic_load(&app->playhead_frame);
            app->selection_anchor_frame = pf;
            app->selection_cursor_frame = pf;
            app->selection_start_frame = pf;
            app->selection_end_frame = pf;
            app->selection_track_idx = atomic_load(&app->selected_track_index);
            atomic_store(&app->visual_mode_active, true);
            atomic_store(&app->has_selection, true);
            LOG_INFO("selection: visual mode started at frame %zu (track_idx=%d)", pf, app->selection_track_idx);
            atomic_store(&app->dirty, true);
            break;
        }
        case ACTION_VISUAL_LINE_SELECT: {
            if (atomic_load(&app->nav_context) == NAV_CONTEXT_TRACKS) {
                tracklist_lock(&app->tracklist);
                int n = app->tracklist.num_tracks;
                tracklist_unlock(&app->tracklist);
                if (n == 0) break;
                atomic_store(&app->group_start, 0);
                atomic_store(&app->group_end, n - 1);
                atomic_store(&app->visual_mode_active, true);
                LOG_INFO("track group: selected all %d track(s)", n);
                atomic_store(&app->dirty, true);
                break;
            }
            int sel_track = atomic_load(&app->selected_track_index);
            size_t end;
            if (sel_track >= 0) {
                tracklist_lock(&app->tracklist);
                end = (sel_track < app->tracklist.num_tracks)
                          ? app->tracklist.tracks[sel_track].clip.num_samples : 0;
                tracklist_unlock(&app->tracklist);
            } else {
                end = compute_timeline_total(app);
            }

            /* A single selected track with real snippet boundaries of
             * its own: Shift+V starts on just the ONE snippet under
             * the playhead instead of grabbing the whole track in one
             * shot -- h/l (via do_scrub()'s visual-resize branch)
             * then extend/shrink it a whole snippet at a time.
             * Everything else (no track selected, or a track with no
             * boundaries) keeps the old immediate whole-track/whole-
             * project behavior. */
            int stepping = 0;
            size_t snip_lo = 0, snip_hi = end;
            if (sel_track >= 0) {
                size_t bounds[MAX_INSERTION_POINTS + 2];
                int nb = track_snippet_bounds(app, sel_track, end, bounds, MAX_INSERTION_POINTS + 2);
                if (nb > 2) {
                    size_t ph = atomic_load(&app->playhead_frame);
                    int idx = nb - 2;
                    for (int i = 0; i < nb - 1; i++) {
                        if (ph >= bounds[i] && ph < bounds[i + 1]) { idx = i; break; }
                    }
                    snip_lo = bounds[idx];
                    snip_hi = bounds[idx + 1];
                    stepping = 1;
                }
            }

            app->selection_anchor_frame = stepping ? snip_lo : 0;
            app->selection_cursor_frame = stepping ? snip_hi : end;
            app->selection_start_frame = stepping ? snip_lo : 0;
            app->selection_end_frame = stepping ? snip_hi : end;
            app->selection_track_idx = sel_track;
            atomic_store(&app->visual_mode_active, true);
            atomic_store(&app->visual_line_snippet_stepping, stepping);
            atomic_store(&app->has_selection, true);
            if (stepping) {
                LOG_INFO("selection: line-select snippet [%zu, %zu) on track %d", snip_lo, snip_hi, sel_track);
                set_reg_status(app, "Snippet selected -- h/l steps by snippet, Enter confirms");
            } else {
                LOG_INFO("selection: line-select whole %s (0 to %zu)", sel_track >= 0 ? "track" : "project", end);
            }
            atomic_store(&app->dirty, true);
            break;
        }
        case ACTION_CONFIRM_VISUAL:
            if (atomic_load(&app->track_move_active)) {
                atomic_store(&app->track_move_active, false);
                LOG_INFO("track move: dropped (Enter) at [%d, %d]", app->track_move_lo, app->track_move_hi);
                set_reg_status(app, "Track move: dropped");
                atomic_store(&app->dirty, true);
                break;
            }
            if (atomic_load(&app->snippet_move_active)) {
                /* Dropping just clears the undo stack -- the swaps
                 * already happened in place as Up/Down was pressed,
                 * same as track move. */
                atomic_store(&app->snippet_move_active, false);
                app->snippet_move_undo_count = 0;
                LOG_INFO("snippet move: dropped (Enter)");
                set_reg_status(app, "Snippet move: dropped");
                atomic_store(&app->dirty, true);
                break;
            }
            if (atomic_load(&app->visual_mode_active)) {
                atomic_store(&app->visual_mode_active, false);
                atomic_store(&app->visual_line_snippet_stepping, false);
                if (atomic_load(&app->nav_context) == NAV_CONTEXT_TRACKS) {
                    int lo = atomic_load(&app->group_start), hi = atomic_load(&app->group_end);
                    if (lo > hi) { int t = lo; lo = hi; hi = t; }
                    LOG_INFO("track group: confirmed [%d, %d]", lo, hi);
                } else {
                    LOG_INFO("selection: confirmed [%zu, %zu)", app->selection_start_frame, app->selection_end_frame);
                }
                atomic_store(&app->dirty, true);
            } else if (atomic_load(&app->nav_context) == NAV_CONTEXT_TRACKS) {
                int h = atomic_load(&app->highlighted_track_idx);
                if (h >= 0) {
                    atomic_store(&app->selected_track_index, h);
                    LOG_INFO("nav: confirmed track %d as selected", h);
                    atomic_store(&app->dirty, true);
                }
            }
            break;
        case ACTION_CANCEL_SELECTION:
            /* A pending whitespace mark is the innermost thing to
             * cancel, same reasoning as the insertion-point check
             * further down -- one Escape press undoes one thing, and
             * this one can't wait behind the mode-exit check below
             * since whitespace marking only ever happens in INSERT
             * mode (that first Escape would otherwise just leave
             * INSERT and strand the pending mark). */
            if (atomic_load(&app->whitespace_pending)) {
                atomic_store(&app->whitespace_pending, false);
                LOG_INFO("whitespace: cancelled");
                set_reg_status(app, "Whitespace insertion cancelled");
                atomic_store(&app->dirty, true);
                break;
            }
            /* Escape always returns to NORMAL first, vim-style -- and
             * ONLY that, same as real vim's Esc-from-insert doesn't also
             * clear whatever was selected before you entered insert.
             * Already in NORMAL (the common case) falls through to the
             * existing selection/group-clearing behavior unchanged. */
            if (atomic_load(&app->current_mode) != MODE_NORMAL) {
                atomic_store(&app->current_mode, MODE_NORMAL);
                LOG_INFO("mode: -> NORMAL");
                atomic_store(&app->dirty, true);
                break;
            }
            if (atomic_load(&app->nav_context) == NAV_CONTEXT_TRACKS) {
                if (atomic_load(&app->track_move_active)) {
                    /* Cancel: walk the block back the exact number of
                     * steps it moved, restoring the original order
                     * rather than just leaving it wherever Escape caught
                     * it. net > 0 means it net moved down, so undo with
                     * direction -1 that many times, and vice versa. */
                    int lo = app->track_move_lo, hi = app->track_move_hi;
                    int net = lo - app->track_move_orig_lo;
                    int undo_dir = net > 0 ? -1 : 1;
                    for (int i = 0; i < (net > 0 ? net : -net); i++)
                        tracklist_move_block(&app->tracklist, &lo, &hi, undo_dir);
                    if (app->track_move_source == 0) {
                        atomic_store(&app->group_start, lo);
                        atomic_store(&app->group_end, hi);
                    } else if (app->track_move_source == 1) {
                        atomic_store(&app->selected_track_index, lo);
                    }
                    atomic_store(&app->highlighted_track_idx, lo);
                    atomic_store(&app->track_move_active, false);
                    LOG_INFO("track move: cancelled, restored to [%d, %d]", lo, hi);
                    set_reg_status(app, "Track move cancelled");
                    atomic_store(&app->dirty, true);
                    break;
                }
                if (atomic_load(&app->group_start) >= 0) {
                    atomic_store(&app->group_start, -1);
                    atomic_store(&app->group_end, -1);
                    atomic_store(&app->visual_mode_active, false);
                    LOG_INFO("track group: cleared");
                    atomic_store(&app->dirty, true);
                } else if (atomic_load(&app->selected_track_index) >= 0 || atomic_load(&app->highlighted_track_idx) >= 0) {
                    atomic_store(&app->selected_track_index, -1);
                    atomic_store(&app->highlighted_track_idx, -1);
                    LOG_INFO("nav: track deselected");
                    atomic_store(&app->dirty, true);
                }
            } else if (atomic_load(&app->snippet_move_active)) {
                /* Cancel: a swap is its own inverse, so undoing is just
                 * replaying every recorded (a0, mid, b1) swap again,
                 * oldest-last (the undo stack is append-order, so walk
                 * it backward). */
                tracklist_lock(&app->tracklist);
                if (app->snippet_move_track >= 0 && app->snippet_move_track < app->tracklist.num_tracks) {
                    Clip *c = &app->tracklist.tracks[app->snippet_move_track].clip;
                    for (int i = app->snippet_move_undo_count - 1; i >= 0; i--)
                        track_clip_swap_adjacent(c, app->snippet_move_undo[i].a0,
                                                   app->snippet_move_undo[i].mid, app->snippet_move_undo[i].b1);
                }
                tracklist_unlock(&app->tracklist);
                app->snippet_move_undo_count = 0;
                atomic_store(&app->snippet_move_active, false);
                LOG_INFO("snippet move: cancelled, restored");
                set_reg_status(app, "Snippet move cancelled");
                atomic_store(&app->dirty, true);
            } else if (atomic_load(&app->has_selection)) {
                atomic_store(&app->has_selection, false);
                atomic_store(&app->visual_mode_active, false);
                atomic_store(&app->visual_line_snippet_stepping, false);
                LOG_INFO("selection: cancelled");
                atomic_store(&app->dirty, true);
            } else if (insertion_points_any(app)) {
                /* Nothing else to cancel at this point -- Escape's next
                 * job is clearing an insertion point, since there's
                 * otherwise no dedicated key for that (it's meant to
                 * persist through everything else). With more than one
                 * now possible, it clears whichever is NEAREST the
                 * playhead -- the one you're most likely looking at. */
                size_t near_frame; uint32_t near_mask;
                if (insertion_points_nearest_any(app, atomic_load(&app->playhead_frame), &near_frame, &near_mask)) {
                    insertion_points_remove_at(app, near_frame, near_mask);
                    LOG_INFO("insertion point: cleared (nearest, frame %zu)", near_frame);
                    set_reg_status(app, "Insertion point cleared");
                    atomic_store(&app->dirty, true);
                }
            }
            break;
        case ACTION_ENTER_INSERT_MODE:
            if (atomic_load(&app->current_mode) == MODE_NORMAL) {
                atomic_store(&app->current_mode, MODE_INSERT);
                LOG_INFO("mode: -> INSERT");
                atomic_store(&app->dirty, true);
            }
            break;
        case ACTION_ENTER_FX_MODE:
            if (atomic_load(&app->current_mode) == MODE_NORMAL) {
                atomic_store(&app->current_mode, MODE_FX);
                LOG_INFO("mode: -> FX (backbone only, no functionality yet)");
                atomic_store(&app->dirty, true);
            }
            break;
        case ACTION_ENTER_BEAT_MODE:
            if (atomic_load(&app->current_mode) == MODE_NORMAL) {
                atomic_store(&app->current_mode, MODE_BEAT);
                LOG_INFO("mode: -> BEAT (backbone only, no functionality yet)");
                atomic_store(&app->dirty, true);
            }
            break;
        case ACTION_TOGGLE_TRACK_MOVE: {
            if (atomic_load(&app->nav_context) != NAV_CONTEXT_TRACKS) {
                /* PLAYBACK context: leader+m picks up a SNIPPET (a
                 * region of one track's audio between two of its own
                 * insertion points) instead of a whole track -- see
                 * AppContext's snippet_move_active comment. */
                if (atomic_load(&app->snippet_move_active)) {
                    atomic_store(&app->snippet_move_active, false);
                    app->snippet_move_undo_count = 0;
                    LOG_INFO("snippet move: dropped at index %d", app->snippet_move_idx);
                    set_reg_status(app, "Snippet move: dropped");
                    atomic_store(&app->dirty, true);
                    break;
                }
                if (atomic_load(&app->state) != STATE_IDLE) {
                    set_reg_status(app, "Snippet move: app busy");
                    break;
                }
                /* Which single track: an active single-track region
                 * selection wins, else whatever track_target_range()
                 * resolves to, as long as it's exactly one track --
                 * group selection is a TRACKS-context concept and a
                 * snippet move never spans tracks (see this round's
                 * design notes: that's cut/paste's job). */
                int track_idx = -1;
                if (atomic_load(&app->has_selection) && app->selection_track_idx >= 0) {
                    track_idx = app->selection_track_idx;
                } else {
                    int lo, hi;
                    if (track_target_range(app, &lo, &hi) && lo == hi)
                        track_idx = lo;
                }
                if (track_idx < 0) {
                    set_reg_status(app, "Snippet move: select a single track first");
                    break;
                }
                tracklist_lock(&app->tracklist);
                if (track_idx >= app->tracklist.num_tracks) {
                    tracklist_unlock(&app->tracklist);
                    set_reg_status(app, "Snippet move: invalid track");
                    break;
                }
                size_t track_len = app->tracklist.tracks[track_idx].clip.num_samples;
                tracklist_unlock(&app->tracklist);

                size_t bounds[MAX_INSERTION_POINTS + 2];
                int nb = track_snippet_bounds(app, track_idx, track_len, bounds, MAX_INSERTION_POINTS + 2);
                if (nb <= 2) {
                    set_reg_status(app, "Snippet move: track has no snippet boundaries");
                    break;
                }
                /* Which snippet: whichever one contains the active
                 * selection's start (if it's on this track) or
                 * otherwise the playhead -- the one most likely under
                 * the user's attention right now. */
                size_t anchor = (atomic_load(&app->has_selection) && app->selection_track_idx == track_idx)
                                    ? app->selection_start_frame : atomic_load(&app->playhead_frame);
                int idx = nb - 2; /* default: the last snippet */
                for (int i = 0; i < nb - 1; i++) {
                    if (anchor >= bounds[i] && anchor < bounds[i + 1]) { idx = i; break; }
                }
                app->snippet_move_track = track_idx;
                app->snippet_move_idx = idx;
                app->snippet_move_undo_count = 0;
                atomic_store(&app->snippet_move_active, true);
                LOG_INFO("snippet move: picked up track %d snippet %d [%zu, %zu)",
                          track_idx, idx, bounds[idx], bounds[idx + 1]);
                set_reg_status(app, "Snippet move: Up/Down swaps with neighbor, <leader>m/Enter drops, Escape cancels");
                atomic_store(&app->dirty, true);
                break;
            }
            if (atomic_load(&app->track_move_active)) {
                /* Second press -- drop it exactly where it is now. */
                atomic_store(&app->track_move_active, false);
                LOG_INFO("track move: dropped at [%d, %d]", app->track_move_lo, app->track_move_hi);
                set_reg_status(app, "Track move: dropped");
                atomic_store(&app->dirty, true);
                break;
            }
            if (atomic_load(&app->state) != STATE_IDLE) {
                set_reg_status(app, "Track move: app busy");
                break;
            }
            int lo, hi;
            if (!track_target_range(app, &lo, &hi)) {
                set_reg_status(app, "Track move: nothing selected or highlighted");
                break;
            }
            app->track_move_source = (atomic_load(&app->group_start) >= 0) ? 0
                                      : (atomic_load(&app->selected_track_index) >= 0) ? 1 : 2;
            app->track_move_lo = lo;
            app->track_move_hi = hi;
            app->track_move_orig_lo = lo;
            app->track_move_orig_hi = hi;
            atomic_store(&app->track_move_active, true);
            LOG_INFO("track move: picked up [%d, %d]", lo, hi);
            set_reg_status(app, "Track move: Up/Down reorders, <leader>m/Enter drops, Escape cancels");
            atomic_store(&app->dirty, true);
            break;
        }
        case ACTION_HIGHLIGHT_UP:
        case ACTION_HIGHLIGHT_DOWN:
        case ACTION_HIGHLIGHT_PAGE_UP:
        case ACTION_HIGHLIGHT_PAGE_DOWN: {
            int up = (action == ACTION_HIGHLIGHT_UP || action == ACTION_HIGHLIGHT_PAGE_UP);
            int step = (action == ACTION_HIGHLIGHT_PAGE_UP || action == ACTION_HIGHLIGHT_PAGE_DOWN) ? 0 : 1;

            if (atomic_load(&app->snippet_move_active)) {
                /* Swaps the picked-up snippet with its immediate
                 * neighbor instead of moving anything by index --
                 * there's no equivalent of a "page" of snippets, so
                 * Page_Up/Page_Down just behave like a single Up/Down
                 * here (step defaults to 1 either way -- see below). */
                int swap_step = step ? step : 1;
                int track_idx = app->snippet_move_track;
                tracklist_lock(&app->tracklist);
                int valid = track_idx >= 0 && track_idx < app->tracklist.num_tracks;
                size_t track_len = valid ? app->tracklist.tracks[track_idx].clip.num_samples : 0;
                tracklist_unlock(&app->tracklist);
                if (!valid) {
                    atomic_store(&app->snippet_move_active, false);
                    set_reg_status(app, "Snippet move: track no longer exists");
                    atomic_store(&app->dirty, true);
                    break;
                }
                for (int s = 0; s < swap_step; s++) {
                    size_t bounds[MAX_INSERTION_POINTS + 2];
                    int nb = track_snippet_bounds(app, track_idx, track_len, bounds, MAX_INSERTION_POINTS + 2);
                    int idx = app->snippet_move_idx;
                    int target_idx = up ? idx - 1 : idx + 1;
                    if (target_idx < 0 || target_idx > nb - 2)
                        break; /* already at that end -- nothing more to do */

                    int lo_idx = up ? target_idx : idx; /* the earlier of the two snippets */
                    size_t a0 = bounds[lo_idx], mid = bounds[lo_idx + 1], b1 = bounds[lo_idx + 2];

                    tracklist_lock(&app->tracklist);
                    int ok = (track_clip_swap_adjacent(&app->tracklist.tracks[track_idx].clip, a0, mid, b1) == 0);
                    tracklist_unlock(&app->tracklist);
                    if (!ok) break;

                    if (app->snippet_move_undo_count < MAX_INSERTION_POINTS) {
                        app->snippet_move_undo[app->snippet_move_undo_count].a0 = a0;
                        app->snippet_move_undo[app->snippet_move_undo_count].mid = mid;
                        app->snippet_move_undo[app->snippet_move_undo_count].b1 = b1;
                        app->snippet_move_undo_count++;
                    }
                    /* The shared midpoint moved -- fork it into one
                     * scoped to just this track at its new position,
                     * leaving any OTHER track that shares the old
                     * point (a global/group-scoped boundary) alone. */
                    size_t new_mid = a0 + (b1 - mid);
                    uint32_t bit = (uint32_t)1 << track_idx;
                    insertion_points_remove_at(app, mid, bit);
                    insertion_points_add(app, new_mid, bit);

                    app->snippet_move_idx = target_idx;
                }
                LOG_INFO("snippet move: now at index %d", app->snippet_move_idx);
                atomic_store(&app->dirty, true);
                break;
            }

            tracklist_lock(&app->tracklist);
            int n = app->tracklist.num_tracks;
            tracklist_unlock(&app->tracklist);
            if (n == 0) break;
            int visible_rows = track_visible_rows(app);
            if (step == 0) step = visible_rows;

            if (atomic_load(&app->track_move_active)) {
                /* Reorder the picked-up block instead of just moving the
                 * highlight cursor -- see ACTION_TOGGLE_TRACK_MOVE above
                 * and the AppContext field comments for the full
                 * picture. Each successful tracklist_move_block() call
                 * updates lo/hi in place; whichever of group/single-
                 * select/highlight the block was picked up as stays in
                 * sync with its new position so the UI keeps drawing it
                 * correctly while it's being dragged around. */
                int lo = app->track_move_lo, hi = app->track_move_hi;
                for (int i = 0; i < step; i++) {
                    if (tracklist_move_block(&app->tracklist, &lo, &hi, up ? -1 : 1) != 0)
                        break;
                }
                app->track_move_lo = lo;
                app->track_move_hi = hi;
                if (app->track_move_source == 0) {
                    atomic_store(&app->group_start, lo);
                    atomic_store(&app->group_end, hi);
                } else if (app->track_move_source == 1) {
                    atomic_store(&app->selected_track_index, lo);
                }
                atomic_store(&app->highlighted_track_idx, lo);
                ensure_track_visible(app, lo, visible_rows);
                LOG_INFO("track move: now at [%d, %d]", lo, hi);
                atomic_store(&app->dirty, true);
                break;
            }

            int cur = atomic_load(&app->highlighted_track_idx);
            if (cur < 0) {
                cur = 0; /* first press with no cursor yet -- land on the top track */
            } else if (up) {
                cur -= step;
                if (cur < 0) cur = 0;
            } else {
                cur += step;
                if (cur > n - 1) cur = n - 1;
            }
            atomic_store(&app->highlighted_track_idx, cur);
            ensure_track_visible(app, cur, visible_rows);
            if (atomic_load(&app->visual_mode_active) && atomic_load(&app->nav_context) == NAV_CONTEXT_TRACKS) {
                atomic_store(&app->group_end, cur);
                LOG_INFO("track group: extended cursor to %d", cur);
            } else {
                LOG_INFO("nav: highlighted track %d", cur);
            }
            atomic_store(&app->dirty, true);
            break;
        }
        case ACTION_JUMP_START:
        case ACTION_JUMP_END: {
            /* vim-style gg/G (or Ctrl+A for the start, see keybinds.c's
             * defaults) -- jumps the playhead to the very start or end
             * of the project, or extends the current time-region
             * selection that far if one's actively being resized,
             * mirroring how ACTION_SCRUB_LEFT/RIGHT treat visual mode. */
            size_t total = compute_timeline_total(app);
            size_t target = (action == ACTION_JUMP_START) ? 0 : total;
            if (atomic_load(&app->visual_mode_active) && atomic_load(&app->nav_context) == NAV_CONTEXT_PLAYBACK) {
                /* Moves the cursor all the way to target, same
                 * anchor/cursor derivation as the scrub visual-resize
                 * branches -- see selection_anchor_frame's comment. */
                app->selection_cursor_frame = target;
                size_t a = app->selection_anchor_frame, c = app->selection_cursor_frame;
                app->selection_start_frame = a < c ? a : c;
                app->selection_end_frame = a < c ? c : a;
                LOG_INFO("selection: resize -> [%zu, %zu)", app->selection_start_frame, app->selection_end_frame);
                ensure_frame_visible(app, target);
                atomic_store(&app->dirty, true);
                break;
            }
            LOG_INFO("UI: jump to %s -> frame %zu", action == ACTION_JUMP_START ? "start" : "end", target);
            request_seek(app, target);
            ensure_frame_visible(app, target);
            break;
        }
        case ACTION_TOGGLE_LOOP_REGION: {
            int cur = atomic_load(&app->loop_region);
            atomic_store(&app->loop_region, !cur);
            LOG_INFO("loop: region loop %s", !cur ? "enabled" : "disabled");
            atomic_store(&app->dirty, true);
            break;
        }
        case ACTION_TOGGLE_LOOP_ALL: {
            int cur = atomic_load(&app->loop_all);
            atomic_store(&app->loop_all, !cur);
            LOG_INFO("loop: whole-project loop %s", !cur ? "enabled" : "disabled");
            atomic_store(&app->dirty, true);
            break;
        }
        case ACTION_TOGGLE_NAV_CONTEXT: {
            int cur = atomic_load(&app->nav_context);
            int next = (cur == NAV_CONTEXT_TRACKS) ? NAV_CONTEXT_PLAYBACK : NAV_CONTEXT_TRACKS;
            atomic_store(&app->nav_context, next);
            LOG_INFO("nav: context -> %s", next == NAV_CONTEXT_TRACKS ? "tracks" : "playback");
            atomic_store(&app->dirty, true);
            break;
        }
        case ACTION_COPY_BEATRAT: do_copy(app, 0); break;
        case ACTION_COPY_SYSTEM: do_copy(app, 1); break;
        case ACTION_CUT_BEATRAT: do_cut(app, 0); break;
        case ACTION_CUT_SYSTEM: do_cut(app, 1); break;
        case ACTION_VOID: do_void(app); break;
        case ACTION_PASTE_BEATRAT: do_paste(app, 0); break;
        case ACTION_PASTE_SYSTEM: do_paste(app, 1); break;
        case ACTION_TOGGLE_MUTE: {
            /* PLAYBACK-only mute, never touches audio data -- see
             * keybinds.h's ACTION_TOGGLE_MUTE comment and Track's
             * `muted` field. No track/group selected (or highlighted)
             * mutes/unmutes every track; a selection scopes it to just
             * those. A genuine toggle either way: if the whole target
             * range is ALREADY muted, this unmutes it instead -- so
             * pressing 'm' again undoes a whole-project mute. */
            int lo, hi;
            if (!track_target_range(app, &lo, &hi)) {
                tracklist_lock(&app->tracklist);
                lo = 0;
                hi = app->tracklist.num_tracks - 1;
                tracklist_unlock(&app->tracklist);
            }
            if (hi < lo) {
                set_reg_status(app, "Mute: no tracks");
                break;
            }
            tracklist_lock(&app->tracklist);
            int all_muted = 1;
            for (int i = lo; i <= hi; i++) {
                if (!atomic_load(&app->tracklist.tracks[i].muted)) { all_muted = 0; break; }
            }
            for (int i = lo; i <= hi; i++)
                atomic_store(&app->tracklist.tracks[i].muted, !all_muted);
            tracklist_unlock(&app->tracklist);
            LOG_INFO("mute: track(s) [%d,%d] -> %s", lo, hi, all_muted ? "unmuted" : "muted");
            set_reg_status(app, all_muted ? "Unmuted track(s) [%d-%d]" : "Muted track(s) [%d-%d]", lo, hi);
            atomic_store(&app->dirty, true);
            break;
        }
        default:
            break;
    }
}

/* Whole-project mute, for the playback-panel speaker button (see
 * MUTE_ALL_BTN_X in handle_click()/redraw()) -- deliberately ignores
 * any current track/group selection, unlike bare 'm' (ACTION_TOGGLE_MUTE
 * above), which only falls back to "every track" when nothing is
 * selected. The button always means "the whole project", regardless
 * of what's selected in the track list at the moment it's clicked. */
static void toggle_mute_all_tracks(AppContext *app) {
    tracklist_lock(&app->tracklist);
    int n = app->tracklist.num_tracks;
    int all_muted = 1;
    for (int i = 0; i < n; i++) {
        if (!atomic_load(&app->tracklist.tracks[i].muted)) { all_muted = 0; break; }
    }
    for (int i = 0; i < n; i++)
        atomic_store(&app->tracklist.tracks[i].muted, !all_muted);
    tracklist_unlock(&app->tracklist);
    LOG_INFO("mute: whole project -> %s", all_muted ? "unmuted" : "muted");
    set_reg_status(app, all_muted ? "Unmuted whole project" : "Muted whole project");
    atomic_store(&app->dirty, true);
}

/* ---------------------------------------------------------------- */
/* Background action threads                                         */
/* ---------------------------------------------------------------- */

static void *import_thread_fn(void *arg) {
    ThreadArg *ta = (ThreadArg *)arg;
    AppContext *app = ta->app;
    char path[512];
    snprintf(path, sizeof(path), "%s", ta->path);
    free(arg);

    WavReader r;
    if (wav_reader_open(&r, path) != 0) {
        LOG_ERROR("import: could not open '%s'", path);
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }

    size_t total_samples = r.data_bytes_total / sizeof(int16_t);
    int16_t *buf = malloc(total_samples * sizeof(int16_t));
    size_t got = buf ? wav_reader_read(&r, buf, total_samples) : 0;
    wav_reader_close(&r);

    if (!buf || got == 0) {
        LOG_ERROR("import: '%s' produced no samples", path);
        free(buf);
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }

    char name[64];
    snprintf(name, sizeof(name), "Track %d", atomic_fetch_add(&app->next_track_number, 1));
    tracklist_add_track(&app->tracklist, name, buf, got, r.sample_rate);
    LOG_INFO("import: added track '%s' (%zu samples)", name, got);

    atomic_store(&app->state, STATE_IDLE);
    atomic_store(&app->dirty, true);
    return NULL;
}

static void *record_thread_fn(void *arg) {
    ThreadArg *ta = (ThreadArg *)arg;
    AppContext *app = ta->app;
    int replace_index = ta->replace_index;
    free(arg);

    snd_pcm_t *pcm = NULL;
    if (app->settings.input_device[0] != '\0') {
        pcm = audio_open_and_configure_named(SND_PCM_STREAM_CAPTURE, app->settings.input_device,
                                              SAMPLE_RATE, CHANNELS, SND_PCM_FORMAT_S16_LE, 200000, "record");
        if (!pcm) {
            LOG_WARN("record: configured device '%s' didn't work at all, falling back to auto",
                      app->settings.input_device);
            confirm_set_message(&app->confirm, "Input device '%s' isn't working. Reset it to Auto?",
                                 app->settings.input_device);
            app->confirm_pending_action = 5;
            atomic_store(&app->confirm_active, true);
        }
    }
    if (!pcm) {
        pcm = audio_open_pcm(SND_PCM_STREAM_CAPTURE, "record");
        if (pcm && audio_configure_pcm(pcm, SAMPLE_RATE, CHANNELS, SND_PCM_FORMAT_S16_LE, 200000, "record") != 0) {
            snd_pcm_close(pcm);
            pcm = NULL;
        }
    }
    if (!pcm) {
        LOG_ERROR("record: could not open any capture device (configured or auto)");
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }

    /* Record now also drives playback of whatever's already in the
     * project, so recording a new part can be laid down against the
     * existing ones instead of into silence -- the user's own
     * suggested fix for "no way to play and record at once". Opening
     * the output device is best-effort: if it fails (or there's
     * nothing to play yet), recording still proceeds capture-only,
     * same as before this round, rather than failing the whole
     * record just because monitoring couldn't start. Snapshotted
     * BEFORE the capture loop starts, same append-only assumption
     * playback_thread_fn relies on -- and, same as that function's
     * `muted` snapshot, a mute toggled mid-recording only takes
     * effect on the NEXT recording, not live. The track being
     * re-recorded into (replace_index, when >= 0) is left out of the
     * monitor mix -- you're replacing it, not duetting with it. */
    tracklist_lock(&app->tracklist);
    int mon_n = app->tracklist.num_tracks;
    Track mon_snapshot[MAX_TRACKS];
    memcpy(mon_snapshot, app->tracklist.tracks, sizeof(Track) * (size_t)mon_n);
    tracklist_unlock(&app->tracklist);

    int mon_muted[MAX_TRACKS];
    for (int t = 0; t < mon_n; t++)
        mon_muted[t] = (t == replace_index) ? 1 : atomic_load(&mon_snapshot[t].muted);

    snd_pcm_t *pcm_out = NULL;
    if (mon_n > 0) {
        if (app->settings.output_device[0] != '\0') {
            pcm_out = audio_open_and_configure_named(SND_PCM_STREAM_PLAYBACK, app->settings.output_device,
                                                       SAMPLE_RATE, CHANNELS, SND_PCM_FORMAT_S16_LE, 200000,
                                                       "record-monitor");
        }
        if (!pcm_out) {
            pcm_out = audio_open_pcm(SND_PCM_STREAM_PLAYBACK, "record-monitor");
            if (pcm_out && audio_configure_pcm(pcm_out, SAMPLE_RATE, CHANNELS, SND_PCM_FORMAT_S16_LE,
                                                 200000, "record-monitor") != 0) {
                snd_pcm_close(pcm_out);
                pcm_out = NULL;
            }
        }
        if (!pcm_out)
            LOG_WARN("record: couldn't open an output device for playback monitoring -- recording capture-only");
    }
    size_t mon_pos = atomic_load(&app->playhead_frame);

    size_t capacity = (size_t)SAMPLE_RATE * 10; /* start at 10s worth, doubles as needed */
    size_t count = 0;
    int16_t *buf = malloc(capacity * sizeof(int16_t));
    if (!buf) {
        LOG_ERROR("record: could not allocate initial buffer");
        snd_pcm_close(pcm);
        if (pcm_out) snd_pcm_close(pcm_out);
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }

    LOG_INFO("record: started%s", pcm_out ? " (with playback monitoring)" : "");
    const size_t CHUNK = 1024;
    int16_t chunk[CHUNK];
    int16_t mon_chunk[CHUNK];

    while (!atomic_load(&app->stop_requested)) {
        snd_pcm_sframes_t got = snd_pcm_readi(pcm, chunk, CHUNK);
        if (got < 0) {
            LOG_WARN("record: xrun/error (%s), attempting recover", snd_strerror((int)got));
            got = snd_pcm_recover(pcm, (int)got, 0);
            if (got < 0) {
                LOG_ERROR("record: recover failed (%s), stopping early", snd_strerror((int)got));
                break;
            }
            continue;
        }

        if (count + (size_t)got > capacity) {
            capacity *= 2;
            int16_t *nb = realloc(buf, capacity * sizeof(int16_t));
            if (!nb) {
                LOG_ERROR("record: out of memory growing buffer, stopping early");
                break;
            }
            buf = nb;
        }
        memcpy(buf + count, chunk, (size_t)got * sizeof(int16_t));
        count += (size_t)got;

        /* Monitor mix: same per-sample mixing a plain playback does
         * (see playback_thread_fn), just with no region/solo/loop
         * logic -- recording plays everything (bar replace_index and
         * anything muted) straight through from wherever the playhead
         * was when Record was pressed, with no defined end: a track
         * shorter than `mon_pos` simply stops contributing, same as
         * playback running past it. */
        if (pcm_out) {
            for (snd_pcm_sframes_t i = 0; i < got; i++) {
                int32_t sum = 0;
                for (int t = 0; t < mon_n; t++) {
                    if (mon_muted[t]) continue;
                    size_t idx = mon_pos + (size_t)i;
                    if (idx < mon_snapshot[t].clip.num_samples)
                        sum += mon_snapshot[t].clip.samples[idx];
                }
                sum = sum * app->settings.volume_percent / 100;
                if (sum > 32767) sum = 32767;
                if (sum < -32768) sum = -32768;
                mon_chunk[i] = (int16_t)sum;
            }
            size_t mon_written = 0;
            while (mon_written < (size_t)got) {
                snd_pcm_sframes_t w = snd_pcm_writei(pcm_out, mon_chunk + mon_written, (size_t)got - mon_written);
                if (w < 0) {
                    LOG_WARN("record: monitor xrun/error (%s), attempting recover", snd_strerror((int)w));
                    w = snd_pcm_recover(pcm_out, (int)w, 0);
                    if (w < 0) {
                        LOG_WARN("record: monitor recover failed (%s), dropping playback monitoring", snd_strerror((int)w));
                        snd_pcm_close(pcm_out);
                        pcm_out = NULL;
                        break;
                    }
                    continue;
                }
                mon_written += (size_t)w;
            }
            mon_pos += (size_t)got;
            atomic_store(&app->playhead_frame, mon_pos);
        }
        atomic_store(&app->dirty, true);
    }

    snd_pcm_drop(pcm);
    snd_pcm_close(pcm);
    if (pcm_out) {
        snd_pcm_drain(pcm_out);
        snd_pcm_close(pcm_out);
    }
    atomic_store(&app->stop_requested, false);

    if (count == 0) {
        free(buf);
        LOG_INFO("record: stopped with no audio captured");
    } else if (replace_index >= 0) {
        tracklist_replace_clip(&app->tracklist, replace_index, buf, count, SAMPLE_RATE);
        LOG_INFO("record: re-recorded track %d (%zu frames, %.1fs)",
                  replace_index, count, (double)count / SAMPLE_RATE);
    } else {
        char name[64];
        snprintf(name, sizeof(name), "Track %d", atomic_fetch_add(&app->next_track_number, 1));
        tracklist_add_track(&app->tracklist, name, buf, count, SAMPLE_RATE);
        LOG_INFO("record: stopped, added track '%s' (%zu frames, %.1fs)",
                  name, count, (double)count / SAMPLE_RATE);
    }

    atomic_store(&app->state, STATE_IDLE);
    atomic_store(&app->dirty, true);
    return NULL;
}

static void *playback_thread_fn(void *arg) {
    AppContext *app = ((ThreadArg *)arg)->app;
    free(arg);

    tracklist_lock(&app->tracklist);
    int n = app->tracklist.num_tracks;
    Track snapshot[MAX_TRACKS];
    memcpy(snapshot, app->tracklist.tracks, sizeof(Track) * (size_t)n);
    tracklist_unlock(&app->tracklist);

    if (n == 0) {
        LOG_INFO("playback: no tracks to play");
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }

    snd_pcm_t *pcm = NULL;
    if (app->settings.output_device[0] != '\0') {
        pcm = audio_open_and_configure_named(SND_PCM_STREAM_PLAYBACK, app->settings.output_device,
                                              SAMPLE_RATE, CHANNELS, SND_PCM_FORMAT_S16_LE, 200000, "playback");
        if (!pcm) {
            LOG_WARN("playback: configured device '%s' didn't work at all, falling back to auto",
                      app->settings.output_device);
            confirm_set_message(&app->confirm, "Output device '%s' isn't working. Reset it to Auto?",
                                 app->settings.output_device);
            app->confirm_pending_action = 4;
            atomic_store(&app->confirm_active, true);
        }
    }
    if (!pcm) {
        pcm = audio_open_pcm(SND_PCM_STREAM_PLAYBACK, "playback");
        if (pcm && audio_configure_pcm(pcm, SAMPLE_RATE, CHANNELS, SND_PCM_FORMAT_S16_LE, 200000, "playback") != 0) {
            snd_pcm_close(pcm);
            pcm = NULL;
        }
    }
    if (!pcm) {
        LOG_ERROR("playback: could not open any playback device (configured or auto)");
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }

    size_t max_len = 0;
    for (int i = 0; i < n; i++)
        if (snapshot[i].clip.num_samples > max_len) max_len = snapshot[i].clip.num_samples;

    /* Solo scope precedence: a track-group selection wins over a
     * time-region's track scope, which wins over a plain single-track
     * selection (no time-region at all), which wins over "play
     * everything". solo_lo/solo_hi are an inclusive track-index range;
     * -1/-1 means no soloing (mix every track). */
    int has_sel = atomic_load(&app->has_selection);
    int grp_lo = atomic_load(&app->group_start);
    int grp_hi = atomic_load(&app->group_end);
    int single_sel = atomic_load(&app->selected_track_index);

    size_t region_start = 0, region_end = max_len;
    int solo_lo = -1, solo_hi = -1;

    if (grp_lo >= 0) {
        solo_lo = grp_lo < grp_hi ? grp_lo : grp_hi;
        solo_hi = grp_lo < grp_hi ? grp_hi : grp_lo;
    } else if (has_sel) {
        region_start = app->selection_start_frame;
        region_end = app->selection_end_frame;
        if (app->selection_track_idx >= 0)
            solo_lo = solo_hi = app->selection_track_idx;
        if (region_end > max_len) region_end = max_len;
        if (region_start > region_end) region_start = region_end;
    } else if (single_sel >= 0) {
        solo_lo = solo_hi = single_sel;
    }

    /* Muted tracks (see Track's `muted` comment) are simply excluded
     * from the mix below, same mechanism as soloing -- snapshotted
     * once here rather than re-read with atomic_load() per sample. */
    int muted[MAX_TRACKS];
    for (int t = 0; t < n; t++)
        muted[t] = atomic_load(&snapshot[t].muted);

    size_t pos = atomic_load(&app->playhead_frame);
    if (pos < region_start || pos >= region_end)
        pos = region_start; /* stale/out-of-region position -- snap to the region's start */

    LOG_INFO("playback: %s from frame %zu, bounds [%zu, %zu), mixing %s",
              has_sel ? "region" : "full timeline", pos, region_start, region_end,
              solo_lo < 0 ? "all tracks" : (solo_lo == solo_hi ? "one track (solo)" : "a track group (solo)"));

    const size_t CHUNK = 1024;
    int16_t out_chunk[CHUNK];

    while (!atomic_load(&app->stop_requested)) {
        if (atomic_exchange(&app->seek_pending, false)) {
            pos = atomic_load(&app->seek_target);
            if (pos < region_start) pos = region_start;
            if (pos > region_end) pos = region_end;
            LOG_INFO("playback: seek to frame %zu during playback", pos);
        }

        if (pos >= region_end) {
            /* Reached the end of whatever bounds we're playing within.
             * Loop if the relevant toggle is on, otherwise we're done. */
            int should_loop = has_sel ? atomic_load(&app->loop_region) : atomic_load(&app->loop_all);
            if (should_loop) {
                pos = region_start;
                LOG_INFO("playback: looping back to frame %zu", pos);
                continue;
            }
            break;
        }

        size_t this_chunk = (region_end - pos) < CHUNK ? (region_end - pos) : CHUNK;

        for (size_t i = 0; i < this_chunk; i++) {
            int32_t sum = 0;
            for (int t = 0; t < n; t++) {
                if (solo_lo >= 0 && (t < solo_lo || t > solo_hi))
                    continue;
                if (muted[t])
                    continue;
                size_t idx = pos + i;
                if (idx < snapshot[t].clip.num_samples)
                    sum += snapshot[t].clip.samples[idx];
            }
            sum = sum * app->settings.volume_percent / 100;
            if (sum > 32767) sum = 32767;
            if (sum < -32768) sum = -32768;
            out_chunk[i] = (int16_t)sum;
        }

        size_t written_total = 0;
        while (written_total < this_chunk) {
            snd_pcm_sframes_t written = snd_pcm_writei(pcm, out_chunk + written_total, this_chunk - written_total);
            if (written < 0) {
                LOG_WARN("playback: xrun/error (%s), attempting recover", snd_strerror((int)written));
                written = snd_pcm_recover(pcm, (int)written, 0);
                if (written < 0) {
                    LOG_ERROR("playback: recover failed (%s), stopping early", snd_strerror((int)written));
                    goto playback_done;
                }
                continue;
            }
            written_total += (size_t)written;
        }
        pos += this_chunk;

        /* Live-update the shared playhead and mark the UI dirty every
         * chunk (~23ms of audio) so the playhead line visibly moves
         * during playback, and so a Pause click resumes from here. */
        atomic_store(&app->playhead_frame, pos);
        atomic_store(&app->dirty, true);
    }

playback_done:
    snd_pcm_drain(pcm);
    snd_pcm_close(pcm);
    if (pos >= region_end) {
        atomic_store(&app->playhead_frame, region_start); /* reached the end naturally -- back to the region/project start */
        LOG_INFO("playback: reached the end, playhead reset to %zu", region_start);
    } else {
        LOG_INFO("playback: paused at frame %zu of [%zu, %zu)", pos, region_start, region_end);
    }
    atomic_store(&app->stop_requested, false);
    atomic_store(&app->state, STATE_IDLE);
    atomic_store(&app->dirty, true);
    return NULL;
}

/* ---------------------------------------------------------------- */
/* Save: writes a project folder (manifest + one WAV per track) under */
/* the app's own data directory (~/.local/share/beatrat/projects/).  */
/* ---------------------------------------------------------------- */

typedef struct {
    AppContext *app;
    char name[256];
} SaveArg;

static void *save_thread_fn(void *arg) {
    SaveArg *sa = (SaveArg *)arg;
    AppContext *app = sa->app;
    char name[256];
    snprintf(name, sizeof(name), "%s", sa->name);
    free(arg);

    char projects_dir[768];
    settings_projects_dir(projects_dir, sizeof(projects_dir));

    char project_dir[900];
    snprintf(project_dir, sizeof(project_dir), "%s/%s", projects_dir, name);
    if (mkdir(project_dir, 0755) != 0 && errno != EEXIST) {
        LOG_ERROR("save: could not create '%s' (%s)", project_dir, strerror(errno));
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }

    tracklist_lock(&app->tracklist);
    int n = app->tracklist.num_tracks;
    Track snapshot[MAX_TRACKS];
    memcpy(snapshot, app->tracklist.tracks, sizeof(Track) * (size_t)n);
    tracklist_unlock(&app->tracklist);

    char manifest_path[1000];
    snprintf(manifest_path, sizeof(manifest_path), "%s/project.manifest", project_dir);
    FILE *mf = fopen(manifest_path, "w");
    if (!mf) {
        LOG_ERROR("save: could not write manifest '%s'", manifest_path);
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }
    fprintf(mf, "beatrat_project v1\n");

    int wrote = 0;
    for (int i = 0; i < n; i++) {
        char wav_name[64];
        snprintf(wav_name, sizeof(wav_name), "track_%03d.wav", i + 1);
        char wav_path[1024];
        snprintf(wav_path, sizeof(wav_path), "%s/%s", project_dir, wav_name);

        WavWriter w;
        if (wav_writer_open(&w, wav_path, snapshot[i].clip.sample_rate, 1, 16) != 0) {
            LOG_ERROR("save: could not write '%s', skipping track '%s'", wav_path, snapshot[i].name);
            continue;
        }
        wav_writer_write(&w, snapshot[i].clip.samples, snapshot[i].clip.num_samples);
        wav_writer_close(&w);

        fprintf(mf, "track=%s|%s|%u\n", snapshot[i].name, wav_name, snapshot[i].clip.sample_rate);
        wrote++;
    }
    fclose(mf);

    LOG_INFO("save: wrote project '%s' (%d of %d track(s)) to '%s'", name, wrote, n, project_dir);
    atomic_store(&app->state, STATE_IDLE);
    atomic_store(&app->dirty, true);
    return NULL;
}

/* ---------------------------------------------------------------- */
/* Export: mixes all tracks down to one file in the chosen format.   */
/* ---------------------------------------------------------------- */

typedef struct {
    AppContext *app;
    char path[768];
    ExportFormat format;
} ExportArg;

static void *export_thread_fn(void *arg) {
    ExportArg *ea = (ExportArg *)arg;
    AppContext *app = ea->app;
    char path[768];
    snprintf(path, sizeof(path), "%s", ea->path);
    ExportFormat format = ea->format;
    free(arg);

    tracklist_lock(&app->tracklist);
    int n = app->tracklist.num_tracks;
    Track snapshot[MAX_TRACKS];
    memcpy(snapshot, app->tracklist.tracks, sizeof(Track) * (size_t)n);
    tracklist_unlock(&app->tracklist);

    if (n == 0) {
        LOG_WARN("export: no tracks to export");
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }

    size_t max_len = 0;
    for (int i = 0; i < n; i++)
        if (snapshot[i].clip.num_samples > max_len) max_len = snapshot[i].clip.num_samples;

    int16_t *mix = malloc(max_len * sizeof(int16_t));
    if (!mix) {
        LOG_ERROR("export: out of memory for mixdown (%zu samples)", max_len);
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }

    /* Muted tracks are left out of the exported mix too, same as
     * playback -- consistent with what you'd actually hear if you
     * played the project right now. */
    int muted[MAX_TRACKS];
    for (int t = 0; t < n; t++)
        muted[t] = atomic_load(&snapshot[t].muted);

    for (size_t i = 0; i < max_len; i++) {
        int32_t sum = 0;
        for (int t = 0; t < n; t++) {
            if (muted[t]) continue;
            if (i < snapshot[t].clip.num_samples)
                sum += snapshot[t].clip.samples[i];
        }
        if (sum > 32767) sum = 32767;
        if (sum < -32768) sum = -32768;
        mix[i] = (int16_t)sum;
    }

    LOG_INFO("export: mixed %d track(s), %zu frames, writing '%s'", n, max_len, path);
    int rc = export_write(path, format, mix, max_len, SAMPLE_RATE);
    free(mix);

    if (rc == 0)
        LOG_INFO("export: wrote '%s'", path);
    else
        LOG_ERROR("export: failed to write '%s'", path);

    atomic_store(&app->state, STATE_IDLE);
    atomic_store(&app->dirty, true);
    return NULL;
}

/* ---------------------------------------------------------------- */
/* Open Project: clears current tracks and loads a saved project's     */
/* manifest + WAV files back into the track list.                      */
/* ---------------------------------------------------------------- */

typedef struct {
    AppContext *app;
    char project_dir[900];
} LoadProjectArg;

static void *load_project_thread_fn(void *arg) {
    LoadProjectArg *la = (LoadProjectArg *)arg;
    AppContext *app = la->app;
    char project_dir[900];
    snprintf(project_dir, sizeof(project_dir), "%s", la->project_dir);
    free(arg);

    char manifest_path[1024];
    snprintf(manifest_path, sizeof(manifest_path), "%s/project.manifest", project_dir);
    FILE *mf = fopen(manifest_path, "r");
    if (!mf) {
        LOG_ERROR("open-project: could not read manifest '%s'", manifest_path);
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }

    tracklist_clear(&app->tracklist);

    char line[1200];
    int loaded = 0;
    if (!fgets(line, sizeof(line), mf)) { /* skip the "beatrat_project v1" header line */
        LOG_ERROR("open-project: '%s' is empty or unreadable", manifest_path);
        fclose(mf);
        atomic_store(&app->state, STATE_IDLE);
        atomic_store(&app->dirty, true);
        return NULL;
    }

    while (fgets(line, sizeof(line), mf)) {
        line[strcspn(line, "\r\n")] = '\0';
        if (strncmp(line, "track=", 6) != 0)
            continue;

        char *name = line + 6;
        char *sep1 = strchr(name, '|');
        if (!sep1) continue;
        *sep1 = '\0';
        char *wav_name = sep1 + 1;
        char *sep2 = strchr(wav_name, '|');
        if (!sep2) continue;
        *sep2 = '\0';
        uint32_t rate = (uint32_t)atoi(sep2 + 1);

        char wav_path[1200];
        snprintf(wav_path, sizeof(wav_path), "%s/%s", project_dir, wav_name);

        WavReader r;
        if (wav_reader_open(&r, wav_path) != 0) {
            LOG_ERROR("open-project: could not read '%s', skipping track '%s'", wav_path, name);
            continue;
        }
        size_t total_samples = r.data_bytes_total / sizeof(int16_t);
        int16_t *buf = malloc(total_samples * sizeof(int16_t));
        size_t got = buf ? wav_reader_read(&r, buf, total_samples) : 0;
        wav_reader_close(&r);
        if (!buf || got == 0) {
            LOG_ERROR("open-project: '%s' produced no samples, skipping track '%s'", wav_path, name);
            free(buf);
            continue;
        }

        tracklist_add_track(&app->tracklist, name, buf, got, rate);
        loaded++;
    }
    fclose(mf);

    /* Keep the "Track N" counter ahead of anything this project already
     * has, so recording/importing right after opening it doesn't hand
     * out a number that collides with one of the names just loaded. */
    tracklist_lock(&app->tracklist);
    for (int i = 0; i < app->tracklist.num_tracks; i++) {
        int n;
        if (sscanf(app->tracklist.tracks[i].name, "Track %d", &n) == 1) {
            int cur = atomic_load(&app->next_track_number);
            if (n + 1 > cur)
                atomic_store(&app->next_track_number, n + 1);
        }
    }
    tracklist_unlock(&app->tracklist);

    LOG_INFO("open-project: loaded %d track(s) from '%s'", loaded, project_dir);
    atomic_store(&app->playhead_frame, 0);
    atomic_store(&app->selected_track_index, -1);
    atomic_store(&app->highlighted_track_idx, -1);
    atomic_store(&app->state, STATE_IDLE);
    atomic_store(&app->dirty, true);
    return NULL;
}

/* ---------------------------------------------------------------- */
/* UI: click handling and drawing                                    */
/* ---------------------------------------------------------------- */

static void spawn_detached(void *(*fn)(void *), AppContext *app) {
    ThreadArg *ta = calloc(1, sizeof(ThreadArg));
    ta->app = app;
    ta->replace_index = -1; /* calloc zero-inits to 0, which would mean "replace track 0" -- must be explicit */
    pthread_t t;
    pthread_create(&t, NULL, fn, ta);
    pthread_detach(t);
}

static void spawn_import(AppContext *app, const char *path) {
    ThreadArg *ta = calloc(1, sizeof(ThreadArg));
    ta->app = app;
    ta->replace_index = -1;
    snprintf(ta->path, sizeof(ta->path), "%s", path);
    pthread_t t;
    pthread_create(&t, NULL, import_thread_fn, ta);
    pthread_detach(t);
}

static void spawn_rerecord(AppContext *app, int idx) {
    ThreadArg *ta = calloc(1, sizeof(ThreadArg));
    ta->app = app;
    ta->replace_index = idx;
    pthread_t t;
    pthread_create(&t, NULL, record_thread_fn, ta);
    pthread_detach(t);
}

static void spawn_save(AppContext *app, const char *name) {
    SaveArg *sa = calloc(1, sizeof(SaveArg));
    sa->app = app;
    snprintf(sa->name, sizeof(sa->name), "%s", name);
    pthread_t t;
    pthread_create(&t, NULL, save_thread_fn, sa);
    pthread_detach(t);
}

static void spawn_load_project(AppContext *app, const char *project_dir) {
    LoadProjectArg *la = calloc(1, sizeof(LoadProjectArg));
    la->app = app;
    snprintf(la->project_dir, sizeof(la->project_dir), "%s", project_dir);
    pthread_t t;
    pthread_create(&t, NULL, load_project_thread_fn, la);
    pthread_detach(t);
}

static void spawn_export(AppContext *app, const char *path, ExportFormat format) {
    ExportArg *ea = calloc(1, sizeof(ExportArg));
    ea->app = app;
    snprintf(ea->path, sizeof(ea->path), "%s", path);
    ea->format = format;
    pthread_t t;
    pthread_create(&t, NULL, export_thread_fn, ea);
    pthread_detach(t);
}

/* Builds the full export path from the current export_name_buf and the
 * configured export directory, then either proceeds immediately (dir
 * already exists) or opens the confirm popup to ask before creating
 * it -- this is the one case in the app where a user-configured
 * directory not existing is worth asking about, rather than silently
 * creating it (an app-internal data dir) or silently failing (reading
 * from a missing import dir just shows zero files). */
static void try_export(AppContext *app, ExportFormat format) {
    char path[768];
    snprintf(path, sizeof(path), "%s/%s.%s", app->settings.default_export_dir,
             app->export_name_buf, export_extension(format));

    atomic_store(&app->export_active, false);

    if (dir_exists(app->settings.default_export_dir)) {
        LOG_INFO("UI: Export -> '%s'", path);
        atomic_store(&app->state, STATE_EXPORTING);
        atomic_store(&app->dirty, true);
        spawn_export(app, path, format);
        return;
    }

    LOG_INFO("export: directory '%s' does not exist, asking before creating", app->settings.default_export_dir);
    confirm_set_message(&app->confirm, "Directory '%s' doesn't exist. Create it?", app->settings.default_export_dir);
    snprintf(app->confirm_pending_dir, sizeof(app->confirm_pending_dir), "%s", app->settings.default_export_dir);
    snprintf(app->pending_export_path, sizeof(app->pending_export_path), "%s", path);
    app->pending_export_format = format;
    app->confirm_pending_action = 1;
    atomic_store(&app->confirm_active, true);
    atomic_store(&app->dirty, true);
}

static void create_whitespace_track(AppContext *app, size_t num_samples) {
    int16_t *buf = NULL;
    if (num_samples > 0) {
        buf = calloc(num_samples, sizeof(int16_t)); /* zeroed = silence */
        if (!buf) {
            LOG_ERROR("new track: out of memory allocating %zu frames of silence", num_samples);
            set_reg_status(app, "New Track: out of memory");
            return;
        }
    }
    char name[64];
    snprintf(name, sizeof(name), "Track %d", atomic_fetch_add(&app->next_track_number, 1));
    int idx = tracklist_add_track(&app->tracklist, name, buf, num_samples, SAMPLE_RATE);
    if (idx < 0) {
        free(buf);
        set_reg_status(app, "New Track: track list full");
        return;
    }
    LOG_INFO("new track: added whitespace track '%s' (%zu frames, %.1fs)",
              name, num_samples, (double)num_samples / SAMPLE_RATE);
    set_reg_status(app, "Added whitespace track '%s' (%.1fs)", name, (double)num_samples / SAMPLE_RATE);
    atomic_store(&app->dirty, true);
}

/* Click (or Enter, from the length prompt) on the "+New Track" row --
 * see the "Normal scrolling rows" comment in redraw() and the matching
 * block in handle_click() for where the virtual row (index ==
 * num_tracks) is drawn/hit-tested. With no
 * tracks yet there's nothing to size the new one against, so this
 * opens a short length prompt instead of creating immediately; with at
 * least one track already present, it just matches the project's
 * current runtime with no prompt at all, per the user's request. */
static void handle_new_track_click(AppContext *app) {
    tracklist_lock(&app->tracklist);
    int n = app->tracklist.num_tracks;
    tracklist_unlock(&app->tracklist);

    if (n == 0) {
        app->new_track_min_buf[0] = '\0';
        app->new_track_sec_buf[0] = '\0';
        atomic_store(&app->new_track_field, 0);
        atomic_store(&app->new_track_prompt_active, true);
        atomic_store(&app->dirty, true);
        return;
    }
    create_whitespace_track(app, compute_timeline_total(app));
}

static size_t compute_timeline_total(AppContext *app) {
    tracklist_lock(&app->tracklist);
    size_t total = 0;
    for (int i = 0; i < app->tracklist.num_tracks; i++)
        if (app->tracklist.tracks[i].clip.num_samples > total)
            total = app->tracklist.tracks[i].clip.num_samples;
    tracklist_unlock(&app->tracklist);
    return total;
}

/* While idle, the playhead can just be set directly. While playback is
 * actively running, the playback thread overwrites playhead_frame with
 * its own advancing position every chunk -- so a direct write here
 * would just get clobbered a moment later. seek_target/seek_pending is
 * how we hand the new position to that thread instead. */
static void request_seek(AppContext *app, size_t new_frame) {
    if (atomic_load(&app->state) == STATE_PLAYING) {
        atomic_store(&app->seek_target, new_frame);
        atomic_store(&app->seek_pending, true);
    } else {
        atomic_store(&app->playhead_frame, new_frame);
    }
    atomic_store(&app->dirty, true);
}

/* Standard EWMH way to toggle always-on-top: send a _NET_WM_STATE
 * ClientMessage to the root window asking the window manager to
 * add/remove _NET_WM_STATE_ABOVE. Works with any EWMH-compliant WM
 * (xfwm4 included). */
static void set_always_on_top(Display *dpy, Window win, int enable) {
    Atom wm_state = XInternAtom(dpy, "_NET_WM_STATE", False);
    Atom wm_above = XInternAtom(dpy, "_NET_WM_STATE_ABOVE", False);

    XEvent xev;
    memset(&xev, 0, sizeof(xev));
    xev.type = ClientMessage;
    xev.xclient.window = win;
    xev.xclient.message_type = wm_state;
    xev.xclient.format = 32;
    xev.xclient.data.l[0] = enable ? 1 : 0; /* 1 = _NET_WM_STATE_ADD, 0 = _NET_WM_STATE_REMOVE */
    xev.xclient.data.l[1] = (long)wm_above;
    xev.xclient.data.l[2] = 0;
    xev.xclient.data.l[3] = 1; /* source indication: normal application */
    xev.xclient.data.l[4] = 0;

    XSendEvent(dpy, DefaultRootWindow(dpy), False,
               SubstructureRedirectMask | SubstructureNotifyMask, &xev);
    XFlush(dpy);
    LOG_INFO("window: always-on-top %s", enable ? "enabled" : "disabled");
}

/* Shared by both the click and keyboard confirm-popup handlers, so the
 * three pending-action branches only need to be written once. */
static void confirm_yes_action(AppContext *app) {
    switch (app->confirm_pending_action) {
        case 1: /* resume export after creating its directory */
            mkdir_p(app->confirm_pending_dir);
            LOG_INFO("export: created '%s', proceeding", app->confirm_pending_dir);
            atomic_store(&app->state, STATE_EXPORTING);
            spawn_export(app, app->pending_export_path, app->pending_export_format);
            break;
        case 2: /* New Project: discard current tracks */
            tracklist_clear(&app->tracklist);
            atomic_store(&app->playhead_frame, 0);
            atomic_store(&app->selected_track_index, -1);
            atomic_store(&app->highlighted_track_idx, -1);
            atomic_store(&app->next_track_number, 1);
            LOG_INFO("UI: New Project confirmed, tracks discarded");
            break;
        case 3: /* Open Project: discard current tracks, then load the chosen one */
            atomic_store(&app->state, STATE_LOADING);
            spawn_load_project(app, app->pending_open_project_path);
            LOG_INFO("UI: Open Project confirmed, loading '%s'", app->pending_open_project_path);
            break;
        case 4: /* a configured output device proved non-functional -- reset to auto */
            app->settings.output_device[0] = '\0';
            settings_save(&app->settings);
            LOG_INFO("settings: output device reset to auto (it wasn't working)");
            break;
        case 5: /* a configured input device proved non-functional -- reset to auto */
            app->settings.input_device[0] = '\0';
            settings_save(&app->settings);
            LOG_INFO("settings: input device reset to auto (it wasn't working)");
            break;
        default:
            break;
    }
    app->confirm_pending_action = 0;
}

/* Steps the Prev(-1)/Next(+1) device selection, skipping hidden
 * devices unless audio_show_hidden is on. Wraps through -1 (Auto).
 * Stops once it's back where it started (safety against an infinite
 * loop if every device happens to be hidden). Writes the chosen
 * device's name (or "" for Auto) into dest. */
static void step_device_selection(AppContext *app, int *idx, AudioDeviceInfo *devices, int num_devices,
                                   int direction, char *dest, size_t dest_size) {
    int start = *idx;
    int show_hidden = atomic_load(&app->audio_show_hidden);
    do {
        *idx += direction;
        if (*idx < -1) *idx = num_devices - 1;
        if (*idx >= num_devices) *idx = -1;
    } while (*idx >= 0 && *idx != start && !show_hidden &&
             settings_is_device_hidden(&app->settings, devices[*idx].name));

    const char *name = (*idx < 0) ? "" : devices[*idx].name;
    snprintf(dest, dest_size, "%s", name);
}

static void handle_click(AppContext *app, int x, int y) {
    if (atomic_load(&app->picker_active)) {
        int shortcut_idx = filepicker_shortcut_at(&app->picker, app->win_w, app->win_h, x, y);
        if (shortcut_idx >= 0) {
            char new_dir[512];
            snprintf(new_dir, sizeof(new_dir), "%s", app->picker.shortcuts[shortcut_idx].path);
            LOG_INFO("UI: file picker shortcut '%s' -> '%s'", app->picker.shortcuts[shortcut_idx].label, new_dir);
            /* Re-open on the new directory but keep the same shortcuts
             * list -- filepicker_open() doesn't touch it. */
            FilePickerShortcut saved[FILEPICKER_MAX_SHORTCUTS];
            int saved_n = app->picker.num_shortcuts;
            memcpy(saved, app->picker.shortcuts, sizeof(saved));
            filepicker_open(&app->picker, new_dir, app->picker.mode);
            filepicker_set_shortcuts(&app->picker, saved, saved_n);
            atomic_store(&app->dirty, true);
        }
        return; /* modal: everything else is keyboard-only */
    }

    if (atomic_load(&app->confirm_active)) {
        int box_w = CONFIRM_BOX_W, box_h = CONFIRM_BOX_H;
        int bx = (app->win_w - box_w) / 2;
        int by2 = (app->win_h - box_h) / 2;

        if (point_in_rect(x, y, bx + CONFIRM_YES_BTN_X, by2 + CONFIRM_BTN_Y, CONFIRM_BTN_W, CONFIRM_BTN_H)) {
            LOG_INFO("UI: confirm Yes");
            atomic_store(&app->confirm_active, false);
            confirm_yes_action(app);
            atomic_store(&app->dirty, true);
            return;
        }
        if (point_in_rect(x, y, bx + CONFIRM_NO_BTN_X, by2 + CONFIRM_BTN_Y, CONFIRM_BTN_W, CONFIRM_BTN_H)) {
            LOG_INFO("UI: confirm No -- cancelled");
            atomic_store(&app->confirm_active, false);
            app->confirm_pending_action = 0;
            atomic_store(&app->dirty, true);
            return;
        }
        return; /* modal: clicks elsewhere do nothing */
    }

    if (atomic_load(&app->save_active)) {
        return; /* modal: keyboard only (single field, Enter to save) */
    }

    if (atomic_load(&app->audio_io_active)) {
        int box_w = AUDIO_BOX_W, box_h = AUDIO_BOX_H;
        int bx = (app->win_w - box_w) / 2, by2 = (app->win_h - box_h) / 2;

        if (point_in_rect(x, y, bx + AUDIO_PREV_X, by2 + AUDIO_OUTPUT_Y, AUDIO_PREV_W, AUDIO_ROW_H)) {
            step_device_selection(app, &app->audio_output_idx, app->audio_output_devices,
                                   app->audio_num_output_devices, -1,
                                   app->settings.output_device, sizeof(app->settings.output_device));
            settings_save(&app->settings);
            LOG_INFO("audio: output device -> '%s'", app->audio_output_idx < 0 ? "auto" : app->settings.output_device);
            atomic_store(&app->dirty, true);
            return;
        }
        if (point_in_rect(x, y, bx + AUDIO_NEXT_X, by2 + AUDIO_OUTPUT_Y, AUDIO_NEXT_W, AUDIO_ROW_H)) {
            step_device_selection(app, &app->audio_output_idx, app->audio_output_devices,
                                   app->audio_num_output_devices, 1,
                                   app->settings.output_device, sizeof(app->settings.output_device));
            settings_save(&app->settings);
            LOG_INFO("audio: output device -> '%s'", app->audio_output_idx < 0 ? "auto" : app->settings.output_device);
            atomic_store(&app->dirty, true);
            return;
        }
        if (point_in_rect(x, y, bx + AUDIO_PREV_X, by2 + AUDIO_INPUT_Y, AUDIO_PREV_W, AUDIO_ROW_H)) {
            step_device_selection(app, &app->audio_input_idx, app->audio_input_devices,
                                   app->audio_num_input_devices, -1,
                                   app->settings.input_device, sizeof(app->settings.input_device));
            settings_save(&app->settings);
            LOG_INFO("audio: input device -> '%s'", app->audio_input_idx < 0 ? "auto" : app->settings.input_device);
            atomic_store(&app->dirty, true);
            return;
        }
        if (point_in_rect(x, y, bx + AUDIO_NEXT_X, by2 + AUDIO_INPUT_Y, AUDIO_NEXT_W, AUDIO_ROW_H)) {
            step_device_selection(app, &app->audio_input_idx, app->audio_input_devices,
                                   app->audio_num_input_devices, 1,
                                   app->settings.input_device, sizeof(app->settings.input_device));
            settings_save(&app->settings);
            LOG_INFO("audio: input device -> '%s'", app->audio_input_idx < 0 ? "auto" : app->settings.input_device);
            atomic_store(&app->dirty, true);
            return;
        }
        if (point_in_rect(x, y, bx + AUDIO_HIDE_X, by2 + AUDIO_OUTPUT_Y, AUDIO_HIDE_W, AUDIO_ROW_H)) {
            if (app->audio_output_idx >= 0) {
                const char *name = app->audio_output_devices[app->audio_output_idx].name;
                if (settings_is_device_hidden(&app->settings, name)) {
                    settings_unhide_device(&app->settings, name);
                    LOG_INFO("audio: unhid '%s'", name);
                } else {
                    settings_hide_device(&app->settings, name);
                    LOG_INFO("audio: hid '%s'", name);
                }
                settings_save(&app->settings);
                atomic_store(&app->dirty, true);
            }
            return;
        }
        if (point_in_rect(x, y, bx + AUDIO_HIDE_X, by2 + AUDIO_INPUT_Y, AUDIO_HIDE_W, AUDIO_ROW_H)) {
            if (app->audio_input_idx >= 0) {
                const char *name = app->audio_input_devices[app->audio_input_idx].name;
                if (settings_is_device_hidden(&app->settings, name)) {
                    settings_unhide_device(&app->settings, name);
                    LOG_INFO("audio: unhid '%s'", name);
                } else {
                    settings_hide_device(&app->settings, name);
                    LOG_INFO("audio: hid '%s'", name);
                }
                settings_save(&app->settings);
                atomic_store(&app->dirty, true);
            }
            return;
        }
        if (point_in_rect(x, y, bx + AUDIO_PREV_X, by2 + AUDIO_SHOW_HIDDEN_Y, box_w - 16, AUDIO_ROW_H)) {
            int cur = atomic_load(&app->audio_show_hidden);
            atomic_store(&app->audio_show_hidden, !cur);
            LOG_INFO("audio: show-hidden %s", !cur ? "enabled" : "disabled");
            atomic_store(&app->dirty, true);
            return;
        }
        if (point_in_rect(x, y, bx + AUDIO_VOL_MINUS_X, by2 + AUDIO_VOLUME_Y, AUDIO_PREV_W, AUDIO_ROW_H)) {
            app->settings.volume_percent -= 5;
            if (app->settings.volume_percent < 0) app->settings.volume_percent = 0;
            settings_save(&app->settings);
            LOG_INFO("audio: volume -> %d%%", app->settings.volume_percent);
            atomic_store(&app->dirty, true);
            return;
        }
        if (point_in_rect(x, y, bx + AUDIO_VOL_PLUS_X, by2 + AUDIO_VOLUME_Y, AUDIO_PREV_W, AUDIO_ROW_H)) {
            app->settings.volume_percent += 5;
            if (app->settings.volume_percent > 150) app->settings.volume_percent = 150;
            settings_save(&app->settings);
            LOG_INFO("audio: volume -> %d%%", app->settings.volume_percent);
            atomic_store(&app->dirty, true);
            return;
        }
        return; /* modal: clicks elsewhere do nothing (Escape closes) */
    }

    if (atomic_load(&app->rename_active)) {
        return; /* modal: keyboard only (single field, Enter to confirm) */
    }

    if (atomic_load(&app->new_track_prompt_active)) {
        return; /* modal: keyboard only (single field, Enter to confirm) */
    }

    if (atomic_load(&app->export_active)) {
        int box_w = EXPORT_BOX_W, box_h = EXPORT_BOX_H;
        int bx = (app->win_w - box_w) / 2;
        int by2 = (app->win_h - box_h) / 2;

        if (point_in_rect(x, y, bx + EXPORT_WAV_BTN_X, by2 + EXPORT_FORMAT_BTN_Y,
                           EXPORT_FORMAT_BTN_W, EXPORT_FORMAT_BTN_H)) {
            LOG_INFO("UI: Export format WAV");
            try_export(app, EXPORT_FORMAT_WAV);
            return;
        }
#ifdef HAVE_FLAC
        if (point_in_rect(x, y, bx + EXPORT_FLAC_BTN_X, by2 + EXPORT_FORMAT_BTN_Y,
                           EXPORT_FORMAT_BTN_W, EXPORT_FORMAT_BTN_H)) {
            LOG_INFO("UI: Export format FLAC");
            try_export(app, EXPORT_FORMAT_FLAC);
            return;
        }
#endif
#ifdef HAVE_LAME
        if (point_in_rect(x, y, bx + EXPORT_MP3_BTN_X, by2 + EXPORT_FORMAT_BTN_Y,
                           EXPORT_FORMAT_BTN_W, EXPORT_FORMAT_BTN_H)) {
            LOG_INFO("UI: Export format MP3");
            try_export(app, EXPORT_FORMAT_MP3);
            return;
        }
#endif
        return; /* modal: clicking the name field does nothing extra -- it's the only field, always focused */
    }

    if (atomic_load(&app->settings_active)) {
        int box_w = SETTINGS_BOX_W, box_h = SETTINGS_BOX_H;
        int bx = (app->win_w - box_w) / 2;
        int by2 = (app->win_h - box_h) / 2;

        int field_x = bx + 8, field_w = box_w - 16;
        if (point_in_rect(x, y, field_x, by2 + SETTINGS_IMPORT_FIELD_Y, field_w, SETTINGS_FIELD_H)) {
            atomic_store(&app->settings_active_field, 0);
            atomic_store(&app->dirty, true);
            return;
        }
        if (point_in_rect(x, y, field_x, by2 + SETTINGS_EXPORT_FIELD_Y, field_w, SETTINGS_FIELD_H)) {
            atomic_store(&app->settings_active_field, 1);
            atomic_store(&app->dirty, true);
            return;
        }
        if (point_in_rect(x, y, field_x, by2 + SETTINGS_ALTDIR_FIELD_Y, field_w, SETTINGS_FIELD_H)) {
            atomic_store(&app->settings_active_field, 2);
            atomic_store(&app->dirty, true);
            return;
        }

        int list_y = by2 + SETTINGS_ALTDIR_LIST_Y;
        for (int i = 0; i < app->settings.num_alt_dirs; i++) {
            int ry = list_y + i * SETTINGS_ALTDIR_ROW_H;
            int rm_x = bx + box_w - 16 - SETTINGS_ALTDIR_REMOVE_W;
            if (point_in_rect(x, y, rm_x, ry, SETTINGS_ALTDIR_REMOVE_W, SETTINGS_ALTDIR_ROW_H - 2)) {
                LOG_INFO("UI: removing alternate directory '%s'", app->settings.alt_dirs[i].label);
                settings_remove_alt_dir(&app->settings, i);
                settings_save(&app->settings);
                atomic_store(&app->dirty, true);
                return;
            }
        }

        int cb_x = bx + SETTINGS_CHECKBOX_X;
        int cb_y = by2 + SETTINGS_CHECKBOX_Y;
        if (point_in_rect(x, y, cb_x, cb_y, SETTINGS_CHECKBOX_SIZE, SETTINGS_CHECKBOX_SIZE)) {
            app->settings.always_on_top = !app->settings.always_on_top;
            set_always_on_top(app->dpy, app->win, app->settings.always_on_top);
            settings_save(&app->settings);
            LOG_INFO("settings: always-on-top %s", app->settings.always_on_top ? "enabled" : "disabled");
            atomic_store(&app->dirty, true);
        }

        int cb2_x = bx + SETTINGS_CHECKBOX_X;
        int cb2_y = by2 + SETTINGS_CHECKBOX2_Y;
        if (point_in_rect(x, y, cb2_x, cb2_y, SETTINGS_CHECKBOX_SIZE, SETTINGS_CHECKBOX_SIZE)) {
            app->settings.auto_deselect_region = !app->settings.auto_deselect_region;
            settings_save(&app->settings);
            LOG_INFO("settings: auto-deselect region %s", app->settings.auto_deselect_region ? "enabled" : "disabled");
            atomic_store(&app->dirty, true);
        }

        if (point_in_rect(x, y, bx + SETTINGS_CHECKBOX_X, by2 + SETTINGS_KEYBINDS_BTN_Y,
                           SETTINGS_KEYBINDS_BTN_W, SETTINGS_KEYBINDS_BTN_H)) {
            LOG_INFO("UI: Keybinds editor opened");
            atomic_store(&app->settings_active, false);
            atomic_store(&app->keybinds_menu_active, true);
            atomic_store(&app->keybinds_menu_selected, 0);
            atomic_store(&app->keybinds_menu_scroll, 0);
            atomic_store(&app->keybinds_menu_awaiting_key, false);
            app->keybinds_menu_status[0] = '\0';
            atomic_store(&app->dirty, true);
        }
        return; /* modal: everything else here is keyboard-only */
    }

    if (atomic_load(&app->keybinds_menu_active)) {
        /* Click a row to select it (same as Up/Down) -- clicking the
         * ALREADY-selected row's binding text starts the rebind
         * capture directly, same as pressing Enter, so a mouse-only
         * user never has to touch the keyboard except for the new key
         * itself. */
        int box_w = KEYBINDS_MENU_BOX_W, box_h = KEYBINDS_MENU_BOX_H;
        int bx = (app->win_w - box_w) / 2;
        int by2 = (app->win_h - box_h) / 2;
        int list_y = by2 + KEYBINDS_MENU_LIST_Y;
        int scroll = atomic_load(&app->keybinds_menu_scroll);
        int selected = atomic_load(&app->keybinds_menu_selected);

        for (int r = 0; r < KEYBINDS_MENU_VISIBLE_ROWS; r++) {
            int idx = scroll + r;
            if (idx >= KEYBINDS_MENU_NUM_ROWS) break;
            int ry = list_y + r * KEYBINDS_MENU_ROW_H;
            if (point_in_rect(x, y, bx + 4, ry, box_w - 8, KEYBINDS_MENU_ROW_H)) {
                if (idx == selected && !atomic_load(&app->keybinds_menu_awaiting_key)) {
                    atomic_store(&app->keybinds_menu_awaiting_key, true);
                    app->keybinds_menu_status[0] = '\0';
                } else {
                    atomic_store(&app->keybinds_menu_selected, idx);
                    atomic_store(&app->keybinds_menu_awaiting_key, false);
                    app->keybinds_menu_status[0] = '\0';
                }
                atomic_store(&app->dirty, true);
                return;
            }
        }
        return; /* modal: clicking outside the rows does nothing */
    }

    int state = atomic_load(&app->state);
    int by = app->win_h - BOTTOM_PANEL_H;

    if (point_in_rect(x, y, IMPORT_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H)) {
        if (state == STATE_IDLE) {
            LOG_INFO("UI: Import clicked, opening file picker");
            filepicker_open(&app->picker, app->settings.default_import_dir, FILEPICKER_MODE_WAV_FILES);
            app->picker_purpose = 0;

            FilePickerShortcut shortcuts[FILEPICKER_MAX_SHORTCUTS];
            int ns = 0;
            snprintf(shortcuts[ns].label, sizeof(shortcuts[ns].label), "Default");
            snprintf(shortcuts[ns].path, sizeof(shortcuts[ns].path), "%s", app->settings.default_import_dir);
            ns++;
            char proj_dir[768];
            settings_projects_dir(proj_dir, sizeof(proj_dir));
            snprintf(shortcuts[ns].label, sizeof(shortcuts[ns].label), "Projects");
            snprintf(shortcuts[ns].path, sizeof(shortcuts[ns].path), "%s", proj_dir);
            ns++;
            char samples_dir[768];
            settings_samples_dir(samples_dir, sizeof(samples_dir));
            snprintf(shortcuts[ns].label, sizeof(shortcuts[ns].label), "Samples");
            snprintf(shortcuts[ns].path, sizeof(shortcuts[ns].path), "%s", samples_dir);
            ns++;
            for (int i = 0; i < app->settings.num_alt_dirs && ns < FILEPICKER_MAX_SHORTCUTS; i++) {
                snprintf(shortcuts[ns].label, sizeof(shortcuts[ns].label), "%s", app->settings.alt_dirs[i].label);
                snprintf(shortcuts[ns].path, sizeof(shortcuts[ns].path), "%s", app->settings.alt_dirs[i].path);
                ns++;
            }
            filepicker_set_shortcuts(&app->picker, shortcuts, ns);

            atomic_store(&app->picker_active, true);
            atomic_store(&app->dirty, true);
        } else {
            LOG_WARN("UI: Import ignored, app busy");
        }
        return;
    }

    if (point_in_rect(x, y, SAVE_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H)) {
        if (state == STATE_IDLE) {
            LOG_INFO("UI: Save clicked");
            default_timestamp_name(app->save_name_buf, sizeof(app->save_name_buf), "beat");
            atomic_store(&app->save_active, true);
            atomic_store(&app->dirty, true);
        } else {
            LOG_WARN("UI: Save ignored, app busy");
        }
        return;
    }

    if (point_in_rect(x, y, EXPORT_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H)) {
        if (state == STATE_IDLE) {
            LOG_INFO("UI: Export clicked");
            default_timestamp_name(app->export_name_buf, sizeof(app->export_name_buf), "beat");
            atomic_store(&app->export_active, true);
            atomic_store(&app->dirty, true);
        } else {
            LOG_WARN("UI: Export ignored, app busy");
        }
        return;
    }

    if (point_in_rect(x, y, NEW_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H)) {
        if (state == STATE_IDLE) {
            tracklist_lock(&app->tracklist);
            int n = app->tracklist.num_tracks;
            tracklist_unlock(&app->tracklist);
            if (n > 0) {
                LOG_INFO("UI: New Project clicked, %d track(s) would be discarded -- asking first", n);
                confirm_set_message(&app->confirm, "Discard %d track(s) and start a new project?", n);
                app->confirm_pending_action = 2;
                atomic_store(&app->confirm_active, true);
                atomic_store(&app->dirty, true);
            } else {
                LOG_INFO("UI: New Project clicked (nothing to discard)");
                atomic_store(&app->playhead_frame, 0);
                atomic_store(&app->selected_track_index, -1);
                atomic_store(&app->highlighted_track_idx, -1);
                atomic_store(&app->next_track_number, 1);
                atomic_store(&app->dirty, true);
            }
        } else {
            LOG_WARN("UI: New Project ignored, app busy");
        }
        return;
    }

    if (point_in_rect(x, y, OPEN_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H)) {
        if (state == STATE_IDLE) {
            LOG_INFO("UI: Open Project clicked");
            char proj_dir[768];
            settings_projects_dir(proj_dir, sizeof(proj_dir));
            filepicker_open(&app->picker, proj_dir, FILEPICKER_MODE_PROJECT_DIRS);
            filepicker_set_shortcuts(&app->picker, NULL, 0);
            app->picker_purpose = 1; /* Open Project, not Import */
            atomic_store(&app->picker_active, true);
            atomic_store(&app->dirty, true);
        } else {
            LOG_WARN("UI: Open Project ignored, app busy");
        }
        return;
    }

    if (point_in_rect(x, y, AUDIO_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H)) {
        if (state == STATE_IDLE) {
            LOG_INFO("UI: Audio I/O clicked");
            app->audio_num_output_devices = audio_enumerate_devices(
                app->audio_output_devices, AUDIO_MAX_DEVICES, 1);
            app->audio_num_input_devices = audio_enumerate_devices(
                app->audio_input_devices, AUDIO_MAX_DEVICES, 0);

            app->audio_output_idx = -1;
            for (int i = 0; i < app->audio_num_output_devices; i++)
                if (strcmp(app->audio_output_devices[i].name, app->settings.output_device) == 0)
                    app->audio_output_idx = i;
            app->audio_input_idx = -1;
            for (int i = 0; i < app->audio_num_input_devices; i++)
                if (strcmp(app->audio_input_devices[i].name, app->settings.input_device) == 0)
                    app->audio_input_idx = i;

            atomic_store(&app->audio_io_active, true);
            atomic_store(&app->dirty, true);
        } else {
            LOG_WARN("UI: Audio I/O ignored, app busy");
        }
        return;
    }

    if (point_in_rect(x, y, SETTINGS_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H)) {
        if (state == STATE_IDLE) {
            LOG_INFO("UI: Settings clicked");
            atomic_store(&app->settings_active, true);
            atomic_store(&app->dirty, true);
        } else {
            LOG_WARN("UI: Settings ignored, app busy");
        }
        return;
    }

    if (point_in_rect(x, y, REC_BTN_X, by + BOTTOM_BTN_YOFF, BTN_W, BTN_H)) {
        if (state == STATE_IDLE) {
            LOG_INFO("UI: Record clicked");
            atomic_store(&app->state, STATE_RECORDING);
            atomic_store(&app->dirty, true);
            spawn_detached(record_thread_fn, app);
        } else if (state == STATE_RECORDING) {
            LOG_INFO("UI: Stop clicked");
            atomic_store(&app->stop_requested, true);
        } else {
            LOG_WARN("UI: Record/Stop ignored, app busy");
        }
        return;
    }

    if (point_in_rect(x, y, PLAY_BTN_X, by + BOTTOM_BTN_YOFF, BTN_W, BTN_H)) {
        try_play_pause(app);
        atomic_store(&app->dirty, true);
        return;
    }

    if (point_in_rect(x, y, RESTART_BTN_X, by + BOTTOM_BTN_YOFF, BTN_W, BTN_H)) {
        try_restart(app);
        return;
    }

    if (point_in_rect(x, y, LOOP_REGION_BTN_X, by + BOTTOM_BTN_YOFF, BTN_W, BTN_H)) {
        do_action(app, ACTION_TOGGLE_LOOP_REGION);
        return;
    }
    if (point_in_rect(x, y, LOOP_ALL_BTN_X, by + BOTTOM_BTN_YOFF, BTN_W, BTN_H)) {
        do_action(app, ACTION_TOGGLE_LOOP_ALL);
        return;
    }

    if (point_in_rect(x, y, MUTE_ALL_BTN_X, by + BOTTOM_BTN_YOFF, MUTE_ALL_BTN_W, BTN_H)) {
        LOG_INFO("UI: whole-project mute icon clicked");
        toggle_mute_all_tracks(app);
        return;
    }

    if (point_in_rect(x, y, ZOOM_OUT_BTN_X, IMPORT_BTN_Y, ZOOM_BTN_W, ZOOM_BTN_H)) {
        do_action(app, ACTION_ZOOM_OUT);
        atomic_store(&app->dirty, true);
        return;
    }
    if (point_in_rect(x, y, ZOOM_IN_BTN_X, IMPORT_BTN_Y, ZOOM_BTN_W, ZOOM_BTN_H)) {
        do_action(app, ACTION_ZOOM_IN);
        atomic_store(&app->dirty, true);
        return;
    }

    /* Track field: label strip (left, selection + Del/Rec) vs. waveform
     * area (right, click-to-seek on the shared timeline). track_area_bottom
     * is the real bottom of the row area now -- `by` alone would include
     * the second time ruler's strip (see redraw()), which isn't a row. */
    int track_field_x = TRACK_FIELD_X;
    int track_field_w = app->win_w - TRACK_ROW_PAD - TRACK_FIELD_X;
    int wf_x0 = track_field_x + UI_TRACK_LABEL_W;
    int wf_w = track_field_w - UI_TRACK_LABEL_W;
    int track_area_bottom = by - RULER_H - HSCROLL_H;

    /* Horizontal scrollbar: click-to-jump or click-and-drag, same flow
     * as the vertical one right below just mapped onto view_start_frame
     * instead of track_scroll_top -- see that block's comment and the
     * MotionNotify hscrollbar_dragging branch in main(). Checked before
     * the vertical one since the two strips never overlap (this one
     * sits below track_area_bottom, the vertical one only runs above
     * it), so order between them doesn't actually matter here. */
    if (x >= wf_x0 && x < wf_x0 + wf_w && y >= by - HSCROLL_H && y < by) {
        size_t total = compute_timeline_total(app);
        double visible_frames = (double)wf_w * app->samples_per_pixel;
        if ((double)total > visible_frames && wf_w > 0) {
            double max_view_start = (double)total - visible_frames;
            double frac = (double)(x - wf_x0) / (double)wf_w;
            if (frac < 0) frac = 0;
            if (frac > 1) frac = 1;
            app->view_start_frame = (size_t)(frac * max_view_start + 0.5);
            atomic_store(&app->hscrollbar_dragging, true);
            atomic_store(&app->dirty, true);
        }
        return;
    }

    /* Scroll indicator: click-to-jump or click-and-drag, same "commit
     * on a few pixels of movement" flow as waveform drag-select uses
     * mouse_button_down/mouse_down_x/y for, just against a different
     * target and with its own scrollbar_dragging flag so the two
     * drags can never be confused with each other. */
    if (x >= TRACK_ROW_PAD && x < TRACK_ROW_PAD + SCROLLBAR_W &&
        y >= TRACK_AREA_TOP && y < track_area_bottom) {
        tracklist_lock(&app->tracklist);
        int n = app->tracklist.num_tracks;
        tracklist_unlock(&app->tracklist);
        TrackLayout tl;
        compute_track_layout(app, n, track_area_bottom, &tl);
        int max_scroll = n > tl.visible_rows ? n - tl.visible_rows : 0;
        if (max_scroll > 0) {
            int bar_h = track_area_bottom - TRACK_AREA_TOP;
            double frac = (double)(y - TRACK_AREA_TOP) / (double)bar_h;
            if (frac < 0) frac = 0;
            if (frac > 1) frac = 1;
            app->track_scroll_top = (int)(frac * max_scroll + 0.5);
            atomic_store(&app->scrollbar_dragging, true);
            atomic_store(&app->dirty, true);
        }
        return;
    }

    if (y >= TRACK_AREA_TOP && y < track_area_bottom) {
        tracklist_lock(&app->tracklist);
        int n = app->tracklist.num_tracks;
        tracklist_unlock(&app->tracklist);

        TrackLayout tl;
        compute_track_layout(app, n, track_area_bottom, &tl);

        /* An open dropdown's item list is drawn on top of everything
         * else (see redraw()'s "Pinned dropdown overlays"), so it has
         * to be hit-tested before anything underneath it -- picking an
         * item makes it the new active pin for that direction and
         * closes the dropdown. */
        if (tl.active_top >= 0 && tl.num_pin_top > 1 && atomic_load(&app->pin_top_dropdown_open)) {
            int list_y = TRACK_AREA_TOP + TRACK_ROW_PAD + TRACK_ROW_H + 2;
            int list_h = (tl.num_pin_top - 1) * PIN_DROPDOWN_ITEM_H;
            if (x >= track_field_x && x < track_field_x + track_field_w && y >= list_y && y < list_y + list_h) {
                int row = (y - list_y) / PIN_DROPDOWN_ITEM_H;
                int shown = 0;
                for (int k = 0; k < tl.num_pin_top; k++) {
                    if (tl.pin_top[k] == tl.active_top) continue;
                    if (shown == row) {
                        app->pinned_active_top = tl.pin_top[k];
                        LOG_INFO("UI: pinned-top active -> track %d", tl.pin_top[k]);
                        break;
                    }
                    shown++;
                }
                atomic_store(&app->pin_top_dropdown_open, false);
                atomic_store(&app->dirty, true);
                return;
            }
        }
        if (tl.active_bottom >= 0 && tl.num_pin_bottom > 1 && atomic_load(&app->pin_bottom_dropdown_open)) {
            int list_h = (tl.num_pin_bottom - 1) * PIN_DROPDOWN_ITEM_H;
            int list_y = tl.normal_bottom + TRACK_PIN_GAP - 2 - list_h;
            if (x >= track_field_x && x < track_field_x + track_field_w && y >= list_y && y < list_y + list_h) {
                int row = (y - list_y) / PIN_DROPDOWN_ITEM_H;
                int shown = 0;
                for (int k = 0; k < tl.num_pin_bottom; k++) {
                    if (tl.pin_bottom[k] == tl.active_bottom) continue;
                    if (shown == row) {
                        app->pinned_active_bottom = tl.pin_bottom[k];
                        LOG_INFO("UI: pinned-bottom active -> track %d", tl.pin_bottom[k]);
                        break;
                    }
                    shown++;
                }
                atomic_store(&app->pin_bottom_dropdown_open, false);
                atomic_store(&app->dirty, true);
                return;
            }
        }

        int row_idx = -2; /* -2 = "no row resolved yet"; -1 stays reserved for "gap between rows" below */
        int row_y = 0;
        int in_row = 0;
        int is_pinned_row = 0;

        if (tl.active_top >= 0 && y < tl.normal_top - TRACK_PIN_GAP) {
            row_y = TRACK_AREA_TOP + TRACK_ROW_PAD;
            in_row = (y >= row_y && y < row_y + TRACK_ROW_H);
            row_idx = tl.active_top;
            is_pinned_row = 1;

            /* The dropdown arrow, when there's more than one pinned
             * this direction -- checked before the generic
             * click-to-select fallback below so it isn't shadowed by
             * it. Toggles only; picking a different track happens via
             * the list hit-test above once it's open. */
            if (tl.num_pin_top > 1) {
                int bx = track_field_x + track_field_w - PIN_DROPDOWN_BTN_SIZE - 3;
                int by3 = row_y + 3;
                if (point_in_rect(x, y, bx, by3, PIN_DROPDOWN_BTN_SIZE, PIN_DROPDOWN_BTN_SIZE)) {
                    int cur = atomic_load(&app->pin_top_dropdown_open);
                    atomic_store(&app->pin_top_dropdown_open, !cur);
                    atomic_store(&app->dirty, true);
                    return;
                }
            }
        } else if (tl.active_bottom >= 0 && y >= tl.normal_bottom + TRACK_PIN_GAP) {
            row_y = tl.normal_bottom + TRACK_PIN_GAP;
            in_row = (y >= row_y && y < row_y + TRACK_ROW_H);
            row_idx = tl.active_bottom;
            is_pinned_row = 1;

            if (tl.num_pin_bottom > 1) {
                int bx = track_field_x + track_field_w - PIN_DROPDOWN_BTN_SIZE - 3;
                int by3 = row_y + 3;
                if (point_in_rect(x, y, bx, by3, PIN_DROPDOWN_BTN_SIZE, PIN_DROPDOWN_BTN_SIZE)) {
                    int cur = atomic_load(&app->pin_bottom_dropdown_open);
                    atomic_store(&app->pin_bottom_dropdown_open, !cur);
                    atomic_store(&app->dirty, true);
                    return;
                }
            }
        } else if (y >= tl.normal_top && y < tl.normal_bottom) {
            int local_row = (y - tl.normal_top) / (TRACK_ROW_H + TRACK_ROW_PAD);
            row_y = tl.normal_top + local_row * (TRACK_ROW_H + TRACK_ROW_PAD);
            in_row = (y >= row_y && y < row_y + TRACK_ROW_H);
            row_idx = app->track_scroll_top + local_row;
            if (row_idx > n || int_in_array(row_idx, tl.pin_bottom, tl.num_pin_bottom))
                in_row = 0; /* scrolled past the "+New Track" row, or double-drawn via a pin -- nothing clickable here */
        }

        if (row_idx == -2)
            return; /* click fell in a gap between the pinned rows and the normal area */

        /* "+New Track" -- the virtual row just past the real track
         * list (row_idx == n). Never reachable as a pinned row (the
         * pin sets only ever hold real track indices), so this is
         * checked ahead of the is_pinned_row branch below without
         * needing to guard against it there too. Handled on its own,
         * before any of the real-track logic further down, since it
         * isn't a real track: no Del/Rec/mute/waveform, just "create a
         * whitespace track". */
        if (row_idx == n && in_row) {
            handle_new_track_click(app);
            return;
        }

        /* A pinned row is click-to-select only (see
         * compute_track_layout()'s comment) -- no Del/Rec/Up-Down
         * buttons and no waveform click-to-seek/drag inside it, to
         * keep this from turning into a second full copy of every row
         * interaction below. */
        if (is_pinned_row) {
            if (!in_row) return;
            /* Click moves the highlight cursor here, same as any other
             * row below -- it no longer commits this as the
             * selection by itself (see the label-strip block's comment
             * for the full reasoning). */
            atomic_store(&app->highlighted_track_idx, row_idx);
            LOG_INFO("UI: pinned track %d highlighted", row_idx);
            atomic_store(&app->dirty, true);
            return;
        }

        if (x < track_field_x + UI_TRACK_LABEL_W) {
            /* Label strip: selection, or Del/Rec if already selected. */
            if (!in_row || row_idx < 0 || row_idx >= n)
                return; /* clicked in the gap between rows -- nothing there */

            /* Mute: always clickable, independent of selected/
             * highlighted -- see ui_draw_track_row()'s comment on why
             * it's always drawn too. */
            if (point_in_rect(x, y, track_field_x + UI_TRACK_MUTE_BTN_X, row_y + UI_TRACK_MUTE_BTN_Y,
                               UI_TRACK_MUTE_BTN_W, UI_TRACK_MUTE_BTN_H)) {
                tracklist_lock(&app->tracklist);
                int was_muted = atomic_load(&app->tracklist.tracks[row_idx].muted);
                atomic_store(&app->tracklist.tracks[row_idx].muted, !was_muted);
                tracklist_unlock(&app->tracklist);
                LOG_INFO("UI: mute icon clicked on track %d -> %s", row_idx, was_muted ? "unmuted" : "muted");
                atomic_store(&app->dirty, true);
                return;
            }

            int selected = (atomic_load(&app->selected_track_index) == row_idx);
            int highlighted_here = (atomic_load(&app->highlighted_track_idx) == row_idx);
            if (selected || highlighted_here) {
                int lx = track_field_x;
                if (point_in_rect(x, y, lx + UI_TRACK_RENAME_BTN_X, row_y + UI_TRACK_RENAME_BTN_Y,
                                   UI_TRACK_RENAME_BTN_W, UI_TRACK_RENAME_BTN_H)) {
                    if (state == STATE_IDLE) {
                        LOG_INFO("UI: rename icon clicked on track %d", row_idx);
                        tracklist_lock(&app->tracklist);
                        snprintf(app->rename_buf, sizeof(app->rename_buf), "%s", app->tracklist.tracks[row_idx].name);
                        tracklist_unlock(&app->tracklist);
                        app->rename_track_idx = row_idx;
                        atomic_store(&app->rename_active, true);
                        atomic_store(&app->dirty, true);
                    }
                    return;
                }
                if (point_in_rect(x, y, lx + UI_TRACK_DEL_BTN_X, row_y + UI_TRACK_BTN_Y_OFF,
                                   UI_TRACK_DEL_BTN_W, UI_TRACK_BTN_H)) {
                    if (state == STATE_IDLE) {
                        LOG_INFO("UI: Delete clicked on track %d", row_idx);
                        tracklist_delete_track(&app->tracklist, row_idx);
                        insertion_points_on_track_deleted(app, row_idx);
                        atomic_store(&app->selected_track_index, -1);
                        atomic_store(&app->highlighted_track_idx, -1);
                        atomic_store(&app->dirty, true);
                    } else {
                        LOG_WARN("UI: Delete ignored, app busy");
                    }
                    return;
                }
                if (point_in_rect(x, y, lx + UI_TRACK_REC_BTN_X, row_y + UI_TRACK_BTN_Y_OFF,
                                   UI_TRACK_REC_BTN_W, UI_TRACK_BTN_H)) {
                    if (state == STATE_IDLE) {
                        LOG_INFO("UI: Re-record clicked on track %d", row_idx);
                        atomic_store(&app->state, STATE_RECORDING);
                        atomic_store(&app->dirty, true);
                        spawn_rerecord(app, row_idx);
                    } else {
                        LOG_WARN("UI: Re-record ignored, app busy");
                    }
                    return;
                }
                if (point_in_rect(x, y, lx + UI_TRACK_UP_BTN_X, row_y + UI_TRACK_BTN2_Y_OFF,
                                   UI_TRACK_UP_BTN_W, UI_TRACK_BTN_H)) {
                    if (state == STATE_IDLE) {
                        int new_idx = tracklist_move_track(&app->tracklist, row_idx, -1);
                        if (new_idx >= 0) {
                            LOG_INFO("UI: moved track %d up", row_idx);
                            if (selected) atomic_store(&app->selected_track_index, new_idx);
                            atomic_store(&app->highlighted_track_idx, new_idx);
                        } else {
                            LOG_INFO("UI: track %d already at the top", row_idx);
                        }
                        atomic_store(&app->dirty, true);
                    } else {
                        LOG_WARN("UI: reorder ignored, app busy");
                    }
                    return;
                }
                if (point_in_rect(x, y, lx + UI_TRACK_DN_BTN_X, row_y + UI_TRACK_BTN2_Y_OFF,
                                   UI_TRACK_DN_BTN_W, UI_TRACK_BTN_H)) {
                    if (state == STATE_IDLE) {
                        int new_idx = tracklist_move_track(&app->tracklist, row_idx, 1);
                        if (new_idx >= 0) {
                            LOG_INFO("UI: moved track %d down", row_idx);
                            if (selected) atomic_store(&app->selected_track_index, new_idx);
                            atomic_store(&app->highlighted_track_idx, new_idx);
                        } else {
                            LOG_INFO("UI: track %d already at the bottom", row_idx);
                        }
                        atomic_store(&app->dirty, true);
                    } else {
                        LOG_WARN("UI: reorder ignored, app busy");
                    }
                    return;
                }
            }

            /* Double-click on the same track's label (within 400ms) opens
             * the rename modal instead of toggling selection. */
            long now_ms = current_time_ms();
            if (row_idx == app->last_label_click_idx && (now_ms - app->last_label_click_ms) < 400) {
                if (state == STATE_IDLE) {
                    LOG_INFO("UI: double-click -- renaming track %d", row_idx);
                    tracklist_lock(&app->tracklist);
                    snprintf(app->rename_buf, sizeof(app->rename_buf), "%s", app->tracklist.tracks[row_idx].name);
                    tracklist_unlock(&app->tracklist);
                    app->rename_track_idx = row_idx;
                    atomic_store(&app->rename_active, true);
                    atomic_store(&app->dirty, true);
                }
                app->last_label_click_idx = -1; /* consumed -- don't let a third click chain into another rename */
                return;
            }
            app->last_label_click_idx = row_idx;
            app->last_label_click_ms = now_ms;

            /* Clicked the label but not a button -- moves the keyboard
             * highlight cursor here (same as Up/Down) and reveals this
             * row's buttons, but does NOT commit it as the selection
             * copy/cut/paste act on. Confirming that still takes Enter
             * (ACTION_CONFIRM_VISUAL), exactly like keyboard navigation
             * already required -- a click used to skip straight to
             * "selected", which meant just clicking a track to see its
             * buttons could silently change what a following cut/paste
             * would act on. */
            atomic_store(&app->highlighted_track_idx, row_idx);
            LOG_INFO("UI: track %d highlighted", row_idx);
            atomic_store(&app->dirty, true);
            return;
        } else {
            /* Waveform area: click-to-seek, mapped through the current
             * viewport rather than the whole timeline, so this stays
             * correct at any zoom/scroll position. */
            int wf_x0 = track_field_x + UI_TRACK_LABEL_W;
            size_t total = compute_timeline_total(app);
            if (total > 0) {
                double frame_d = (double)app->view_start_frame + (double)(x - wf_x0) * app->samples_per_pixel;
                if (frame_d < 0) frame_d = 0;
                if (frame_d > (double)total) frame_d = (double)total;
                size_t new_frame = (size_t)frame_d;
                LOG_INFO("UI: timeline click -> frame %zu of %zu", new_frame, total);
                request_seek(app, new_frame);

                /* Remember where the button went down, in case this
                 * turns into a drag (see the MotionNotify/ButtonRelease
                 * handling in main()) -- a plain click still just seeks
                 * as above; nothing else happens unless the pointer
                 * actually moves before release. */
                app->drag_anchor_frame = new_frame;
                app->drag_track_idx = in_row ? row_idx : -1;
                app->mouse_down_x = x;
                app->mouse_down_y = y;
                atomic_store(&app->mouse_button_down, true);
            }
            return;
        }
    }
}

/* Short "empty" / "2.1s" / "3x" summary for the register status
 * readout in the bottom panel -- deliberately terser than
 * register.c's own describe_register (which serves the X11 clipboard
 * as a TARGETS/STRING fallback and has room to be more descriptive). */
static void describe_register_brief(const Register *r, char *out, size_t out_size) {
    if (!r->valid || r->num_clips == 0) {
        snprintf(out, out_size, "empty");
        return;
    }
    if (r->num_clips == 1) {
        const RegisterClip *c = &r->clips[0];
        double secs = c->sample_rate > 0 ? (double)c->num_samples / (double)c->sample_rate : 0.0;
        snprintf(out, out_size, "%.1fs", secs);
    } else {
        snprintf(out, out_size, "%dx", r->num_clips);
    }
}

static void redraw(AppContext *app) {
    Display *dpy = app->dpy;
    GC gc = app->gc;
    int state = atomic_load(&app->state);

    /* Everything below draws into an off-screen pixmap instead of the
     * real window directly -- see backbuf's comment on AppContext.
     * `win` keeps its old name and is used exactly the same way at
     * every call site below (they all take a plain Drawable, and
     * Window/Pixmap/Drawable are all just XID under the hood), so
     * nothing past this point needed to change -- only this
     * declaration, and the blit at the very end. Recreated whenever
     * the window's size has changed since the last redraw. */
    if (!app->backbuf || app->backbuf_w != app->win_w || app->backbuf_h != app->win_h) {
        if (app->backbuf) XFreePixmap(dpy, app->backbuf);
        int depth = DefaultDepth(dpy, DefaultScreen(dpy));
        app->backbuf = XCreatePixmap(dpy, app->win, (unsigned)app->win_w, (unsigned)app->win_h, (unsigned)depth);
        app->backbuf_w = app->win_w;
        app->backbuf_h = app->win_h;
    }
    Drawable win = app->backbuf;

    /* Background fills the whole window; track rows are drawn over it. */
    XSetForeground(dpy, gc, 0xD4D0C8);
    XFillRectangle(dpy, win, gc, 0, 0, app->win_w, app->win_h);

    /* Top panel */
    XSetForeground(dpy, gc, 0x0A246A);
    XFillRectangle(dpy, win, gc, 0, 0, app->win_w, TOP_PANEL_H);
    XSetForeground(dpy, gc, 0xFFFFFF);
    XDrawString(dpy, win, gc, 8, 18, "Beat Rat", 8);
    ui_draw_button(dpy, win, gc, IMPORT_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H,
                   "Import", state == STATE_IMPORTING);
    ui_draw_button(dpy, win, gc, SAVE_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H,
                   "Save", state == STATE_SAVING);
    ui_draw_button(dpy, win, gc, EXPORT_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H,
                   "Export", state == STATE_EXPORTING);
    ui_draw_button(dpy, win, gc, SETTINGS_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H,
                   "Settings", atomic_load(&app->settings_active));
    ui_draw_button(dpy, win, gc, NEW_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H, "New", 0);
    ui_draw_button(dpy, win, gc, OPEN_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H,
                   "Open", state == STATE_LOADING);
    ui_draw_button(dpy, win, gc, AUDIO_BTN_X, IMPORT_BTN_Y, BTN_W, BTN_H,
                   "Audio", atomic_load(&app->audio_io_active));
    ui_draw_button(dpy, win, gc, ZOOM_OUT_BTN_X, IMPORT_BTN_Y, ZOOM_BTN_W, ZOOM_BTN_H, "-", 0);
    ui_draw_button(dpy, win, gc, ZOOM_IN_BTN_X, IMPORT_BTN_Y, ZOOM_BTN_W, ZOOM_BTN_H, "+", 0);

    /* Interaction-mode and track/region-context badges -- two SEPARATE
     * colored boxes, deliberately not folded into one piece of text, so
     * each reads at a glance: "what can my keyboard do right now"
     * (mode) is a different question from "what will y/d/x/p act on"
     * (nav context), and conflating them into one string made that
     * harder to parse than it needed to be. NORMAL mode uses the same
     * neutral color as the rest of the UI since it IS the regular,
     * nothing-special state; the others (and both nav contexts) each
     * get their own pastel so they stand out. Drawn in their own strip
     * (MODE_STRIP_H) rather than the bottom transport row, which is
     * already packed edge to edge at the minimum window width. */
    {
        int strip_y = TOP_PANEL_H + (MODE_STRIP_H - MODE_BADGE_H) / 2;
        AppMode mode = atomic_load(&app->current_mode);
        const char *mode_str = mode == MODE_INSERT ? "INSERT" :
                                mode == MODE_FX     ? "FX" :
                                mode == MODE_BEAT    ? "BEAT" : "NORMAL";
        UiBadgeColor mode_color = mode == MODE_INSERT ? UI_BADGE_GREEN :
                                   mode == MODE_FX     ? UI_BADGE_PURPLE :
                                   mode == MODE_BEAT    ? UI_BADGE_ORANGE : UI_BADGE_NEUTRAL;
        ui_draw_badge(dpy, win, gc, MODE_BADGE_X, strip_y, MODE_BADGE_W, MODE_BADGE_H, mode_str, mode_color);

        int ctx = atomic_load(&app->nav_context);
        const char *ctx_str = ctx == NAV_CONTEXT_TRACKS ? "TRACKS" : "REGION";
        UiBadgeColor ctx_color = ctx == NAV_CONTEXT_TRACKS ? UI_BADGE_BLUE : UI_BADGE_PINK;
        ui_draw_badge(dpy, win, gc, NAVCTX_BADGE_X, strip_y, MODE_BADGE_W, MODE_BADGE_H, ctx_str, ctx_color);
    }

    /* Bottom panel */
    int by = app->win_h - BOTTOM_PANEL_H;
    XSetForeground(dpy, gc, 0xD4D0C8);
    XFillRectangle(dpy, win, gc, 0, by, app->win_w, BOTTOM_PANEL_H);
    XSetForeground(dpy, gc, 0x808080);
    XDrawLine(dpy, win, gc, 0, by, app->win_w, by);

    /* Icon glyphs instead of text labels -- Record/Stop/Play/Pause are
     * the universal media-player shapes, so they read at a glance
     * without needing a button wide enough to spell the word. */
    UiIcon rec_icon = (state == STATE_RECORDING) ? UI_ICON_STOP : UI_ICON_RECORD;
    UiIcon play_icon = (state == STATE_PLAYING) ? UI_ICON_PAUSE : UI_ICON_PLAY;
    ui_draw_icon_button(dpy, win, gc, REC_BTN_X, by + BOTTOM_BTN_YOFF, BTN_W, BTN_H,
                   rec_icon, state == STATE_RECORDING);
    ui_draw_icon_button(dpy, win, gc, PLAY_BTN_X, by + BOTTOM_BTN_YOFF, BTN_W, BTN_H,
                   play_icon, state == STATE_PLAYING);
    ui_draw_icon_button(dpy, win, gc, RESTART_BTN_X, by + BOTTOM_BTN_YOFF, BTN_W, BTN_H,
                   UI_ICON_RESTART, 0);
    ui_draw_icon_button(dpy, win, gc, LOOP_REGION_BTN_X, by + BOTTOM_BTN_YOFF, BTN_W, BTN_H,
                   UI_ICON_LOOP, atomic_load(&app->loop_region));
    ui_draw_icon_button(dpy, win, gc, LOOP_ALL_BTN_X, by + BOTTOM_BTN_YOFF, BTN_W, BTN_H,
                   UI_ICON_LOOP, atomic_load(&app->loop_all));
    /* Both loop buttons share the same circular-arrow glyph -- a tiny
     * "R"/"A" in the corner is the only thing telling Region-loop and
     * Loop-all apart now that neither spells its name out. */
    XSetForeground(dpy, gc, 0x000000);
    XDrawString(dpy, win, gc, LOOP_REGION_BTN_X + BTN_W - 8, by + BOTTOM_BTN_YOFF + BTN_H - 2, "R", 1);
    XDrawString(dpy, win, gc, LOOP_ALL_BTN_X + BTN_W - 8, by + BOTTOM_BTN_YOFF + BTN_H - 2, "A", 1);

    /* Whole-project mute -- same speaker glyphs as the per-track
     * buttons (ui_draw_track_row()), "muted" here meaning EVERY
     * current track is muted, not just "at least one" -- matching
     * the icon to what clicking it again (unmute everything) would
     * actually do, same semantics as toggle_mute_all_tracks(). */
    {
        tracklist_lock(&app->tracklist);
        int n = app->tracklist.num_tracks;
        int all_muted = (n > 0);
        for (int i = 0; i < n; i++) {
            if (!atomic_load(&app->tracklist.tracks[i].muted)) { all_muted = 0; break; }
        }
        tracklist_unlock(&app->tracklist);
        ui_draw_icon_button(dpy, win, gc, MUTE_ALL_BTN_X, by + BOTTOM_BTN_YOFF, MUTE_ALL_BTN_W, BTN_H,
                       all_muted ? UI_ICON_SPEAKER_MUTED : UI_ICON_SPEAKER, 0);
    }

    /* Register/command status -- debugging/at-a-glance info in the
     * spirit of the tempo/tick-mark/dB-readout items already in Phase
     * 2's scope: read-only, no editing here. Right-aligned so it
     * doesn't collide with the Loop All button even at the minimum
     * window width. Mode and nav context have their own badges above
     * now, so this is just the registers, the last op's result, or the
     * ':' command line. */
    {
        char rbuf[16], sbuf[16], status[200];
        describe_register_brief(&app->beatrat_register, rbuf, sizeof(rbuf));
        describe_register_brief(&app->system_register, sbuf, sizeof(sbuf));
        if (atomic_load(&app->cli_active)) {
            /* The command line itself takes over this whole readout
             * while open -- nvim-style, a plain ':' prompt with the
             * in-progress input and a block cursor. */
            snprintf(status, sizeof(status), ":%s_", app->cli_input);
        } else if (app->last_reg_msg[0]) {
            /* A copy/cut/void/paste result takes over this readout --
             * it's more actionable right now than the always-visible
             * register summary, which is still one keypress away from
             * being current again anyway. */
            snprintf(status, sizeof(status), "%s", app->last_reg_msg);
        } else if (app->cli_result[0]) {
            /* Last :command's result, shown until the next thing (a
             * register op or another :command) takes the readout over
             * again. */
            snprintf(status, sizeof(status), "%s", app->cli_result);
        } else {
            snprintf(status, sizeof(status), "Y:%s  Sys:%s", rbuf, sbuf);
        }
        XSetForeground(dpy, gc, 0x000000);
        int sx = app->win_w - (int)strlen(status) * 6 - 8;
        XDrawString(dpy, win, gc, sx, by + BOTTOM_BTN_YOFF + BTN_H / 2 + 4, status, (int)strlen(status));
    }

    /* Track field, rendered against the current viewport (a visible
     * window into the shared timeline: view_start_frame + zoom level)
     * so a single playhead position means the same moment across every
     * track, and so scrolling/zooming actually has something to act on. */
    tracklist_lock(&app->tracklist);
    int n = app->tracklist.num_tracks;
    tracklist_unlock(&app->tracklist);

    update_viewport_for_tracks(app);
    clamp_view_start(app);

    int track_field_x = TRACK_FIELD_X;
    int track_field_w = app->win_w - TRACK_ROW_PAD - TRACK_FIELD_X;
    int wf_x0 = track_field_x + UI_TRACK_LABEL_W;
    int wf_w = track_field_w - UI_TRACK_LABEL_W;
    int track_area_bottom = by - RULER_H - HSCROLL_H;

    /* Ruler strip, between the mode-badge strip and the first track
     * row -- (TRACK_AREA_TOP - RULER_H) rather than TOP_PANEL_H
     * directly, since the mode/context badges now have their own strip
     * in between (see MODE_STRIP_H). */
    ui_draw_time_ruler(dpy, win, gc, wf_x0, TRACK_AREA_TOP - RULER_H, wf_w, RULER_H,
                        app->view_start_frame, app->samples_per_pixel, SAMPLE_RATE);
    XSetForeground(dpy, gc, 0xD4D0C8);
    XFillRectangle(dpy, win, gc, track_field_x, TRACK_AREA_TOP - RULER_H, UI_TRACK_LABEL_W, RULER_H);

    /* Vertical scroll + pinning layout for this frame -- computed
     * twice: once (with whatever track_scroll_top carried over from
     * last frame) just to learn visible_rows so scroll can be clamped,
     * then again with the clamped value so the pinned sets below are
     * accurate. See compute_track_layout()'s comment. */
    TrackLayout tl;
    compute_track_layout(app, n, track_area_bottom, &tl);
    int max_scroll = n > tl.visible_rows ? n - tl.visible_rows : 0;
    if (app->track_scroll_top > max_scroll) app->track_scroll_top = max_scroll;
    if (app->track_scroll_top < 0) app->track_scroll_top = 0;
    compute_track_layout(app, n, track_area_bottom, &tl);

    int selected_idx = atomic_load(&app->selected_track_index);
    int highlighted_idx = atomic_load(&app->highlighted_track_idx);

    /* Time-region selection highlight: computed once here, used below
     * by (a) a full-height band drawn right now, before any track row
     * -- a non-selected row's own opaque background then draws over
     * it, so it only actually shows through in the gaps between rows,
     * which is what makes it read as "the selected tracks" despite
     * spanning the whole track area -- (b) each selected row's own
     * in-waveform tint (ui_draw_track_row's hl_x0/hl_x1, see its
     * comment), and (c) the red boundary outline drawn after all rows.
     * sel_hl_x1 <= sel_hl_x0 means "nothing to highlight right now". */
    int sel_hl_x0 = 0, sel_hl_x1 = 0, sel_hl_all_tracks = 0;
    if (atomic_load(&app->has_selection)) {
        double sel_start_rel = (double)app->selection_start_frame - (double)app->view_start_frame;
        double sel_end_rel = (double)app->selection_end_frame - (double)app->view_start_frame;
        int sx = wf_x0 + (int)(sel_start_rel / app->samples_per_pixel);
        int ex = wf_x0 + (int)(sel_end_rel / app->samples_per_pixel);
        if (sx < wf_x0) sx = wf_x0;
        if (ex > wf_x0 + wf_w) ex = wf_x0 + wf_w;
        if (ex > sx) {
            sel_hl_x0 = sx;
            sel_hl_x1 = ex;
            sel_hl_all_tracks = (app->selection_track_idx < 0);
        }
    }
    if (sel_hl_x1 > sel_hl_x0) {
        XSetForeground(dpy, gc, 0xFFF2B0);
        XFillRectangle(dpy, win, gc, sel_hl_x0, TRACK_AREA_TOP,
                        sel_hl_x1 - sel_hl_x0, track_area_bottom - TRACK_AREA_TOP);
    }

    tracklist_lock(&app->tracklist);

    /* Pinned-top row: the ACTIVE member of the current track-mode
     * selection that's scrolled above the visible window -- just one
     * row regardless of how many are pinned that direction, to keep a
     * big group selection from eating the whole track area. Drawn with
     * `selected` forced to 0 (no Del/Rec/Up/Dn buttons here -- see
     * handle_click()'s pinned-row branch, which is click-to-select
     * only) and its own thick gold border standing in for both "this
     * is selected" and "this is pinned". When more than one track is
     * pinned this direction, a small dropdown arrow in the row lets you
     * pick which of the others becomes the active one instead. */
    if (tl.active_top >= 0) {
        int i = tl.active_top;
        int ry = TRACK_AREA_TOP + TRACK_ROW_PAD;
        int row_hl_x0, row_hl_x1;
        row_selection_highlight(sel_hl_x0, sel_hl_x1, sel_hl_all_tracks,
                                 app->selection_track_idx, i, &row_hl_x0, &row_hl_x1);
        ui_draw_track_row(dpy, win, gc, track_field_x, ry, track_field_w, TRACK_ROW_H,
                           &app->tracklist.tracks[i], i, app->view_start_frame,
                           app->samples_per_pixel, 0, highlighted_idx == i,
                           row_hl_x0, row_hl_x1);
        XSetForeground(dpy, gc, 0xFFD400);
        XSetLineAttributes(dpy, gc, 2, LineSolid, CapButt, JoinMiter);
        XDrawRectangle(dpy, win, gc, track_field_x, ry, track_field_w, TRACK_ROW_H);
        XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);

        if (tl.num_pin_top > 1) {
            int bx = track_field_x + track_field_w - PIN_DROPDOWN_BTN_SIZE - 3;
            int by3 = ry + 3;
            ui_draw_button(dpy, win, gc, bx, by3, PIN_DROPDOWN_BTN_SIZE, PIN_DROPDOWN_BTN_SIZE,
                           atomic_load(&app->pin_top_dropdown_open) ? "^" : "v", 0);
            /* The expanded list itself (when open) is drawn later, as
             * an overlay on top of everything else in the track area
             * -- see the "Pinned dropdown overlays" block below -- so
             * it's never obscured by the normal scrolling rows or the
             * pinned-bottom row drawn after this point. */
        }
    }
    if (tl.active_top >= 0) {
        int sep_y = tl.normal_top - TRACK_PIN_GAP / 2;
        XSetForeground(dpy, gc, 0x000000);
        XDrawLine(dpy, win, gc, track_field_x, sep_y, track_field_x + track_field_w, sep_y);
    }

    /* Normal scrolling rows, plus one extra virtual row (i == n) for
     * "+New Track" -- appended right underneath the real list, per the
     * user's request, and scrolling with it rather than staying fixed,
     * since it's just one more row in this same loop. It never gets a
     * pinned slot of its own (pin_bottom only ever holds real track
     * indices) and is never selectable/highlightable, so it skips
     * straight past both of those checks below. */
    int ty = tl.normal_top;
    for (int i = app->track_scroll_top; i <= n; i++) {
        if (int_in_array(i, tl.pin_bottom, tl.num_pin_bottom))
            continue; /* drawn separately below, in its pinned slot */
        if (ty + TRACK_ROW_H > tl.normal_bottom)
            break;
        if (i == n) {
            ui_draw_bevel(dpy, win, gc, track_field_x, ty, track_field_w, TRACK_ROW_H, 0);
            XSetForeground(dpy, gc, 0x000000);
            const char *label = "+ New Track";
            XDrawString(dpy, win, gc, track_field_x + 10, ty + TRACK_ROW_H / 2 + 4,
                        label, (int)strlen(label));
        } else {
            int row_hl_x0, row_hl_x1;
            row_selection_highlight(sel_hl_x0, sel_hl_x1, sel_hl_all_tracks,
                                     app->selection_track_idx, i, &row_hl_x0, &row_hl_x1);
            ui_draw_track_row(dpy, win, gc, track_field_x, ty, track_field_w, TRACK_ROW_H,
                               &app->tracklist.tracks[i], i, app->view_start_frame,
                               app->samples_per_pixel, selected_idx == i, highlighted_idx == i,
                               row_hl_x0, row_hl_x1);
        }
        ty += TRACK_ROW_H + TRACK_ROW_PAD;
    }

    /* Pinned-bottom row -- mirrors pinned-top, for the active member
     * of the selection that's scrolled below the visible window. */
    if (tl.active_bottom >= 0) {
        int sep_y = tl.normal_bottom + TRACK_PIN_GAP / 2;
        XSetForeground(dpy, gc, 0x000000);
        XDrawLine(dpy, win, gc, track_field_x, sep_y, track_field_x + track_field_w, sep_y);

        int i = tl.active_bottom;
        int ry = tl.normal_bottom + TRACK_PIN_GAP;
        int row_hl_x0, row_hl_x1;
        row_selection_highlight(sel_hl_x0, sel_hl_x1, sel_hl_all_tracks,
                                 app->selection_track_idx, i, &row_hl_x0, &row_hl_x1);
        ui_draw_track_row(dpy, win, gc, track_field_x, ry, track_field_w, TRACK_ROW_H,
                           &app->tracklist.tracks[i], i, app->view_start_frame,
                           app->samples_per_pixel, 0, highlighted_idx == i,
                           row_hl_x0, row_hl_x1);
        XSetForeground(dpy, gc, 0xFFD400);
        XSetLineAttributes(dpy, gc, 2, LineSolid, CapButt, JoinMiter);
        XDrawRectangle(dpy, win, gc, track_field_x, ry, track_field_w, TRACK_ROW_H);
        XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);

        if (tl.num_pin_bottom > 1) {
            int bx = track_field_x + track_field_w - PIN_DROPDOWN_BTN_SIZE - 3;
            int by3 = ry + 3;
            ui_draw_button(dpy, win, gc, bx, by3, PIN_DROPDOWN_BTN_SIZE, PIN_DROPDOWN_BTN_SIZE,
                           atomic_load(&app->pin_bottom_dropdown_open) ? "v" : "^", 0);
            /* List drawn later as an overlay -- see "Pinned dropdown
             * overlays" below, same as the pinned-top one. */
        }
    }

    tracklist_unlock(&app->tracklist);

    /* The old "No tracks yet -- Import or Record to add one" message
     * that used to live here is now redundant -- and would overlap --
     * with the "+New Track" row above, which is always drawn (including
     * with zero real tracks) and makes the same point more usefully. */

    /* Second ruler strip, mirroring the one at TOP_PANEL_H but pinned
     * just above the transport panel instead of just below the top
     * one -- so the current time reference is visible no matter which
     * end of a zoomed-in track you're looking at. Drawn after the
     * track rows (not before) so it stays on top and legible even if
     * a tall track list would otherwise draw into this strip. */
    ui_draw_time_ruler(dpy, win, gc, wf_x0, by - RULER_H - HSCROLL_H, wf_w, RULER_H,
                        app->view_start_frame, app->samples_per_pixel, SAMPLE_RATE);
    XSetForeground(dpy, gc, 0xD4D0C8);
    XFillRectangle(dpy, win, gc, track_field_x, by - RULER_H - HSCROLL_H, UI_TRACK_LABEL_W, RULER_H);

    /* Horizontal scrollbar -- click-to-jump or click-and-drag over the
     * timeline, same "frac of the bar" math as the vertical one just
     * below, just mapped onto view_start_frame/samples_per_pixel
     * instead of track_scroll_top (see handle_click()'s matching
     * block and the MotionNotify hscrollbar_dragging branch). Sits in
     * its own strip directly under the ruler's tick marks and right
     * above the transport panel. */
    {
        int hbar_x = wf_x0, hbar_y = by - HSCROLL_H;
        int hbar_w = wf_w;
        size_t total = compute_timeline_total(app);
        double visible_frames = (double)wf_w * app->samples_per_pixel;
        XSetForeground(dpy, gc, 0xB0ACA4);
        XFillRectangle(dpy, win, gc, hbar_x, hbar_y, hbar_w, HSCROLL_H);
        if ((double)total > visible_frames && hbar_w > 0) {
            double max_view_start = (double)total - visible_frames;
            int thumb_w = (int)((double)hbar_w * visible_frames / (double)total);
            if (thumb_w < 14) thumb_w = 14;
            if (thumb_w > hbar_w) thumb_w = hbar_w;
            double frac = max_view_start > 0 ? (double)app->view_start_frame / max_view_start : 0.0;
            int thumb_x = hbar_x + (int)((hbar_w - thumb_w) * frac);
            XSetForeground(dpy, gc, 0x0A246A);
            XFillRectangle(dpy, win, gc, thumb_x, hbar_y, thumb_w, HSCROLL_H);
        } else {
            XSetForeground(dpy, gc, 0x808080);
            XDrawRectangle(dpy, win, gc, hbar_x, hbar_y, hbar_w - 1, HSCROLL_H - 1);
        }
    }

    /* Thin scroll indicator, to the left of the label strip -- shows
     * where the current scroll position sits within the whole track
     * list, click-and-drag to move it (see handle_click()/the
     * MotionNotify scrollbar_dragging branch in main()). A plain
     * outline (nothing to drag) when everything already fits on
     * screen at once. */
    {
        int bar_x = TRACK_ROW_PAD, bar_y = TRACK_AREA_TOP;
        int bar_h = track_area_bottom - TRACK_AREA_TOP;
        XSetForeground(dpy, gc, 0xB0ACA4);
        XFillRectangle(dpy, win, gc, bar_x, bar_y, SCROLLBAR_W, bar_h);
        if (n > tl.visible_rows && bar_h > 0) {
            int thumb_h = (int)((double)bar_h * tl.visible_rows / n);
            if (thumb_h < 14) thumb_h = 14;
            if (thumb_h > bar_h) thumb_h = bar_h;
            double frac = max_scroll > 0 ? (double)app->track_scroll_top / max_scroll : 0.0;
            int thumb_y = bar_y + (int)((bar_h - thumb_h) * frac);
            XSetForeground(dpy, gc, 0x0A246A);
            XFillRectangle(dpy, win, gc, bar_x, thumb_y, SCROLLBAR_W, thumb_h);
        } else {
            XSetForeground(dpy, gc, 0x808080);
            XDrawRectangle(dpy, win, gc, bar_x, bar_y, SCROLLBAR_W - 1, (bar_h > 0 ? bar_h : 1) - 1);
        }
    }

    /* Track-mode selection border: one gold outline per selected track
     * that's actually on screen right now, wherever it's drawn --
     * normal scroll position OR one of the pinned mini-panels above
     * (those already got their border drawn in the pinned loops, so
     * they're skipped here to avoid doubling up). This replaces the
     * old single bounding rectangle over [lo,hi], which only made
     * sense back when a selected range was always drawn contiguously
     * -- pinning can now split it across three separate clusters on
     * screen. */
    {
        int lo, hi;
        if (compute_selected_track_range(app, &lo, &hi)) {
            XSetForeground(dpy, gc, 0xFFD400);
            XSetLineAttributes(dpy, gc, 2, LineSolid, CapButt, JoinMiter);
            for (int i = lo; i <= hi && i < n; i++) {
                if (int_in_array(i, tl.pin_top, tl.num_pin_top) ||
                    int_in_array(i, tl.pin_bottom, tl.num_pin_bottom))
                    continue;
                int ry = track_row_y_if_visible(app, &tl, i);
                if (ry < 0) continue;
                XDrawRectangle(dpy, win, gc, track_field_x, ry, track_field_w, TRACK_ROW_H);
            }
            XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);
        }
    }

    /* Selection highlight: a region can span all tracks (selection_track_idx
     * == -1) or just one, depending on whether a track was selected when
     * the selection was made. Drawn as an outline (not a fill) so the
     * waveform underneath stays fully visible. Skipped entirely for a
     * single-track region whose track has scrolled off-screen and
     * isn't pinned (time-region selection doesn't pin -- only the
     * track-mode selection above does). */
    if (atomic_load(&app->has_selection)) {
        double sel_start_rel = (double)app->selection_start_frame - (double)app->view_start_frame;
        double sel_end_rel = (double)app->selection_end_frame - (double)app->view_start_frame;
        int sx = wf_x0 + (int)(sel_start_rel / app->samples_per_pixel);
        int ex = wf_x0 + (int)(sel_end_rel / app->samples_per_pixel);
        if (sx < wf_x0) sx = wf_x0;
        if (ex > wf_x0 + wf_w) ex = wf_x0 + wf_w;
        int top_y = -1, bottom_y = -1;
        if (app->selection_track_idx < 0) {
            top_y = TRACK_AREA_TOP;
            bottom_y = track_area_bottom;
        } else {
            int row_y = track_row_y_if_visible(app, &tl, app->selection_track_idx);
            if (row_y >= 0) { top_y = row_y; bottom_y = row_y + TRACK_ROW_H; }
        }
        if (ex > sx && top_y >= 0) {
            XSetForeground(dpy, gc, 0xFF0000);
            XSetLineAttributes(dpy, gc, 2, LineSolid, CapButt, JoinMiter);
            XDrawRectangle(dpy, win, gc, sx, top_y, ex - sx, bottom_y - top_y - 1);
            XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);

            /* Length readout, right above the selection's own top edge
             * -- the rectangle alone only shows where the selection is,
             * not how long it is, which took actually opening the
             * export/save dialog (or doing the math from the frame
             * counts in the log) to find out before this. */
            double sel_secs = (double)(app->selection_end_frame - app->selection_start_frame) / SAMPLE_RATE;
            char lenbuf[32];
            snprintf(lenbuf, sizeof(lenbuf), "%.2fs", sel_secs);
            int len_y = (top_y - 4 >= TRACK_AREA_TOP) ? top_y - 4 : top_y + 12;
            XSetForeground(dpy, gc, 0xFF0000);
            XDrawString(dpy, win, gc, sx, len_y, lenbuf, (int)strlen(lenbuf));
        }
    }

    /* Whitespace-pending region (space/space in INSERT mode, not yet
     * confirmed) -- previously had no visual at all, which made the
     * pre-roll case (scrubbing the mark past frame 0, to insert
     * silence before the project's own start) especially confusing:
     * there's no ruler position to point at out there, so the region
     * would just seem to vanish. Drawn as its own dashed green box
     * (never confused with the solid red region-selection one above)
     * clipped to the visible track area, plus a "<<" marker right at
     * the area's own left edge with how much pre-roll silence is
     * currently staged whenever any of the marked region sits before
     * frame 0. */
    if (atomic_load(&app->whitespace_pending)) {
        long mark = atomic_load(&app->whitespace_mark_frame);
        long cur = atomic_load(&app->whitespace_cursor_frame);
        long ws_lo = mark < cur ? mark : cur;
        long ws_hi = mark < cur ? cur : mark;

        double lo_rel = (double)ws_lo - (double)app->view_start_frame;
        double hi_rel = (double)ws_hi - (double)app->view_start_frame;
        int wsx = wf_x0 + (int)(lo_rel / app->samples_per_pixel);
        int wex = wf_x0 + (int)(hi_rel / app->samples_per_pixel);
        int clipped_sx = wsx < wf_x0 ? wf_x0 : wsx;
        int clipped_ex = wex > wf_x0 + wf_w ? wf_x0 + wf_w : wex;

        if (clipped_ex > clipped_sx) {
            XSetForeground(dpy, gc, 0x00A000);
            XSetLineAttributes(dpy, gc, 2, LineOnOffDash, CapButt, JoinMiter);
            XDrawRectangle(dpy, win, gc, clipped_sx, TRACK_AREA_TOP,
                            clipped_ex - clipped_sx, track_area_bottom - TRACK_AREA_TOP - 1);
            XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);
        }
        if (ws_lo < 0) {
            double preroll_secs = (double)(-ws_lo) / SAMPLE_RATE;
            char buf[32];
            snprintf(buf, sizeof(buf), "<< %.2fs", preroll_secs);
            XSetForeground(dpy, gc, 0x00A000);
            XDrawString(dpy, win, gc, wf_x0 + 2, TRACK_AREA_TOP + 12, buf, (int)strlen(buf));
        }
    }

    /* Track/group move preview (leader+m in NAV_CONTEXT_TRACKS) --
     * previously the moving block had no visual beyond its ordinary
     * selection border, no different from just having it selected, so
     * there was nothing showing it was actually "picked up" or which
     * way Up/Down would carry it. A thick solid border now marks the
     * block itself, and a dashed border on whichever single neighbor
     * Up/Down would swap it with next previews where it's about to
     * land. */
    if (atomic_load(&app->track_move_active)) {
        int mv_lo = app->track_move_lo, mv_hi = app->track_move_hi;
        tracklist_lock(&app->tracklist);
        int total_tracks = app->tracklist.num_tracks;
        tracklist_unlock(&app->tracklist);
        for (int i = mv_lo; i <= mv_hi; i++) {
            int row_y = track_row_y_if_visible(app, &tl, i);
            if (row_y < 0) continue;
            XSetForeground(dpy, gc, 0xFF8000);
            XSetLineAttributes(dpy, gc, 3, LineSolid, CapButt, JoinMiter);
            XDrawRectangle(dpy, win, gc, track_field_x, row_y, track_field_w, TRACK_ROW_H - 1);
            XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);
        }
        int neighbor_up = mv_lo - 1, neighbor_down = mv_hi + 1;
        int preview_idx = (neighbor_up >= 0) ? neighbor_up
                           : (neighbor_down < total_tracks ? neighbor_down : -1);
        if (preview_idx >= 0) {
            int row_y = track_row_y_if_visible(app, &tl, preview_idx);
            if (row_y >= 0) {
                XSetForeground(dpy, gc, 0xFF8000);
                XSetLineAttributes(dpy, gc, 2, LineOnOffDash, CapButt, JoinMiter);
                XDrawRectangle(dpy, win, gc, track_field_x, row_y, track_field_w, TRACK_ROW_H - 1);
                XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);
            }
        }
    }

    /* Snippet move preview (leader+m in NAV_CONTEXT_PLAYBACK) -- same
     * problem as track move above, just scoped to one track's
     * waveform: the picked-up snippet itself gets a thick solid
     * outline, and whichever neighboring snippet Up/Down would swap
     * it with gets a dashed preview outline, both mapped through the
     * current viewport the same way the playhead/selection lines
     * above are. */
    if (atomic_load(&app->snippet_move_active)) {
        int sm_track = app->snippet_move_track;
        tracklist_lock(&app->tracklist);
        int sm_valid = (sm_track >= 0 && sm_track < app->tracklist.num_tracks);
        size_t sm_len = sm_valid ? app->tracklist.tracks[sm_track].clip.num_samples : 0;
        tracklist_unlock(&app->tracklist);
        if (sm_valid) {
            size_t sm_bounds[MAX_INSERTION_POINTS + 2];
            int sm_nb = track_snippet_bounds(app, sm_track, sm_len, sm_bounds, MAX_INSERTION_POINTS + 2);
            int sm_idx = app->snippet_move_idx;
            int row_y = track_row_y_if_visible(app, &tl, sm_track);
            if (row_y >= 0 && sm_idx >= 0 && sm_idx + 1 < sm_nb) {
                /* Local helper-by-hand: map a frame to an x pixel
                 * within the current viewport, clipped to the
                 * waveform area -- same math as every other frame/x
                 * conversion in this function, just inlined three
                 * times here rather than threading a new helper
                 * through for one small block. */
                #define SNIP_X(f) (wf_x0 + (int)(((double)(f) - (double)app->view_start_frame) / app->samples_per_pixel))
                int cur_sx = SNIP_X(sm_bounds[sm_idx]), cur_ex = SNIP_X(sm_bounds[sm_idx + 1]);
                if (cur_sx < wf_x0) cur_sx = wf_x0;
                if (cur_ex > wf_x0 + wf_w) cur_ex = wf_x0 + wf_w;
                if (cur_ex > cur_sx) {
                    XSetForeground(dpy, gc, 0xFF8000);
                    XSetLineAttributes(dpy, gc, 3, LineSolid, CapButt, JoinMiter);
                    XDrawRectangle(dpy, win, gc, cur_sx, row_y, cur_ex - cur_sx, TRACK_ROW_H - 1);
                    XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);
                }
                int prev_idx = -1;
                if (sm_idx - 1 >= 0) prev_idx = sm_idx - 1;
                else if (sm_idx + 2 < sm_nb) prev_idx = sm_idx + 1;
                if (prev_idx >= 0) {
                    int nsx = SNIP_X(sm_bounds[prev_idx]), nex = SNIP_X(sm_bounds[prev_idx + 1]);
                    if (nsx < wf_x0) nsx = wf_x0;
                    if (nex > wf_x0 + wf_w) nex = wf_x0 + wf_w;
                    if (nex > nsx) {
                        XSetForeground(dpy, gc, 0xFF8000);
                        XSetLineAttributes(dpy, gc, 2, LineOnOffDash, CapButt, JoinMiter);
                        XDrawRectangle(dpy, win, gc, nsx, row_y, nex - nsx, TRACK_ROW_H - 1);
                        XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);
                    }
                }
                #undef SNIP_X
            }
        }
    }

    /* Playhead: one vertical line spanning the whole track field, at
     * the position corresponding to playhead_frame within the current
     * viewport. Red, matching the region-selection outline above, so
     * the two read as the same family of "where you are / what you've
     * marked" markers. Track palette index 0 is also pure red, but the
     * playhead/selection lines are drawn on top of everything else in
     * the track area, not blended into a waveform, so the two never
     * actually get confused for one another on screen. Off-screen (out
     * of the current viewport) simply isn't drawn. */
    size_t timeline_total = compute_timeline_total(app);
    if (timeline_total > 0) {
        size_t frame = atomic_load(&app->playhead_frame);
        double rel = (double)frame - (double)app->view_start_frame;
        int px = wf_x0 + (int)(rel / app->samples_per_pixel);
        if (px >= wf_x0 && px <= wf_x0 + wf_w) {
            XSetForeground(dpy, gc, 0xFF0000);
            XDrawLine(dpy, win, gc, px, TRACK_AREA_TOP, px, by);
        }

        char timebuf[96];
        size_t near_frame; uint32_t near_mask;
        int has_ip = insertion_points_nearest_any(app, frame, &near_frame, &near_mask);
        if (has_ip) {
            /* Kept deliberately tight (no spaces around the slash) so a
             * long project's timestamps plus this don't run into the
             * Loop Rgn button at the minimum window width. Shows the
             * point NEAREST the playhead -- with more than one point
             * now possible, that's the one most relevant right now;
             * a count is appended whenever there's more than one. */
            pthread_mutex_lock(&app->insertion_points_mutex);
            int total_ip = app->num_insertion_points;
            pthread_mutex_unlock(&app->insertion_points_mutex);
            if (total_ip > 1)
                snprintf(timebuf, sizeof(timebuf), "%.1fs/%.1fs Ins:%.1fs(+%d)",
                         (double)frame / SAMPLE_RATE, (double)timeline_total / SAMPLE_RATE,
                         (double)near_frame / SAMPLE_RATE, total_ip - 1);
            else
                snprintf(timebuf, sizeof(timebuf), "%.1fs/%.1fs Ins:%.1fs",
                         (double)frame / SAMPLE_RATE, (double)timeline_total / SAMPLE_RATE,
                         (double)near_frame / SAMPLE_RATE);
        } else {
            snprintf(timebuf, sizeof(timebuf), "%.1fs / %.1fs",
                     (double)frame / SAMPLE_RATE, (double)timeline_total / SAMPLE_RATE);
        }
        XSetForeground(dpy, gc, 0x000000);
        XDrawString(dpy, win, gc, TIME_READOUT_X, by + BOTTOM_BTN_YOFF + BTN_H / 2 + 4,
                    timebuf, (int)strlen(timebuf));
    }

    /* Insertion points: STICKY vertical markers 'cut' drops on the
     * timeline (see AppContext's insertion_points comment) -- dashed
     * and a different color from the solid cyan playhead line above,
     * so the two are never confused: the playhead is "where we are",
     * these are "where cut split things and paste will splice back
     * in", and they can easily be in different places. Same off-
     * screen handling as the playhead: not drawn if scrolled out of
     * the current viewport. Every point draws full-height regardless
     * of which track(s) it's actually scoped to -- distinguishing a
     * global line from a single-track one visually (e.g. only
     * spanning that track's row) is a later polish pass, not done
     * here yet. */
    {
        pthread_mutex_lock(&app->insertion_points_mutex);
        int n_ip = app->num_insertion_points;
        size_t ip_frames[MAX_INSERTION_POINTS];
        for (int i = 0; i < n_ip; i++) ip_frames[i] = app->insertion_points[i].frame;
        pthread_mutex_unlock(&app->insertion_points_mutex);

        XSetForeground(dpy, gc, 0xFF7F00);
        XSetLineAttributes(dpy, gc, 2, LineOnOffDash, CapButt, JoinMiter);
        for (int i = 0; i < n_ip; i++) {
            double rel = (double)ip_frames[i] - (double)app->view_start_frame;
            int px = wf_x0 + (int)(rel / app->samples_per_pixel);
            if (px >= wf_x0 && px <= wf_x0 + wf_w)
                XDrawLine(dpy, win, gc, px, TRACK_AREA_TOP, px, by);
        }
        XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);
    }

    /* Pinned dropdown overlays: the "other pinned tracks" list for
     * whichever direction's arrow (drawn above, in the pinned-top/
     * pinned-bottom blocks) was clicked open -- see handle_click().
     * Drawn last, on top of everything else in the track area, so a
     * long selection's dropdown is never obscured by the normal
     * scrolling rows underneath it. The top one opens downward from
     * its row; the bottom one opens upward, so it stays on screen
     * rather than running off the bottom of the window. */
    if (tl.active_top >= 0 && tl.num_pin_top > 1 && atomic_load(&app->pin_top_dropdown_open)) {
        int list_y = TRACK_AREA_TOP + TRACK_ROW_PAD + TRACK_ROW_H + 2;
        int list_h = (tl.num_pin_top - 1) * PIN_DROPDOWN_ITEM_H;
        tracklist_lock(&app->tracklist);
        ui_draw_bevel(dpy, win, gc, track_field_x, list_y, track_field_w, list_h, 0);
        int row = 0;
        for (int k = 0; k < tl.num_pin_top; k++) {
            if (tl.pin_top[k] == tl.active_top) continue;
            int iy = list_y + row * PIN_DROPDOWN_ITEM_H;
            XSetForeground(dpy, gc, 0x000000);
            const char *name = app->tracklist.tracks[tl.pin_top[k]].name;
            XDrawString(dpy, win, gc, track_field_x + 6, iy + 15, name, (int)strlen(name));
            row++;
        }
        tracklist_unlock(&app->tracklist);
    }
    if (tl.active_bottom >= 0 && tl.num_pin_bottom > 1 && atomic_load(&app->pin_bottom_dropdown_open)) {
        int list_h = (tl.num_pin_bottom - 1) * PIN_DROPDOWN_ITEM_H;
        int list_y = tl.normal_bottom + TRACK_PIN_GAP - 2 - list_h;
        tracklist_lock(&app->tracklist);
        ui_draw_bevel(dpy, win, gc, track_field_x, list_y, track_field_w, list_h, 0);
        int row = 0;
        for (int k = 0; k < tl.num_pin_bottom; k++) {
            if (tl.pin_bottom[k] == tl.active_bottom) continue;
            int iy = list_y + row * PIN_DROPDOWN_ITEM_H;
            XSetForeground(dpy, gc, 0x000000);
            const char *name = app->tracklist.tracks[tl.pin_bottom[k]].name;
            XDrawString(dpy, win, gc, track_field_x + 6, iy + 15, name, (int)strlen(name));
            row++;
        }
        tracklist_unlock(&app->tracklist);
    }

    if (atomic_load(&app->picker_active))
        filepicker_draw(&app->picker, dpy, win, gc, app->win_w, app->win_h);
    if (atomic_load(&app->settings_active))
        settings_draw(&app->settings, dpy, win, gc, app->win_w, app->win_h,
                      atomic_load(&app->settings_active_field), app->altdir_input_buf);
    if (atomic_load(&app->keybinds_menu_active))
        keybinds_menu_draw(&app->keybinds, dpy, win, gc, app->win_w, app->win_h,
                            atomic_load(&app->keybinds_menu_scroll), atomic_load(&app->keybinds_menu_selected),
                            atomic_load(&app->keybinds_menu_awaiting_key), app->keybinds_menu_status);

    if (atomic_load(&app->audio_io_active)) {
        int box_w = AUDIO_BOX_W, box_h = AUDIO_BOX_H;
        int bx = (app->win_w - box_w) / 2, by2 = (app->win_h - box_h) / 2;
        ui_draw_bevel(dpy, win, gc, bx, by2, box_w, box_h, 0);
        XSetForeground(dpy, gc, 0x000000);
        XDrawString(dpy, win, gc, bx + 8, by2 + 18, "Audio I/O", 9);

        const char *out_label = "Output:";
        XDrawString(dpy, win, gc, bx + 8, by2 + AUDIO_OUTPUT_Y - 6, out_label, (int)strlen(out_label));
        ui_draw_button(dpy, win, gc, bx + AUDIO_PREV_X, by2 + AUDIO_OUTPUT_Y, AUDIO_PREV_W, AUDIO_ROW_H, "<", 0);
        ui_draw_button(dpy, win, gc, bx + AUDIO_NEXT_X, by2 + AUDIO_OUTPUT_Y, AUDIO_NEXT_W, AUDIO_ROW_H, ">", 0);
        if (app->audio_output_idx >= 0) {
            int out_hidden = settings_is_device_hidden(&app->settings, app->audio_output_devices[app->audio_output_idx].name);
            ui_draw_button(dpy, win, gc, bx + AUDIO_HIDE_X, by2 + AUDIO_OUTPUT_Y, AUDIO_HIDE_W, AUDIO_ROW_H,
                           out_hidden ? "Unhide" : "Hide", 0);
        }
        {
            const char *desc = (app->audio_output_idx < 0) ? "Auto (PipeWire/ALSA default)"
                                : app->audio_output_devices[app->audio_output_idx].desc;
            char trunc[48];
            snprintf(trunc, sizeof(trunc), "%s", desc);
            if (strlen(desc) >= sizeof(trunc) - 1) { trunc[sizeof(trunc) - 4] = '.'; trunc[sizeof(trunc) - 3] = '.'; trunc[sizeof(trunc) - 2] = '.'; trunc[sizeof(trunc) - 1] = '\0'; }
            XDrawString(dpy, win, gc, bx + AUDIO_PREV_X + AUDIO_PREV_W + 8, by2 + AUDIO_OUTPUT_Y + 15,
                        trunc, (int)strlen(trunc));
        }

        const char *in_label = "Input:";
        XDrawString(dpy, win, gc, bx + 8, by2 + AUDIO_INPUT_Y - 6, in_label, (int)strlen(in_label));
        ui_draw_button(dpy, win, gc, bx + AUDIO_PREV_X, by2 + AUDIO_INPUT_Y, AUDIO_PREV_W, AUDIO_ROW_H, "<", 0);
        ui_draw_button(dpy, win, gc, bx + AUDIO_NEXT_X, by2 + AUDIO_INPUT_Y, AUDIO_NEXT_W, AUDIO_ROW_H, ">", 0);
        if (app->audio_input_idx >= 0) {
            int in_hidden = settings_is_device_hidden(&app->settings, app->audio_input_devices[app->audio_input_idx].name);
            ui_draw_button(dpy, win, gc, bx + AUDIO_HIDE_X, by2 + AUDIO_INPUT_Y, AUDIO_HIDE_W, AUDIO_ROW_H,
                           in_hidden ? "Unhide" : "Hide", 0);
        }
        {
            const char *desc = (app->audio_input_idx < 0) ? "Auto (PipeWire/ALSA default)"
                                : app->audio_input_devices[app->audio_input_idx].desc;
            char trunc[48];
            snprintf(trunc, sizeof(trunc), "%s", desc);
            if (strlen(desc) >= sizeof(trunc) - 1) { trunc[sizeof(trunc) - 4] = '.'; trunc[sizeof(trunc) - 3] = '.'; trunc[sizeof(trunc) - 2] = '.'; trunc[sizeof(trunc) - 1] = '\0'; }
            XDrawString(dpy, win, gc, bx + AUDIO_PREV_X + AUDIO_PREV_W + 8, by2 + AUDIO_INPUT_Y + 15,
                        trunc, (int)strlen(trunc));
        }

        const char *vol_label = "Volume:";
        XDrawString(dpy, win, gc, bx + 8, by2 + AUDIO_VOLUME_Y - 6, vol_label, (int)strlen(vol_label));
        ui_draw_button(dpy, win, gc, bx + AUDIO_VOL_MINUS_X, by2 + AUDIO_VOLUME_Y, AUDIO_PREV_W, AUDIO_ROW_H, "-", 0);
        ui_draw_button(dpy, win, gc, bx + AUDIO_VOL_PLUS_X, by2 + AUDIO_VOLUME_Y, AUDIO_PREV_W, AUDIO_ROW_H, "+", 0);
        {
            char volbuf[16];
            snprintf(volbuf, sizeof(volbuf), "%d%%", app->settings.volume_percent);
            int vw = (int)strlen(volbuf) * 6;
            XDrawString(dpy, win, gc, bx + (box_w - vw) / 2, by2 + AUDIO_VOLUME_Y + 15, volbuf, (int)strlen(volbuf));
        }

        int show_hidden = atomic_load(&app->audio_show_hidden);
        ui_draw_button(dpy, win, gc, bx + AUDIO_PREV_X, by2 + AUDIO_SHOW_HIDDEN_Y, box_w - 16, AUDIO_ROW_H,
                       show_hidden ? "Showing Hidden Devices (click to hide them again)" : "Show Hidden Devices",
                       show_hidden);

        XSetForeground(dpy, gc, 0x606060);
        const char *hint = "Esc to close";
        XDrawString(dpy, win, gc, bx + 8, by2 + box_h - 10, hint, (int)strlen(hint));
    }

    if (atomic_load(&app->rename_active)) {
        int box_w = SAVE_BOX_W, box_h = SAVE_BOX_H;
        int bx = (app->win_w - box_w) / 2, by2 = (app->win_h - box_h) / 2;
        ui_draw_bevel(dpy, win, gc, bx, by2, box_w, box_h, 0);
        XSetForeground(dpy, gc, 0x000000);
        XDrawString(dpy, win, gc, bx + 8, by2 + 18, "Rename Track", 12);
        const char *label = "New name:";
        XDrawString(dpy, win, gc, bx + 8, by2 + 38, label, (int)strlen(label));
        XSetForeground(dpy, gc, 0xFFFFFF);
        XFillRectangle(dpy, win, gc, bx + 8, by2 + SAVE_FIELD_Y, box_w - 16, SAVE_FIELD_H);
        XSetForeground(dpy, gc, 0x000000);
        XDrawRectangle(dpy, win, gc, bx + 8, by2 + SAVE_FIELD_Y, box_w - 16, SAVE_FIELD_H);
        XDrawString(dpy, win, gc, bx + 12, by2 + SAVE_FIELD_Y + 16,
                    app->rename_buf, (int)strlen(app->rename_buf));
        int cursor_x = bx + 12 + (int)strlen(app->rename_buf) * 6 + 1;
        XDrawLine(dpy, win, gc, cursor_x, by2 + SAVE_FIELD_Y + 3, cursor_x, by2 + SAVE_FIELD_Y + SAVE_FIELD_H - 3);
        XSetForeground(dpy, gc, 0x606060);
        const char *hint = "Enter to rename -- Esc to cancel";
        XDrawString(dpy, win, gc, bx + 8, by2 + box_h - 12, hint, (int)strlen(hint));
    }

    if (atomic_load(&app->new_track_prompt_active)) {
        int box_w = SAVE_BOX_W, box_h = SAVE_BOX_H;
        int bx = (app->win_w - box_w) / 2, by2 = (app->win_h - box_h) / 2;
        ui_draw_bevel(dpy, win, gc, bx, by2, box_w, box_h, 0);
        XSetForeground(dpy, gc, 0x000000);
        XDrawString(dpy, win, gc, bx + 8, by2 + 18, "New Track", 9);
        const char *label = "Length:";
        XDrawString(dpy, win, gc, bx + 8, by2 + 38, label, (int)strlen(label));

        /* Two small fields -- "{min} m  {sec} s" -- rather than one
         * seconds box, per the user's request. Whichever field Tab (or
         * the initial click) last landed on gets a highlighted border
         * and the blinking-style cursor bar; the other just shows its
         * digits plain. */
        int field = atomic_load(&app->new_track_field);
        int min_box_x = bx + 70, min_box_w = 44;
        int sec_box_x = min_box_x + min_box_w + 20, sec_box_w = 44;
        int field_y = by2 + SAVE_FIELD_Y, field_h = SAVE_FIELD_H;

        XSetForeground(dpy, gc, 0xFFFFFF);
        XFillRectangle(dpy, win, gc, min_box_x, field_y, min_box_w, field_h);
        XFillRectangle(dpy, win, gc, sec_box_x, field_y, sec_box_w, field_h);
        XSetForeground(dpy, gc, 0x000000);
        XDrawRectangle(dpy, win, gc, min_box_x, field_y, min_box_w, field_h);
        XDrawRectangle(dpy, win, gc, sec_box_x, field_y, sec_box_w, field_h);
        if (field == 0) {
            XSetLineAttributes(dpy, gc, 2, LineSolid, CapButt, JoinMiter);
            XDrawRectangle(dpy, win, gc, min_box_x - 1, field_y - 1, min_box_w + 2, field_h + 2);
            XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);
        } else {
            XSetLineAttributes(dpy, gc, 2, LineSolid, CapButt, JoinMiter);
            XDrawRectangle(dpy, win, gc, sec_box_x - 1, field_y - 1, sec_box_w + 2, field_h + 2);
            XSetLineAttributes(dpy, gc, 1, LineSolid, CapButt, JoinMiter);
        }
        XDrawString(dpy, win, gc, min_box_x + 4, field_y + 16,
                    app->new_track_min_buf, (int)strlen(app->new_track_min_buf));
        XDrawString(dpy, win, gc, sec_box_x + 4, field_y + 16,
                    app->new_track_sec_buf, (int)strlen(app->new_track_sec_buf));
        XDrawString(dpy, win, gc, min_box_x + min_box_w + 4, field_y + 16, "m", 1);
        XDrawString(dpy, win, gc, sec_box_x + sec_box_w + 4, field_y + 16, "s", 1);

        int cur_box_x = (field == 0) ? min_box_x : sec_box_x;
        const char *cur_buf = (field == 0) ? app->new_track_min_buf : app->new_track_sec_buf;
        int cursor_x = cur_box_x + 4 + (int)strlen(cur_buf) * 6 + 1;
        XDrawLine(dpy, win, gc, cursor_x, field_y + 3, cursor_x, field_y + field_h - 3);

        XSetForeground(dpy, gc, 0x606060);
        const char *hint = "Tab switches field -- Enter creates -- Esc cancels";
        XDrawString(dpy, win, gc, bx + 8, by2 + box_h - 12, hint, (int)strlen(hint));
    }

    if (atomic_load(&app->save_active)) {
        int box_w = SAVE_BOX_W, box_h = SAVE_BOX_H;
        int bx = (app->win_w - box_w) / 2, by2 = (app->win_h - box_h) / 2;
        ui_draw_bevel(dpy, win, gc, bx, by2, box_w, box_h, 0);
        XSetForeground(dpy, gc, 0x000000);
        XDrawString(dpy, win, gc, bx + 8, by2 + 18, "Save Project", 12);
        const char *label = "Project name:";
        XDrawString(dpy, win, gc, bx + 8, by2 + 38, label, (int)strlen(label));
        XSetForeground(dpy, gc, 0xFFFFFF);
        XFillRectangle(dpy, win, gc, bx + 8, by2 + SAVE_FIELD_Y, box_w - 16, SAVE_FIELD_H);
        XSetForeground(dpy, gc, 0x000000);
        XDrawRectangle(dpy, win, gc, bx + 8, by2 + SAVE_FIELD_Y, box_w - 16, SAVE_FIELD_H);
        XDrawString(dpy, win, gc, bx + 12, by2 + SAVE_FIELD_Y + 16,
                    app->save_name_buf, (int)strlen(app->save_name_buf));
        {
            int cursor_x = bx + 12 + (int)strlen(app->save_name_buf) * 6 + 1;
            XDrawLine(dpy, win, gc, cursor_x, by2 + SAVE_FIELD_Y + 3, cursor_x, by2 + SAVE_FIELD_Y + SAVE_FIELD_H - 3);
        }
        XSetForeground(dpy, gc, 0x606060);
        const char *hint = "Enter to save -- Esc to cancel";
        XDrawString(dpy, win, gc, bx + 8, by2 + box_h - 12, hint, (int)strlen(hint));
    }

    if (atomic_load(&app->export_active)) {
        int box_w = EXPORT_BOX_W, box_h = EXPORT_BOX_H;
        int bx = (app->win_w - box_w) / 2, by2 = (app->win_h - box_h) / 2;
        ui_draw_bevel(dpy, win, gc, bx, by2, box_w, box_h, 0);
        XSetForeground(dpy, gc, 0x000000);
        XDrawString(dpy, win, gc, bx + 8, by2 + 18, "Export", 6);
        const char *label = "File name:";
        XDrawString(dpy, win, gc, bx + 8, by2 + 38, label, (int)strlen(label));
        XSetForeground(dpy, gc, 0xFFFFFF);
        XFillRectangle(dpy, win, gc, bx + 8, by2 + EXPORT_FIELD_Y, box_w - 16, EXPORT_FIELD_H);
        XSetForeground(dpy, gc, 0x000000);
        XDrawRectangle(dpy, win, gc, bx + 8, by2 + EXPORT_FIELD_Y, box_w - 16, EXPORT_FIELD_H);
        XDrawString(dpy, win, gc, bx + 12, by2 + EXPORT_FIELD_Y + 16,
                    app->export_name_buf, (int)strlen(app->export_name_buf));
        {
            int cursor_x = bx + 12 + (int)strlen(app->export_name_buf) * 6 + 1;
            XDrawLine(dpy, win, gc, cursor_x, by2 + EXPORT_FIELD_Y + 3, cursor_x, by2 + EXPORT_FIELD_Y + EXPORT_FIELD_H - 3);
        }

        ui_draw_button(dpy, win, gc, bx + EXPORT_WAV_BTN_X, by2 + EXPORT_FORMAT_BTN_Y,
                       EXPORT_FORMAT_BTN_W, EXPORT_FORMAT_BTN_H, "WAV", 0);
#ifdef HAVE_FLAC
        ui_draw_button(dpy, win, gc, bx + EXPORT_FLAC_BTN_X, by2 + EXPORT_FORMAT_BTN_Y,
                       EXPORT_FORMAT_BTN_W, EXPORT_FORMAT_BTN_H, "FLAC", 0);
#endif
#ifdef HAVE_LAME
        ui_draw_button(dpy, win, gc, bx + EXPORT_MP3_BTN_X, by2 + EXPORT_FORMAT_BTN_Y,
                       EXPORT_FORMAT_BTN_W, EXPORT_FORMAT_BTN_H, "MP3", 0);
#endif
        XSetForeground(dpy, gc, 0x606060);
        const char *hint = "Click a format to export -- Enter = WAV -- Esc to cancel";
        XDrawString(dpy, win, gc, bx + 8, by2 + box_h - 12, hint, (int)strlen(hint));
    }

    if (atomic_load(&app->confirm_active))
        confirm_draw(&app->confirm, dpy, win, gc, app->win_w, app->win_h);

    if (atomic_load(&app->leader_active)) {
        int box_w = 300, box_h = 30 + 20 * app->keybinds.num_leader_chords;
        int bx = (app->win_w - box_w) / 2, by2 = (app->win_h - box_h) / 2;
        ui_draw_bevel(dpy, win, gc, bx, by2, box_w, box_h, 0);
        XSetForeground(dpy, gc, 0x000000);
        XDrawString(dpy, win, gc, bx + 8, by2 + 18, "Leader", 6);
        for (int i = 0; i < app->keybinds.num_leader_chords; i++) {
            char line[64];
            const char *keyname = XKeysymToString(app->keybinds.leader_chords[i].keysym);
            snprintf(line, sizeof(line), "%s -> %s", keyname ? keyname : "?",
                     action_display_name(app->keybinds.leader_chords[i].action));
            XDrawString(dpy, win, gc, bx + 8, by2 + 38 + i * 20, line, (int)strlen(line));
        }
    }

    /* Everything above landed on the off-screen pixmap, invisibly --
     * this is the one and only point anything actually reaches the
     * screen, in a single operation, so there's no longer a window of
     * time where the user can see a partially-drawn frame. */
    XCopyArea(dpy, app->backbuf, app->win, gc, 0, 0, (unsigned)app->win_w, (unsigned)app->win_h, 0, 0);
    XFlush(dpy);
}

/* ---------------------------------------------------------------- */

int main(int argc, char **argv) {
    /* -s/--silence: skip the terminal half of every log line (still
     * writes to the log file -- see log_set_quiet()). Mainly so the
     * dev-loop workflow of running beatrat straight from a terminal
     * you want to keep using for other things doesn't get buried under
     * a log line for literally every keypress and click. */
    int silence = 0;
    for (int i = 1; i < argc; i++) {
        if (strcmp(argv[i], "-s") == 0 || strcmp(argv[i], "--silence") == 0) {
            silence = 1;
        } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
            printf("Usage: beatrat [-s|--silence]\n"
                   "  -s, --silence   Don't echo log lines to the terminal (still logs to file)\n");
            return 0;
        } else {
            fprintf(stderr, "beatrat: unrecognized option '%s' (try --help)\n", argv[i]);
            return 1;
        }
    }

    log_init(LOG_PATH);
    log_set_quiet(silence);
    LOG_INFO("Beat Rat v%s starting up", BEATRAT_VERSION);
    if (silence)
        LOG_INFO("terminal logging silenced (-s/--silence) -- still writing to the log file");

    Settings settings;
    settings_load(&settings);

    Display *dpy = XOpenDisplay(NULL);
    if (!dpy) {
        LOG_ERROR("cannot open X display");
        log_close();
        return 1;
    }

    int screen = DefaultScreen(dpy);
    Window root = RootWindow(dpy, screen);
    Colormap cmap = DefaultColormap(dpy, screen);

    Window win = XCreateSimpleWindow(dpy, root, 100, 100, WIN_W, WIN_H, 0,
                                      BlackPixel(dpy, screen), WhitePixel(dpy, screen));
    XStoreName(dpy, win, "Beat Rat");
    XSetIconName(dpy, win, "Beat Rat");

    XSizeHints *size_hints = XAllocSizeHints();
    size_hints->flags = PMinSize;
    size_hints->min_width = MIN_WIN_W;
    size_hints->min_height = MIN_WIN_H;
    XSetWMNormalHints(dpy, win, size_hints);
    XFree(size_hints);

    Atom wm_delete = XInternAtom(dpy, "WM_DELETE_WINDOW", False);
    XSetWMProtocols(dpy, win, &wm_delete, 1);
    /* SelectionRequest/SelectionClear (for the system register's
     * best-effort CLIPBOARD export -- register.c) are delivered to the
     * selection owner regardless of the event mask below; nothing to
     * add here for them specifically. */
    XSelectInput(dpy, win, ExposureMask | KeyPressMask | ButtonPressMask | ButtonReleaseMask |
                           Button1MotionMask | StructureNotifyMask);
    XMapWindow(dpy, win);
    XFlush(dpy);
    if (settings.always_on_top)
        set_always_on_top(dpy, win, 1);

    GC gc = XCreateGC(dpy, win, 0, NULL);
    ui_init_colors(dpy, cmap);

    AppContext app;
    app.dpy = dpy;
    app.win = win;
    app.gc = gc;
    app.win_w = WIN_W;
    app.win_h = WIN_H;
    app.backbuf = (Pixmap)0;
    app.backbuf_w = 0;
    app.backbuf_h = 0;
    app.settings = settings;
    app.clipboard_atom = XInternAtom(dpy, "CLIPBOARD", False);
    app.targets_atom = XInternAtom(dpy, "TARGETS", False);
    app.beatrat_audio_atom = XInternAtom(dpy, "application/x-beatrat-clip", False);
    memset(&app.beatrat_register, 0, sizeof(app.beatrat_register));
    memset(&app.system_register, 0, sizeof(app.system_register));
    tracklist_init(&app.tracklist);
    atomic_init(&app.state, STATE_IDLE);
    atomic_init(&app.dirty, true);
    atomic_init(&app.stop_requested, false);
    atomic_init(&app.picker_active, false);
    atomic_init(&app.settings_active, false);
    atomic_init(&app.settings_active_field, 0);
    app.altdir_input_buf[0] = '\0';
    atomic_init(&app.keybinds_menu_active, false);
    atomic_init(&app.keybinds_menu_selected, 0);
    atomic_init(&app.keybinds_menu_scroll, 0);
    atomic_init(&app.keybinds_menu_awaiting_key, false);
    app.keybinds_menu_status[0] = '\0';
    atomic_init(&app.next_track_number, 1);
    app.picker_purpose = 0;
    app.last_label_click_idx = -1;
    app.last_label_click_ms = 0;
    atomic_init(&app.rename_active, false);
    app.rename_buf[0] = '\0';
    atomic_init(&app.new_track_prompt_active, false);
    app.new_track_min_buf[0] = '\0';
    app.new_track_sec_buf[0] = '\0';
    atomic_init(&app.new_track_field, 0);
    app.rename_track_idx = 0;
    app.view_start_frame = 0;
    app.samples_per_pixel = 1.0;
    app.zoom_manually_set = 0;
    keybinds_load(&app.keybinds);
    atomic_init(&app.has_selection, false);
    app.selection_start_frame = 0;
    app.selection_end_frame = 0;
    app.selection_track_idx = -1;
    pthread_mutex_init(&app.insertion_points_mutex, NULL);
    app.num_insertion_points = 0;
    atomic_init(&app.whitespace_pending, false);
    atomic_init(&app.whitespace_mark_frame, 0);
    atomic_init(&app.whitespace_cursor_frame, 0);
    atomic_init(&app.snippet_move_active, false);
    app.snippet_move_track = -1;
    app.snippet_move_idx = 0;
    app.snippet_move_undo_count = 0;
    atomic_init(&app.visual_line_snippet_stepping, false);
    app.selection_anchor_frame = 0;
    app.selection_cursor_frame = 0;
    atomic_init(&app.visual_mode_active, false);
    atomic_init(&app.loop_region, false);
    atomic_init(&app.loop_all, false);
    atomic_init(&app.highlighted_track_idx, -1);
    atomic_init(&app.nav_context, NAV_CONTEXT_TRACKS);
    atomic_init(&app.group_start, -1);
    atomic_init(&app.group_end, -1);
    atomic_init(&app.track_move_active, false);
    app.track_move_lo = app.track_move_hi = -1;
    app.track_move_orig_lo = app.track_move_orig_hi = -1;
    app.track_move_source = 0;
    atomic_init(&app.leader_active, false);
    atomic_init(&app.audio_io_active, false);
    app.audio_num_output_devices = 0;
    app.audio_output_idx = -1;
    app.audio_num_input_devices = 0;
    app.audio_input_idx = -1;
    atomic_init(&app.audio_show_hidden, false);
    atomic_init(&app.save_active, false);
    atomic_init(&app.export_active, false);
    atomic_init(&app.confirm_active, false);
    app.confirm_pending_action = 0;
    atomic_init(&app.playhead_frame, 0);
    atomic_init(&app.seek_target, 0);
    atomic_init(&app.seek_pending, false);
    atomic_init(&app.selected_track_index, -1);
    app.last_reg_msg[0] = '\0';
    atomic_init(&app.mouse_button_down, false);
    atomic_init(&app.mouse_dragging_selection, false);
    app.mouse_down_x = 0;
    app.mouse_down_y = 0;
    app.drag_anchor_frame = 0;
    app.drag_track_idx = -1;
    app.track_scroll_top = 0;
    atomic_init(&app.scrollbar_dragging, false);
    atomic_init(&app.hscrollbar_dragging, false);
    app.pinned_active_top = -1;
    app.pinned_active_bottom = -1;
    atomic_init(&app.pin_top_dropdown_open, false);
    atomic_init(&app.pin_bottom_dropdown_open, false);
    app.last_g_press_ms = 0;
    atomic_init(&app.current_mode, MODE_NORMAL);
    atomic_init(&app.cli_active, false);
    app.cli_input[0] = '\0';
    app.cli_result[0] = '\0';

    int xfd = ConnectionNumber(dpy);
    int running = 1;

    while (running) {
        while (XPending(dpy)) {
            XEvent ev;
            XNextEvent(dpy, &ev);
            switch (ev.type) {
                case Expose:
                    atomic_store(&app.dirty, true);
                    break;
                case ConfigureNotify:
                    if (ev.xconfigure.width != app.win_w || ev.xconfigure.height != app.win_h) {
                        app.win_w = ev.xconfigure.width;
                        app.win_h = ev.xconfigure.height;
                        LOG_INFO("window: resized to %dx%d", app.win_w, app.win_h);
                        atomic_store(&app.dirty, true);
                    }
                    break;
                case ClientMessage:
                    if ((Atom)ev.xclient.data.l[0] == wm_delete)
                        running = 0;
                    break;
                case KeyPress:
                    if (atomic_load(&app.picker_active)) {
                        KeySym ks = XLookupKeysym(&ev.xkey, 0);
                        if (ks == XK_Escape) {
                            LOG_INFO("file picker: cancelled");
                            atomic_store(&app.picker_active, false);
                        } else if (ks == XK_Return) {
                            char path[512];
                            if (filepicker_selected_path(&app.picker, path, sizeof(path)) == 0) {
                                atomic_store(&app.picker_active, false);
                                if (app.picker_purpose == 0) {
                                    LOG_INFO("file picker: selected '%s' to import", path);
                                    atomic_store(&app.state, STATE_IMPORTING);
                                    spawn_import(&app, path);
                                } else {
                                    LOG_INFO("file picker: selected project '%s'", path);
                                    tracklist_lock(&app.tracklist);
                                    int n = app.tracklist.num_tracks;
                                    tracklist_unlock(&app.tracklist);
                                    if (n > 0) {
                                        confirm_set_message(&app.confirm, "Discard %d track(s) and open this project?", n);
                                        snprintf(app.pending_open_project_path, sizeof(app.pending_open_project_path), "%s", path);
                                        app.confirm_pending_action = 3;
                                        atomic_store(&app.confirm_active, true);
                                    } else {
                                        atomic_store(&app.state, STATE_LOADING);
                                        spawn_load_project(&app, path);
                                    }
                                }
                            } else {
                                LOG_INFO("file picker: nothing selected");
                            }
                        } else if (ks == XK_Up) {
                            filepicker_move_selection(&app.picker, -1);
                        } else if (ks == XK_Down) {
                            filepicker_move_selection(&app.picker, 1);
                        } else if (ks == XK_BackSpace) {
                            filepicker_backspace(&app.picker);
                        } else {
                            char buf[8];
                            KeySym dummy;
                            int len = XLookupString(&ev.xkey, buf, sizeof(buf) - 1, &dummy, NULL);
                            if (len == 1 && isprint((unsigned char)buf[0]))
                                filepicker_append_char(&app.picker, buf[0]);
                        }
                        atomic_store(&app.dirty, true);
                    } else if (atomic_load(&app.confirm_active)) {
                        KeySym ks = XLookupKeysym(&ev.xkey, 0);
                        if (ks == XK_y || ks == XK_Y || ks == XK_Return) {
                            LOG_INFO("UI: confirm Yes (keyboard)");
                            atomic_store(&app.confirm_active, false);
                            confirm_yes_action(&app);
                        } else if (ks == XK_n || ks == XK_N || ks == XK_Escape) {
                            LOG_INFO("UI: confirm No (keyboard) -- cancelled");
                            atomic_store(&app.confirm_active, false);
                            app.confirm_pending_action = 0;
                        }
                        atomic_store(&app.dirty, true);
                    } else if (atomic_load(&app.save_active)) {
                        KeySym ks = XLookupKeysym(&ev.xkey, 0);
                        if (ks == XK_Escape) {
                            LOG_INFO("save: cancelled");
                            atomic_store(&app.save_active, false);
                        } else if (ks == XK_Return) {
                            LOG_INFO("UI: Save confirmed -> '%s'", app.save_name_buf);
                            atomic_store(&app.save_active, false);
                            atomic_store(&app.state, STATE_SAVING);
                            spawn_save(&app, app.save_name_buf);
                        } else if (ks == XK_BackSpace) {
                            settings_backspace_from(app.save_name_buf);
                        } else {
                            char buf[8];
                            KeySym dummy;
                            int len = XLookupString(&ev.xkey, buf, sizeof(buf) - 1, &dummy, NULL);
                            if (len == 1 && isprint((unsigned char)buf[0]))
                                settings_append_char_to(app.save_name_buf, sizeof(app.save_name_buf), buf[0]);
                        }
                        atomic_store(&app.dirty, true);
                    } else if (atomic_load(&app.audio_io_active)) {
                        KeySym ks = XLookupKeysym(&ev.xkey, 0);
                        if (ks == XK_Escape) {
                            LOG_INFO("audio: I/O panel closed");
                            atomic_store(&app.audio_io_active, false);
                            atomic_store(&app.dirty, true);
                        }
                    } else if (atomic_load(&app.rename_active)) {
                        KeySym ks = XLookupKeysym(&ev.xkey, 0);
                        if (ks == XK_Escape) {
                            LOG_INFO("rename: cancelled");
                            atomic_store(&app.rename_active, false);
                        } else if (ks == XK_Return) {
                            LOG_INFO("UI: renamed track %d -> '%s'", app.rename_track_idx, app.rename_buf);
                            tracklist_rename_track(&app.tracklist, app.rename_track_idx, app.rename_buf);
                            atomic_store(&app.rename_active, false);
                        } else if (ks == XK_BackSpace) {
                            settings_backspace_from(app.rename_buf);
                        } else {
                            char buf[8];
                            KeySym dummy;
                            int len = XLookupString(&ev.xkey, buf, sizeof(buf) - 1, &dummy, NULL);
                            if (len == 1 && isprint((unsigned char)buf[0]))
                                settings_append_char_to(app.rename_buf, sizeof(app.rename_buf), buf[0]);
                        }
                        atomic_store(&app.dirty, true);
                    } else if (atomic_load(&app.new_track_prompt_active)) {
                        /* Same shape as rename_active just above, but two
                         * digit-only fields (minutes, seconds) instead of
                         * one free-text one, Tab moves between them, and
                         * Enter creates the track instead of renaming one. */
                        KeySym ks = XLookupKeysym(&ev.xkey, 0);
                        int field = atomic_load(&app.new_track_field);
                        char *cur_buf = (field == 0) ? app.new_track_min_buf : app.new_track_sec_buf;
                        if (ks == XK_Escape) {
                            LOG_INFO("new track: cancelled");
                            atomic_store(&app.new_track_prompt_active, false);
                        } else if (ks == XK_Tab) {
                            atomic_store(&app.new_track_field, field == 0 ? 1 : 0);
                        } else if (ks == XK_Return) {
                            int mins = atoi(app.new_track_min_buf);
                            int secs_part = atoi(app.new_track_sec_buf);
                            double secs = (double)mins * 60.0 + (double)secs_part;
                            if (secs > 0.0) {
                                atomic_store(&app.new_track_prompt_active, false);
                                create_whitespace_track(&app, (size_t)(secs * SAMPLE_RATE));
                            } else {
                                set_reg_status(&app, "New Track: enter a length");
                            }
                        } else if (ks == XK_BackSpace) {
                            settings_backspace_from(cur_buf);
                        } else {
                            char buf[8];
                            KeySym dummy;
                            int len = XLookupString(&ev.xkey, buf, sizeof(buf) - 1, &dummy, NULL);
                            if (len == 1 && isdigit((unsigned char)buf[0]))
                                settings_append_char_to(cur_buf, (field == 0) ? sizeof(app.new_track_min_buf)
                                                                              : sizeof(app.new_track_sec_buf), buf[0]);
                        }
                        atomic_store(&app.dirty, true);
                    } else if (atomic_load(&app.export_active)) {
                        KeySym ks = XLookupKeysym(&ev.xkey, 0);
                        if (ks == XK_Escape) {
                            LOG_INFO("export: cancelled");
                            atomic_store(&app.export_active, false);
                        } else if (ks == XK_Return) {
                            LOG_INFO("UI: Export confirmed (Enter defaults to WAV)");
                            try_export(&app, EXPORT_FORMAT_WAV);
                        } else if (ks == XK_BackSpace) {
                            settings_backspace_from(app.export_name_buf);
                        } else {
                            char buf[8];
                            KeySym dummy;
                            int len = XLookupString(&ev.xkey, buf, sizeof(buf) - 1, &dummy, NULL);
                            if (len == 1 && isprint((unsigned char)buf[0]))
                                settings_append_char_to(app.export_name_buf, sizeof(app.export_name_buf), buf[0]);
                        }
                        atomic_store(&app.dirty, true);
                    } else if (atomic_load(&app.settings_active)) {
                        KeySym ks = XLookupKeysym(&ev.xkey, 0);
                        int field = atomic_load(&app.settings_active_field);

                        if (ks == XK_Escape) {
                            LOG_INFO("settings: closed");
                            atomic_store(&app.settings_active, false);
                        } else if (ks == XK_Return && field == 2) {
                            if (app.altdir_input_buf[0] != '\0') {
                                if (settings_add_alt_dir(&app.settings, app.altdir_input_buf) >= 0) {
                                    LOG_INFO("settings: added alternate directory '%s'", app.altdir_input_buf);
                                    settings_save(&app.settings);
                                }
                                app.altdir_input_buf[0] = '\0';
                            }
                        } else if (ks == XK_Return) {
                            LOG_INFO("settings: closed");
                            atomic_store(&app.settings_active, false);
                        } else if (ks == XK_Tab) {
                            atomic_store(&app.settings_active_field, (field + 1) % 3);
                        } else if (ks == XK_BackSpace) {
                            char *f = (field == 0) ? app.settings.default_import_dir
                                      : (field == 1) ? app.settings.default_export_dir
                                                      : app.altdir_input_buf;
                            settings_backspace_from(f);
                            if (field != 2)
                                settings_save(&app.settings);
                        } else {
                            char buf[8];
                            KeySym dummy;
                            int len = XLookupString(&ev.xkey, buf, sizeof(buf) - 1, &dummy, NULL);
                            if (len == 1 && isprint((unsigned char)buf[0])) {
                                char *f = (field == 0) ? app.settings.default_import_dir
                                          : (field == 1) ? app.settings.default_export_dir
                                                          : app.altdir_input_buf;
                                size_t cap = (field == 2) ? sizeof(app.altdir_input_buf)
                                                           : sizeof(app.settings.default_import_dir);
                                settings_append_char_to(f, cap, buf[0]);
                                if (field != 2)
                                    settings_save(&app.settings);
                            }
                        }
                        atomic_store(&app.dirty, true);
                    } else if (atomic_load(&app.keybinds_menu_active)) {
                        /* See keybinds_menu.h/main.c's handle_click() --
                         * Enter (or clicking the already-selected row)
                         * starts a capture, and the VERY NEXT keypress
                         * (any key, including Escape) is consumed here
                         * as that capture rather than falling through to
                         * Up/Down/Enter/Delete navigation below. */
                        KeySym ks = XLookupKeysym(&ev.xkey, 0);
                        int selected = atomic_load(&app.keybinds_menu_selected);
                        int scroll = atomic_load(&app.keybinds_menu_scroll);

                        if (atomic_load(&app.keybinds_menu_awaiting_key)) {
                            unsigned int mods = ev.xkey.state & (ControlMask | ShiftMask | Mod4Mask | Mod1Mask);
                            if (ks == XK_Escape && mods == 0) {
                                LOG_INFO("keybinds menu: rebind cancelled");
                                app.keybinds_menu_status[0] = '\0';
                            } else {
                                AppAction a = keybinds_menu_row_action(selected);
                                char conflict[96];
                                keybinds_rebind(&app.keybinds, a, ks, mods, conflict, sizeof(conflict));
                                char keybuf_display[64];
                                keybinds_format_binding(&app.keybinds, a, keybuf_display, sizeof(keybuf_display));
                                if (conflict[0])
                                    snprintf(app.keybinds_menu_status, sizeof(app.keybinds_menu_status),
                                              "%s -> %s (%s)", keybinds_action_name(a), keybuf_display, conflict);
                                else
                                    snprintf(app.keybinds_menu_status, sizeof(app.keybinds_menu_status),
                                              "%s -> %s", keybinds_action_name(a), keybuf_display);
                            }
                            atomic_store(&app.keybinds_menu_awaiting_key, false);
                        } else if (ks == XK_Escape) {
                            LOG_INFO("keybinds menu: closed");
                            atomic_store(&app.keybinds_menu_active, false);
                        } else if (ks == XK_Up) {
                            if (selected > 0) {
                                selected--;
                                atomic_store(&app.keybinds_menu_selected, selected);
                                if (selected < scroll)
                                    atomic_store(&app.keybinds_menu_scroll, selected);
                                app.keybinds_menu_status[0] = '\0';
                            }
                        } else if (ks == XK_Down) {
                            if (selected < KEYBINDS_MENU_NUM_ROWS - 1) {
                                selected++;
                                atomic_store(&app.keybinds_menu_selected, selected);
                                if (selected >= scroll + KEYBINDS_MENU_VISIBLE_ROWS)
                                    atomic_store(&app.keybinds_menu_scroll, selected - KEYBINDS_MENU_VISIBLE_ROWS + 1);
                                app.keybinds_menu_status[0] = '\0';
                            }
                        } else if (ks == XK_Return) {
                            atomic_store(&app.keybinds_menu_awaiting_key, true);
                            app.keybinds_menu_status[0] = '\0';
                        } else if (ks == XK_Delete || ks == XK_BackSpace) {
                            AppAction a = keybinds_menu_row_action(selected);
                            char conflict[96];
                            keybinds_rebind(&app.keybinds, a, NoSymbol, 0, conflict, sizeof(conflict));
                            snprintf(app.keybinds_menu_status, sizeof(app.keybinds_menu_status),
                                      "%s disabled", keybinds_action_name(a));
                        }
                        atomic_store(&app.dirty, true);
                    } else if (atomic_load(&app.cli_active)) {
                        /* Barebones ':' command line, NORMAL-mode only (see
                         * AppContext.cli_active's comment). Only 'w' (open
                         * the Save dialog, same as clicking Save) and
                         * 'h'/'help' exist right now -- a real command set
                         * can grow here later without touching how this
                         * overlay opens, closes, or captures typing. */
                        KeySym ks = XLookupKeysym(&ev.xkey, 0);
                        if (ks == XK_Escape) {
                            atomic_store(&app.cli_active, false);
                            app.cli_input[0] = '\0';
                            LOG_INFO("cli: cancelled");
                        } else if (ks == XK_Return) {
                            atomic_store(&app.cli_active, false);
                            const char *cmd = app.cli_input;
                            if (cmd[0] == '\0') {
                                /* nothing typed -- just close, same as Esc */
                            } else if (strcmp(cmd, "w") == 0 || strcmp(cmd, "write") == 0) {
                                if (atomic_load(&app.state) == STATE_IDLE) {
                                    default_timestamp_name(app.save_name_buf, sizeof(app.save_name_buf), "beat");
                                    atomic_store(&app.save_active, true);
                                    snprintf(app.cli_result, sizeof(app.cli_result), ":w -- opened Save dialog");
                                    LOG_INFO("cli: :w -- opened Save dialog");
                                } else {
                                    snprintf(app.cli_result, sizeof(app.cli_result), ":w ignored -- app busy");
                                    LOG_WARN("cli: :w ignored, app busy");
                                }
                            } else if (strcmp(cmd, "h") == 0 || strcmp(cmd, "help") == 0) {
                                snprintf(app.cli_result, sizeof(app.cli_result),
                                          ":w write (Save dialog)   :h/:help this help");
                                LOG_INFO("cli: :h -- showed help");
                            } else {
                                snprintf(app.cli_result, sizeof(app.cli_result), "Unknown command: %s", cmd);
                                LOG_WARN("cli: unknown command '%s'", cmd);
                            }
                            app.cli_input[0] = '\0';
                        } else if (ks == XK_BackSpace) {
                            size_t len = strlen(app.cli_input);
                            if (len > 0)
                                app.cli_input[len - 1] = '\0';
                        } else {
                            char buf[8];
                            KeySym dummy;
                            int len = XLookupString(&ev.xkey, buf, sizeof(buf) - 1, &dummy, NULL);
                            if (len == 1 && isprint((unsigned char)buf[0])) {
                                size_t cl = strlen(app.cli_input);
                                if (cl + 1 < sizeof(app.cli_input)) {
                                    app.cli_input[cl] = buf[0];
                                    app.cli_input[cl + 1] = '\0';
                                }
                            }
                        }
                        atomic_store(&app.dirty, true);
                    } else {
                        KeySym ks = XLookupKeysym(&ev.xkey, 0);
                        /* Mod4Mask (Super) and Mod1Mask (Alt) added alongside the
                         * original Ctrl/Shift so both the system-register
                         * modifiers (Shift for ctrl-style binds, Super for
                         * vim-style binds) and a user-configured Alt leader (see
                         * keysym_to_modmask below) actually reach keybinds.conf. */
                        unsigned int mods = ev.xkey.state & (ControlMask | ShiftMask | Mod4Mask | Mod1Mask);

                        if (atomic_load(&app.leader_active)) {
                            atomic_store(&app.leader_active, false);
                            /* The leader key defaults to Super_L, which doubles as
                             * a real modifier (Mod4Mask) -- so "hold Super, press
                             * y" and "tap Super, release, press y" are genuinely
                             * different gestures on the wire, not an ambiguity we
                             * have to guess at: if the leader key's own modifier
                             * bit is STILL set on this keypress, Super (or
                             * whatever the leader key is) never went up in
                             * between, so this is a direct modifier+key binding
                             * (like copy_system=super+y), not a leader chord --
                             * skip the chord-menu lookup entirely and dispatch it
                             * exactly like any other keybind below. */
                            unsigned int leader_bit = keysym_to_modmask(app.keybinds.leader_keysym);
                            if (leader_bit != 0 && (ev.xkey.state & leader_bit)) {
                                AppAction action = keybinds_lookup(&app.keybinds, ks, mods);
                                LOG_INFO("keybinds (leader held): keysym=%s mods=0x%x -> action=%d",
                                          XKeysymToString(ks), mods, (int)action);
                                if (action == ACTION_QUIT) running = 0;
                                else if (action != ACTION_NONE) do_action(&app, action);
                            } else {
                                /* Leader was tapped and released -- the LazyVim-
                                 * style chord-menu flow. Anything unrecognized
                                 * (including Escape) just cancels leader mode
                                 * silently, no error needed. */
                                for (int i = 0; i < app.keybinds.num_leader_chords; i++) {
                                    if (app.keybinds.leader_chords[i].keysym == ks &&
                                        app.keybinds.leader_chords[i].modifiers == mods) {
                                        LOG_INFO("leader: chord -> action %d", (int)app.keybinds.leader_chords[i].action);
                                        do_action(&app, app.keybinds.leader_chords[i].action);
                                        break;
                                    }
                                }
                            }
                            atomic_store(&app.dirty, true);
                        } else if (app.keybinds.leader_keysym != NoSymbol &&
                                   ks == app.keybinds.leader_keysym && mods == app.keybinds.leader_mods &&
                                   atomic_load(&app.current_mode) == MODE_NORMAL) {
                            /* Leader only means anything in NORMAL mode --
                             * see AppMode's comment. In any other mode this
                             * falls through to the generic lookup below like
                             * any other unrecognized combo (harmless: there's
                             * nothing bound to Ctrl+space itself). */
                            atomic_store(&app.leader_active, true);
                            LOG_INFO("leader: waiting for chord key");
                            atomic_store(&app.dirty, true);
                        } else if (atomic_load(&app.current_mode) == MODE_NORMAL &&
                                   ks == XK_semicolon && mods == ShiftMask) {
                            /* ':' (typed as semicolon+Shift -- XLookupKeysym
                             * never reports the shifted keysym itself, same
                             * as Shift+g meaning 'G' elsewhere) opens the
                             * barebones command line, nvim-style. NORMAL
                             * mode only, same gating as the leader key. */
                            atomic_store(&app.cli_active, true);
                            app.cli_input[0] = '\0';
                            LOG_INFO("cli: opened");
                            atomic_store(&app.dirty, true);
                        } else {
                            AppMode mode = atomic_load(&app.current_mode);

                            /* Hardcoded vim "gg": bare 'g' twice within
                             * 400ms jumps to the start, same as Ctrl+A
                             * (see keybinds.c's default ACTION_JUMP_START
                             * bindings) -- see the AppContext.last_g_press_ms
                             * comment for why this lives here instead of
                             * in the generic keybinds table. Runs
                             * alongside the normal lookup below, not
                             * instead of it, so a config that rebinds
                             * plain 'g' to something else still works
                             * exactly as it always did. */
                            if (ks == XK_g && mods == 0) {
                                long now_ms = current_time_ms();
                                if (now_ms - app.last_g_press_ms < 400) {
                                    LOG_INFO("nav: gg -> jump to start");
                                    do_action(&app, ACTION_JUMP_START);
                                    app.last_g_press_ms = 0;
                                } else {
                                    app.last_g_press_ms = now_ms;
                                }
                            }

                            /* INSERT-mode vim h/l: scrub left/right, same as
                             * the Left/Right arrows. Hardcoded (not in
                             * keybinds.conf's flat table) because it's a
                             * genuine mode-conditional override -- bare 'l'
                             * means toggle_loop_region everywhere else (see
                             * keybinds.c's defaults), and only INSERT mode
                             * steals it back for movement, mirroring how
                             * vim's own h/j/k/l only mean navigation once
                             * you're in a mode that uses them that way. j/k
                             * aren't handled here since they're plain
                             * highlight_up/down aliases in the table itself,
                             * with no such conflict to gate around. */
                            int insert_hl_handled = 0;
                            if (mode == MODE_INSERT && ks == XK_h && mods == 0) {
                                do_action(&app, ACTION_SCRUB_LEFT);
                                insert_hl_handled = 1;
                            } else if (mode == MODE_INSERT && ks == XK_l && mods == 0) {
                                do_action(&app, ACTION_SCRUB_RIGHT);
                                insert_hl_handled = 1;
                            }

                            /* INSERT-mode bare space: whitespace mark/
                             * confirm (see do_whitespace_mark()), NOT
                             * play/pause -- space only plays in NORMAL
                             * mode. Hardcoded for the same reason as
                             * the h/l override just above: this is a
                             * genuine mode-conditional steal of a key
                             * that means something else everywhere
                             * else (ACTION_PLAY_PAUSE's default
                             * binding), not a flat keybinds.conf entry. */
                            int whitespace_handled = 0;
                            if (mode == MODE_INSERT && ks == XK_space && mods == 0) {
                                do_whitespace_mark(&app);
                                whitespace_handled = 1;
                            }

                            /* Ctrl+Up/Ctrl+Down: zoom in/out, same as the
                             * equal/minus keys -- only while in region
                             * (NAV_CONTEXT_PLAYBACK) mode. Hardcoded rather
                             * than a keybinds.conf entry for the same reason
                             * as the h/l override above: the existing
                             * zoom_in/zoom_out actions aren't context-gated,
                             * and this one deliberately is. */
                            int ctrl_zoom_handled = 0;
                            if (ks == XK_Up && mods == ControlMask &&
                                atomic_load(&app.nav_context) == NAV_CONTEXT_PLAYBACK) {
                                do_zoom(&app, 1);
                                ctrl_zoom_handled = 1;
                            } else if (ks == XK_Down && mods == ControlMask &&
                                       atomic_load(&app.nav_context) == NAV_CONTEXT_PLAYBACK) {
                                do_zoom(&app, 0);
                                ctrl_zoom_handled = 1;
                            }

                            /* Ctrl+Left/Ctrl+Right: the same scrub as plain
                             * Left/Right, just a finer step (see
                             * fine_scrub_step()'s comment) -- for lining the
                             * playhead/cursor up precisely once ordinary
                             * scrubbing has gotten close. Hardcoded for the
                             * same reason as the overrides above: plain
                             * scrub_left/scrub_right aren't step-size-aware,
                             * and this one deliberately is. */
                            int ctrl_scrub_handled = 0;
                            if (ks == XK_Left && mods == ControlMask) {
                                do_scrub(&app, -1, fine_scrub_step(&app));
                                ctrl_scrub_handled = 1;
                            } else if (ks == XK_Right && mods == ControlMask) {
                                do_scrub(&app, 1, fine_scrub_step(&app));
                                ctrl_scrub_handled = 1;
                            }

                            if (!insert_hl_handled && !ctrl_zoom_handled && !ctrl_scrub_handled && !whitespace_handled) {
                                AppAction action = keybinds_lookup(&app.keybinds, ks, mods);
                                LOG_INFO("keybinds: keysym=%s mods=0x%x -> action=%d",
                                          XKeysymToString(ks), mods, (int)action);
                                if (action == ACTION_QUIT) {
                                    running = 0;
                                } else if (action != ACTION_NONE) {
                                    do_action(&app, action);
                                }
                            }
                            atomic_store(&app.dirty, true);
                        }
                    }
                    break;
                case ButtonPress:
                    if (ev.xbutton.button == Button1) {
                        handle_click(&app, ev.xbutton.x, ev.xbutton.y);
                    } else if (ev.xbutton.button == Button4 || ev.xbutton.button == Button5) {
                        /* Scroll wheel: plain = pan the timeline, Ctrl = zoom.
                         * Only meaningful over the track field, but harmless
                         * elsewhere since do_zoom/do_pan just adjust shared
                         * viewport state either way. */
                        int forward = (ev.xbutton.button == Button5);
                        if (ev.xbutton.state & ControlMask)
                            do_zoom(&app, !forward); /* wheel up (Button4) = zoom in */
                        else
                            do_pan(&app, forward);
                        atomic_store(&app.dirty, true);
                    }
                    break;
                case MotionNotify:
                    /* Only ever arrives while Button1 is held (that's
                     * what Button1MotionMask means), and only means
                     * anything once handle_click's waveform branch has
                     * set a drag anchor. A plain click that never moves
                     * enough never sets mouse_dragging_selection, so it
                     * stays exactly the "click to seek" behavior from
                     * before this feature existed. */
                    if (atomic_load(&app.scrollbar_dragging)) {
                        tracklist_lock(&app.tracklist);
                        int n = app.tracklist.num_tracks;
                        tracklist_unlock(&app.tracklist);
                        int by2 = app.win_h - BOTTOM_PANEL_H;
                        int track_area_bottom2 = by2 - RULER_H - HSCROLL_H;
                        TrackLayout tl2;
                        compute_track_layout(&app, n, track_area_bottom2, &tl2);
                        int max_scroll = n > tl2.visible_rows ? n - tl2.visible_rows : 0;
                        if (max_scroll > 0) {
                            int bar_h = track_area_bottom2 - TRACK_AREA_TOP;
                            double frac = (double)(ev.xmotion.y - TRACK_AREA_TOP) / (double)bar_h;
                            if (frac < 0) frac = 0;
                            if (frac > 1) frac = 1;
                            app.track_scroll_top = (int)(frac * max_scroll + 0.5);
                            atomic_store(&app.dirty, true);
                        }
                        break;
                    }
                    if (atomic_load(&app.hscrollbar_dragging)) {
                        int track_field_x2 = TRACK_FIELD_X;
                        int track_field_w2 = app.win_w - TRACK_ROW_PAD - TRACK_FIELD_X;
                        int wf_x02 = track_field_x2 + UI_TRACK_LABEL_W;
                        int wf_w2 = track_field_w2 - UI_TRACK_LABEL_W;
                        size_t total = compute_timeline_total(&app);
                        double visible_frames = (double)wf_w2 * app.samples_per_pixel;
                        if ((double)total > visible_frames && wf_w2 > 0) {
                            double max_view_start = (double)total - visible_frames;
                            double frac = (double)(ev.xmotion.x - wf_x02) / (double)wf_w2;
                            if (frac < 0) frac = 0;
                            if (frac > 1) frac = 1;
                            app.view_start_frame = (size_t)(frac * max_view_start + 0.5);
                            atomic_store(&app.dirty, true);
                        }
                        break;
                    }
                    if (atomic_load(&app.mouse_button_down)) {
                        if (!atomic_load(&app.mouse_dragging_selection)) {
                            int dx = ev.xmotion.x - app.mouse_down_x;
                            int dy = ev.xmotion.y - app.mouse_down_y;
                            if (dx < 0) dx = -dx;
                            if (dy < 0) dy = -dy;
                            if (dx > 4 || dy > 4) {
                                atomic_store(&app.mouse_dragging_selection, true);
                                atomic_store(&app.nav_context, NAV_CONTEXT_PLAYBACK);
                                app.selection_track_idx = app.drag_track_idx;
                                app.selection_start_frame = app.drag_anchor_frame;
                                app.selection_end_frame = app.drag_anchor_frame;
                                app.selection_anchor_frame = app.drag_anchor_frame;
                                app.selection_cursor_frame = app.drag_anchor_frame;
                                atomic_store(&app.visual_mode_active, true);
                                atomic_store(&app.has_selection, true);
                                LOG_INFO("selection: drag started at frame %zu (track_idx=%d)",
                                          app.drag_anchor_frame, app.drag_track_idx);
                            }
                        }
                        if (atomic_load(&app.mouse_dragging_selection)) {
                            int track_field_x = TRACK_FIELD_X;
                            int wf_x0 = track_field_x + UI_TRACK_LABEL_W;
                            size_t total = compute_timeline_total(&app);
                            double frame_d = (double)app.view_start_frame
                                              + (double)(ev.xmotion.x - wf_x0) * app.samples_per_pixel;
                            if (frame_d < 0) frame_d = 0;
                            if (frame_d > (double)total) frame_d = (double)total;
                            size_t cur_frame = (size_t)frame_d;
                            app.selection_cursor_frame = cur_frame;
                            size_t a = app.drag_anchor_frame;
                            if (a <= cur_frame) {
                                app.selection_start_frame = a;
                                app.selection_end_frame = cur_frame;
                            } else {
                                app.selection_start_frame = cur_frame;
                                app.selection_end_frame = a;
                            }
                            atomic_store(&app.dirty, true);
                        }
                    }
                    break;
                case ButtonRelease:
                    if (ev.xbutton.button == Button1) {
                        if (atomic_load(&app.mouse_dragging_selection)) {
                            atomic_store(&app.mouse_dragging_selection, false);
                            atomic_store(&app.visual_mode_active, false);
                            LOG_INFO("selection: drag-select -> [%zu, %zu)",
                                      app.selection_start_frame, app.selection_end_frame);
                            atomic_store(&app.dirty, true);
                        }
                        atomic_store(&app.mouse_button_down, false);
                        atomic_store(&app.scrollbar_dragging, false);
                        atomic_store(&app.hscrollbar_dragging, false);
                    }
                    break;
                case SelectionRequest:
                    /* Best-effort CLIPBOARD export for the system
                     * register (register.c) -- another app is asking
                     * for whatever Beat Rat currently owns on the
                     * CLIPBOARD selection. */
                    register_handle_selection_request(dpy, &ev.xselectionrequest,
                                                       app.targets_atom, app.beatrat_audio_atom,
                                                       &app.system_register);
                    break;
                case SelectionClear:
                    /* Another app claimed CLIPBOARD ownership for
                     * itself -- nothing to do; the system register's
                     * own copy of the data is unaffected, and paste
                     * always reads from that, never from X11. */
                    LOG_INFO("register: lost CLIPBOARD ownership (another app copied something)");
                    break;
            }
        }

        if (atomic_exchange(&app.dirty, false))
            redraw(&app);

        /* Wait for either the next X event or a short timeout, so
         * background threads finishing (import/record/playback) get
         * picked up and redrawn promptly without busy-spinning. */
        fd_set fds;
        FD_ZERO(&fds);
        FD_SET(xfd, &fds);
        struct timeval tv = { 0, 50000 }; /* 50ms */
        select(xfd + 1, &fds, NULL, NULL, &tv);
    }

    /* Ask any in-flight background thread to stop, then actually wait for
     * it to finish (state returns to STATE_IDLE) before freeing track
     * data -- a fixed short sleep isn't long enough for a multi-second
     * recording or playback still in progress, and freeing sample buffers
     * out from under a still-running thread would be a use-after-free.
     * Capped at 5s so a stuck thread can't hang shutdown forever. */
    atomic_store(&app.stop_requested, true);
    int shutdown_wait_iters = 0;
    while (atomic_load(&app.state) != STATE_IDLE && shutdown_wait_iters < 100) {
        usleep(50000);
        shutdown_wait_iters++;
    }
    if (atomic_load(&app.state) != STATE_IDLE)
        LOG_WARN("shutdown: background thread did not finish within 5s, exiting anyway");

    tracklist_free(&app.tracklist);
    pthread_mutex_destroy(&app.insertion_points_mutex);
    register_clear(&app.beatrat_register);
    register_clear(&app.system_register);
    if (app.backbuf) XFreePixmap(dpy, app.backbuf);
    XFreeGC(dpy, gc);
    XDestroyWindow(dpy, win);
    XCloseDisplay(dpy);
    LOG_INFO("Beat Rat exiting");
    log_close();
    return 0;
}

