Files
jack-looper/tui.c
Loic Coenen 6eac3059fb fix: add missing include for transport functions in tui.c
Co-authored-by: aider (deepseek/deepseek-coder) <aider@aider.chat>
2026-05-02 15:58:07 +00:00

681 lines
23 KiB
C

#include "tui.h"
#include "wav_io.h"
#include "transport.h"
#include <ncurses.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>
#include <stdatomic.h>
#define GRID_ROWS 8
#define GRID_COLS 8
#define CELL_WIDTH 6
#define CELL_HEIGHT 3
// Color pairs
enum {
COLOR_EMPTY = 1,
COLOR_RECORDING,
COLOR_LOOPING,
COLOR_STOPPED,
COLOR_SELECTED,
COLOR_HELP
};
static Engine *g_engine = NULL;
static DispatchFn g_dispatch = NULL;
static int selected_row = 0;
static int selected_col = 0;
static bool show_help = false;
// Modes
typedef enum {
MODE_NORMAL,
MODE_VISUAL,
MODE_MOVE
} UIMode;
// Mark storage
#define MAX_MARKS 26 // a-z
static int marks[MAX_MARKS]; // stores clip_index for each mark
// Visual mode state
static int visual_start_row = 0;
static int visual_start_col = 0;
static int visual_end_row = 0;
static int visual_end_col = 0;
// Yank buffer
typedef struct {
int *clip_indices;
int count;
} YankBuffer;
static YankBuffer yank_buffer = {NULL, 0};
// Current mode
static UIMode current_mode = MODE_NORMAL;
// Convert clip state to color pair
static int state_to_color(ClipState state) {
switch (state) {
case CLIP_EMPTY: return COLOR_EMPTY;
case CLIP_RECORDING: return COLOR_RECORDING;
case CLIP_LOOPING: return COLOR_LOOPING;
case CLIP_STOPPED: return COLOR_STOPPED;
default: return COLOR_EMPTY;
}
}
// Get clip index from grid position
static int grid_to_clip_index(int row, int col) {
return row * GRID_COLS + col;
}
// Check if a cell is in the visual selection
static bool is_in_visual_selection(int row, int col) {
if (current_mode != MODE_VISUAL) return false;
int min_row = (visual_start_row < visual_end_row) ? visual_start_row : visual_end_row;
int max_row = (visual_start_row > visual_end_row) ? visual_start_row : visual_end_row;
int min_col = (visual_start_col < visual_end_col) ? visual_start_col : visual_end_col;
int max_col = (visual_start_col > visual_end_col) ? visual_start_col : visual_end_col;
return (row >= min_row && row <= max_row && col >= min_col && col <= max_col);
}
// Get all clip indices in the visual selection
static int* get_selected_clips(int *count) {
int min_row = (visual_start_row < visual_end_row) ? visual_start_row : visual_end_row;
int max_row = (visual_start_row > visual_end_row) ? visual_start_row : visual_end_row;
int min_col = (visual_start_col < visual_end_col) ? visual_start_col : visual_end_col;
int max_col = (visual_start_col > visual_end_col) ? visual_start_col : visual_end_col;
int num_rows = max_row - min_row + 1;
int num_cols = max_col - min_col + 1;
*count = num_rows * num_cols;
int *clips = (int *)malloc(*count * sizeof(int));
if (!clips) {
*count = 0;
return NULL;
}
int idx = 0;
for (int r = min_row; r <= max_row; r++) {
for (int c = min_col; c <= max_col; c++) {
clips[idx++] = grid_to_clip_index(r, c);
}
}
return clips;
}
// Delete (reset) clips via dispatch
static void delete_clips(int *clip_indices, int count) {
for (int i = 0; i < count; i++) {
Action action = { .type = ACTION_RESET_CLIP, .data.reset_clip = { .clip_index = clip_indices[i] } };
g_dispatch(action);
}
}
// Yank clips (store their indices)
static void yank_clips(int *clip_indices, int count) {
if (yank_buffer.clip_indices) {
free(yank_buffer.clip_indices);
}
yank_buffer.clip_indices = (int *)malloc(count * sizeof(int));
if (yank_buffer.clip_indices) {
memcpy(yank_buffer.clip_indices, clip_indices, count * sizeof(int));
yank_buffer.count = count;
}
}
// Paste clips (trigger them via dispatch)
static void paste_clips(void) {
if (!yank_buffer.clip_indices || yank_buffer.count == 0) return;
int first_yanked_row = yank_buffer.clip_indices[0] / GRID_COLS;
int first_yanked_col = yank_buffer.clip_indices[0] % GRID_COLS;
int row_offset = selected_row - first_yanked_row;
int col_offset = selected_col - first_yanked_col;
for (int i = 0; i < yank_buffer.count; i++) {
int orig_row = yank_buffer.clip_indices[i] / GRID_COLS;
int orig_col = yank_buffer.clip_indices[i] % GRID_COLS;
int new_row = orig_row + row_offset;
int new_col = orig_col + col_offset;
if (new_row >= 0 && new_row < GRID_ROWS && new_col >= 0 && new_col < GRID_COLS) {
int new_clip_idx = grid_to_clip_index(new_row, new_col);
// Trigger three times to cycle: empty -> recording -> looping -> stopped
for (int j = 0; j < 3; j++) {
Action action = { .type = ACTION_TRIGGER_CLIP, .data.trigger_clip = { .clip_index = new_clip_idx } };
g_dispatch(action);
}
}
}
}
// Set a mark at current position
static void set_mark(char mark_char) {
if (mark_char >= 'a' && mark_char <= 'z') {
int idx = mark_char - 'a';
marks[idx] = grid_to_clip_index(selected_row, selected_col);
}
}
// Go to a mark
static void go_to_mark(char mark_char) {
if (mark_char >= 'a' && mark_char <= 'z') {
int idx = mark_char - 'a';
int clip_idx = marks[idx];
if (clip_idx >= 0 && clip_idx < GRID_ROWS * GRID_COLS) {
selected_row = clip_idx / GRID_COLS;
selected_col = clip_idx % GRID_COLS;
}
}
}
// Play next scene (next row)
static void play_next_scene(void) {
int next_row = (selected_row + 1) % GRID_ROWS;
Action action = { .type = ACTION_TRIGGER_SCENE, .data.trigger_scene = { .scene_index = next_row } };
g_dispatch(action);
selected_row = next_row;
}
// Play previous scene (previous row)
static void play_prev_scene(void) {
int prev_row = (selected_row - 1 + GRID_ROWS) % GRID_ROWS;
Action action = { .type = ACTION_TRIGGER_SCENE, .data.trigger_scene = { .scene_index = prev_row } };
g_dispatch(action);
selected_row = prev_row;
}
// Draw a single cell
static void draw_cell(int row, int col, bool selected) {
int clip_idx = grid_to_clip_index(row, col);
AppState state = dispatcher_get_state();
Clip *clip = &state.clips[clip_idx];
int y = row * CELL_HEIGHT + 1;
int x = col * CELL_WIDTH + 1;
int color = state_to_color(clip->state);
if (selected) {
color = COLOR_SELECTED;
} else if (current_mode == MODE_VISUAL && is_in_visual_selection(row, col)) {
color = COLOR_SELECTED;
}
attron(COLOR_PAIR(color));
for (int dy = 0; dy < CELL_HEIGHT; dy++) {
for (int dx = 0; dx < CELL_WIDTH; dx++) {
mvaddch(y + dy, x + dx, ' ');
}
}
mvprintw(y + 1, x + 1, "%2d", clip_idx);
char state_char;
switch (clip->state) {
case CLIP_EMPTY: state_char = ' '; break;
case CLIP_RECORDING: state_char = 'R'; break;
case CLIP_LOOPING: state_char = 'L'; break;
case CLIP_STOPPED: state_char = 'S'; break;
default: state_char = '?'; break;
}
mvaddch(y + 1, x + 4, state_char);
attroff(COLOR_PAIR(color));
}
// Draw the entire grid
static void draw_grid(void) {
clear();
attron(A_BOLD);
mvprintw(0, 0, "JACK Looper - 8x8 Clip Grid");
attroff(A_BOLD);
AppState state = dispatcher_get_state();
for (int row = 0; row < GRID_ROWS; row++) {
for (int col = 0; col < GRID_COLS; col++) {
bool selected = (row == selected_row && col == selected_col);
draw_cell(row, col, selected);
}
}
int clip_idx = grid_to_clip_index(selected_row, selected_col);
Clip *clip = &state.clips[clip_idx];
mvprintw(GRID_ROWS * CELL_HEIGHT + 1, 0,
"Selected: Clip %d | State: %s | Buffer: %zu samples",
clip_idx, clip_state_to_string(clip->state), clip->buffer_size);
mvprintw(GRID_ROWS * CELL_HEIGHT + 2, 0,
"Transport: %s | Clock: %s | BPM: %.1f | Quantize: %s | Threshold: %u",
transport_state_to_string(state.transport_state),
clock_source_to_string(state.clock_source),
state.bpm,
quantize_mode_to_string(state.quantize_mode),
(unsigned int)state.quantize_threshold);
const char *mode_str;
switch (current_mode) {
case MODE_NORMAL: mode_str = "NORMAL"; break;
case MODE_VISUAL: mode_str = "VISUAL"; break;
case MODE_MOVE: mode_str = "MOVE"; break;
default: mode_str = "?"; break;
}
mvprintw(GRID_ROWS * CELL_HEIGHT + 3, 0, "Mode: %s", mode_str);
if (show_help) {
attron(COLOR_PAIR(COLOR_HELP));
mvprintw(GRID_ROWS * CELL_HEIGHT + 4, 0, "=== Help ===");
mvprintw(GRID_ROWS * CELL_HEIGHT + 5, 0, "h/j/k/l - Navigate grid (left/down/up/right)");
mvprintw(GRID_ROWS * CELL_HEIGHT + 6, 0, "t - Trigger selected clip");
mvprintw(GRID_ROWS * CELL_HEIGHT + 7, 0, "r - Reset selected clip");
mvprintw(GRID_ROWS * CELL_HEIGHT + 8, 0, "s - Trigger scene (current row)");
mvprintw(GRID_ROWS * CELL_HEIGHT + 9, 0, "Space - Play/Pause transport");
mvprintw(GRID_ROWS * CELL_HEIGHT + 10, 0, "S - Stop transport");
mvprintw(GRID_ROWS * CELL_HEIGHT + 11, 0, "C - Toggle clock source (Internal/MIDI)");
mvprintw(GRID_ROWS * CELL_HEIGHT + 12, 0, "q - Toggle quantize mode (off/beat/bar)");
mvprintw(GRID_ROWS * CELL_HEIGHT + 13, 0, "T - Set quantize threshold");
mvprintw(GRID_ROWS * CELL_HEIGHT + 14, 0, "x - Reset transport position");
mvprintw(GRID_ROWS * CELL_HEIGHT + 15, 0, "? - Toggle help");
mvprintw(GRID_ROWS * CELL_HEIGHT + 16, 0, "Esc/q - Quit");
attroff(COLOR_PAIR(COLOR_HELP));
}
refresh();
}
// Handle command mode input (after pressing ':')
static bool handle_command_mode(void) {
char cmd_buffer[256];
int cmd_pos = 0;
memset(cmd_buffer, 0, sizeof(cmd_buffer));
int prev_nodelay = nodelay(stdscr, FALSE);
mvprintw(LINES - 1, 0, ":");
clrtoeol();
refresh();
while (1) {
int ch = getch();
if (ch == '\n' || ch == '\r') {
cmd_buffer[cmd_pos] = '\0';
mvprintw(LINES - 1, 0, " ");
refresh();
if (strcmp(cmd_buffer, "q") == 0) {
nodelay(stdscr, prev_nodelay);
return true;
} else if (strncmp(cmd_buffer, "load ", 5) == 0) {
char *rest = cmd_buffer + 5;
int clip_idx = atoi(rest);
char *filename = rest;
while (*filename && *filename != ' ') filename++;
if (*filename) {
*filename = '\0';
filename++;
while (*filename == ' ') filename++;
if (*filename) {
Action action = { .type = ACTION_LOAD_CLIP, .data.load_clip = { .clip_index = clip_idx } };
strncpy(action.data.load_clip.filename, filename, 255);
action.data.load_clip.filename[255] = '\0';
g_dispatch(action);
}
}
} else if (strncmp(cmd_buffer, "save ", 5) == 0) {
int clip_idx = atoi(cmd_buffer + 5);
Action action = { .type = ACTION_SAVE_CLIP, .data.save_clip = { .clip_index = clip_idx } };
g_dispatch(action);
}
nodelay(stdscr, prev_nodelay);
return false;
} else if (ch == 27) {
mvprintw(LINES - 1, 0, " ");
refresh();
nodelay(stdscr, prev_nodelay);
return false;
} else if (ch == KEY_BACKSPACE || ch == 127) {
if (cmd_pos > 0) {
cmd_pos--;
cmd_buffer[cmd_pos] = '\0';
mvprintw(LINES - 1, 0, ":%s ", cmd_buffer);
refresh();
}
} else if (cmd_pos < (int)sizeof(cmd_buffer) - 1) {
cmd_buffer[cmd_pos++] = (char)ch;
cmd_buffer[cmd_pos] = '\0';
mvprintw(LINES - 1, 0, ":%s", cmd_buffer);
refresh();
}
}
nodelay(stdscr, prev_nodelay);
return false;
}
// Handle mouse events
static void handle_mouse_event(MEVENT *event) {
int grid_row = (event->y - 1) / CELL_HEIGHT;
int grid_col = (event->x - 1) / CELL_WIDTH;
if (grid_row < 0 || grid_row >= GRID_ROWS || grid_col < 0 || grid_col >= GRID_COLS) {
return;
}
if (event->bstate & BUTTON1_CLICKED) {
selected_row = grid_row;
selected_col = grid_col;
int clip_idx = grid_to_clip_index(selected_row, selected_col);
Action action = { .type = ACTION_TRIGGER_CLIP, .data.trigger_clip = { .clip_index = clip_idx } };
g_dispatch(action);
} else if (event->bstate & BUTTON3_CLICKED) {
selected_row = grid_row;
selected_col = grid_col;
int clip_idx = grid_to_clip_index(selected_row, selected_col);
Action action = { .type = ACTION_RESET_CLIP, .data.reset_clip = { .clip_index = clip_idx } };
g_dispatch(action);
} else if (event->bstate & BUTTON2_CLICKED) {
selected_row = grid_row;
selected_col = grid_col;
Action action = { .type = ACTION_TRIGGER_SCENE, .data.trigger_scene = { .scene_index = selected_row } };
g_dispatch(action);
} else if (event->bstate & BUTTON1_DOUBLE_CLICKED) {
selected_row = grid_row;
selected_col = grid_col;
Action action = { .type = ACTION_TRIGGER_SCENE, .data.trigger_scene = { .scene_index = selected_row } };
g_dispatch(action);
}
}
void tui_init(Engine *engine) {
g_engine = engine;
g_dispatch = engine->dispatch;
initscr();
cbreak();
noecho();
keypad(stdscr, TRUE);
curs_set(0);
mousemask(BUTTON1_CLICKED | BUTTON3_CLICKED | BUTTON2_CLICKED | BUTTON1_DOUBLE_CLICKED, NULL);
mouseinterval(10);
if (has_colors()) {
start_color();
init_pair(COLOR_EMPTY, COLOR_WHITE, COLOR_BLACK);
init_pair(COLOR_RECORDING, COLOR_RED, COLOR_BLACK);
init_pair(COLOR_LOOPING, COLOR_GREEN, COLOR_BLACK);
init_pair(COLOR_STOPPED, COLOR_BLUE, COLOR_BLACK);
init_pair(COLOR_SELECTED, COLOR_BLACK, COLOR_CYAN);
init_pair(COLOR_HELP, COLOR_CYAN, COLOR_BLACK);
}
}
void tui_run(Engine *engine) {
if (!engine) return;
g_engine = engine;
g_dispatch = engine->dispatch;
for (int i = 0; i < MAX_MARKS; i++) {
marks[i] = -1;
}
draw_grid();
while (1) {
int ch = getch();
if (ch == ERR) {
break;
}
if (ch == KEY_MOUSE) {
MEVENT event;
if (getmouse(&event) == OK) {
handle_mouse_event(&event);
draw_grid();
continue;
}
}
if (current_mode == MODE_MOVE) {
switch (ch) {
case 'h': case KEY_LEFT:
selected_col = (selected_col - 1 + GRID_COLS) % GRID_COLS;
break;
case 'j': case KEY_DOWN:
selected_row = (selected_row + 1) % GRID_ROWS;
break;
case 'k': case KEY_UP:
selected_row = (selected_row - 1 + GRID_ROWS) % GRID_ROWS;
break;
case 'l': case KEY_RIGHT:
selected_col = (selected_col + 1) % GRID_COLS;
break;
case '\n': case '\r': case 27:
current_mode = MODE_NORMAL;
break;
}
draw_grid();
continue;
}
if (current_mode == MODE_VISUAL) {
switch (ch) {
case 'h': case KEY_LEFT:
visual_end_col = (visual_end_col - 1 + GRID_COLS) % GRID_COLS;
break;
case 'j': case KEY_DOWN:
visual_end_row = (visual_end_row + 1) % GRID_ROWS;
break;
case 'k': case KEY_UP:
visual_end_row = (visual_end_row - 1 + GRID_ROWS) % GRID_ROWS;
break;
case 'l': case KEY_RIGHT:
visual_end_col = (visual_end_col + 1) % GRID_COLS;
break;
case 'd': {
int count;
int *clips = get_selected_clips(&count);
if (clips) {
delete_clips(clips, count);
free(clips);
}
current_mode = MODE_NORMAL;
break;
}
case 'y': {
int count;
int *clips = get_selected_clips(&count);
if (clips) {
yank_clips(clips, count);
free(clips);
}
current_mode = MODE_NORMAL;
break;
}
case 27:
current_mode = MODE_NORMAL;
break;
}
draw_grid();
continue;
}
// Normal mode
switch (ch) {
case 'h': case KEY_LEFT:
selected_col = (selected_col - 1 + GRID_COLS) % GRID_COLS;
break;
case 'j': case KEY_DOWN:
selected_row = (selected_row + 1) % GRID_ROWS;
break;
case 'k': case KEY_UP:
selected_row = (selected_row - 1 + GRID_ROWS) % GRID_ROWS;
break;
case 'l': case KEY_RIGHT:
selected_col = (selected_col + 1) % GRID_COLS;
break;
case 't': {
int clip_idx = grid_to_clip_index(selected_row, selected_col);
Action action = { .type = ACTION_TRIGGER_CLIP, .data.trigger_clip = { .clip_index = clip_idx } };
g_dispatch(action);
break;
}
case 'd': {
int clip_idx = grid_to_clip_index(selected_row, selected_col);
Action action = { .type = ACTION_RESET_CLIP, .data.reset_clip = { .clip_index = clip_idx } };
g_dispatch(action);
break;
}
case 'y': {
int clip_idx = grid_to_clip_index(selected_row, selected_col);
yank_clips(&clip_idx, 1);
break;
}
case 'p':
paste_clips();
break;
case 's': {
Action action = { .type = ACTION_TRIGGER_SCENE, .data.trigger_scene = { .scene_index = selected_row } };
g_dispatch(action);
break;
}
case 'q': {
AppState state = dispatcher_get_state();
QuantizeMode modes[] = {QUANTIZE_OFF, QUANTIZE_BEAT, QUANTIZE_BAR};
int num_modes = sizeof(modes) / sizeof(modes[0]);
int current = 0;
for (int i = 0; i < num_modes; i++) {
if (state.quantize_mode == modes[i]) {
current = i;
break;
}
}
QuantizeMode next = modes[(current + 1) % num_modes];
Action action = { .type = ACTION_SET_QUANTIZE_MODE, .data.set_quantize_mode = { .mode = next } };
g_dispatch(action);
break;
}
case 'T': {
AppState state = dispatcher_get_state();
jack_nframes_t new_threshold = (state.quantize_threshold == 0) ? 1000 : 0;
Action action = { .type = ACTION_SET_QUANTIZE_THRESHOLD, .data.set_quantize_threshold = { .threshold = new_threshold } };
g_dispatch(action);
break;
}
case ' ': {
Action action = { .type = ACTION_TRANSPORT_TOGGLE_PLAY };
g_dispatch(action);
break;
}
case 'S': {
Action action = { .type = ACTION_TRANSPORT_STOP };
g_dispatch(action);
break;
}
case 'C': {
AppState state = dispatcher_get_state();
ClockSource next = (state.clock_source == CLOCK_SOURCE_INTERNAL) ?
CLOCK_SOURCE_MIDI : CLOCK_SOURCE_INTERNAL;
Action action = { .type = ACTION_SET_CLOCK_SOURCE, .data.set_clock_source = { .source = next } };
g_dispatch(action);
break;
}
case 'x': {
Action action = { .type = ACTION_RESET_TRANSPORT };
g_dispatch(action);
break;
}
case ':': {
bool should_quit = handle_command_mode();
if (should_quit) return;
break;
}
case '?':
show_help = !show_help;
break;
case 'v': {
current_mode = MODE_VISUAL;
visual_start_row = selected_row;
visual_start_col = selected_col;
visual_end_row = selected_row;
visual_end_col = selected_col;
break;
}
case 'V': {
current_mode = MODE_VISUAL;
visual_start_row = selected_row;
visual_start_col = 0;
visual_end_row = selected_row;
visual_end_col = GRID_COLS - 1;
break;
}
case 'm': {
current_mode = MODE_MOVE;
break;
}
case 'N':
play_next_scene();
break;
case 'P':
play_prev_scene();
break;
case '\'': {
nodelay(stdscr, FALSE);
int mark_ch = getch();
nodelay(stdscr, TRUE);
if (mark_ch != ERR) go_to_mark((char)mark_ch);
break;
}
case 'u': {
Action action = { .type = ACTION_UNDO };
g_dispatch(action);
break;
}
case 18: { // Ctrl+R
Action action = { .type = ACTION_REDO };
g_dispatch(action);
break;
}
case 27: case 'Q':
return;
default:
if (ch == 'm') {
nodelay(stdscr, FALSE);
int mark_ch = getch();
nodelay(stdscr, TRUE);
if (mark_ch != ERR) set_mark((char)mark_ch);
}
break;
}
draw_grid();
}
}
void tui_cleanup(void) {
if (yank_buffer.clip_indices) {
free(yank_buffer.clip_indices);
yank_buffer.clip_indices = NULL;
yank_buffer.count = 0;
}
mousemask(0, NULL);
curs_set(1);
endwin();
}