1534 lines
55 KiB
C
1534 lines
55 KiB
C
#include "tui.h"
|
|
#include "wav_io.h"
|
|
#include "transport.h"
|
|
#include "carla.h"
|
|
#include <ncurses.h>
|
|
#include <string.h>
|
|
#include <strings.h>
|
|
#include <stdlib.h>
|
|
#include <signal.h>
|
|
#include <stdatomic.h>
|
|
#include <ctype.h>
|
|
#include <dirent.h>
|
|
#include <sys/stat.h>
|
|
#include <dirent.h>
|
|
#include <sys/stat.h>
|
|
#include <math.h>
|
|
#include <unistd.h>
|
|
|
|
#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;
|
|
|
|
// Grid of grids state
|
|
static int selected_grid = 0; // Which grid we're viewing (0-7)
|
|
static bool zoom_mode = false; // Whether we're in zoom mode
|
|
|
|
// View modes
|
|
typedef enum {
|
|
VIEW_GRID,
|
|
VIEW_RACK
|
|
} UIView;
|
|
|
|
static UIView current_view = VIEW_GRID;
|
|
static int rack_selected_channel = 0;
|
|
static int rack_selected_plugin = -1;
|
|
static int rack_selected_param = -1;
|
|
|
|
// Fuzzy search state
|
|
typedef struct {
|
|
char query[256];
|
|
int query_len;
|
|
int selected_index;
|
|
int num_results;
|
|
int result_indices[256];
|
|
bool active;
|
|
char prompt[64];
|
|
void (*callback)(const char *selection);
|
|
const char **items; // custom list of strings (NULL if using plugins)
|
|
int num_items; // number of items in custom list
|
|
bool free_items; // whether to free items when done
|
|
} FuzzySearch;
|
|
|
|
static FuzzySearch fuzzy_search = {0};
|
|
|
|
// 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 grid, int row, int col) {
|
|
return grid * GRID_ROWS * GRID_COLS + 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(selected_grid, 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_grid = yank_buffer.clip_indices[0] / (GRID_ROWS * GRID_COLS);
|
|
int first_yanked_row = (yank_buffer.clip_indices[0] % (GRID_ROWS * GRID_COLS)) / GRID_COLS;
|
|
int first_yanked_col = (yank_buffer.clip_indices[0] % (GRID_ROWS * GRID_COLS)) % GRID_COLS;
|
|
int grid_offset = selected_grid - first_yanked_grid;
|
|
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_grid = yank_buffer.clip_indices[i] / (GRID_ROWS * GRID_COLS);
|
|
int remainder = yank_buffer.clip_indices[i] % (GRID_ROWS * GRID_COLS);
|
|
int orig_row = remainder / GRID_COLS;
|
|
int orig_col = remainder % GRID_COLS;
|
|
|
|
int new_grid = orig_grid + grid_offset;
|
|
int new_row = orig_row + row_offset;
|
|
int new_col = orig_col + col_offset;
|
|
|
|
if (new_grid >= 0 && new_grid < NUM_GRIDS &&
|
|
new_row >= 0 && new_row < GRID_ROWS &&
|
|
new_col >= 0 && new_col < GRID_COLS) {
|
|
int new_clip_idx = grid_to_clip_index(new_grid, 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_grid, 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 < NUM_GRIDS * GRID_ROWS * GRID_COLS) {
|
|
selected_grid = clip_idx / (GRID_ROWS * GRID_COLS);
|
|
int remainder = clip_idx % (GRID_ROWS * GRID_COLS);
|
|
selected_row = remainder / GRID_COLS;
|
|
selected_col = remainder % 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 + selected_grid * GRID_ROWS } };
|
|
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 + selected_grid * GRID_ROWS } };
|
|
g_dispatch(action);
|
|
selected_row = prev_row;
|
|
}
|
|
|
|
// Callback for adding a plugin via fuzzy search
|
|
static void rack_add_plugin_callback(const char *selection) {
|
|
if (selection) {
|
|
// Parse URI from selection (format: "uri - name")
|
|
char uri[512];
|
|
const char *space = strchr(selection, ' ');
|
|
if (space) {
|
|
size_t len = space - selection;
|
|
strncpy(uri, selection, len);
|
|
uri[len] = '\0';
|
|
} else {
|
|
strncpy(uri, selection, sizeof(uri) - 1);
|
|
}
|
|
|
|
Action action = {
|
|
.type = ACTION_RACK_ADD_PLUGIN,
|
|
.data.rack_add_plugin = {
|
|
.channel = rack_selected_channel,
|
|
.type = PLUGIN_TYPE_INTERNAL
|
|
}
|
|
};
|
|
strncpy(action.data.rack_add_plugin.uri, uri, sizeof(action.data.rack_add_plugin.uri) - 1);
|
|
action.data.rack_add_plugin.uri[sizeof(action.data.rack_add_plugin.uri) - 1] = '\0';
|
|
g_dispatch(action);
|
|
}
|
|
}
|
|
|
|
// Callback for MIDI from selection
|
|
static void midi_from_callback(const char *selection) {
|
|
if (selection) {
|
|
printf("Selected MIDI input: %s\n", selection);
|
|
// In a real implementation, this would connect JACK ports
|
|
}
|
|
}
|
|
|
|
// Callback for MIDI to selection
|
|
static void midi_to_callback(const char *selection) {
|
|
if (selection) {
|
|
printf("Selected MIDI output: %s\n", selection);
|
|
// In a real implementation, this would connect JACK ports
|
|
}
|
|
}
|
|
|
|
// List WAV files in a directory (returns malloc'd array of strings, count via *count)
|
|
static const char **list_wav_files(const char *dir, int *count) {
|
|
DIR *d = opendir(dir);
|
|
if (!d) {
|
|
*count = 0;
|
|
return NULL;
|
|
}
|
|
|
|
int capacity = 64;
|
|
const char **files = malloc(capacity * sizeof(char*));
|
|
int n = 0;
|
|
|
|
struct dirent *entry;
|
|
while ((entry = readdir(d)) != NULL) {
|
|
const char *name = entry->d_name;
|
|
size_t len = strlen(name);
|
|
if (len > 4 && strcasecmp(name + len - 4, ".wav") == 0) {
|
|
// Check if it's a regular file
|
|
char fullpath[1024];
|
|
snprintf(fullpath, sizeof(fullpath), "%s/%s", dir, name);
|
|
struct stat st;
|
|
if (stat(fullpath, &st) == 0 && S_ISREG(st.st_mode)) {
|
|
if (n >= capacity) {
|
|
capacity *= 2;
|
|
files = realloc(files, capacity * sizeof(char*));
|
|
}
|
|
files[n] = strdup(name);
|
|
n++;
|
|
}
|
|
}
|
|
}
|
|
closedir(d);
|
|
|
|
*count = n;
|
|
return files;
|
|
}
|
|
|
|
// Callback for loading a sample via fuzzy search
|
|
static void load_sample_callback(const char *selection) {
|
|
if (selection) {
|
|
// Build full path (assume samples directory)
|
|
char fullpath[1024];
|
|
const char *samples_dir = getenv("JACK_LOOPER_SAMPLES_DIR");
|
|
if (!samples_dir) samples_dir = ".";
|
|
snprintf(fullpath, sizeof(fullpath), "%s/%s", samples_dir, selection);
|
|
|
|
int clip_idx = grid_to_clip_index(selected_grid, selected_row, selected_col);
|
|
Action action = { .type = ACTION_LOAD_CLIP, .data.load_clip = { .clip_index = clip_idx } };
|
|
strncpy(action.data.load_clip.filename, fullpath, 255);
|
|
action.data.load_clip.filename[255] = '\0';
|
|
g_dispatch(action);
|
|
}
|
|
}
|
|
|
|
// Fuzzy matching function
|
|
static bool fuzzy_match(const char *pattern, const char *text) {
|
|
if (!pattern || !*pattern) return true;
|
|
if (!text) return false;
|
|
|
|
while (*pattern) {
|
|
char p = *pattern;
|
|
while (*text && tolower((unsigned char)*text) != tolower((unsigned char)p)) text++;
|
|
if (!*text) return false;
|
|
text++;
|
|
pattern++;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Draw fuzzy search
|
|
static void draw_fuzzy_search(void) {
|
|
int start_y = LINES / 3;
|
|
int start_x = COLS / 4;
|
|
int width = COLS / 2;
|
|
int height = LINES / 3;
|
|
|
|
// Create a temporary window for the border
|
|
WINDOW *win = newwin(height + 2, width + 2, start_y - 1, start_x - 1);
|
|
if (!win) return;
|
|
wclear(win);
|
|
box(win, 0, 0);
|
|
wrefresh(win);
|
|
delwin(win);
|
|
|
|
// Draw title (inside the border)
|
|
attron(A_BOLD);
|
|
mvprintw(start_y, start_x, "%s", fuzzy_search.prompt);
|
|
attroff(A_BOLD);
|
|
|
|
// Draw search box
|
|
mvprintw(start_y + 1, start_x, "> %s", fuzzy_search.query);
|
|
|
|
// Draw results
|
|
for (int i = 0; i < height - 2 && i < fuzzy_search.num_results; i++) {
|
|
int idx = fuzzy_search.result_indices[i];
|
|
if (i == fuzzy_search.selected_index) {
|
|
attron(A_REVERSE);
|
|
}
|
|
const char *item = NULL;
|
|
if (fuzzy_search.items) {
|
|
if (idx >= 0 && idx < fuzzy_search.num_items) {
|
|
item = fuzzy_search.items[idx];
|
|
}
|
|
} else {
|
|
int count;
|
|
const char **plugins = carla_get_available_plugins(&g_engine->carla_host, &count);
|
|
if (plugins && idx >= 0 && idx < count) {
|
|
item = plugins[idx];
|
|
}
|
|
}
|
|
if (item) {
|
|
mvprintw(start_y + 2 + i, start_x, "%s", item);
|
|
}
|
|
if (i == fuzzy_search.selected_index) {
|
|
attroff(A_REVERSE);
|
|
}
|
|
}
|
|
|
|
// Draw help line
|
|
mvprintw(start_y + height + 2, start_x, "j/k: navigate, h/l: page, Enter: select, Esc: cancel");
|
|
}
|
|
|
|
// Handle fuzzy search input
|
|
static bool handle_fuzzy_search(int ch) {
|
|
if (!fuzzy_search.active) return false;
|
|
|
|
switch (ch) {
|
|
case '\n': case '\r': {
|
|
if (fuzzy_search.num_results > 0 && fuzzy_search.selected_index >= 0) {
|
|
int idx = fuzzy_search.result_indices[fuzzy_search.selected_index];
|
|
const char *selection = NULL;
|
|
if (fuzzy_search.items) {
|
|
if (idx >= 0 && idx < fuzzy_search.num_items) {
|
|
selection = fuzzy_search.items[idx];
|
|
}
|
|
} else {
|
|
int count;
|
|
const char **plugins = carla_get_available_plugins(NULL, &count);
|
|
if (plugins && idx >= 0 && idx < count) {
|
|
selection = plugins[idx];
|
|
}
|
|
}
|
|
if (selection && fuzzy_search.callback) {
|
|
fuzzy_search.callback(selection);
|
|
}
|
|
}
|
|
// Free custom items if needed
|
|
if (fuzzy_search.free_items && fuzzy_search.items) {
|
|
for (int i = 0; i < fuzzy_search.num_items; i++) {
|
|
free((void*)fuzzy_search.items[i]);
|
|
}
|
|
free(fuzzy_search.items);
|
|
fuzzy_search.items = NULL;
|
|
fuzzy_search.num_items = 0;
|
|
fuzzy_search.free_items = false;
|
|
}
|
|
fuzzy_search.active = false;
|
|
return true;
|
|
}
|
|
case 27: { // ESC
|
|
// Free custom items if needed
|
|
if (fuzzy_search.free_items && fuzzy_search.items) {
|
|
for (int i = 0; i < fuzzy_search.num_items; i++) {
|
|
free((void*)fuzzy_search.items[i]);
|
|
}
|
|
free(fuzzy_search.items);
|
|
fuzzy_search.items = NULL;
|
|
fuzzy_search.num_items = 0;
|
|
fuzzy_search.free_items = false;
|
|
}
|
|
fuzzy_search.active = false;
|
|
return true;
|
|
}
|
|
case KEY_BACKSPACE: case 127: {
|
|
if (fuzzy_search.query_len > 0) {
|
|
fuzzy_search.query[--fuzzy_search.query_len] = '\0';
|
|
// Update results
|
|
fuzzy_search.num_results = 0;
|
|
if (fuzzy_search.items) {
|
|
for (int i = 0; i < fuzzy_search.num_items; i++) {
|
|
if (fuzzy_match(fuzzy_search.query, fuzzy_search.items[i])) {
|
|
fuzzy_search.result_indices[fuzzy_search.num_results++] = i;
|
|
}
|
|
}
|
|
} else {
|
|
int count;
|
|
const char **plugins = carla_get_available_plugins(&g_engine->carla_host, &count);
|
|
for (int i = 0; i < count; i++) {
|
|
if (fuzzy_match(fuzzy_search.query, plugins[i])) {
|
|
fuzzy_search.result_indices[fuzzy_search.num_results++] = i;
|
|
}
|
|
}
|
|
}
|
|
if (fuzzy_search.selected_index >= fuzzy_search.num_results) {
|
|
fuzzy_search.selected_index = fuzzy_search.num_results - 1;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
case 'k': case KEY_UP: {
|
|
if (fuzzy_search.selected_index > 0) {
|
|
fuzzy_search.selected_index--;
|
|
}
|
|
return true;
|
|
}
|
|
case 'j': case KEY_DOWN: {
|
|
if (fuzzy_search.selected_index < fuzzy_search.num_results - 1) {
|
|
fuzzy_search.selected_index++;
|
|
}
|
|
return true;
|
|
}
|
|
case 'h': {
|
|
// Page up - move up by 10 items
|
|
fuzzy_search.selected_index -= 10;
|
|
if (fuzzy_search.selected_index < 0) {
|
|
fuzzy_search.selected_index = 0;
|
|
}
|
|
return true;
|
|
}
|
|
case 'l': {
|
|
// Page down - move down by 10 items
|
|
fuzzy_search.selected_index += 10;
|
|
if (fuzzy_search.selected_index >= fuzzy_search.num_results) {
|
|
fuzzy_search.selected_index = fuzzy_search.num_results - 1;
|
|
}
|
|
return true;
|
|
}
|
|
default: {
|
|
if (ch >= 32 && ch <= 126 && fuzzy_search.query_len < 255) {
|
|
fuzzy_search.query[fuzzy_search.query_len++] = (char)ch;
|
|
fuzzy_search.query[fuzzy_search.query_len] = '\0';
|
|
// Update results
|
|
fuzzy_search.num_results = 0;
|
|
if (fuzzy_search.items) {
|
|
for (int i = 0; i < fuzzy_search.num_items; i++) {
|
|
if (fuzzy_match(fuzzy_search.query, fuzzy_search.items[i])) {
|
|
fuzzy_search.result_indices[fuzzy_search.num_results++] = i;
|
|
}
|
|
}
|
|
} else {
|
|
int count;
|
|
const char **plugins = carla_get_available_plugins(&g_engine->carla_host, &count);
|
|
for (int i = 0; i < count; i++) {
|
|
if (fuzzy_match(fuzzy_search.query, plugins[i])) {
|
|
fuzzy_search.result_indices[fuzzy_search.num_results++] = i;
|
|
}
|
|
}
|
|
}
|
|
fuzzy_search.selected_index = 0;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start fuzzy search with custom items list (or NULL for plugins)
|
|
static void start_fuzzy_search_items(const char *prompt, void (*callback)(const char *),
|
|
const char **items, int num_items, bool free_items) {
|
|
fuzzy_search.active = true;
|
|
fuzzy_search.query_len = 0;
|
|
fuzzy_search.query[0] = '\0';
|
|
fuzzy_search.selected_index = 0;
|
|
strncpy(fuzzy_search.prompt, prompt, sizeof(fuzzy_search.prompt) - 1);
|
|
fuzzy_search.prompt[sizeof(fuzzy_search.prompt) - 1] = '\0';
|
|
fuzzy_search.callback = callback;
|
|
fuzzy_search.items = items;
|
|
fuzzy_search.num_items = num_items;
|
|
fuzzy_search.free_items = free_items;
|
|
|
|
// Initialize results with all items
|
|
fuzzy_search.num_results = 0;
|
|
if (items) {
|
|
for (int i = 0; i < num_items; i++) {
|
|
fuzzy_search.result_indices[fuzzy_search.num_results++] = i;
|
|
}
|
|
} else {
|
|
int count = 0;
|
|
carla_get_available_plugins(&g_engine->carla_host, &count);
|
|
for (int i = 0; i < count; i++) {
|
|
fuzzy_search.result_indices[fuzzy_search.num_results++] = i;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start fuzzy search (using plugins)
|
|
static void start_fuzzy_search(const char *prompt, void (*callback)(const char *)) {
|
|
start_fuzzy_search_items(prompt, callback, NULL, 0, false);
|
|
}
|
|
|
|
// Draw rack view
|
|
static void draw_rack_view(void) {
|
|
clear();
|
|
|
|
attron(A_BOLD);
|
|
mvprintw(0, 0, "JACK Looper - Rack View (Channel %d)", rack_selected_channel);
|
|
attroff(A_BOLD);
|
|
|
|
AppState state = dispatcher_get_state();
|
|
ChannelRack *rack = &state.carla_host.channel_racks[rack_selected_channel];
|
|
|
|
// Draw channel selector
|
|
mvprintw(1, 0, "Channel: ");
|
|
for (int ch = 0; ch < 8; ch++) {
|
|
if (ch == rack_selected_channel) {
|
|
attron(A_REVERSE);
|
|
}
|
|
mvprintw(1, 10 + ch * 4, "Ch%d", ch);
|
|
if (ch == rack_selected_channel) {
|
|
attroff(A_REVERSE);
|
|
}
|
|
}
|
|
|
|
// Draw volume
|
|
mvprintw(2, 0, "Volume: %.2f [ - ] to decrease, [ = ] to increase", rack->volume);
|
|
|
|
// Draw bypass toggle
|
|
mvprintw(3, 0, "Bypass: %s [b] to toggle", rack->bypassed ? "ON" : "OFF");
|
|
|
|
// Draw plugin chain
|
|
mvprintw(4, 0, "=== Plugin Chain ===");
|
|
|
|
int y = 5;
|
|
for (int p = 0; p < rack->num_plugins; p++) {
|
|
PluginInfo *plugin = &rack->plugins[p];
|
|
|
|
if (p == rack_selected_plugin) {
|
|
attron(A_REVERSE);
|
|
}
|
|
mvprintw(y++, 2, "[%d] %s", p, plugin->name);
|
|
if (p == rack_selected_plugin) {
|
|
attroff(A_REVERSE);
|
|
}
|
|
|
|
// Draw parameters if this plugin is selected
|
|
if (p == rack_selected_plugin) {
|
|
for (int param = 0; param < plugin->num_parameters; param++) {
|
|
if (param == rack_selected_param) {
|
|
attron(A_REVERSE);
|
|
}
|
|
mvprintw(y++, 4, "%s: %.3f",
|
|
plugin->parameter_names[param],
|
|
plugin->parameters[param]);
|
|
if (param == rack_selected_param) {
|
|
attroff(A_REVERSE);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw controls help
|
|
mvprintw(y + 1, 0, "=== Controls ===");
|
|
mvprintw(y + 2, 0, "h/l - Previous/Next channel (or page in fuzzy search)");
|
|
mvprintw(y + 3, 0, "j/k - Navigate plugins (or items in fuzzy search)");
|
|
mvprintw(y + 4, 0, "a - Add plugin to rack");
|
|
mvprintw(y + 5, 0, "d - Remove selected plugin");
|
|
mvprintw(y + 6, 0, "b - Toggle bypass");
|
|
mvprintw(y + 7, 0, "-/= - Decrease/Increase volume");
|
|
mvprintw(y + 8, 0, "Enter - Select parameter to edit");
|
|
mvprintw(y + 9, 0, "Tab - Switch to grid view");
|
|
mvprintw(y + 10, 0, "q/Esc - Quit");
|
|
|
|
refresh();
|
|
}
|
|
|
|
// Handle rack view input
|
|
static bool handle_rack_view(int ch) {
|
|
AppState state = dispatcher_get_state();
|
|
ChannelRack *rack = &state.carla_host.channel_racks[rack_selected_channel];
|
|
|
|
switch (ch) {
|
|
case 'h': case KEY_LEFT:
|
|
rack_selected_channel = (rack_selected_channel - 1 + 8) % 8;
|
|
rack_selected_plugin = -1;
|
|
rack_selected_param = -1;
|
|
break;
|
|
case 'l': case KEY_RIGHT:
|
|
rack_selected_channel = (rack_selected_channel + 1) % 8;
|
|
rack_selected_plugin = -1;
|
|
rack_selected_param = -1;
|
|
break;
|
|
case 'j': case KEY_DOWN:
|
|
if (rack_selected_plugin < rack->num_plugins - 1) {
|
|
rack_selected_plugin++;
|
|
rack_selected_param = -1;
|
|
}
|
|
break;
|
|
case 'k': case KEY_UP:
|
|
if (rack_selected_plugin > 0) {
|
|
rack_selected_plugin--;
|
|
rack_selected_param = -1;
|
|
} else if (rack_selected_plugin == 0) {
|
|
rack_selected_plugin = -1;
|
|
}
|
|
break;
|
|
case 'a': {
|
|
// Start fuzzy search for plugin selection
|
|
start_fuzzy_search("Add plugin:", rack_add_plugin_callback);
|
|
break;
|
|
}
|
|
case 'd': {
|
|
if (rack_selected_plugin >= 0 && rack_selected_plugin < rack->num_plugins) {
|
|
Action action = {
|
|
.type = ACTION_RACK_REMOVE_PLUGIN,
|
|
.data.rack_remove_plugin = {
|
|
.channel = rack_selected_channel,
|
|
.plugin_index = rack_selected_plugin
|
|
}
|
|
};
|
|
g_dispatch(action);
|
|
rack_selected_plugin = -1;
|
|
rack_selected_param = -1;
|
|
}
|
|
break;
|
|
}
|
|
case 'b': {
|
|
Action action = {
|
|
.type = ACTION_RACK_BYPASS,
|
|
.data.rack_bypass = {
|
|
.channel = rack_selected_channel,
|
|
.bypass = !rack->bypassed
|
|
}
|
|
};
|
|
g_dispatch(action);
|
|
break;
|
|
}
|
|
case '-': case '_': {
|
|
float new_vol = fmaxf(0.0f, rack->volume - 0.1f);
|
|
Action action = {
|
|
.type = ACTION_RACK_SET_VOLUME,
|
|
.data.rack_set_volume = {
|
|
.channel = rack_selected_channel,
|
|
.volume = new_vol
|
|
}
|
|
};
|
|
g_dispatch(action);
|
|
break;
|
|
}
|
|
case '=': case '+': {
|
|
float new_vol = fminf(2.0f, rack->volume + 0.1f);
|
|
Action action = {
|
|
.type = ACTION_RACK_SET_VOLUME,
|
|
.data.rack_set_volume = {
|
|
.channel = rack_selected_channel,
|
|
.volume = new_vol
|
|
}
|
|
};
|
|
g_dispatch(action);
|
|
break;
|
|
}
|
|
case '\t': {
|
|
current_view = VIEW_GRID;
|
|
break;
|
|
}
|
|
case 'q': case 27:
|
|
return true; // Quit
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Draw a single cell
|
|
static void draw_cell(int grid, int row, int col, bool selected) {
|
|
int clip_idx = grid_to_clip_index(grid, row, col);
|
|
AppState state = dispatcher_get_state();
|
|
|
|
int y = row * CELL_HEIGHT + 3; // Offset by 2 for grid selector
|
|
int x = col * CELL_WIDTH + 1;
|
|
|
|
int color;
|
|
if (state.show_midi_grid) {
|
|
color = state_to_color(state.midi_clips[clip_idx].state);
|
|
} else {
|
|
color = state_to_color(state.clips[clip_idx].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, ' ');
|
|
}
|
|
}
|
|
|
|
if (state.show_midi_grid) {
|
|
MidiClip *mclip = &state.midi_clips[clip_idx];
|
|
mvprintw(y + 1, x + 1, "%2d", clip_idx);
|
|
char state_char;
|
|
switch (mclip->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);
|
|
} else {
|
|
Clip *clip = &state.clips[clip_idx];
|
|
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();
|
|
|
|
AppState state = dispatcher_get_state();
|
|
|
|
if (zoom_mode) {
|
|
// Draw grid selector overview
|
|
attron(A_BOLD);
|
|
mvprintw(0, 0, "JACK Looper - Grid Selector (h/j/k/l navigate, Enter select, z exit)");
|
|
attroff(A_BOLD);
|
|
|
|
// Draw mini grids
|
|
for (int g = 0; g < NUM_GRIDS; g++) {
|
|
int gx = (g % 4) * 20;
|
|
int gy = (g / 4) * 10 + 2;
|
|
|
|
if (g == selected_grid) {
|
|
attron(A_REVERSE);
|
|
}
|
|
mvprintw(gy, gx, "Grid %d", g);
|
|
if (g == selected_grid) {
|
|
attroff(A_REVERSE);
|
|
}
|
|
|
|
// Draw mini representation (4x4 sampling of the 8x8 grid)
|
|
for (int r = 0; r < 4; r++) {
|
|
for (int c = 0; c < 4; c++) {
|
|
int clip_idx = grid_to_clip_index(g, r * 2, c * 2);
|
|
AppState state = dispatcher_get_state();
|
|
Clip *clip = &state.clips[clip_idx];
|
|
int color = state_to_color(clip->state);
|
|
attron(COLOR_PAIR(color));
|
|
mvaddch(gy + 1 + r, gx + 1 + c, ' ');
|
|
attroff(COLOR_PAIR(color));
|
|
}
|
|
}
|
|
}
|
|
|
|
refresh();
|
|
return;
|
|
}
|
|
|
|
// Normal grid view
|
|
attron(A_BOLD);
|
|
if (state.show_midi_grid) {
|
|
mvprintw(0, 0, "JACK Looper - MIDI Grid %d", selected_grid);
|
|
} else {
|
|
mvprintw(0, 0, "JACK Looper - Audio Grid %d", selected_grid);
|
|
}
|
|
attroff(A_BOLD);
|
|
|
|
// Draw grid selector bar at top
|
|
mvprintw(1, 0, "Grid: ");
|
|
for (int g = 0; g < NUM_GRIDS; g++) {
|
|
if (g == selected_grid) {
|
|
attron(A_REVERSE);
|
|
}
|
|
mvprintw(1, 6 + g * 4, "G%d", g);
|
|
if (g == selected_grid) {
|
|
attroff(A_REVERSE);
|
|
}
|
|
}
|
|
|
|
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(selected_grid, row, col, selected);
|
|
}
|
|
}
|
|
|
|
int clip_idx = grid_to_clip_index(selected_grid, selected_row, selected_col);
|
|
|
|
if (state.show_midi_grid) {
|
|
MidiClip *mclip = &state.midi_clips[clip_idx];
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 3, 0,
|
|
"Selected: Clip %d | Grid %d | State: %s | MIDI events: %d",
|
|
clip_idx, selected_grid, clip_state_to_string(mclip->state), mclip->event_count);
|
|
} else {
|
|
Clip *clip = &state.clips[clip_idx];
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 3, 0,
|
|
"Selected: Clip %d | Grid %d | State: %s | Buffer: %zu samples",
|
|
clip_idx, selected_grid, clip_state_to_string(clip->state), clip->buffer_size);
|
|
}
|
|
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 4, 0,
|
|
"Transport: %s | Clock: %s | BPM: %.1f | Quantize: %s | Grid: %s",
|
|
transport_state_to_string(state.transport_state),
|
|
clock_source_to_string(state.clock_source),
|
|
state.bpm,
|
|
quantize_mode_to_string(state.quantize_mode),
|
|
state.show_midi_grid ? "MIDI" : "AUDIO");
|
|
|
|
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 + 5, 0, "Mode: %s", mode_str);
|
|
|
|
if (show_help) {
|
|
attron(COLOR_PAIR(COLOR_HELP));
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 6, 0, "=== Help ===");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 7, 0, "h/j/k/l - Navigate grid (left/down/up/right)");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 8, 0, "t - Trigger selected clip");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 9, 0, "r - Reset selected clip");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 10, 0, "s - Trigger scene (current row)");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 11, 0, "Space - Play/Pause transport");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 12, 0, "S - Stop transport");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 13, 0, "C - Toggle clock source (Internal/MIDI)");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 14, 0, "q - Toggle quantize mode (off/beat/bar)");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 15, 0, "T - Set quantize threshold");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 16, 0, "x - Reset transport position");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 17, 0, "z - Toggle grid selector (zoom mode)");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 18, 0, "? - Toggle help");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 19, 0, "Esc/q - Quit");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 20, 0, "In fuzzy search: j/k navigate, h/l page, Enter select, Esc cancel");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 21, 0, "L - Load sample into selected clip (fuzzy search)");
|
|
mvprintw(GRID_ROWS * CELL_HEIGHT + 22, 0, "G - Toggle Audio/MIDI grid");
|
|
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 (strcmp(cmd_buffer, "view rack") == 0) {
|
|
current_view = VIEW_RACK;
|
|
rack_selected_channel = selected_row;
|
|
rack_selected_plugin = -1;
|
|
rack_selected_param = -1;
|
|
} else if (strncmp(cmd_buffer, "rack ", 5) == 0) {
|
|
// :rack <plugin_name> - add plugin to selected channel's rack
|
|
const char *plugin_name = cmd_buffer + 5;
|
|
Action action = {
|
|
.type = ACTION_RACK_ADD_PLUGIN,
|
|
.data.rack_add_plugin = {
|
|
.channel = selected_row,
|
|
.type = PLUGIN_TYPE_INTERNAL
|
|
}
|
|
};
|
|
strncpy(action.data.rack_add_plugin.uri, plugin_name, sizeof(action.data.rack_add_plugin.uri) - 1);
|
|
action.data.rack_add_plugin.uri[sizeof(action.data.rack_add_plugin.uri) - 1] = '\0';
|
|
g_dispatch(action);
|
|
} else if (strcmp(cmd_buffer, "from") == 0) {
|
|
// :from - open fuzzy search for MIDI input source
|
|
start_fuzzy_search("Select MIDI input source:", midi_from_callback);
|
|
} else if (strcmp(cmd_buffer, "to") == 0) {
|
|
// :to - open fuzzy search for MIDI output destination
|
|
start_fuzzy_search("Select MIDI output destination:", midi_to_callback);
|
|
} else if (strcmp(cmd_buffer, "load") == 0) {
|
|
// Start fuzzy search for WAV files
|
|
const char *samples_dir = getenv("JACK_LOOPER_SAMPLES_DIR");
|
|
if (!samples_dir) samples_dir = ".";
|
|
int count;
|
|
const char **files = list_wav_files(samples_dir, &count);
|
|
if (files && count > 0) {
|
|
start_fuzzy_search_items("Load sample:", load_sample_callback,
|
|
files, count, true);
|
|
} else {
|
|
// No files found, show message
|
|
mvprintw(LINES - 1, 0, "No WAV files found in %s", samples_dir);
|
|
refresh();
|
|
napms(1500);
|
|
}
|
|
} 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) {
|
|
const char *filename = cmd_buffer + 5;
|
|
while (*filename == ' ') filename++;
|
|
if (*filename) {
|
|
Action action = { .type = ACTION_SAVE_PROJECT };
|
|
strncpy(action.data.save_project.filename, filename, sizeof(action.data.save_project.filename) - 1);
|
|
action.data.save_project.filename[sizeof(action.data.save_project.filename) - 1] = '\0';
|
|
g_dispatch(action);
|
|
}
|
|
} else if (strncmp(cmd_buffer, "load ", 5) == 0) {
|
|
const char *filename = cmd_buffer + 5;
|
|
while (*filename == ' ') filename++;
|
|
if (*filename) {
|
|
Action action = { .type = ACTION_LOAD_PROJECT };
|
|
strncpy(action.data.load_project.filename, filename, sizeof(action.data.load_project.filename) - 1);
|
|
action.data.load_project.filename[sizeof(action.data.load_project.filename) - 1] = '\0';
|
|
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 - 3) / CELL_HEIGHT; // Offset for grid selector
|
|
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_grid, selected_row, selected_col);
|
|
AppState s = dispatcher_get_state();
|
|
if (s.show_midi_grid) {
|
|
Action action = { .type = ACTION_MIDI_CLIP_TRIGGER, .data.midi_clip_trigger = { .clip_index = clip_idx } };
|
|
g_dispatch(action);
|
|
} else {
|
|
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_grid, selected_row, selected_col);
|
|
AppState s = dispatcher_get_state();
|
|
if (s.show_midi_grid) {
|
|
Action action = { .type = ACTION_MIDI_CLIP_RESET, .data.midi_clip_reset = { .clip_index = clip_idx } };
|
|
g_dispatch(action);
|
|
} else {
|
|
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 + selected_grid * GRID_ROWS } };
|
|
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 + selected_grid * GRID_ROWS } };
|
|
g_dispatch(action);
|
|
}
|
|
}
|
|
|
|
void tui_init(Engine *engine) {
|
|
g_engine = engine;
|
|
g_dispatch = engine->dispatch;
|
|
|
|
initscr();
|
|
if (!has_colors()) {
|
|
endwin();
|
|
fprintf(stderr, "Terminal does not support colors\n");
|
|
exit(1);
|
|
}
|
|
|
|
cbreak();
|
|
noecho();
|
|
keypad(stdscr, TRUE);
|
|
curs_set(0);
|
|
|
|
// Check terminal size
|
|
if (LINES < 20 || COLS < 40) {
|
|
endwin();
|
|
fprintf(stderr, "Terminal too small (need at least 20x40)\n");
|
|
exit(1);
|
|
}
|
|
|
|
mousemask(BUTTON1_CLICKED | BUTTON3_CLICKED | BUTTON2_CLICKED | BUTTON1_DOUBLE_CLICKED, NULL);
|
|
mouseinterval(10);
|
|
|
|
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;
|
|
}
|
|
|
|
// Carla is now initialized in main.c before dispatcher starts
|
|
draw_grid();
|
|
|
|
while (1) {
|
|
// Handle fuzzy search first
|
|
if (fuzzy_search.active) {
|
|
draw_fuzzy_search();
|
|
int ch = getch();
|
|
if (handle_fuzzy_search(ch)) {
|
|
// Fuzzy search closed, redraw current view
|
|
if (current_view == VIEW_GRID) {
|
|
draw_grid();
|
|
} else {
|
|
draw_rack_view();
|
|
}
|
|
} else {
|
|
draw_fuzzy_search();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Handle current view
|
|
if (current_view == VIEW_RACK) {
|
|
draw_rack_view();
|
|
int ch = getch();
|
|
if (handle_rack_view(ch)) {
|
|
return; // Quit
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Grid view (existing code)
|
|
int ch = getch();
|
|
if (ch == ERR) {
|
|
break;
|
|
}
|
|
|
|
if (ch == KEY_MOUSE) {
|
|
MEVENT event;
|
|
if (getmouse(&event) == OK) {
|
|
handle_mouse_event(&event);
|
|
draw_grid();
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Handle zoom mode navigation
|
|
if (zoom_mode) {
|
|
switch (ch) {
|
|
case 'h': case KEY_LEFT:
|
|
selected_grid = (selected_grid - 1 + NUM_GRIDS) % NUM_GRIDS;
|
|
break;
|
|
case 'l': case KEY_RIGHT:
|
|
selected_grid = (selected_grid + 1) % NUM_GRIDS;
|
|
break;
|
|
case 'j': case KEY_DOWN:
|
|
selected_grid = (selected_grid + 4) % NUM_GRIDS; // Move down a row (4 grids per row)
|
|
break;
|
|
case 'k': case KEY_UP:
|
|
selected_grid = (selected_grid - 4 + NUM_GRIDS) % NUM_GRIDS; // Move up a row
|
|
break;
|
|
case '\n': case '\r':
|
|
// Select this grid and exit zoom mode
|
|
zoom_mode = false;
|
|
break;
|
|
case 'z':
|
|
zoom_mode = false;
|
|
break;
|
|
case 27: case 'Q':
|
|
return;
|
|
}
|
|
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) {
|
|
AppState s = dispatcher_get_state();
|
|
if (s.show_midi_grid) {
|
|
for (int i = 0; i < count; i++) {
|
|
Action action = { .type = ACTION_MIDI_CLIP_RESET, .data.midi_clip_reset = { .clip_index = clips[i] } };
|
|
g_dispatch(action);
|
|
}
|
|
} else {
|
|
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_grid, selected_row, selected_col);
|
|
AppState s = dispatcher_get_state();
|
|
if (s.show_midi_grid) {
|
|
Action action = { .type = ACTION_MIDI_CLIP_TRIGGER, .data.midi_clip_trigger = { .clip_index = clip_idx } };
|
|
g_dispatch(action);
|
|
} else {
|
|
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_grid, selected_row, selected_col);
|
|
AppState s = dispatcher_get_state();
|
|
if (s.show_midi_grid) {
|
|
Action action = { .type = ACTION_MIDI_CLIP_RESET, .data.midi_clip_reset = { .clip_index = clip_idx } };
|
|
g_dispatch(action);
|
|
} else {
|
|
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_grid, 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 + selected_grid * GRID_ROWS } };
|
|
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 'z': {
|
|
zoom_mode = !zoom_mode;
|
|
break;
|
|
}
|
|
case 'G': {
|
|
AppState s = dispatcher_get_state();
|
|
Action a = { .type = ACTION_SET_SHOW_MIDI_GRID, .data.set_show_midi_grid = { .show = !s.show_midi_grid } };
|
|
g_dispatch(a);
|
|
break;
|
|
}
|
|
case 'L': {
|
|
// Start fuzzy search for WAV files
|
|
const char *samples_dir = getenv("JACK_LOOPER_SAMPLES_DIR");
|
|
if (!samples_dir) samples_dir = ".";
|
|
int count;
|
|
const char **files = list_wav_files(samples_dir, &count);
|
|
if (files && count > 0) {
|
|
start_fuzzy_search_items("Load sample:", load_sample_callback,
|
|
files, count, true);
|
|
} else {
|
|
// No files found, show message
|
|
mvprintw(LINES - 1, 0, "No WAV files found in %s", samples_dir);
|
|
refresh();
|
|
napms(1500);
|
|
}
|
|
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 '-': case '_': {
|
|
// Decrease volume of selected channel
|
|
AppState state = dispatcher_get_state();
|
|
float current_vol = carla_get_channel_volume(&state.carla_host, selected_row);
|
|
float new_vol = fmaxf(0.0f, current_vol - 0.1f);
|
|
Action action = {
|
|
.type = ACTION_RACK_SET_VOLUME,
|
|
.data.rack_set_volume = {
|
|
.channel = selected_row,
|
|
.volume = new_vol
|
|
}
|
|
};
|
|
g_dispatch(action);
|
|
break;
|
|
}
|
|
case '=': case '+': {
|
|
// Increase volume of selected channel
|
|
AppState state = dispatcher_get_state();
|
|
float current_vol = carla_get_channel_volume(&state.carla_host, selected_row);
|
|
float new_vol = fminf(2.0f, current_vol + 0.1f);
|
|
Action action = {
|
|
.type = ACTION_RACK_SET_VOLUME,
|
|
.data.rack_set_volume = {
|
|
.channel = selected_row,
|
|
.volume = new_vol
|
|
}
|
|
};
|
|
g_dispatch(action);
|
|
break;
|
|
}
|
|
case '\t': {
|
|
// Switch to rack view
|
|
current_view = VIEW_RACK;
|
|
rack_selected_channel = selected_row;
|
|
rack_selected_plugin = -1;
|
|
rack_selected_param = -1;
|
|
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();
|
|
}
|