#include #include #include #include #include #include #include #include #include #include #include #include #include #include "config.h" #define TODO_PROGRAM "todo" #define TODO_VERSION "0.1.0" #define MAX_ITEMS 4096 #define MAX_TEXT 256 #define MAX_COMMAND 64 #define MAX_HISTORY 64 _Static_assert(MAX_ITEMS >= 1, "MAX_ITEMS must be positive"); _Static_assert(MAX_TEXT >= 16, "MAX_TEXT too small"); _Static_assert(MAX_COMMAND >= 8, "MAX_COMMAND too small"); _Static_assert(MAX_HISTORY >= 1, "MAX_HISTORY must be positive"); _Static_assert(LEFT_MARGIN >= 0, "LEFT_MARGIN must be non-negative"); _Static_assert(SECTION_INDENT >= 0, "SECTION_INDENT must be non-negative"); _Static_assert(SUBTASK_INDENT >= 0, "SUBTASK_INDENT must be non-negative"); _Static_assert(WRAP_COLUMN > LEFT_MARGIN + SUBTASK_INDENT + 8, "WRAP_COLUMN too small for the indents"); #define CLEAR_EOL "\x1b[K" #define CURSOR_HOME "\x1b[H" #define CURSOR_SHOW "\x1b[?25h" #define CURSOR_HIDE "\x1b[?25l" #define CURSOR_RESET "\x1b[0 q" #define SCREEN_ENTER "\x1b[?1049h" #define SCREEN_LEAVE "\x1b[?1049l" enum State { STATE_TODO, STATE_DOING, STATE_DONE }; struct Item { enum State state; bool folded; char text[MAX_TEXT]; }; struct List { struct Item items[MAX_ITEMS]; int count; }; enum Mode { MODE_NORMAL, MODE_INSERT, MODE_REPLACE, MODE_COMMAND, MODE_SEARCH, MODE_CONFIRM, MODE_HELP, MODE_VISUAL }; enum Pending { PENDING_NONE, PENDING_RESET, PENDING_CLEAR }; enum Key { KEY_UP = 256, KEY_DOWN, KEY_LEFT, KEY_RIGHT }; struct Editor { int cursor; int scroll; int edit_pos; int count; int anchor; enum Mode mode; char command[MAX_COMMAND]; char search[MAX_COMMAND]; char message[MAX_COMMAND]; }; enum ThemeId { THEME_DEFAULT, THEME_GRUVBOX, THEME_NORD, THEME_MONO, THEME_LIGHT, THEME_CUSTOM }; struct Theme { const char* highlight; const char* highlight_reset; const char* section; const char* section_reset; const char* note; const char* note_reset; const char* done; const char* done_reset; const char* number; const char* number_reset; const char* muted; const char* muted_reset; const char* match; const char* match_reset; const char* important; const char* important_reset; }; static const struct Theme themes[] = { [THEME_DEFAULT] = { .highlight = "\x1b[48;5;235m", .highlight_reset = "\x1b[49m", .section = "\x1b[1m", .section_reset = "\x1b[22m", .note = "\x1b[2;3m", .note_reset = "\x1b[22;23m", .done = "\x1b[2m", .done_reset = "\x1b[22m", .number = "\x1b[2m", .number_reset = "\x1b[22m", .muted = "\x1b[2m", .muted_reset = "\x1b[0m", .match = "\x1b[7m", .match_reset = "\x1b[27m", .important = "\x1b[1m", .important_reset = "\x1b[22m", }, [THEME_GRUVBOX] = { .highlight = "\x1b[48;5;237m", .highlight_reset = "\x1b[49m", .section = "\x1b[1;38;5;214m", .section_reset = "\x1b[22;39m", .note = "\x1b[3;38;5;108m", .note_reset = "\x1b[23;39m", .done = "\x1b[38;5;245m", .done_reset = "\x1b[39m", .number = "\x1b[38;5;243m", .number_reset = "\x1b[39m", .muted = "\x1b[38;5;245m", .muted_reset = "\x1b[39m", .match = "\x1b[30;48;5;214m", .match_reset = "\x1b[39;49m", .important = "\x1b[1;38;5;208m", .important_reset = "\x1b[22;39m", }, [THEME_NORD] = { .highlight = "\x1b[48;5;238m", .highlight_reset = "\x1b[49m", .section = "\x1b[1;38;5;110m", .section_reset = "\x1b[22;39m", .note = "\x1b[3;38;5;103m", .note_reset = "\x1b[23;39m", .done = "\x1b[38;5;240m", .done_reset = "\x1b[39m", .number = "\x1b[38;5;240m", .number_reset = "\x1b[39m", .muted = "\x1b[38;5;109m", .muted_reset = "\x1b[39m", .match = "\x1b[30;48;5;109m", .match_reset = "\x1b[39;49m", .important = "\x1b[1;38;5;110m", .important_reset = "\x1b[22;39m", }, [THEME_MONO] = { .highlight = "\x1b[7m", .highlight_reset = "\x1b[27m", .section = "\x1b[1m", .section_reset = "\x1b[22m", .note = "\x1b[3m", .note_reset = "\x1b[23m", .done = "\x1b[9m", .done_reset = "\x1b[29m", .number = "", .number_reset = "", .muted = "", .muted_reset = "", .match = "\x1b[7m", .match_reset = "\x1b[27m", .important = "\x1b[1m", .important_reset = "\x1b[22m", }, [THEME_LIGHT] = { .highlight = "\x1b[48;5;252m", .highlight_reset = "\x1b[49m", .section = "\x1b[1;38;5;25m", .section_reset = "\x1b[22;39m", .note = "\x1b[3;38;5;66m", .note_reset = "\x1b[23;39m", .done = "\x1b[38;5;245m", .done_reset = "\x1b[39m", .number = "\x1b[38;5;246m", .number_reset = "\x1b[39m", .muted = "\x1b[38;5;244m", .muted_reset = "\x1b[39m", .match = "\x1b[30;48;5;222m", .match_reset = "\x1b[39;49m", .important = "\x1b[1;38;5;124m", .important_reset = "\x1b[22;39m", }, [THEME_CUSTOM] = CUSTOM_THEME, }; static const char* const theme_names[] = { [THEME_DEFAULT] = "default", [THEME_GRUVBOX] = "gruvbox", [THEME_NORD] = "nord", [THEME_MONO] = "mono", [THEME_LIGHT] = "light", [THEME_CUSTOM] = "custom", }; #define THEME_COUNT ((int) (sizeof themes / sizeof themes[0])) static int theme_index = THEME; static const struct Theme* theme = &themes[THEME]; static const char* mode_name(enum Mode mode) { switch (mode) { case MODE_NORMAL: return "NORMAL"; case MODE_INSERT: return "INSERT"; case MODE_REPLACE: return "REPLACE"; case MODE_COMMAND: return "COMMAND"; case MODE_SEARCH: return "SEARCH"; case MODE_CONFIRM: return "CONFIRM"; case MODE_HELP: return "HELP"; case MODE_VISUAL: return "VISUAL"; } return "?"; } static inline bool is_editing(enum Mode mode) { return mode == MODE_INSERT || mode == MODE_REPLACE; } static inline int utf8_width(const char* text, int bytes) { int width = 0; for (int i = 0; i < bytes && text[i] != '\0'; i++) if (((uint8_t) text[i] & 0xC0) != 0x80) width++; return width; } static inline int utf8_left(const char* text, int position) { if (position <= 0) return 0; position--; while (position > 0 && ((uint8_t) text[position] & 0xC0) == 0x80) position--; return position; } static inline int utf8_right(const char* text, int position) { int length = (int) strlen(text); if (position >= length) return length; position++; while (position < length && ((uint8_t) text[position] & 0xC0) == 0x80) position++; return position; } static inline int caret_cols(const char* name, const char* text, int edit_pos) { int offset = (int) (name - text); int byte = edit_pos - offset < 0 ? 0 : edit_pos - offset; return utf8_width(name, byte); } static void todo_path(char* buffer, size_t size) { const char* env_path = getenv("TODO_FILE"); if (env_path != NULL && env_path[0] != '\0') { (void) snprintf(buffer, size, "%s", env_path); return; } const char* home = getenv("HOME"); (void) snprintf(buffer, size, "%s/.todo.txt", home != NULL ? home : "."); } static void strip_newline(char* string) { size_t length = strlen(string); while (length > 0 && (string[length - 1] == '\n' || string[length - 1] == '\r')) string[--length] = '\0'; } static bool dirty; static void load(struct List* list, const char* path) { list->count = 0; FILE* file = fopen(path, "r"); if (file == NULL) return; char line[MAX_TEXT + 8]; while (list->count < MAX_ITEMS && fgets(line, sizeof line, file) != NULL) { strip_newline(line); enum State state = STATE_TODO; const char* text = line; if (line[0] == '[' && line[1] != '\0' && line[2] == ']') { if (line[1] == 'x' || line[1] == 'X') state = STATE_DONE; else if (line[1] == '-') state = STATE_DOING; text = line + 3; while (*text == ' ') text++; } if (*text == '\0') continue; struct Item* item = &list->items[list->count++]; item->state = state; item->folded = false; (void) snprintf(item->text, sizeof item->text, "%s", text); } (void) fclose(file); dirty = false; } static inline bool is_section(const struct Item* item) { return item->text[0] == '='; } static inline bool is_subtask(const struct Item* item) { return item->text[0] == '-'; } static inline bool is_optional(const struct Item* item) { return item->text[0] == '*'; } static inline bool is_note(const struct Item* item) { return item->text[0] == '>'; } static inline bool is_important(const struct Item* item) { return item->text[0] == '!'; } static inline bool is_child(const struct Item* item) { return is_subtask(item) || is_note(item); } static inline bool is_task(const struct Item* item) { return !is_section(item) && !is_child(item); } static inline bool valid_cursor(const struct List* list, int index) { return index >= 0 && index < list->count; } static inline char state_char(enum State state) { return state == STATE_DONE ? 'x' : state == STATE_DOING ? '-' : ' '; } static inline const char* checkbox(enum State state, bool optional) { if (optional) return state == STATE_DONE ? MARK_OPT_DONE : state == STATE_DOING ? MARK_OPT_DOING : MARK_OPT_TODO; return state == STATE_DONE ? MARK_DONE : state == STATE_DOING ? MARK_DOING : MARK_TODO; } static inline int task_group(const struct Item* item) { if (item->state == STATE_DONE) return 2; if (is_optional(item)) return 1; return 0; } static inline int section_level(const struct Item* item) { int level = 0; while (item->text[level] == '=') level++; return level; } static inline int text_start(const struct Item* item) { int i = 0; if (is_section(item)) i = section_level(item); else if (is_subtask(item) || is_optional(item) || is_note(item)) i = 1; while (item->text[i] == ' ') i++; return i; } static void normalize_tasks(struct List* list) { for (int i = 0; i < list->count; i++) { if (!is_task(&list->items[i])) continue; int total = 0; int done = 0; int active = 0; for (int j = i + 1; j < list->count && is_child(&list->items[j]); j++) { if (!is_subtask(&list->items[j])) continue; total++; if (list->items[j].state == STATE_DONE) done++; else if (list->items[j].state == STATE_DOING) active++; } if (total == 0) continue; if (done == total) list->items[i].state = STATE_DONE; else if (done > 0 || active > 0) list->items[i].state = STATE_DOING; else list->items[i].state = STATE_TODO; } } static void sort_list(struct List* list) { struct Item* sorted = malloc((size_t) list->count * sizeof *sorted); if (sorted == NULL) return; int out = 0; int i = 0; while (i < list->count) { if (is_section(&list->items[i])) { sorted[out++] = list->items[i]; i++; continue; } int segment = i; while (segment < list->count && !is_section(&list->items[segment])) segment++; for (int group = 0; group < 3; group++) { int k = i; while (k < segment) { int block = k + 1; while (block < segment && is_child(&list->items[block])) block++; if (task_group(&list->items[k]) == group) { sorted[out++] = list->items[k]; int head = k + 1; while (head < block && is_note(&list->items[head])) sorted[out++] = list->items[head++]; for (int sub_pass = 0; sub_pass < 2; sub_pass++) { int unit = head; while (unit < block) { int unit_end = unit + 1; while (unit_end < block && is_note(&list->items[unit_end])) unit_end++; if ((list->items[unit].state == STATE_DONE) == sub_pass) for (int s = unit; s < unit_end; s++) sorted[out++] = list->items[s]; unit = unit_end; } } } k = block; } } i = segment; } for (int x = 0; x < list->count; x++) list->items[x] = sorted[x]; free(sorted); } static int task_block_end(const struct List* list, int start) { int i = start + 1; while (i < list->count && is_child(&list->items[i])) i++; return i; } static int item_block_end(const struct List* list, int start) { if (start >= 0 && start < list->count && is_task(&list->items[start])) return task_block_end(list, start); return start + 1; } static int block_end(const struct List* list, int start) { int i = start + 1; while (i < list->count && !is_section(&list->items[i])) i++; return i; } static void section_counts(const struct List* list, int index, int* done, int* total) { int level = section_level(&list->items[index]); *done = 0; *total = 0; for (int i = index + 1; i < list->count; i++) { if (is_section(&list->items[i])) { if (section_level(&list->items[i]) <= level) break; continue; } if (!is_task(&list->items[i])) continue; (*total)++; *done += list->items[i].state == STATE_DONE; } } static int section_of(const struct List* list, int index) { for (int i = index; i >= 0; i--) if (is_section(&list->items[i])) return i; return -1; } static int fold_root(const struct List* list, int index) { for (int k = 0; k < index; k++) { if (!is_section(&list->items[k]) || !list->items[k].folded) continue; int level = section_level(&list->items[k]); int end = k + 1; while (end < list->count && !(is_section(&list->items[end]) && section_level(&list->items[end]) <= level)) end++; if (index < end) return k; } return -1; } static bool hidden(const struct List* list, int index) { return fold_root(list, index) >= 0; } static int next_visible(const struct List* list, int index) { for (int i = index + 1; i < list->count; i++) if (!hidden(list, i)) return i; return -1; } static int prev_visible(const struct List* list, int index) { for (int i = index - 1; i >= 0; i--) if (!hidden(list, i)) return i; return -1; } static void archive_path(const char* path, char* buf, size_t size) { const char* env = getenv("TODO_ARCHIVE"); if (env != NULL && env[0] != '\0') { (void) snprintf(buf, size, "%s", env); return; } size_t len = strlen(path); if (len > 4 && strcmp(path + len - 4, ".txt") == 0) (void) snprintf(buf, size, "%.*s.archive.txt", (int) (len - 4), path); else (void) snprintf(buf, size, "%s.archive", path); } static int archive_done(struct List* list, const char* path) { normalize_tasks(list); char apath[4096]; archive_path(path, apath, sizeof apath); FILE* file = fopen(apath, "a"); if (file == NULL) return -1; bool ok = true; int archived = 0; for (int i = 0; i < list->count; ) { int end = item_block_end(list, i); if (is_task(&list->items[i]) && list->items[i].state == STATE_DONE) for (int j = i; j < end; j++) { const struct Item* it = &list->items[j]; int written; if (is_section(it) || is_note(it)) written = fprintf(file, "%s\n", it->text); else written = fprintf(file, "[%c] %s\n", state_char(it->state), it->text); if (written < 0) ok = false; archived++; } i = end; } if (ok && fflush(file) != 0) ok = false; (void) fsync(fileno(file)); if (fclose(file) != 0) ok = false; if (!ok) return -1; int kept = 0; for (int i = 0; i < list->count; ) { int end = item_block_end(list, i); if (!(is_task(&list->items[i]) && list->items[i].state == STATE_DONE)) for (int j = i; j < end; j++) list->items[kept++] = list->items[j]; i = end; } list->count = kept; return archived; } static void reverse_items(struct List* list, int low, int high) { while (low < high - 1) { struct Item moved = list->items[low]; list->items[low] = list->items[high - 1]; list->items[high - 1] = moved; low++; high--; } } static int save(struct List* list, const char* path) { normalize_tasks(list); char tmp[4096]; (void) snprintf(tmp, sizeof tmp, "%s.tmp", path); FILE* file = fopen(tmp, "w"); if (file == NULL) return -1; bool ok = true; for (int i = 0; i < list->count; i++) { int written; if (is_section(&list->items[i]) || is_note(&list->items[i])) written = fprintf(file, "%s\n", list->items[i].text); else written = fprintf(file, "[%c] %s\n", state_char(list->items[i].state), list->items[i].text); if (written < 0) ok = false; } if (ok && fflush(file) != 0) ok = false; (void) fsync(fileno(file)); if (fclose(file) != 0) ok = false; if (ok && rename(tmp, path) != 0) ok = false; if (!ok) { (void) remove(tmp); return -1; } dirty = false; return 0; } static void commit(struct List* list) { normalize_tasks(list); sort_list(list); } static void apply_prose(struct List* list, int skip) { if (!PROSE) return; for (int i = 0; i < list->count; i++) { if (i == skip) continue; struct Item* item = &list->items[i]; int len = (int) strlen(item->text); for (int c = 0; c < len; c++) { uint8_t ch = (uint8_t) item->text[c]; if (ch >= 'a' && ch <= 'z') { item->text[c] = (char) (ch - 32); break; } if ((ch >= 'A' && ch <= 'Z') || ch >= 0x80) break; } if (is_section(item) || is_important(item) || len == 0) continue; char last = item->text[len - 1]; if (last != '.' && last != '!' && last != '?' && last != ';' && last != ':' && len + 1 < (int) sizeof item->text) { item->text[len] = '.'; item->text[len + 1] = '\0'; } } } struct Snapshot { struct Item* items; int count; }; static struct Snapshot undo_stack[MAX_HISTORY]; static struct Snapshot redo_stack[MAX_HISTORY]; static int undo_count; static int redo_count; static uint64_t edits; static struct Snapshot snapshot_take(const struct List* list) { struct Snapshot snap; snap.count = list->count; snap.items = malloc((size_t) list->count * sizeof *snap.items); if (snap.items != NULL) memcpy(snap.items, list->items, (size_t) list->count * sizeof *snap.items); return snap; } static void snapshot_apply(struct List* list, const struct Snapshot* snap) { list->count = snap->count; memcpy(list->items, snap->items, (size_t) snap->count * sizeof *snap->items); } static void stack_push(struct Snapshot* stack, int* count, struct Snapshot snap) { if (*count == MAX_HISTORY) { free(stack[0].items); memmove(&stack[0], &stack[1], (size_t) (MAX_HISTORY - 1) * sizeof *stack); (*count)--; } stack[(*count)++] = snap; } static void stack_clear(struct Snapshot* stack, int* count) { while (*count > 0) free(stack[--(*count)].items); } static void history_record(const struct List* list) { dirty = true; edits++; struct Snapshot snap = snapshot_take(list); if (snap.items == NULL) return; stack_push(undo_stack, &undo_count, snap); stack_clear(redo_stack, &redo_count); } static void history_undo(struct List* list) { if (undo_count == 0) return; stack_push(redo_stack, &redo_count, snapshot_take(list)); struct Snapshot snap = undo_stack[--undo_count]; snapshot_apply(list, &snap); free(snap.items); dirty = true; } static void history_redo(struct List* list) { if (redo_count == 0) return; stack_push(undo_stack, &undo_count, snapshot_take(list)); struct Snapshot snap = redo_stack[--redo_count]; snapshot_apply(list, &snap); free(snap.items); dirty = true; } static void insert_item(struct List* list, int index); static struct Item yank_register[MAX_ITEMS]; static int yank_count; static enum Pending pending_action; static char replace_original[MAX_TEXT]; static int replace_start; static void paste_item(struct List* list, struct Editor* editor, int index) { if (yank_count <= 0 || list->count + yank_count > MAX_ITEMS) return; if (index < 0) index = 0; if (index > list->count) index = list->count; history_record(list); for (int i = 0; i < yank_count; i++) insert_item(list, index + i); for (int i = 0; i < yank_count; i++) list->items[index + i] = yank_register[i]; editor->cursor = index; } static inline char lower(char character) { return character >= 'A' && character <= 'Z' ? (char) (character + 32) : character; } static bool contains_fold(const char* haystack, const char* needle) { if (needle[0] == '\0') return false; for (; *haystack != '\0'; haystack++) { const char* h = haystack; const char* n = needle; while (*h != '\0' && *n != '\0' && lower(*h) == lower(*n)) { h++; n++; } if (*n == '\0') return true; } return false; } static int match_at(const char* text, int position, const char* needle) { int k = 0; while (needle[k] != '\0' && text[position + k] != '\0' && lower(text[position + k]) == lower(needle[k])) k++; return needle[k] == '\0' ? k : 0; } static int match_offset(const char* text, const char* needle) { if (needle[0] == '\0') return -1; for (int i = 0; text[i] != '\0'; i++) if (match_at(text, i, needle) > 0) return i; return -1; } static int find_match(const struct List* list, int from, int direction, const char* query) { if (list->count == 0 || query[0] == '\0') return -1; for (int step = 1; step <= list->count; step++) { int index = ((from + direction * step) % list->count + list->count) % list->count; if (contains_fold(list->items[index].text, query)) return index; } return -1; } static void goto_match(const struct List* list, struct Editor* editor, int direction) { int match = find_match(list, editor->cursor, direction, editor->search); if (match < 0) return; editor->cursor = match; int offset = match_offset(list->items[match].text, editor->search); if (offset >= 0) editor->edit_pos = offset; } static void insert_item(struct List* list, int index) { if (list->count >= MAX_ITEMS) return; if (index < 0) index = 0; if (index > list->count) index = list->count; for (int i = list->count; i > index; i--) list->items[i] = list->items[i - 1]; list->items[index].state = STATE_TODO; list->items[index].folded = false; list->items[index].text[0] = '\0'; list->count++; } static void delete_item(struct List* list, int index) { if (index < 0 || index >= list->count) return; for (int i = index; i < list->count - 1; i++) list->items[i] = list->items[i + 1]; list->count--; } static struct termios saved_termios; static void restore_terminal(void) { static const char reset[] = CURSOR_SHOW CURSOR_RESET SCREEN_LEAVE; (void) tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved_termios); (void) write(STDOUT_FILENO, reset, sizeof reset - 1); } static void on_signal(int signal_number) { (void) signal_number; restore_terminal(); _exit(EXIT_SUCCESS); } static int enter_raw_mode(void) { if (tcgetattr(STDIN_FILENO, &saved_termios) != 0) return -1; struct termios raw = saved_termios; raw.c_lflag &= ~((tcflag_t) (ICANON | ECHO | ISIG)); raw.c_cc[VMIN] = 1; raw.c_cc[VTIME] = 0; if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) != 0) return -1; (void) atexit(restore_terminal); (void) signal(SIGINT, on_signal); (void) signal(SIGTERM, on_signal); (void) write(STDOUT_FILENO, SCREEN_ENTER CURSOR_HIDE, 14); return 0; } static int screen_rows(void) { struct winsize window; if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &window) == 0 && window.ws_row > 0) return window.ws_row; return 24; } static int screen_cols(void) { struct winsize window; if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &window) == 0 && window.ws_col > 0) return window.ws_col; return 80; } static int content_rows(void) { int rows = screen_rows() - 4; return rows < 1 ? 1 : rows; } struct Line { const char* background; const char* background_reset; const char* prefix; int prefix_cols; int first_col; int cols; const char* style; const char* style_reset; const char* text; const char* search; bool is_cursor; int caret; bool upper; }; static int wrap_lines(const char* name, int cols, int first_col, int cont_col, int* starts, int* ends, int max_lines); static inline int display_width(const char* name, int from, int to); struct Out { char* buffer; int length; int cap; }; static void append(struct Out* out, const char* format, ...) { if (out->length >= out->cap) return; va_list args; va_start(args, format); int written = vsnprintf(out->buffer + out->length, (size_t) (out->cap - out->length), format, args); va_end(args); if (written > 0) out->length += written; if (out->length > out->cap) out->length = out->cap; } static void emit_wrapped(struct Out* out, const struct Line* spec, int* emitted, int visible, int* cursor_row, int* cursor_column) { const char* background = spec->background; const char* background_reset = spec->background_reset; const char* prefix = spec->prefix; int prefix_cols = spec->prefix_cols; int first_col = spec->first_col; int cols = spec->cols; const char* style = spec->style; const char* style_reset = spec->style_reset; const char* text = spec->text; const char* search = spec->search; bool is_cursor = spec->is_cursor; int caret_col = spec->caret; bool upper = spec->upper; int total = (int) strlen(text); char mask[MAX_TEXT]; memset(mask, 0, sizeof mask); if (search != NULL && search[0] != '\0') for (int i = 0; i < total && i < MAX_TEXT; i++) { int m = match_at(text, i, search); for (int k = 0; k < m && i + k < MAX_TEXT; k++) mask[i + k] = 1; } int starts[MAX_TEXT]; int ends[MAX_TEXT]; int lines = wrap_lines(text, cols, first_col, prefix_cols, starts, ends, MAX_TEXT); for (int line = 0; line < lines && *emitted < visible; line++) { int base = line == 0 ? first_col : prefix_cols; int start = starts[line]; int end = ends[line]; if (line == 0) append(out, "%s%s%s", background, prefix, style); else append(out, "%s%*s%s", background, prefix_cols, "", style); for (int i = start; i < end;) { int j = i; while (j < end && mask[j] == mask[i]) j++; const char* chunk = text + i; char upper_buf[MAX_TEXT]; if (upper) { for (int k = 0; k < j - i; k++) { char c = text[i + k]; upper_buf[k] = c >= 'a' && c <= 'z' ? (char) (c - 32) : c; } chunk = upper_buf; } if (mask[i]) append(out, "%s%.*s%s", theme->match, j - i, chunk, theme->match_reset); else append(out, "%.*s", j - i, chunk); i = j; } if (upper && line == lines - 1) append(out, "!"); append(out, "%s" CLEAR_EOL "%s\r\n", style_reset, background_reset); if (is_cursor && *cursor_row < 0) { int line_start_col = display_width(text, 0, start); int width = display_width(text, start, end); if (caret_col >= line_start_col && caret_col <= line_start_col + width) { *cursor_row = 4 + *emitted; *cursor_column = base + 1 + caret_col - line_start_col; } } (*emitted)++; } } static void to_roman(int number, char* buffer, size_t size) { static const int values[] = { 1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1 }; static const char* const symbols[] = { "M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I" }; size_t length = 0; buffer[0] = '\0'; for (int i = 0; i < 13; i++) while (number >= values[i] && length + 2 < size) { length += (size_t) snprintf(buffer + length, size - length, "%s", symbols[i]); number -= values[i]; } } static int wrap_column(void) { int cols = screen_cols(); return cols > WRAP_COLUMN ? WRAP_COLUMN : cols; } static int compute_number_width(const struct List* list) { int max_run = 0; int run = 0; for (int i = 0; i < list->count; i++) { if (is_section(&list->items[i])) run = 0; else if (!is_child(&list->items[i]) && ++run > max_run) max_run = run; } int number_width = 1; for (int count = max_run; count >= 10; count /= 10) number_width++; return number_width; } static void build_line(const struct List* list, int index, int number_width, bool editing, char* prefix, size_t prefix_size, struct Line* line); static int render_help(int measure) { static const struct { const char* label; const char* body; } entries[] = { { "move", "h j k l 0 $ line w b word { } section gg G" }, { "edit", "i insert a / A append o new R replace" }, { "", "x delete char dd cut item D clear done" }, { "", "cw ce cb c0 c$ cc (also d: dw de db ...)" }, { "", "ct cf ci ca \"'>" }, { "", ". repeat the last change" }, { "", ">> << demote / promote (task<->subtask, = depth)" }, { "state", "space cycle todo/doing/done yy p P yank/paste" }, { "", "J K move item or section" }, { "visual","V select lines then d y space" }, { "fold", "za toggle section zo open zc close" }, { "find", "/ n N esc clears the highlight" }, { "undo", "u ctrl-r" }, { "cmd", ":w :q :q! :wq :x :theme NAME :help" }, { "", ":reset :clear :reorder :archive" }, { "items", "= section == subsection - subtask" }, { "", "* optional > note ! important" }, }; static const char* const rule = "\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80" "\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80" "\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80" "\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80"; char buffer[8 * 1024]; struct Out out = { buffer, 0, (int) sizeof buffer }; int rows = screen_rows(); int count = (int) (sizeof entries / sizeof entries[0]); append(&out, CURSOR_HOME CURSOR_HIDE); for (int row = 0; row < rows; row++) { if (row == 1) append(&out, " %stodo%s %skeys%s", theme->important, theme->important_reset, theme->muted, theme->muted_reset); else if (row == 2) append(&out, " %s%s%s", theme->muted, rule, theme->muted_reset); else if (row >= 4 && row - 4 < count) append(&out, " %s%-7s%s%s", theme->section, entries[row - 4].label, theme->section_reset, entries[row - 4].body); else if (row == 4 + count + 1) append(&out, " %spress any key to return%s", theme->muted, theme->muted_reset); append(&out, CLEAR_EOL); if (row < rows - 1) append(&out, "\r\n"); } if (!measure) (void) write(STDOUT_FILENO, buffer, (size_t) out.length); return -1; } static int render(const struct List* list, const struct Editor* editor, int measure) { enum Mode mode = editor->mode; if (mode == MODE_HELP) return render_help(measure); int cursor = editor->cursor; int scroll = editor->scroll; int edit_pos = editor->edit_pos; const char* command = editor->command; int rows = screen_rows(); int cols = wrap_column(); int visible = content_rows(); const char* matches = mode == MODE_SEARCH ? command : editor->search; int sel_lo = -1; int sel_hi = -1; if (mode == MODE_VISUAL) { sel_lo = editor->anchor < cursor ? editor->anchor : cursor; sel_hi = editor->anchor < cursor ? cursor : editor->anchor; } int total_items = 0; int done = 0; for (int i = 0; i < list->count; i++) if (is_task(&list->items[i])) { total_items++; done += list->items[i].state == STATE_DONE; } int number_width = compute_number_width(list); char date[64]; char clock[32]; time_t now = time(NULL); const struct tm* local = localtime(&now); if (local == NULL || strftime(date, sizeof date, DATE_FORMAT, local) == 0) date[0] = '\0'; if (local == NULL || strftime(clock, sizeof clock, TIME_FORMAT, local) == 0) clock[0] = '\0'; char buffer[64 * 1024]; struct Out out = { buffer, 0, (int) sizeof buffer }; append(&out, CURSOR_HOME "%*s%s%s%s" CLEAR_EOL "\r\n%*s%s%s%s" CLEAR_EOL "\r\n" CLEAR_EOL "\r\n", LEFT_MARGIN, "", theme->muted, date, theme->muted_reset, LEFT_MARGIN, "", theme->muted, clock, theme->muted_reset); int emitted = 0; int index = scroll; int prev_group = -1; int cursor_row = -1; int cursor_column = -1; while (emitted < visible && index < list->count) { const struct Item* item = &list->items[index]; if (hidden(list, index)) { index++; continue; } bool selected = index == cursor || (sel_lo >= 0 && index >= sel_lo && index <= sel_hi); const char* background = selected ? theme->highlight : ""; const char* background_reset = selected ? theme->highlight_reset : ""; if (is_section(item)) { if (emitted > 0) { append(&out, CLEAR_EOL "\r\n"); emitted++; if (emitted >= visible) break; } prev_group = -1; if (index == cursor) cursor_row = 4 + emitted; int level = section_level(item); int section_indent = LEFT_MARGIN + (level - 1) * SECTION_INDENT; if (is_editing(mode) && index == cursor) { append(&out, "%s%*s%s%s%s" CLEAR_EOL "%s\r\n", background, section_indent, "", theme->section, item->text, theme->section_reset, background_reset); } else { int section_number = 0; for (int k = index; k >= 0; k--) if (is_section(&list->items[k])) { int other = section_level(&list->items[k]); if (other == level) section_number++; else if (other < level) break; } char roman[16]; to_roman(section_number, roman, sizeof roman); if (level >= 2) for (char* letter = roman; *letter != '\0'; letter++) if (*letter >= 'A' && *letter <= 'Z') *letter = (char) (*letter + 32); const char* name = item->text + level; while (*name == ' ') name++; char progress[64]; int n = 0; int section_done, section_total; section_counts(list, index, §ion_done, §ion_total); progress[0] = '\0'; if (section_total > 0) n += snprintf(progress + n, sizeof progress - (size_t) n, " %s(%d/%d)%s", theme->muted, section_done, section_total, theme->muted_reset); if (item->folded) (void) snprintf(progress + n, sizeof progress - (size_t) n, " %s\xe2\x96\xb8%s", theme->muted, theme->muted_reset); append(&out, "%s%*s%s%s. %s%s%s" CLEAR_EOL "%s\r\n", background, section_indent, "", theme->section, roman, name, theme->section_reset, progress, background_reset); } emitted++; index++; continue; } if (is_task(item)) { int group = task_group(item); if (prev_group >= 0 && group != prev_group) { append(&out, CLEAR_EOL "\r\n"); emitted++; if (emitted >= visible) break; } prev_group = group; } char prefix[48]; struct Line line = { 0 }; build_line(list, index, number_width, is_editing(mode) && index == cursor, prefix, sizeof prefix, &line); line.cols = cols; line.background = background; line.background_reset = background_reset; line.search = matches; line.is_cursor = index == cursor; line.caret = index == cursor ? caret_cols(line.text, item->text, edit_pos) : 0; emit_wrapped(&out, &line, &emitted, visible, &cursor_row, &cursor_column); index++; } while (emitted < visible) { append(&out, CLEAR_EOL "\r\n"); emitted++; } if (mode == MODE_COMMAND || mode == MODE_SEARCH) append(&out, "\x1b[%d;1H%*s%c%s" CLEAR_EOL, rows, LEFT_MARGIN, "", mode == MODE_SEARCH ? '/' : ':', command); else if (mode == MODE_CONFIRM) append(&out, "\x1b[%d;1H%*s%s [y/n]" CLEAR_EOL, rows, LEFT_MARGIN, "", pending_action == PENDING_CLEAR ? "clear the whole list?" : "reset all items to todo?"); else if (editor->message[0] != '\0') append(&out, "\x1b[%d;1H%*s%s%s%s" CLEAR_EOL, rows, LEFT_MARGIN, "", theme->muted, editor->message, theme->muted_reset); else append(&out, "\x1b[%d;1H%*s%s[%s] %d/%d done%s" CLEAR_EOL, rows, LEFT_MARGIN, "", theme->muted, mode_name(mode), done, total_items, theme->muted_reset); if (mode == MODE_COMMAND || mode == MODE_SEARCH) { cursor_column = LEFT_MARGIN + 2 + (int) strlen(command); append(&out, "\x1b[%d;%dH" CURSOR_INSERT CURSOR_SHOW, rows, cursor_column); } else if (mode != MODE_CONFIRM && valid_cursor(list, cursor) && cursor_row >= 0) { if (is_section(&list->items[cursor])) { int level = section_level(&list->items[cursor]); int section_indent = LEFT_MARGIN + (level - 1) * SECTION_INDENT; int caret = utf8_width(list->items[cursor].text, edit_pos); cursor_column = is_editing(mode) ? section_indent + 1 + caret : section_indent + 1; } const char* shape = mode == MODE_REPLACE ? CURSOR_REPLACE : mode == MODE_INSERT ? CURSOR_INSERT : CURSOR_NORMAL; if (cursor_column >= 0) append(&out, "\x1b[%d;%dH%s" CURSOR_SHOW, cursor_row, cursor_column, shape); } else { append(&out, CURSOR_HIDE); } if (!measure) (void) write(STDOUT_FILENO, buffer, (size_t) out.length); return cursor_row; } #define DOT_CAP 512 static int pending_key = -1; static int dot_record[DOT_CAP]; static int dot_record_len; static int rec_buf[DOT_CAP]; static int rec_len; static bool recording; static int replay_buf[DOT_CAP]; static int replay_len; static int replay_pos = -1; static void unget_key(int key) { pending_key = key; if (recording && rec_len > 0) rec_len--; } static int read_key_raw(void) { char character; if (read(STDIN_FILENO, &character, 1) != 1) return -1; if (character != 27) return (uint8_t) character; int flags = fcntl(STDIN_FILENO, F_GETFL); (void) fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK); char first; if (read(STDIN_FILENO, &first, 1) != 1) { (void) fcntl(STDIN_FILENO, F_SETFL, flags); return 27; } if (first == '[') { char second; ssize_t got = read(STDIN_FILENO, &second, 1); (void) fcntl(STDIN_FILENO, F_SETFL, flags); if (got == 1) switch (second) { case 'A': return KEY_UP; case 'B': return KEY_DOWN; case 'C': return KEY_RIGHT; case 'D': return KEY_LEFT; } return 27; } (void) fcntl(STDIN_FILENO, F_SETFL, flags); pending_key = (uint8_t) first; return 27; } static int read_key(void) { int key; if (pending_key != -1) { key = pending_key; pending_key = -1; } else if (replay_pos >= 0 && replay_pos < replay_len) { key = replay_buf[replay_pos++]; } else { replay_pos = -1; key = read_key_raw(); } if (recording && key != -1) { if (rec_len < DOT_CAP) rec_buf[rec_len++] = key; else recording = false; } return key; } static bool is_change_start(int key) { switch (key) { case 'x': case 'd': case 'c': case 'o': case 'i': case 'A': case 'R': case 'p': case 'P': case 'D': case 'J': case 'K': case ' ': case '>': case '<': return true; } return false; } static inline const char* strip_marker(const char* text) { const char* name = text + 1; while (*name == ' ') name++; return name; } static int base_indent_of(const struct List* list, int index) { for (int k = index - 1; k >= 0; k--) if (is_section(&list->items[k])) return LEFT_MARGIN + section_level(&list->items[k]) * SECTION_INDENT; return LEFT_MARGIN; } static void build_line(const struct List* list, int index, int number_width, bool editing, char* prefix, size_t prefix_size, struct Line* line) { const struct Item* item = &list->items[index]; int base = base_indent_of(list, index); line->style = ""; line->style_reset = ""; line->prefix = prefix; line->upper = false; if (is_note(item)) { int indent = base + number_width + 6; line->text = item->text; line->first_col = indent; line->prefix_cols = indent + 2; line->style = theme->note; line->style_reset = theme->note_reset; (void) snprintf(prefix, prefix_size, "%*s", indent, ""); return; } if (item->state == STATE_DONE) { line->style = theme->done; line->style_reset = theme->done_reset; } if (is_subtask(item)) { int indent = base + number_width + SUBTASK_INDENT; const char* box = checkbox(item->state, 0); line->text = editing ? item->text : strip_marker(item->text); line->first_col = line->prefix_cols = indent + utf8_width(box, (int) strlen(box)) + 1; (void) snprintf(prefix, prefix_size, "%*s%s ", indent, "", box); return; } int number = 0; for (int k = index; k >= 0 && !is_section(&list->items[k]); k--) if (!is_child(&list->items[k])) number++; const char* box = checkbox(item->state, is_optional(item)); line->text = is_optional(item) && !editing ? strip_marker(item->text) : item->text; line->upper = is_important(item) && !editing; if (line->upper) { line->style = theme->important; line->style_reset = theme->important_reset; } line->first_col = line->prefix_cols = base + number_width + utf8_width(box, (int) strlen(box)) + 3; (void) snprintf(prefix, prefix_size, "%*s%s%*d.%s %s ", base, "", theme->number, number_width, number, theme->number_reset, box); } static int wrap_lines(const char* name, int cols, int first_col, int cont_col, int* starts, int* ends, int max_lines) { int total = (int) strlen(name); int byte = 0; int line = 0; do { while (line > 0 && byte < total && name[byte] == ' ') byte++; if (line > 0 && byte >= total) break; int base = line == 0 ? first_col : cont_col; int avail = cols - base; if (avail < 1) avail = 1; int end = byte; int width = 0; int space_pos = -1; while (end < total && width < avail) { if (name[end] == ' ') space_pos = end; end++; while (end < total && ((uint8_t) name[end] & 0xC0) == 0x80) end++; width++; } if (end < total && name[end] != ' ' && space_pos > byte) end = space_pos; if (line < max_lines) { starts[line] = byte; ends[line] = end; } byte = end; line++; } while (byte < total); if (line == 0) { if (max_lines > 0) { starts[0] = 0; ends[0] = 0; } line = 1; } return line; } static inline int display_width(const char* name, int from, int to) { int width = 0; for (int i = from; i < to; i++) if (((uint8_t) name[i] & 0xC0) != 0x80) width++; return width; } static int offset_at_width(const char* name, int start, int end, int want) { int i = start; int width = 0; while (i < end && width < want) { i++; while (i < end && ((uint8_t) name[i] & 0xC0) == 0x80) i++; width++; } return i; } static void place_on_line(const struct List* list, int index, int number_width, int cols, int last, int goal, int* edit_pos) { if (is_section(&list->items[index])) { *edit_pos = text_start(&list->items[index]); return; } char prefix[48]; struct Line descriptor; build_line(list, index, number_width, 0, prefix, sizeof prefix, &descriptor); const char* name = descriptor.text; int first_col = descriptor.first_col; int cont_col = descriptor.prefix_cols; int name_offset = (int) (name - list->items[index].text); int starts[MAX_TEXT]; int ends[MAX_TEXT]; int lines = wrap_lines(name, cols, first_col, cont_col, starts, ends, MAX_TEXT); int line = last ? lines - 1 : 0; int base = line == 0 ? first_col : cont_col; int want = goal - base; if (want < 0) want = 0; *edit_pos = name_offset + offset_at_width(name, starts[line], ends[line], want); } static void move_visual(struct List* list, struct Editor* editor, int number_width, int cols, int down) { int* cursor = &editor->cursor; int* edit_pos = &editor->edit_pos; if (!valid_cursor(list, *cursor)) return; const struct Item* item = &list->items[*cursor]; int goal = 0; if (!is_section(item)) { char prefix[48]; struct Line descriptor; build_line(list, *cursor, number_width, 0, prefix, sizeof prefix, &descriptor); const char* name = descriptor.text; int first_col = descriptor.first_col; int cont_col = descriptor.prefix_cols; int name_offset = (int) (name - item->text); int name_length = (int) strlen(name); int starts[MAX_TEXT]; int ends[MAX_TEXT]; int lines = wrap_lines(name, cols, first_col, cont_col, starts, ends, MAX_TEXT); int position = *edit_pos - name_offset; if (position < 0) position = 0; if (position > name_length) position = name_length; int line = 0; for (int i = 0; i < lines; i++) if (starts[i] <= position) line = i; int base = line == 0 ? first_col : cont_col; goal = base + display_width(name, starts[line], position); if (down && line < lines - 1) { int want = goal - cont_col; if (want < 0) want = 0; *edit_pos = name_offset + offset_at_width(name, starts[line + 1], ends[line + 1], want); return; } if (!down && line > 0) { int target = line - 1; int target_base = target == 0 ? first_col : cont_col; int want = goal - target_base; if (want < 0) want = 0; *edit_pos = name_offset + offset_at_width(name, starts[target], ends[target], want); return; } } if (down) { int next = next_visible(list, *cursor); if (next >= 0) { *cursor = next; place_on_line(list, *cursor, number_width, cols, 0, goal, edit_pos); } } else { int prev = prev_visible(list, *cursor); if (prev >= 0) { *cursor = prev; place_on_line(list, *cursor, number_width, cols, 1, goal, edit_pos); } } } static inline int matching_close(int open) { switch (open) { case '(': return ')'; case '[': return ']'; case '{': return '}'; case '<': return '>'; } return 0; } static inline int matching_open(int close) { switch (close) { case ')': return '('; case ']': return '['; case '}': return '{'; case '>': return '<'; } return 0; } static void apply_change(struct List* list, struct Editor* editor, int from, int to) { struct Item* item = &list->items[editor->cursor]; int length = (int) strlen(item->text); if (to > length) to = length; if (from < 0 || to < from) return; history_record(list); memmove(&item->text[from], &item->text[to], (size_t) (length - to) + 1); editor->edit_pos = from; editor->mode = MODE_INSERT; } static void delete_text(struct List* list, struct Editor* editor, int from, int to) { struct Item* item = &list->items[editor->cursor]; int length = (int) strlen(item->text); if (to > length) to = length; if (from < 0 || to <= from) return; history_record(list); memmove(&item->text[from], &item->text[to], (size_t) (length - to) + 1); editor->edit_pos = from; } static int motion_range(struct List* list, const struct Editor* editor, int op, int* from, int* to) { int cursor = editor->cursor; int edit_pos = editor->edit_pos; int count = editor->count > 0 ? editor->count : 1; const char* text = list->items[cursor].text; int length = (int) strlen(text); int low = text_start(&list->items[cursor]); *from = edit_pos; *to = edit_pos; if (op == 'w') { int position = edit_pos; for (int r = 0; r < count; r++) { while (position < length && text[position] != ' ') position++; while (position < length && text[position] == ' ') position++; } *to = position; return 1; } if (op == 'e') { int position = edit_pos; for (int r = 0; r < count; r++) { while (position < length && text[position] == ' ') position++; while (position < length && text[position] != ' ') position++; } *to = position; return 1; } if (op == 'b') { int position = edit_pos; for (int r = 0; r < count; r++) { if (position > low) position--; while (position > low && text[position] == ' ') position--; while (position > low && text[position - 1] != ' ') position--; } *from = position; return 1; } if (op == '$') { *to = length; return 1; } if (op == '0') { *from = low; return 1; } if (op == 'l') { for (int r = 0; r < count; r++) *to = utf8_right(text, *to); return 1; } if (op == 'h') { for (int r = 0; r < count; r++) *from = utf8_left(text, *from); return 1; } if (op != 't' && op != 'f' && op != 'i' && op != 'a') { unget_key(op); return 0; } int target = read_key(); if (target < 32 || target >= 256) return 0; if (op == 't' || op == 'f') { int found = -1; for (int i = edit_pos + 1; i < length; i++) if ((uint8_t) text[i] == target) { found = i; break; } if (found < 0) return 0; *from = edit_pos; *to = op == 'f' ? found + 1 : found; return 1; } int open_char = target; int close_char = matching_close(target); if (close_char == 0) { int open = matching_open(target); if (open != 0) open_char = open; close_char = target; } int open_at = -1; int close_at = -1; for (int i = edit_pos; i >= low; i--) if ((uint8_t) text[i] == open_char) { open_at = i; break; } if (open_at >= 0) { for (int i = open_at + 1; i < length; i++) if ((uint8_t) text[i] == close_char) { close_at = i; break; } if (close_at < edit_pos) { open_at = -1; close_at = -1; } } if (open_at < 0) { for (int i = edit_pos; i < length; i++) if ((uint8_t) text[i] == open_char) { open_at = i; break; } for (int i = open_at + 1; open_at >= 0 && i < length; i++) if ((uint8_t) text[i] == close_char) { close_at = i; break; } } if (open_at < 0 || close_at < 0) return 0; if (op == 'i') { *from = open_at + 1; *to = close_at; } else { *from = open_at; *to = close_at + 1; } return 1; } static int normal_key(struct List* list, struct Editor* editor, int key) { int* cursor = &editor->cursor; int* edit_pos = &editor->edit_pos; enum Mode* mode = &editor->mode; char* command = editor->command; char* search = editor->search; int count = editor->count > 0 ? editor->count : 1; switch (key) { case 27: search[0] = '\0'; break; case ':': { command[0] = '\0'; *mode = MODE_COMMAND; } break; case '/': { command[0] = '\0'; *mode = MODE_SEARCH; } break; case 'n': goto_match(list, editor, 1); break; case 'N': goto_match(list, editor, -1); break; case 'y': { int next = read_key(); if (next != 'y') { unget_key(next); break; } if (valid_cursor(list, *cursor)) { int start = *cursor; int end = item_block_end(list, start); yank_count = 0; for (int i = start; i < end && yank_count < MAX_ITEMS; i++) yank_register[yank_count++] = list->items[i]; } } break; case 'p': paste_item(list, editor, item_block_end(list, *cursor)); break; case 'P': paste_item(list, editor, *cursor); break; case 'j': case KEY_DOWN: { int number_width = compute_number_width(list); int cols = wrap_column(); for (int repeat = 0; repeat < count; repeat++) move_visual(list, editor, number_width, cols, 1); } break; case 'k': case KEY_UP: { int number_width = compute_number_width(list); int cols = wrap_column(); for (int repeat = 0; repeat < count; repeat++) move_visual(list, editor, number_width, cols, 0); } break; case 'h': case KEY_LEFT: case 'l': case KEY_RIGHT: case '0': case '$': case 'w': case 'b': if (valid_cursor(list, *cursor)) { int op = key == KEY_LEFT ? 'h' : key == KEY_RIGHT ? 'l' : key; int from; int to; (void) motion_range(list, editor, op, &from, &to); *edit_pos = op == 'h' || op == '0' || op == 'b' ? from : to; } break; case 'u': history_undo(list); break; case 18: history_redo(list); break; case ' ': if (valid_cursor(list, *cursor) && !is_section(&list->items[*cursor]) && !is_note(&list->items[*cursor])) { history_record(list); struct Item* item = &list->items[*cursor]; item->state = (enum State) ((item->state + 1) % 3); commit(list); } break; case 'x': if (valid_cursor(list, *cursor) && *edit_pos < (int) strlen(list->items[*cursor].text)) { history_record(list); struct Item* item = &list->items[*cursor]; for (int repeat = 0; repeat < count && *edit_pos < (int) strlen(item->text); repeat++) { int next = utf8_right(item->text, *edit_pos); memmove(&item->text[*edit_pos], &item->text[next], strlen(item->text) - (size_t) next + 1); } } break; case 'o': { history_record(list); int at = item_block_end(list, *cursor); insert_item(list, at); *cursor = at < list->count ? at : list->count - 1; *edit_pos = 0; *mode = MODE_INSERT; } break; case 'A': { history_record(list); if (list->count == 0) { insert_item(list, 0); *cursor = 0; } if (valid_cursor(list, *cursor)) { *edit_pos = (int) strlen(list->items[*cursor].text); *mode = MODE_INSERT; } } break; case 'i': if (valid_cursor(list, *cursor)) { history_record(list); int start = text_start(&list->items[*cursor]); if (*edit_pos < start) *edit_pos = start; *mode = MODE_INSERT; } break; case 'R': { history_record(list); if (list->count == 0) { insert_item(list, 0); *cursor = 0; *edit_pos = 0; } if (valid_cursor(list, *cursor)) { (void) snprintf(replace_original, sizeof replace_original, "%s", list->items[*cursor].text); replace_start = *edit_pos; *mode = MODE_REPLACE; } } break; case 'c': { if (!valid_cursor(list, *cursor)) break; int op = read_key(); if (op == 'c') { const struct Item* item = &list->items[*cursor]; apply_change(list, editor, text_start(item), (int) strlen(item->text)); break; } if (op == 'w') op = 'e'; int from; int to; if (motion_range(list, editor, op, &from, &to)) apply_change(list, editor, from, to); } break; case 'g': { int next = read_key(); if (next == 'g') *cursor = 0; else unget_key(next); } break; case 'G': *cursor = list->count - 1; if (valid_cursor(list, *cursor) && hidden(list, *cursor)) { int prev = prev_visible(list, *cursor); if (prev >= 0) *cursor = prev; } break; case 'V': if (valid_cursor(list, *cursor)) { editor->anchor = *cursor; *mode = MODE_VISUAL; } break; case 'z': { int next = read_key(); int section = section_of(list, *cursor); if (section < 0) break; if (next == 'a') list->items[section].folded = !list->items[section].folded; else if (next == 'o') list->items[section].folded = false; else if (next == 'c') list->items[section].folded = true; else { unget_key(next); break; } } break; case '>': { int op = read_key(); if (op != '>' || !valid_cursor(list, *cursor)) { unget_key(op); break; } struct Item* item = &list->items[*cursor]; char buf[MAX_TEXT]; if (is_section(item)) (void) snprintf(buf, sizeof buf, "=%s", item->text); else if (is_task(item) && !is_optional(item) && !is_important(item)) (void) snprintf(buf, sizeof buf, "- %s", item->text + text_start(item)); else break; history_record(list); (void) snprintf(item->text, sizeof item->text, "%s", buf); commit(list); } break; case '<': { int op = read_key(); if (op != '<' || !valid_cursor(list, *cursor)) { unget_key(op); break; } struct Item* item = &list->items[*cursor]; char buf[MAX_TEXT]; if (is_section(item) && section_level(item) >= 2) (void) snprintf(buf, sizeof buf, "%s", item->text + 1); else if (is_subtask(item)) (void) snprintf(buf, sizeof buf, "%s", item->text + text_start(item)); else break; history_record(list); (void) snprintf(item->text, sizeof item->text, "%s", buf); commit(list); } break; case '}': { int next = *cursor + 1; while (next < list->count && !is_section(&list->items[next])) next++; *cursor = next < list->count ? next : list->count - 1; } break; case '{': { int previous = *cursor - 1; while (previous >= 0 && !is_section(&list->items[previous])) previous--; *cursor = previous >= 0 ? previous : 0; } break; case 'd': { int op = read_key(); if (op == 'd') { if (valid_cursor(list, *cursor)) { history_record(list); int start = *cursor; int end = start; for (int repeat = 0; repeat < count && end < list->count; repeat++) end = item_block_end(list, end); yank_count = 0; for (int i = start; i < end && yank_count < MAX_ITEMS; i++) yank_register[yank_count++] = list->items[i]; for (int i = start; i < end; i++) delete_item(list, start); commit(list); } break; } if (!valid_cursor(list, *cursor)) { unget_key(op); break; } int from; int to; if (motion_range(list, editor, op, &from, &to)) delete_text(list, editor, from, to); } break; case 'J': { if (!valid_cursor(list, *cursor)) break; if (is_section(&list->items[*cursor])) { int start = *cursor; int end = block_end(list, start); if (end < list->count) { history_record(list); int next_end = block_end(list, end); reverse_items(list, start, end); reverse_items(list, end, next_end); reverse_items(list, start, next_end); *cursor = start + (next_end - end); } } else if (!is_child(&list->items[*cursor])) { int start = *cursor; int end = task_block_end(list, start); if (end < list->count && !is_section(&list->items[end])) { history_record(list); int next_end = task_block_end(list, end); reverse_items(list, start, end); reverse_items(list, end, next_end); reverse_items(list, start, next_end); *cursor = start + (next_end - end); } } else if (*cursor + 1 < list->count && is_child(&list->items[*cursor + 1])) { history_record(list); struct Item moved = list->items[*cursor]; list->items[*cursor] = list->items[*cursor + 1]; list->items[*cursor + 1] = moved; (*cursor)++; } } break; case 'K': { if (!valid_cursor(list, *cursor)) break; if (is_section(&list->items[*cursor])) { int start = *cursor; if (start > 0) { history_record(list); int previous = start - 1; while (previous >= 0 && !is_section(&list->items[previous])) previous--; int previous_start = previous >= 0 ? previous : 0; int end = block_end(list, start); reverse_items(list, previous_start, start); reverse_items(list, start, end); reverse_items(list, previous_start, end); *cursor = previous_start; } } else if (!is_child(&list->items[*cursor])) { int start = *cursor; int previous = start - 1; while (previous >= 0 && is_child(&list->items[previous])) previous--; if (previous >= 0 && !is_section(&list->items[previous])) { history_record(list); int end = task_block_end(list, start); reverse_items(list, previous, start); reverse_items(list, start, end); reverse_items(list, previous, end); *cursor = previous; } } else if (*cursor > 0 && is_child(&list->items[*cursor - 1])) { history_record(list); struct Item moved = list->items[*cursor]; list->items[*cursor] = list->items[*cursor - 1]; list->items[*cursor - 1] = moved; (*cursor)--; } } break; case 'D': { history_record(list); int kept = 0; int i = 0; while (i < list->count) { int end = item_block_end(list, i); bool drop = is_task(&list->items[i]) && list->items[i].state == STATE_DONE; if (!drop) for (int j = i; j < end; j++) list->items[kept++] = list->items[j]; i = end; } list->count = kept; commit(list); } break; default: break; } return 1; } static void insert_key(struct List* list, struct Editor* editor, int key) { int* cursor = &editor->cursor; int* edit_pos = &editor->edit_pos; enum Mode* mode = &editor->mode; struct Item* item = &list->items[*cursor]; size_t length = strlen(item->text); if (key == 27 || ((key == '\r' || key == '\n') && item->text[0] == '\0')) { if (item->text[0] == '\0') { delete_item(list, *cursor); if (*cursor >= list->count) *cursor = list->count - 1; if (*cursor < 0) *cursor = 0; } normalize_tasks(list); *mode = MODE_NORMAL; return; } if (key == '\r' || key == '\n') { int at = item_block_end(list, *cursor); insert_item(list, at); *cursor = at; *edit_pos = 0; return; } if (key == KEY_LEFT) { *edit_pos = utf8_left(item->text, *edit_pos); return; } if (key == KEY_RIGHT) { *edit_pos = utf8_right(item->text, *edit_pos); return; } if (key == 127 || key == 8) { if (*mode == MODE_REPLACE) { if (*edit_pos > replace_start) { int start = utf8_left(item->text, *edit_pos); int original_length = (int) strlen(replace_original); if (*edit_pos <= original_length) for (int i = start; i < *edit_pos; i++) item->text[i] = replace_original[i]; else memmove(&item->text[start], &item->text[*edit_pos], length - (size_t) *edit_pos + 1); *edit_pos = start; } } else if (*edit_pos > 0) { int start = utf8_left(item->text, *edit_pos); memmove(&item->text[start], &item->text[*edit_pos], length - (size_t) *edit_pos + 1); *edit_pos = start; } return; } if (key >= 32 && key < 256 && length + 1 < sizeof item->text) { if (*mode == MODE_REPLACE && *edit_pos < (int) length) item->text[*edit_pos] = (char) key; else { memmove(&item->text[*edit_pos + 1], &item->text[*edit_pos], length - (size_t) *edit_pos + 1); item->text[*edit_pos] = (char) key; } (*edit_pos)++; } } static void complete_word(char* buf, size_t size, const char* const* options, int count) { size_t len = strlen(buf); const char* first = NULL; size_t common = 0; for (int i = 0; i < count; i++) { if (strncmp(options[i], buf, len) != 0) continue; if (first == NULL) { first = options[i]; common = strlen(first); continue; } size_t k = 0; while (k < common && options[i][k] == first[k]) k++; common = k; } if (first == NULL || common >= size) return; memcpy(buf, first, common); buf[common] = '\0'; } static int command_key(struct List* list, const char* path, struct Editor* editor, int key) { static const char* const names[] = { "w", "q", "q!", "wq", "x", "reset", "clear", "reorder", "archive", "theme", "help", }; enum Mode* mode = &editor->mode; char* command = editor->command; size_t length = strlen(command); if (key == '\t') { if (strncmp(command, "theme ", 6) == 0) complete_word(command + 6, MAX_COMMAND - 6, theme_names, THEME_COUNT); else complete_word(command, MAX_COMMAND, names, (int) (sizeof names / sizeof names[0])); return 1; } if (key == 27) { command[0] = '\0'; *mode = MODE_NORMAL; return 1; } if (key == '\r' || key == '\n') { if (command[0] == '\0') { *mode = MODE_NORMAL; return 1; } if (strcmp(command, "reset") == 0 || strcmp(command, "clear") == 0) { pending_action = strcmp(command, "clear") == 0 ? PENDING_CLEAR : PENDING_RESET; command[0] = '\0'; *mode = MODE_CONFIRM; return 1; } if (strcmp(command, "reorder") == 0) { history_record(list); commit(list); command[0] = '\0'; *mode = MODE_NORMAL; return 1; } if (strcmp(command, "archive") == 0) { history_record(list); int moved = archive_done(list, path); if (moved < 0) (void) snprintf(editor->message, sizeof editor->message, "could not write archive"); else { commit(list); (void) snprintf(editor->message, sizeof editor->message, "archived %d item%s", moved, moved == 1 ? "" : "s"); } command[0] = '\0'; *mode = MODE_NORMAL; return 1; } if (strcmp(command, "help") == 0 || strcmp(command, "?") == 0) { command[0] = '\0'; *mode = MODE_HELP; return 1; } if (strcmp(command, "theme") == 0 || strncmp(command, "theme ", 6) == 0) { const char* name = command + 5; while (*name == ' ') name++; if (*name == '\0') theme_index = (theme_index + 1) % THEME_COUNT; else { int found = -1; for (int i = 0; i < THEME_COUNT; i++) if (strcmp(name, theme_names[i]) == 0) found = i; if (found < 0) (void) snprintf(editor->message, sizeof editor->message, "no theme: %s", name); else theme_index = found; } theme = &themes[theme_index]; command[0] = '\0'; *mode = MODE_NORMAL; return 1; } bool force = strcmp(command, "q!") == 0; bool write = strcmp(command, "w") == 0 || strcmp(command, "wq") == 0 || strcmp(command, "x") == 0; bool quit = force || strcmp(command, "q") == 0 || strcmp(command, "wq") == 0 || strcmp(command, "x") == 0; if (!write && !quit) { (void) snprintf(editor->message, sizeof editor->message, "not a command: %s", command); command[0] = '\0'; *mode = MODE_NORMAL; return 1; } if (quit && !write && !force && dirty) { (void) snprintf(editor->message, sizeof editor->message, "unsaved changes (:q! to discard)"); command[0] = '\0'; *mode = MODE_NORMAL; return 1; } if (write && save(list, path) != 0) { (void) snprintf(editor->message, sizeof editor->message, "could not write %s", path); command[0] = '\0'; *mode = MODE_NORMAL; return 1; } *mode = MODE_NORMAL; command[0] = '\0'; if (quit) return 0; return 1; } if (key == 127 || key == 8) { if (length > 0) command[length - 1] = '\0'; else *mode = MODE_NORMAL; return 1; } if (key >= 32 && key < 256 && length + 1 < MAX_COMMAND) { command[length] = (char) key; command[length + 1] = '\0'; } return 1; } static void search_key(const struct List* list, struct Editor* editor, int key) { enum Mode* mode = &editor->mode; char* command = editor->command; char* search = editor->search; size_t length = strlen(command); if (key == 27) { command[0] = '\0'; *mode = MODE_NORMAL; return; } if (key == '\r' || key == '\n') { (void) snprintf(search, MAX_COMMAND, "%s", command); command[0] = '\0'; *mode = MODE_NORMAL; goto_match(list, editor, 1); return; } if (key == 127 || key == 8) { if (length > 0) command[length - 1] = '\0'; else *mode = MODE_NORMAL; return; } if (key >= 32 && key < 256 && length + 1 < MAX_COMMAND) { command[length] = (char) key; command[length + 1] = '\0'; } } static void confirm_key(struct List* list, struct Editor* editor, int key) { enum Mode* mode = &editor->mode; if (key == 'y' || key == 'Y') { history_record(list); if (pending_action == PENDING_RESET) { for (int i = 0; i < list->count; i++) if (!is_section(&list->items[i])) list->items[i].state = STATE_TODO; } else if (pending_action == PENDING_CLEAR) list->count = 0; commit(list); } pending_action = PENDING_NONE; *mode = MODE_NORMAL; } static void visual_key(struct List* list, struct Editor* editor, int key) { int* cursor = &editor->cursor; enum Mode* mode = &editor->mode; int lo = editor->anchor < *cursor ? editor->anchor : *cursor; int hi = editor->anchor < *cursor ? *cursor : editor->anchor; switch (key) { case 27: case 'V': *mode = MODE_NORMAL; break; case 'j': case KEY_DOWN: { int next = next_visible(list, *cursor); if (next >= 0) *cursor = next; } break; case 'k': case KEY_UP: { int prev = prev_visible(list, *cursor); if (prev >= 0) *cursor = prev; } break; case 'G': *cursor = list->count - 1; break; case 'g': { int next = read_key(); if (next == 'g') *cursor = 0; else unget_key(next); } break; case 'y': yank_count = 0; for (int i = lo; i <= hi && i < list->count && yank_count < MAX_ITEMS; i++) yank_register[yank_count++] = list->items[i]; *mode = MODE_NORMAL; break; case 'd': case 'x': if (valid_cursor(list, lo)) { history_record(list); yank_count = 0; for (int i = lo; i <= hi && i < list->count && yank_count < MAX_ITEMS; i++) yank_register[yank_count++] = list->items[i]; for (int i = lo; i <= hi && lo < list->count; i++) delete_item(list, lo); *cursor = lo; commit(list); } *mode = MODE_NORMAL; break; case ' ': if (valid_cursor(list, lo)) { history_record(list); for (int i = lo; i <= hi && i < list->count; i++) if (!is_section(&list->items[i]) && !is_note(&list->items[i])) list->items[i].state = (enum State) ((list->items[i].state + 1) % 3); commit(list); } *mode = MODE_NORMAL; break; default: break; } } static void run(struct List* list, const char* path) { if (!isatty(STDIN_FILENO)) return; if (enter_raw_mode() != 0) exit(EXIT_FAILURE); normalize_tasks(list); sort_list(list); struct Editor editor = { 0 }; editor.mode = MODE_NORMAL; uint64_t edits_at_start = 0; for (;;) { apply_prose(list, is_editing(editor.mode) ? editor.cursor : -1); if (editor.cursor >= list->count) editor.cursor = list->count - 1; if (editor.cursor < 0) editor.cursor = 0; if (list->count > 0) { const char* text = list->items[editor.cursor].text; int text_length = (int) strlen(text); int caret_max = is_editing(editor.mode) ? text_length : (text_length > 0 ? text_length - 1 : 0); int caret_min = is_editing(editor.mode) ? 0 : text_start(&list->items[editor.cursor]); if (caret_min > caret_max) caret_min = caret_max; if (editor.edit_pos > caret_max) editor.edit_pos = caret_max; if (editor.edit_pos < caret_min) editor.edit_pos = caret_min; while (editor.edit_pos > caret_min && ((uint8_t) text[editor.edit_pos] & 0xC0) == 0x80) editor.edit_pos--; } if (editor.cursor < editor.scroll) editor.scroll = editor.cursor; while (editor.scroll < editor.cursor && render(list, &editor, 1) < 0) editor.scroll++; (void) render(list, &editor, 0); int key = read_key(); if (key == -1) break; editor.message[0] = '\0'; if (editor.mode == MODE_NORMAL && key == '.' && replay_pos < 0 && dot_record_len > 0) { memcpy(replay_buf, dot_record, (size_t) dot_record_len * sizeof *replay_buf); replay_len = dot_record_len; replay_pos = 0; continue; } if (editor.mode == MODE_NORMAL && replay_pos < 0 && !recording && is_change_start(key)) { recording = true; rec_len = 0; rec_buf[rec_len++] = key; edits_at_start = edits; } switch (editor.mode) { case MODE_NORMAL: { if ((key >= '1' && key <= '9') || (key == '0' && editor.count > 0)) { editor.count = editor.count * 10 + (key - '0'); if (editor.count > 9999) editor.count = 9999; break; } (void) normal_key(list, &editor, key); editor.count = 0; } break; case MODE_INSERT: case MODE_REPLACE: insert_key(list, &editor, key); break; case MODE_COMMAND: if (command_key(list, path, &editor, key) == 0) return; break; case MODE_SEARCH: search_key(list, &editor, key); break; case MODE_CONFIRM: confirm_key(list, &editor, key); break; case MODE_HELP: editor.mode = MODE_NORMAL; break; case MODE_VISUAL: visual_key(list, &editor, key); break; } if (recording && editor.mode == MODE_NORMAL) { recording = false; if (edits != edits_at_start && rec_len > 0) { memcpy(dot_record, rec_buf, (size_t) rec_len * sizeof *dot_record); dot_record_len = rec_len; } } if (valid_cursor(list, editor.cursor) && hidden(list, editor.cursor)) { int root = fold_root(list, editor.cursor); if (root >= 0) editor.cursor = root; } } } static void print_help(void) { (void) printf( "%s %s - a little modal todo list\n" "\n" "usage: %s open the list ($TODO_FILE, or ~/.todo.txt)\n" " %s -h | --help\n" " %s -v | --version\n" "\n" "keys:\n" " h j k l / arrows move w / b words 0 / $ line ends\n" " { / } sections gg / G top/bottom\n" " o new item i insert at cursor A append text\n" " R overtype (replace) 3j 2dd counts\n" " x delete char under cursor space cycle state\n" " cw ce cb c0 c$ cc change word / to start / end / whole\n" " dw de db d0 d$ delete over a motion (also c/d t f i a)\n" " ct{c} cf{c} change till / onto a char\n" " ci{d} ca{d} change inside / around () [] {} <> \" '\n" " dd cut item D clear done yy p P yank/paste\n" " . repeat the last change V visual select (then d / y / space)\n" " >> << demote / promote (task<->subtask, section depth)\n" " za fold section (zo open, zc close)\n" " J / K move item or section / n N search (highlights as you type)\n" " esc clear search highlight u ctrl-r undo / redo\n" " :w :q :q! :wq :x write / quit (q! discards) :reset all back to todo\n" " :clear empty the list\n" " :reorder re-sort the list :theme NAME switch theme\n" " :archive move done items to the archive file :help :? show keys\n" "\n" "items: '=' section, '==' subsection, '-' subtask, '*' optional, '>' note,\n" " '!' important (shown bold, in caps, with a trailing !).\n" "\n" "by hachem \n", TODO_PROGRAM, TODO_VERSION, TODO_PROGRAM, TODO_PROGRAM, TODO_PROGRAM); } int main(int argc, char** argv) { if (argc >= 2 && (strcmp(argv[1], "-h") == 0 || strcmp(argv[1], "--help") == 0)) { print_help(); return EXIT_SUCCESS; } if (argc >= 2 && (strcmp(argv[1], "-v") == 0 || strcmp(argv[1], "--version") == 0)) { (void) printf("%s %s\n", TODO_PROGRAM, TODO_VERSION); return EXIT_SUCCESS; } (void) setlocale(LC_ALL, ""); char path[4096]; todo_path(path, sizeof path); struct List* list = malloc(sizeof *list); if (list == NULL) { (void) fprintf(stderr, "%s: out of memory\n", TODO_PROGRAM); return EXIT_FAILURE; } load(list, path); run(list, path); free(list); return EXIT_SUCCESS; }