From 2c4129f640fa900afbae3804ccb3e8abbbde9fa5 Mon Sep 17 00:00:00 2001 From: Loic Coenen Date: Fri, 1 May 2026 19:14:58 +0000 Subject: [PATCH] feat: add vim-like keyboard shortcuts with visual mode, marks, yank/paste, and scene navigation Co-authored-by: aider (deepseek/deepseek-coder) --- tui.c | 363 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 361 insertions(+), 2 deletions(-) diff --git a/tui.c b/tui.c index a6454db..99411d0 100644 --- a/tui.c +++ b/tui.c @@ -24,6 +24,34 @@ 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 +static int mark_selected = -1; // -1 = no mark selected + +// 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) { @@ -40,6 +68,145 @@ 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; +} + +// Get all clip indices in a row +static int* get_row_clips(int row, int *count) { + *count = GRID_COLS; + int *clips = (int *)malloc(*count * sizeof(int)); + if (!clips) { + *count = 0; + return NULL; + } + + for (int c = 0; c < GRID_COLS; c++) { + clips[c] = grid_to_clip_index(row, c); + } + + return clips; +} + +// Delete (reset) clips +static void delete_clips(int *clip_indices, int count) { + for (int i = 0; i < count; i++) { + engine_reset_clip(g_engine, clip_indices[i]); + } + engine_process_commands(g_engine); +} + +// Yank clips (store their indices) +static void yank_clips(int *clip_indices, int count) { + // Free previous yank buffer + 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) +static void paste_clips(void) { + if (!yank_buffer.clip_indices || yank_buffer.count == 0) return; + + // Calculate offset from current position to first yanked clip + 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; + + // Bounds check + 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); + engine_trigger_clip(g_engine, new_clip_idx); + } + } + engine_process_commands(g_engine); +} + +// 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; + engine_trigger_scene(g_engine, next_row); + engine_process_commands(g_engine); + 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; + engine_trigger_scene(g_engine, prev_row); + engine_process_commands(g_engine); + 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); @@ -51,6 +218,9 @@ static void draw_cell(int row, int col, bool selected) { int color = state_to_color(clip->state); if (selected) { color = COLOR_SELECTED; + } else if (current_mode == MODE_VISUAL && is_in_visual_selection(row, col)) { + // Use a different highlight for visual selection (invert colors) + color = COLOR_SELECTED; // Same as selected for now } attron(COLOR_PAIR(color)); @@ -109,6 +279,17 @@ static void draw_grid(void) { quantize_mode_to_string((QuantizeMode)atomic_load(&g_engine->quantize_mode_atomic)), (unsigned int)atomic_load(&g_engine->quantize_threshold_atomic)); + // Draw mode indicator + 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); + // Draw help if active if (show_help) { attron(COLOR_PAIR(COLOR_HELP)); @@ -236,15 +417,113 @@ void tui_run(Engine *engine) { g_engine = engine; + // Initialize marks + for (int i = 0; i < MAX_MARKS; i++) { + marks[i] = -1; + } + draw_grid(); while (1) { int ch = getch(); if (ch == ERR) { - // getch returned error (e.g., signal interrupted) break; } + // Handle mode-specific input + 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: // Escape + current_mode = MODE_NORMAL; + break; + + default: + 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: // Escape + current_mode = MODE_NORMAL; + break; + + default: + break; + } + + draw_grid(); + continue; + } + + // Normal mode switch (ch) { case 'h': case KEY_LEFT: @@ -273,13 +552,27 @@ void tui_run(Engine *engine) { break; } - case 'r': { + case 'd': { + // Delete (reset) current clip int clip_idx = grid_to_clip_index(selected_row, selected_col); engine_reset_clip(engine, clip_idx); engine_process_commands(engine); break; } + case 'y': { + // Yank current clip + int clip_idx = grid_to_clip_index(selected_row, selected_col); + yank_clips(&clip_idx, 1); + break; + } + + case 'p': { + // Paste + paste_clips(); + break; + } + case 's': { // Trigger scene for current row engine_trigger_scene(engine, selected_row); @@ -332,11 +625,70 @@ void tui_run(Engine *engine) { show_help = !show_help; break; + case 'v': { + // Enter visual mode + 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': { + // Select entire line + 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': { + // Enter move mode + current_mode = MODE_MOVE; + break; + } + + case 'N': { + // Play next scene + play_next_scene(); + break; + } + + case 'P': { + // Play previous scene + play_prev_scene(); + break; + } + + case '\'': { + // Go to mark - wait for next character + nodelay(stdscr, FALSE); + int mark_ch = getch(); + nodelay(stdscr, TRUE); + if (mark_ch != ERR) { + go_to_mark((char)mark_ch); + } + break; + } + case 27: // Escape key case 'Q': return; default: + // Check for mark setting (m) + if (ch == 'm') { + // Wait for next character + nodelay(stdscr, FALSE); + int mark_ch = getch(); + nodelay(stdscr, TRUE); + if (mark_ch != ERR) { + set_mark((char)mark_ch); + } + } break; } @@ -345,6 +697,13 @@ void tui_run(Engine *engine) { } void tui_cleanup(void) { + // Free yank buffer + if (yank_buffer.clip_indices) { + free(yank_buffer.clip_indices); + yank_buffer.clip_indices = NULL; + yank_buffer.count = 0; + } + // Restore terminal settings curs_set(1); endwin();