#include #include #include #include #include #include #include #include #include #define TODO_PROGRAM "todo" #define TODO_VERSION "0.1.0" #define MAX_ITEMS 4096 #define MAX_TEXT 256 #define MAX_COMMAND 64 #define LEFT_MARGIN 2 #define SECTION_INDENT 2 #define SUBTASK_INDENT 4 enum State { STATE_TODO, STATE_DOING, STATE_DONE }; struct Item { enum State state; char text[MAX_TEXT]; }; struct List { struct Item items[MAX_ITEMS]; int count; }; enum Mode { MODE_NORMAL, MODE_INSERT, MODE_COMMAND }; enum Key { KEY_UP = 256, KEY_DOWN, KEY_LEFT, KEY_RIGHT }; static const char* mode_name(enum Mode mode) { switch (mode) { case MODE_NORMAL: return "NORMAL"; case MODE_INSERT: return "INSERT"; case MODE_COMMAND: return "COMMAND"; } return "?"; } 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 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; 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; (void) snprintf(item->text, sizeof item->text, "%s", text); } (void) fclose(file); } static int is_section(const struct Item* item) { return item->text[0] == '='; } static int is_subtask(const struct Item* item) { return item->text[0] == '-'; } static int section_level(const struct Item* item) { int level = 0; while (item->text[level] == '=') level++; return level; } static void normalize_tasks(struct List* list) { for (int i = 0; i < list->count; i++) { if (is_section(&list->items[i]) || is_subtask(&list->items[i])) continue; int total = 0; int done = 0; int active = 0; for (int j = i + 1; j < list->count && is_subtask(&list->items[j]); j++) { 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 pass = 0; pass < 2; pass++) { int k = i; while (k < segment) { int block = k + 1; while (block < segment && is_subtask(&list->items[block])) block++; if ((list->items[k].state == STATE_DONE) == pass) { sorted[out++] = list->items[k]; for (int sub_pass = 0; sub_pass < 2; sub_pass++) for (int s = k + 1; s < block; s++) if ((list->items[s].state == STATE_DONE) == sub_pass) sorted[out++] = list->items[s]; } k = block; } } i = segment; } for (int x = 0; x < list->count; x++) list->items[x] = sorted[x]; free(sorted); } 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 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 void save(struct List* list, const char* path) { normalize_tasks(list); FILE* file = fopen(path, "w"); if (file == NULL) return; for (int i = 0; i < list->count; i++) { if (is_section(&list->items[i])) { (void) fprintf(file, "%s\n", list->items[i].text); continue; } char marker = list->items[i].state == STATE_DONE ? 'x' : list->items[i].state == STATE_DOING ? '-' : ' '; (void) fprintf(file, "[%c] %s\n", marker, list->items[i].text); } (void) fclose(file); } static void commit(struct List* list) { normalize_tasks(list); sort_list(list); } 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].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[] = "\x1b[?25h\x1b[0 q\x1b[?1049l"; (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; exit(EXIT_SUCCESS); } static int enter_raw_mode(void) { if (!isatty(STDIN_FILENO)) { (void) fprintf(stderr, "%s: not a terminal\n", TODO_PROGRAM); return -1; } 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, "\x1b[?1049h\x1b[?25l", 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 content_rows(void) { int rows = screen_rows() - 3; return rows < 1 ? 1 : rows; } 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 void render(const struct List* list, int cursor, int scroll, enum Mode mode, int edit_pos, const char* command) { int rows = screen_rows(); int visible = content_rows(); int total_items = 0; int done = 0; for (int i = 0; i < list->count; i++) if (!is_section(&list->items[i]) && !is_subtask(&list->items[i])) { total_items++; done += list->items[i].state == STATE_DONE; } 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_subtask(&list->items[i]) && ++run > max_run) max_run = run; } int number_width = 1; for (int count = max_run; count >= 10; count /= 10) number_width++; char date[64]; time_t now = time(NULL); struct tm* local = localtime(&now); if (local == NULL || strftime(date, sizeof date, "%a %d %b %Y", local) == 0) date[0] = '\0'; char buffer[64 * 1024]; int length = 0; length += snprintf(buffer + length, sizeof buffer - (size_t) length, "\x1b[H%*s\x1b[2m%s\x1b[0m\x1b[K\r\n\x1b[K\r\n", LEFT_MARGIN, "", date); int emitted = 0; int index = scroll; while (emitted < visible && index < list->count) { const struct Item* item = &list->items[index]; const char* background = index == cursor ? "\x1b[48;5;235m" : ""; const char* background_reset = index == cursor ? "\x1b[49m" : ""; if (is_section(item)) { if (emitted > 0) { length += snprintf(buffer + length, sizeof buffer - (size_t) length, "\x1b[K\r\n"); emitted++; if (emitted >= visible) break; } int level = section_level(item); int section_indent = LEFT_MARGIN + (level - 1) * SECTION_INDENT; if (mode == MODE_INSERT && index == cursor) { length += snprintf(buffer + length, sizeof buffer - (size_t) length, "%s%*s\x1b[1m%s\x1b[22m\x1b[K%s\r\n", background, section_indent, "", item->text, 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++; length += snprintf(buffer + length, sizeof buffer - (size_t) length, "%s%*s\x1b[1m%s. %s\x1b[22m\x1b[K%s\r\n", background, section_indent, "", roman, name, background_reset); } emitted++; index++; continue; } int heading_level = 0; for (int k = index - 1; k >= 0; k--) if (is_section(&list->items[k])) { heading_level = section_level(&list->items[k]); break; } int base_indent = LEFT_MARGIN + heading_level * SECTION_INDENT; const char* box = item->state == STATE_DONE ? "[x]" : item->state == STATE_DOING ? "[-]" : "[ ]"; const char* dim = item->state == STATE_DONE ? "\x1b[2m" : ""; const char* dim_reset = item->state == STATE_DONE ? "\x1b[22m" : ""; if (is_subtask(item)) { int indent = base_indent + SUBTASK_INDENT; const char* name = item->text; if (!(mode == MODE_INSERT && index == cursor)) { name = item->text + 1; while (*name == ' ') name++; } length += snprintf(buffer + length, sizeof buffer - (size_t) length, "%s%*s%s %s%s%s\x1b[K%s\r\n", background, indent, "", box, dim, name, dim_reset, background_reset); } else { int number = 0; for (int k = index; k >= 0 && !is_section(&list->items[k]); k--) if (!is_subtask(&list->items[k])) number++; length += snprintf(buffer + length, sizeof buffer - (size_t) length, "%s%*s\x1b[2m%*d.\x1b[22m %s %s%s%s\x1b[K%s\r\n", background, base_indent, "", number_width, number, box, dim, item->text, dim_reset, background_reset); } emitted++; index++; } while (emitted < visible) { length += snprintf(buffer + length, sizeof buffer - (size_t) length, "\x1b[K\r\n"); emitted++; } if (mode == MODE_COMMAND) length += snprintf(buffer + length, sizeof buffer - (size_t) length, "\x1b[%d;1H%*s:%s\x1b[K", rows, LEFT_MARGIN, "", command); else length += snprintf(buffer + length, sizeof buffer - (size_t) length, "\x1b[%d;1H%*s\x1b[2m[%s] %d/%d done\x1b[0m\x1b[K", rows, LEFT_MARGIN, "", mode_name(mode), done, total_items); if (mode == MODE_COMMAND) { int cursor_column = LEFT_MARGIN + 2 + (int) strlen(command); length += snprintf(buffer + length, sizeof buffer - (size_t) length, "\x1b[%d;%dH\x1b[6 q\x1b[?25h", rows, cursor_column); } else if (cursor >= 0 && cursor < list->count) { int rows_before = 0; for (int i = scroll; i < cursor; i++) rows_before += is_section(&list->items[i]) && i > scroll ? 2 : 1; if (is_section(&list->items[cursor]) && cursor > scroll) rows_before++; int cursor_row = 3 + rows_before; int cursor_column; if (is_section(&list->items[cursor])) { int level = section_level(&list->items[cursor]); int section_indent = LEFT_MARGIN + (level - 1) * SECTION_INDENT; cursor_column = mode == MODE_INSERT ? section_indent + 1 + edit_pos : section_indent + 1; } else { int heading_level = 0; for (int k = cursor - 1; k >= 0; k--) if (is_section(&list->items[k])) { heading_level = section_level(&list->items[k]); break; } int base_indent = LEFT_MARGIN + heading_level * SECTION_INDENT; int text_start; if (is_subtask(&list->items[cursor])) text_start = base_indent + SUBTASK_INDENT + 5; else text_start = base_indent + number_width + 7; cursor_column = text_start + edit_pos; } const char* shape = mode == MODE_INSERT ? "\x1b[6 q" : "\x1b[2 q"; length += snprintf(buffer + length, sizeof buffer - (size_t) length, "\x1b[%d;%dH%s\x1b[?25h", cursor_row, cursor_column, shape); } else { length += snprintf(buffer + length, sizeof buffer - (size_t) length, "\x1b[?25l"); } (void) write(STDOUT_FILENO, buffer, (size_t) length); } static int read_key(void) { char character; if (read(STDIN_FILENO, &character, 1) != 1) return -1; if (character != 27) return (unsigned char) character; struct termios current; tcgetattr(STDIN_FILENO, ¤t); struct termios timed = current; timed.c_cc[VMIN] = 0; timed.c_cc[VTIME] = 1; (void) tcsetattr(STDIN_FILENO, TCSANOW, &timed); char sequence[2]; ssize_t first = read(STDIN_FILENO, &sequence[0], 1); ssize_t second = first == 1 ? read(STDIN_FILENO, &sequence[1], 1) : 0; (void) tcsetattr(STDIN_FILENO, TCSANOW, ¤t); if (first == 1 && second == 1 && sequence[0] == '[') switch (sequence[1]) { case 'A': return KEY_UP; case 'B': return KEY_DOWN; case 'C': return KEY_RIGHT; case 'D': return KEY_LEFT; } return 27; } static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit_pos, char* command, int key) { switch (key) { case 'q': return 0; case ':': command[0] = '\0'; *mode = MODE_COMMAND; break; case 'j': case KEY_DOWN: (*cursor)++; break; case 'k': case KEY_UP: (*cursor)--; break; case 'h': case KEY_LEFT: (*edit_pos)--; break; case 'l': case KEY_RIGHT: (*edit_pos)++; break; case ' ': if (*cursor >= 0 && *cursor < list->count && !is_section(&list->items[*cursor])) { struct Item* item = &list->items[*cursor]; item->state = (enum State) ((item->state + 1) % 3); commit(list); } break; case 'x': if (*cursor >= 0 && *cursor < list->count && !is_section(&list->items[*cursor])) { struct Item* item = &list->items[*cursor]; item->state = item->state == STATE_DONE ? STATE_TODO : STATE_DONE; commit(list); } break; case 'a': case 'o': insert_item(list, *cursor + 1); *cursor = *cursor + 1 < list->count ? *cursor + 1 : list->count - 1; *edit_pos = 0; *mode = MODE_INSERT; break; case 'A': if (list->count == 0) { insert_item(list, 0); *cursor = 0; } if (*cursor >= 0 && *cursor < list->count) { *edit_pos = (int) strlen(list->items[*cursor].text); *mode = MODE_INSERT; } break; case 'i': if (*cursor >= 0 && *cursor < list->count) { if (is_section(&list->items[*cursor])) { int position = 1; while (list->items[*cursor].text[position] == ' ') position++; *edit_pos = position; } *mode = MODE_INSERT; } break; case 'R': if (list->count == 0) { insert_item(list, 0); *cursor = 0; } if (*cursor >= 0 && *cursor < list->count) { list->items[*cursor].text[0] = '\0'; *edit_pos = 0; *mode = MODE_INSERT; } break; case 'g': { char next; if (read(STDIN_FILENO, &next, 1) == 1 && next == 'g') *cursor = 0; break; } case 'G': *cursor = list->count - 1; 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': { char next; if (read(STDIN_FILENO, &next, 1) == 1 && next == 'd') { delete_item(list, *cursor); commit(list); } break; } case 'J': if (*cursor < 0 || *cursor >= list->count) break; if (is_section(&list->items[*cursor])) { int start = *cursor; int end = block_end(list, start); if (end < list->count) { 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 (*cursor + 1 < list->count && !is_section(&list->items[*cursor + 1])) { struct Item moved = list->items[*cursor]; list->items[*cursor] = list->items[*cursor + 1]; list->items[*cursor + 1] = moved; (*cursor)++; } break; case 'K': if (*cursor < 0 || *cursor >= list->count) break; if (is_section(&list->items[*cursor])) { int start = *cursor; if (start > 0) { 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 (*cursor > 0 && !is_section(&list->items[*cursor - 1])) { struct Item moved = list->items[*cursor]; list->items[*cursor] = list->items[*cursor - 1]; list->items[*cursor - 1] = moved; (*cursor)--; } break; case 'D': { int kept = 0; for (int i = 0; i < list->count; i++) if (list->items[i].state != STATE_DONE) list->items[kept++] = list->items[i]; list->count = kept; commit(list); break; } default: break; } return 1; } static void insert_key(struct List* list, int* cursor, enum Mode* mode, int* edit_pos, int key) { 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') { insert_item(list, *cursor + 1); (*cursor)++; *edit_pos = 0; return; } if (key == KEY_LEFT) { if (*edit_pos > 0) (*edit_pos)--; return; } if (key == KEY_RIGHT) { if (*edit_pos < (int) length) (*edit_pos)++; return; } if (key == 127 || key == 8) { if (*edit_pos > 0) { memmove(&item->text[*edit_pos - 1], &item->text[*edit_pos], length - (size_t) *edit_pos + 1); (*edit_pos)--; } return; } if (key >= 32 && key <= 126 && length + 1 < sizeof item->text) { 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 int command_key(struct List* list, const char* path, enum Mode* mode, char* command, int key) { size_t length = strlen(command); if (key == 27) { command[0] = '\0'; *mode = MODE_NORMAL; return 1; } if (key == '\r' || key == '\n') { int write = strchr(command, 'w') != NULL || strcmp(command, "x") == 0; int quit = strchr(command, 'q') != NULL || strcmp(command, "x") == 0; *mode = MODE_NORMAL; if (write) save(list, path); 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 <= 126 && length + 1 < MAX_COMMAND) { command[length] = (char) key; command[length + 1] = '\0'; } return 1; } static void run(struct List* list, const char* path) { if (enter_raw_mode() != 0) exit(EXIT_FAILURE); normalize_tasks(list); sort_list(list); enum Mode mode = MODE_NORMAL; int cursor = 0; int scroll = 0; int edit_pos = 0; char command[MAX_COMMAND] = ""; for (;;) { int visible = content_rows(); if (cursor >= list->count) cursor = list->count - 1; if (cursor < 0) cursor = 0; if (cursor < scroll) scroll = cursor; if (cursor >= scroll + visible) scroll = cursor - visible + 1; if (list->count > 0) { int text_length = (int) strlen(list->items[cursor].text); int caret_max = mode == MODE_INSERT ? text_length : (text_length > 0 ? text_length - 1 : 0); if (edit_pos > caret_max) edit_pos = caret_max; if (edit_pos < 0) edit_pos = 0; } render(list, cursor, scroll, mode, edit_pos, command); int key = read_key(); if (key == -1) break; switch (mode) { case MODE_NORMAL: if (normal_key(list, &cursor, &mode, &edit_pos, command, key) == 0) return; break; case MODE_INSERT: insert_key(list, &cursor, &mode, &edit_pos, key); break; case MODE_COMMAND: if (command_key(list, path, &mode, command, key) == 0) return; break; } } } int main(void) { (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; }