diff options
| author | hachem <im@hachem.wtf> | 2026-10-03 08:44:46 +0200 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2026-10-03 08:44:46 +0200 |
| commit | 908e6d88f32c6eb1855d13156fad301c402a1798 (patch) | |
| tree | 33c6e459c466052cf235a227720dddab86abd749 | |
| parent | 7fe8c5ed9d741a58118f145a5d5ed14e533b9d39 (diff) | |
feat: more qol after using it for a couple days
| -rw-r--r-- | Makefile | 5 | ||||
| -rw-r--r-- | README.md | 22 | ||||
| -rw-r--r-- | config.h | 12 | ||||
| -rw-r--r-- | todo.1 | 83 | ||||
| -rw-r--r-- | todo.c | 1172 |
5 files changed, 1123 insertions, 171 deletions
@@ -27,6 +27,7 @@ endif TARGET := todo SRC := todo.c +HDR := config.h PREFIX ?= /usr/local bindir := $(DESTDIR)$(PREFIX)/bin @@ -34,8 +35,8 @@ mandir := $(DESTDIR)$(PREFIX)/share/man/man1 all: $(TARGET) -$(TARGET): $(SRC) - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +$(TARGET): $(SRC) $(HDR) + $(CC) $(CFLAGS) $(SRC) -o $@ $(LDFLAGS) clean: rm -rf $(TARGET) $(TARGET).dSYM @@ -6,7 +6,8 @@ a little modal todo list that lives in my terminal. ```text = movement - h j k l move (h/l caret in the item, j/k between items) + h j k l move (h/l caret in the item, j/k by display line) + 0 / $ start / end of the item w / b next / previous word { / } previous / next section gg / G top / bottom @@ -17,19 +18,25 @@ a little modal todo list that lives in my terminal. i insert at the cursor A append to the item's text R replace mode (overtype from the cursor) - x toggle done + x delete the character under the cursor + cw ce cb change word (ce to end, cb back) + c0 c$ cc change to start / end of line, or the whole line + ct{c} cf{c} change up to (cf: onto) the next {c} + ci{d} ca{d} change inside / around a pair: () [] {} <> " ' space cycle todo -> doing -> done - dd cut the item + dd cut the item (with its subtasks and notes) D clear every done item - yy p P yank, paste below, paste above - J / K move the item down / up (on a heading, moves the whole section) + yy p P yank, paste below, paste above (a task carries its block) + J / K move the item down / up (a task carries its block; a heading, its section) = other - / n N search, next match, previous match + / n N search (highlights as you type, jumps to match), next, previous + esc clear the search highlight u ctrl-r undo, redo :w :q :wq :x write, quit, write and quit :reset set every item back to todo :clear remove every item + :reorder re-sort / regroup the list ``` ## items @@ -39,6 +46,7 @@ a little modal todo list that lives in my terminal. == phase one a subsection -> i. phase one - write the docs a subtask * maybe later an optional task -> ( ) +> extra detail a note on the task above buy milk a plain task ``` @@ -57,6 +65,8 @@ it's one file, so if you don't want the makefile: $ cc -O2 todo.c -o todo ``` +tweak the layout, wrap column and highlight colour in [config.h](config.h). + ## license `todo` is licensed under the mit license. see [license](LICENSE) for more. diff --git a/config.h b/config.h new file mode 100644 index 0000000..059d178 --- /dev/null +++ b/config.h @@ -0,0 +1,12 @@ +#define LEFT_MARGIN 2 +#define SECTION_INDENT 2 +#define SUBTASK_INDENT 4 +#define WRAP_COLUMN 80 + +#define MAX_ITEMS 4096 +#define MAX_TEXT 256 +#define MAX_COMMAND 64 +#define MAX_HISTORY 64 + +#define HIGHLIGHT "\x1b[48;5;235m" +#define HIGHLIGHT_RESET "\x1b[49m" @@ -45,6 +45,10 @@ automatically once all of its subtasks are done. An optional task, shown with parentheses .RB ( "( )" ) instead of a checkbox. +.TP +.BI > " text" +A note on the task above it: extra description, shown indented and dimmed with +no checkbox. Notes move with their task. .PP Within each section, items are grouped \(em required, then optional, then done \(em with a blank line between the groups. @@ -60,8 +64,12 @@ done. .SS Movement .TP .BR h ", " j ", " k ", " l -Move: \fBh\fR/\fBl\fR move the cursor within the item, \fBj\fR/\fBk\fR between -items. Arrow keys work too. +Move: \fBh\fR/\fBl\fR move the cursor within the item, \fBj\fR/\fBk\fR by display +line, stepping through a wrapped item before moving to the next. Arrow keys work +too. +.TP +.BR 0 ", " $ +Move the cursor to the start or end of the item. .TP .BR w ", " b Move to the next or previous word. @@ -90,26 +98,77 @@ Append to the item's text. Enter replace mode, overtyping characters from the cursor. .TP .B x -Toggle the item between done and not done. +Delete the character under the cursor. +.TP +.BR cw ", " ce ", " cb ", " c0 ", " c$ ", " cc +Change over a motion: a word +.RB ( cw ", " ce ), +back a word +.RB ( cb ), +to the start +.RB ( c0 ) +or end +.RB ( c$ ) +of the line, or the whole line +.RB ( cc ). +A count applies, as in +.BR 2cw . +.TP +.BI ct c "\fR, \fP" cf c +Change the text up to the next +.IR c , +then enter insert mode. +.B cf +deletes the +.I c +as well. +.TP +.BI ci d "\fR, \fP" ca d +Change the text inside +.RB ( ci ) +or around +.RB ( ca ) +the delimiter pair +.IR d , +one of +.BR () , +.BR [] , +.BR {} , +.BR <> , +or a matching +.B \(dq +or +.BR ' . +If the cursor is before the pair, the next one on the line is used. .TP .B space Cycle the item through todo, doing and done. .TP .B dd -Cut the item (also copies it for pasting). +Cut the item (also copies it for pasting). A task takes its subtasks and notes +with it. .TP .B D Remove every done item. .TP .BR yy ", " p ", " P -Yank the item, paste it below, or paste it above. +Yank the item, paste it below, or paste it above. A task carries its subtasks +and notes as one block. .TP .BR J ", " K -Move the item down or up. On a section heading this moves the whole section. +Move the item down or up. A task moves with its subtasks and notes; a section +heading moves the whole section. .SS Other .TP .BR / ", " n ", " N -Search for text, then jump to the next or previous match. +Search for text and jump to the match, placing the cursor on the word; +.B n +and +.B N +repeat forward and backward. Matches are highlighted as you type and stay +highlighted until the next search or until +.B escape +is pressed in normal mode. .TP .BR u ", " "ctrl-r" Undo and redo. @@ -141,6 +200,10 @@ Set every item back to todo, clearing all done and doing states. .TP .B :clear Remove every item, emptying the list. +.TP +.B :reorder +Re-sort the list, regrouping items (required, optional, done) within each +section. Useful after reordering or hand-editing. .SH OPTIONS .TP .BR \-h ", " \-\-help @@ -157,8 +220,10 @@ a section .RB ( "= name" ), a subtask .RB ( "- name" ), -or an optional task -.RB ( "* name" ). +an optional task +.RB ( "* name" ), +or a note +.RB ( "> text" ). Override the location with .BR $TODO_FILE . .SH ENVIRONMENT @@ -9,17 +9,11 @@ #include <time.h> #include <unistd.h> +#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 LEFT_MARGIN 2 -#define SECTION_INDENT 2 -#define SUBTASK_INDENT 4 - enum State { STATE_TODO, @@ -84,8 +78,6 @@ static int is_editing(enum Mode mode) return mode == MODE_INSERT || mode == MODE_REPLACE; } -/* utf-8 helpers: edit_pos is a byte offset, but the cursor and h/l/backspace - * work in whole characters. a continuation byte has its top two bits as 10. */ static int utf8_width(const char* text, int bytes) { int width = 0; @@ -205,6 +197,11 @@ static int is_optional(const struct Item* item) return item->text[0] == '*'; } +static int is_note(const struct Item* item) +{ + return item->text[0] == '>'; +} + static int task_group(const struct Item* item) { if (item->state == STATE_DONE) @@ -226,19 +223,39 @@ static int section_level(const struct Item* item) return level; } +static 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_section(&list->items[i]) || is_subtask(&list->items[i])) + if (is_section(&list->items[i]) || is_subtask(&list->items[i]) || is_note(&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++) + for (int j = i + 1; + j < list->count && (is_subtask(&list->items[j]) || is_note(&list->items[j])); + j++) { + if (!is_subtask(&list->items[j])) + continue; + total++; if (list->items[j].state == STATE_DONE) @@ -291,17 +308,37 @@ static void sort_list(struct List* list) { int block = k + 1; - while (block < segment && is_subtask(&list->items[block])) + while (block < segment + && (is_subtask(&list->items[block]) || is_note(&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++) - for (int s = k + 1; s < block; s++) - if ((list->items[s].state == STATE_DONE) == sub_pass) - sorted[out++] = list->items[s]; + { + 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; @@ -317,6 +354,27 @@ static void sort_list(struct List* list) free(sorted); } +static int task_block_end(const struct List* list, int start) +{ + int i = start + 1; + + while (i < list->count && (is_subtask(&list->items[i]) || is_note(&list->items[i]))) + i++; + + return i; +} + +static int item_block_end(const struct List* list, int start) +{ + if (start >= 0 && start < list->count + && !is_section(&list->items[start]) + && !is_subtask(&list->items[start]) + && !is_note(&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; @@ -350,7 +408,7 @@ static void save(struct List* list, const char* path) for (int i = 0; i < list->count; i++) { - if (is_section(&list->items[i])) + if (is_section(&list->items[i]) || is_note(&list->items[i])) { (void) fprintf(file, "%s\n", list->items[i].text); continue; @@ -372,8 +430,6 @@ static void commit(struct List* list) sort_list(list); } -#define MAX_HISTORY 64 - struct Snapshot { struct Item* items; @@ -458,8 +514,8 @@ static void history_redo(struct List* list) static void insert_item(struct List* list, int index); -static struct Item yank_register; -static int yank_full; +static struct Item yank_register[MAX_ITEMS]; +static int yank_count; static enum Pending pending_action; @@ -468,17 +524,24 @@ static int replace_start; static void paste_item(struct List* list, int index, int* cursor) { - if (list->count >= MAX_ITEMS || !yank_full) + 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); - insert_item(list, index); - if (index >= 0 && index < list->count) - { - list->items[index] = yank_register; - *cursor = index; - } + 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]; + + *cursor = index; } static char lower(char c) @@ -509,6 +572,28 @@ static int contains_fold(const char* haystack, const char* needle) return 0; } +static int match_at(const char* text, int pos, const char* needle) +{ + int k = 0; + + while (needle[k] != '\0' && text[pos + k] != '\0' && lower(text[pos + 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') @@ -610,6 +695,16 @@ static int screen_rows(void) 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; @@ -617,6 +712,121 @@ static int content_rows(void) return rows < 1 ? 1 : rows; } +static int emit_wrapped(char* buffer, int length, int cols, + const char* background, const char* background_reset, + const char* prefix, int prefix_cols, int first_col, + const char* style, const char* style_reset, + const char* text, const char* search, int* emitted, int visible, + int is_cursor, int caret_cols, int* cursor_row, int* cursor_column) +{ + int total = (int) strlen(text); + int byte = 0; + int line = 0; + int text_col = 0; + + 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; + } + + do + { + while (line > 0 && byte < total && text[byte] == ' ') + { + byte++; + text_col++; + } + + if (line > 0 && byte >= total) + break; + + int base = line == 0 ? first_col : prefix_cols; + int avail = cols - base; + + if (avail < 1) + avail = 1; + + int row = 4 + *emitted; + int line_start_col = text_col; + + int end = byte; + int width = 0; + int space_pos = -1; + int space_width = 0; + + while (end < total && width < avail) + { + if (text[end] == ' ') + { + space_pos = end; + space_width = width; + } + + end++; + + while (end < total && ((unsigned char) text[end] & 0xC0) == 0x80) + end++; + + width++; + } + + if (end < total && text[end] != ' ' && space_pos > byte) + { + end = space_pos; + width = space_width; + } + + if (line == 0) + length += snprintf(buffer + length, 64 * 1024 - (size_t) length, + "%s%s%s", background, prefix, style); + else + length += snprintf(buffer + length, 64 * 1024 - (size_t) length, + "%s%*s%s", background, prefix_cols, "", style); + + for (int i = byte; i < end;) + { + int j = i; + + while (j < end && mask[j] == mask[i]) + j++; + + if (mask[i]) + length += snprintf(buffer + length, 64 * 1024 - (size_t) length, + "\x1b[7m%.*s\x1b[27m", j - i, text + i); + else + length += snprintf(buffer + length, 64 * 1024 - (size_t) length, + "%.*s", j - i, text + i); + + i = j; + } + + length += snprintf(buffer + length, 64 * 1024 - (size_t) length, + "%s\x1b[K%s\r\n", style_reset, background_reset); + + if (is_cursor && *cursor_row < 0 + && caret_cols >= line_start_col && caret_cols <= line_start_col + width) + { + *cursor_row = row; + *cursor_column = base + 1 + caret_cols - line_start_col; + } + + byte = end; + text_col = line_start_col + width; + line++; + (*emitted)++; + } + while (byte < total && *emitted < visible); + + return length; +} + 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 }; @@ -633,21 +843,15 @@ static void to_roman(int number, char* buffer, size_t size) } } -static void render(const struct List* list, int cursor, int scroll, enum Mode mode, int edit_pos, const char* command) +static int wrap_column(void) { - int rows = screen_rows(); - int visible = content_rows(); + int cols = screen_cols(); - 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; - } + return cols > WRAP_COLUMN ? WRAP_COLUMN : cols; +} +static int compute_number_width(const struct List* list) +{ int max_run = 0; int run = 0; @@ -655,7 +859,7 @@ static void render(const struct List* list, int cursor, int scroll, enum Mode mo { if (is_section(&list->items[i])) run = 0; - else if (!is_subtask(&list->items[i]) && ++run > max_run) + else if (!is_subtask(&list->items[i]) && !is_note(&list->items[i]) && ++run > max_run) max_run = run; } @@ -664,6 +868,29 @@ static void render(const struct List* list, int cursor, int scroll, enum Mode mo for (int count = max_run; count >= 10; count /= 10) number_width++; + return number_width; +} + +static int render(const struct List* list, int cursor, int scroll, enum Mode mode, int edit_pos, const char* command, const char* search, int measure) +{ + int rows = screen_rows(); + int cols = wrap_column(); + int visible = content_rows(); + + const char* matches = mode == MODE_SEARCH ? command : search; + + 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]) && !is_note(&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); @@ -686,13 +913,14 @@ static void render(const struct List* list, int cursor, int scroll, enum Mode mo 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]; - const char* background = index == cursor ? "\x1b[48;5;235m" : ""; - const char* background_reset = index == cursor ? "\x1b[49m" : ""; + const char* background = index == cursor ? HIGHLIGHT : ""; + const char* background_reset = index == cursor ? HIGHLIGHT_RESET : ""; if (is_section(item)) { @@ -768,6 +996,26 @@ static void render(const struct List* list, int cursor, int scroll, enum Mode mo int base_indent = LEFT_MARGIN + heading_level * SECTION_INDENT; + if (is_note(item)) + { + int indent = base_indent + number_width + 6; + const char* name = item->text; + + char prefix[16]; + (void) snprintf(prefix, sizeof prefix, "%*s", indent, ""); + + int caret = 0; + + if (index == cursor) + caret = utf8_width(name, edit_pos); + + length = emit_wrapped(buffer, length, cols, background, background_reset, + prefix, indent + 2, indent, "\x1b[2;3m", "\x1b[22;23m", name, + matches, &emitted, visible, index == cursor, caret, &cursor_row, &cursor_column); + index++; + continue; + } + const char* box = item->state == STATE_DONE ? "[x]" : item->state == STATE_DOING ? "[-]" : "[ ]"; @@ -787,12 +1035,21 @@ static void render(const struct List* list, int cursor, int scroll, enum Mode mo name++; } + char prefix[32]; + (void) snprintf(prefix, sizeof prefix, "%*s%s ", indent, "", box); + + int caret = 0; + if (index == cursor) - cursor_row = 4 + emitted; + { + int offset = (int) (name - item->text); + int byte = edit_pos - offset < 0 ? 0 : edit_pos - offset; + caret = utf8_width(name, byte); + } - 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); + length = emit_wrapped(buffer, length, cols, background, background_reset, + prefix, indent + 4, indent + 4, dim, dim_reset, name, + matches, &emitted, visible, index == cursor, caret, &cursor_row, &cursor_column); } else { @@ -812,7 +1069,7 @@ static void render(const struct List* list, int cursor, int scroll, enum Mode mo int number = 0; for (int k = index; k >= 0 && !is_section(&list->items[k]); k--) - if (!is_subtask(&list->items[k])) + if (!is_subtask(&list->items[k]) && !is_note(&list->items[k])) number++; const char* task_box = is_optional(item) @@ -828,16 +1085,25 @@ static void render(const struct List* list, int cursor, int scroll, enum Mode mo name++; } + char prefix[48]; + (void) snprintf(prefix, sizeof prefix, "%*s\x1b[2m%*d.\x1b[22m %s ", + base_indent, "", number_width, number, task_box); + + int caret = 0; + if (index == cursor) - cursor_row = 4 + emitted; + { + int offset = (int) (name - item->text); + int byte = edit_pos - offset < 0 ? 0 : edit_pos - offset; + caret = utf8_width(name, byte); + } - 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, - task_box, dim, name, dim_reset, background_reset); + length = emit_wrapped(buffer, length, cols, background, background_reset, + prefix, base_indent + number_width + 6, base_indent + number_width + 6, + dim, dim_reset, name, + matches, &emitted, visible, index == cursor, caret, &cursor_row, &cursor_column); } - emitted++; index++; } @@ -863,67 +1129,53 @@ static void render(const struct List* list, int cursor, int scroll, enum Mode mo if (mode == MODE_COMMAND || mode == MODE_SEARCH) { - int cursor_column = LEFT_MARGIN + 2 + (int) strlen(command); + 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 (mode != MODE_CONFIRM && cursor >= 0 && cursor < list->count && cursor_row >= 0) { - int cursor_column; - int caret = utf8_width(list->items[cursor].text, edit_pos); - 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; } - 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 + number_width + SUBTASK_INDENT + 5; - else - text_start = base_indent + number_width + 7; - - cursor_column = text_start + caret; - } const char* shape = mode == MODE_REPLACE ? "\x1b[4 q" : 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); + if (cursor_column >= 0) + 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); + if (!measure) + (void) write(STDOUT_FILENO, buffer, (size_t) length); + + return cursor_row; } -static int read_key(void) +static int pending_key = -1; + +static void unget_key(int key) { - static int pending = -1; + pending_key = key; +} - if (pending != -1) +static int read_key(void) +{ + if (pending_key != -1) { - int key = pending; - pending = -1; + int key = pending_key; + pending_key = -1; return key; } @@ -935,9 +1187,6 @@ static int read_key(void) if (character != 27) return (unsigned char) character; - /* an escape: peek for an arrow sequence without blocking. a byte that is - * not part of a sequence is pushed back so a lone escape followed by a - * command (e.g. :wq) never swallows the following keys. */ int flags = fcntl(STDIN_FILENO, F_GETFL); (void) fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK); @@ -968,15 +1217,307 @@ static int read_key(void) } (void) fcntl(STDIN_FILENO, F_SETFL, flags); - pending = (unsigned char) first; + pending_key = (unsigned char) first; return 27; } +static const char* item_display(const struct List* list, int index, int number_width, + int* first_col, int* cont_col) +{ + const struct Item* item = &list->items[index]; + 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; + + if (is_note(item)) + { + int indent = base_indent + number_width + 6; + *first_col = indent; + *cont_col = indent + 2; + return item->text; + } + + if (is_subtask(item)) + { + int indent = base_indent + number_width + SUBTASK_INDENT; + *first_col = *cont_col = indent + 4; + const char* name = item->text + 1; + + while (*name == ' ') + name++; + + return name; + } + + *first_col = *cont_col = base_indent + number_width + 6; + const char* name = item->text; + + if (is_optional(item)) + { + name = item->text + 1; + + while (*name == ' ') + name++; + } + + return name; +} + +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 && ((unsigned char) 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 int display_width(const char* name, int from, int to) +{ + int width = 0; + + for (int i = from; i < to; i++) + if (((unsigned char) 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 && ((unsigned char) 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; + } + + int first_col; + int cont_col; + const char* name = item_display(list, index, number_width, &first_col, &cont_col); + 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, int* cursor, int* edit_pos, int number_width, int cols, int down) +{ + if (*cursor < 0 || *cursor >= list->count) + return; + + const struct Item* item = &list->items[*cursor]; + int line = 0; + int lines = 1; + int goal = 0; + + if (!is_section(item)) + { + int first_col; + int cont_col; + const char* name = item_display(list, *cursor, number_width, &first_col, &cont_col); + int name_offset = (int) (name - item->text); + int name_length = (int) strlen(name); + + int starts[MAX_TEXT]; + int ends[MAX_TEXT]; + lines = wrap_lines(name, cols, first_col, cont_col, starts, ends, MAX_TEXT); + + int pos = *edit_pos - name_offset; + + if (pos < 0) + pos = 0; + + if (pos > name_length) + pos = name_length; + + for (int i = 0; i < lines; i++) + if (starts[i] <= pos) + line = i; + + int base = line == 0 ? first_col : cont_col; + goal = base + display_width(name, starts[line], pos); + + 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 && *cursor + 1 < list->count) + { + (*cursor)++; + place_on_line(list, *cursor, number_width, cols, 0, goal, edit_pos); + } + else if (!down && *cursor > 0) + { + (*cursor)--; + place_on_line(list, *cursor, number_width, cols, 1, goal, edit_pos); + } +} + +static int matching_close(int open) +{ + switch (open) + { + case '(': return ')'; + case '[': return ']'; + case '{': return '}'; + case '<': return '>'; + } + + return 0; +} + +static int matching_open(int close) +{ + switch (close) + { + case ')': return '('; + case ']': return '['; + case '}': return '{'; + case '>': return '<'; + } + + return 0; +} + +static void apply_change(struct List* list, int cursor, enum Mode* mode, int* edit_pos, int from, int to) +{ + struct Item* item = &list->items[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); + *edit_pos = from; + *mode = MODE_INSERT; +} + static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit_pos, - char* command, const char* search, int count, int key) + char* command, char* search, int count, int key) { switch (key) { + case 27: + search[0] = '\0'; + break; + case ':': command[0] = '\0'; *mode = MODE_COMMAND; @@ -992,7 +1533,13 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit int match = find_match(list, *cursor, 1, search); if (match >= 0) + { *cursor = match; + int off = match_offset(list->items[match].text, search); + + if (off >= 0) + *edit_pos = off; + } break; } @@ -1002,27 +1549,43 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit int match = find_match(list, *cursor, -1, search); if (match >= 0) + { *cursor = match; + int off = match_offset(list->items[match].text, search); + + if (off >= 0) + *edit_pos = off; + } break; } case 'y': { - char next; + int next = read_key(); - if (read(STDIN_FILENO, &next, 1) == 1 && next == 'y' - && *cursor >= 0 && *cursor < list->count) + if (next != 'y') { - yank_register = list->items[*cursor]; - yank_full = 1; + unget_key(next); + break; + } + + if (*cursor >= 0 && *cursor < list->count) + { + 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, *cursor + 1, cursor); + paste_item(list, item_block_end(list, *cursor), cursor); break; case 'P': @@ -1031,13 +1594,27 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit case 'j': case KEY_DOWN: - *cursor += count; + { + int number_width = compute_number_width(list); + int cols = wrap_column(); + + for (int repeat = 0; repeat < count; repeat++) + move_visual(list, cursor, edit_pos, number_width, cols, 1); + break; + } case 'k': case KEY_UP: - *cursor -= count; + { + int number_width = compute_number_width(list); + int cols = wrap_column(); + + for (int repeat = 0; repeat < count; repeat++) + move_visual(list, cursor, edit_pos, number_width, cols, 0); + break; + } case 'h': case KEY_LEFT: @@ -1055,6 +1632,18 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit break; + case '0': + if (*cursor >= 0 && *cursor < list->count) + *edit_pos = text_start(&list->items[*cursor]); + + break; + + case '$': + if (*cursor >= 0 && *cursor < list->count) + *edit_pos = (int) strlen(list->items[*cursor].text); + + break; + case 'w': if (*cursor >= 0 && *cursor < list->count) { @@ -1108,7 +1697,7 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit break; case ' ': - if (*cursor >= 0 && *cursor < list->count && !is_section(&list->items[*cursor])) + if (*cursor >= 0 && *cursor < list->count && !is_section(&list->items[*cursor]) && !is_note(&list->items[*cursor])) { history_record(list); struct Item* item = &list->items[*cursor]; @@ -1119,23 +1708,34 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit break; case 'x': - if (*cursor >= 0 && *cursor < list->count && !is_section(&list->items[*cursor])) + if (*cursor >= 0 && *cursor < list->count + && *edit_pos < (int) strlen(list->items[*cursor].text)) { history_record(list); + struct Item* item = &list->items[*cursor]; - item->state = item->state == STATE_DONE ? STATE_TODO : STATE_DONE; - commit(list); + + 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); - insert_item(list, *cursor + 1); - *cursor = *cursor + 1 < list->count ? *cursor + 1 : list->count - 1; + + 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); @@ -1159,15 +1759,10 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit { history_record(list); - if (is_section(&list->items[*cursor])) - { - int position = 1; - - while (list->items[*cursor].text[position] == ' ') - position++; + int start = text_start(&list->items[*cursor]); - *edit_pos = position; - } + if (*edit_pos < start) + *edit_pos = start; *mode = MODE_INSERT; } @@ -1193,12 +1788,193 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit break; + case 'c': + { + if (*cursor < 0 || *cursor >= list->count) + break; + + int op = read_key(); + + const char* text = list->items[*cursor].text; + int length = (int) strlen(text); + int low = text_start(&list->items[*cursor]); + + if (op == 'w' || op == 'e' || op == 'b' || op == '$' + || op == '0' || op == 'c' || op == 'h' || op == 'l') + { + int from = *edit_pos; + int to = *edit_pos; + + if (op == 'w' || op == 'e') + { + int pos = *edit_pos; + + for (int r = 0; r < count; r++) + { + while (pos < length && text[pos] == ' ') + pos++; + + while (pos < length && text[pos] != ' ') + pos++; + } + + to = pos; + } + else if (op == 'b') + { + int pos = *edit_pos; + + for (int r = 0; r < count; r++) + { + if (pos > low) + pos--; + + while (pos > low && text[pos] == ' ') + pos--; + + while (pos > low && text[pos - 1] != ' ') + pos--; + } + + from = pos; + } + else if (op == '$') + { + to = length; + } + else if (op == '0') + { + from = low; + } + else if (op == 'c') + { + from = low; + to = length; + } + else if (op == 'l') + { + for (int r = 0; r < count; r++) + to = utf8_right(text, to); + } + else + { + for (int r = 0; r < count; r++) + from = utf8_left(text, from); + } + + apply_change(list, *cursor, mode, edit_pos, from, to); + break; + } + + if (op != 't' && op != 'f' && op != 'i' && op != 'a') + { + unget_key(op); + break; + } + + int target = read_key(); + + if (target < 32 || target >= 256) + break; + + if (op == 't' || op == 'f') + { + int found = -1; + + for (int i = *edit_pos + 1; i < length; i++) + if ((unsigned char) text[i] == target) + { + found = i; + break; + } + + if (found >= 0) + apply_change(list, *cursor, mode, edit_pos, + *edit_pos, op == 'f' ? found + 1 : found); + + break; + } + + 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; + } + else + { + close_char = target; + } + } + + int open_at = -1; + int close_at = -1; + + for (int i = *edit_pos; i >= low; i--) + if ((unsigned char) text[i] == open_char) + { + open_at = i; + break; + } + + if (open_at >= 0) + { + for (int i = open_at + 1; i < length; i++) + if ((unsigned char) 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 ((unsigned char) text[i] == open_char) + { + open_at = i; + break; + } + + for (int i = open_at + 1; open_at >= 0 && i < length; i++) + if ((unsigned char) text[i] == close_char) + { + close_at = i; + break; + } + } + + if (open_at < 0 || close_at < 0) + break; + + if (op == 'i') + apply_change(list, *cursor, mode, edit_pos, open_at + 1, close_at); + else + apply_change(list, *cursor, mode, edit_pos, open_at, close_at + 1); + + break; + } + case 'g': { - char next; + int next = read_key(); - if (read(STDIN_FILENO, &next, 1) == 1 && next == 'g') + if (next == 'g') *cursor = 0; + else + unget_key(next); break; } @@ -1231,17 +2007,31 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit case 'd': { - char next; + int next = read_key(); - if (read(STDIN_FILENO, &next, 1) == 1 && next == 'd' - && *cursor >= 0 && *cursor < list->count) + if (next != 'd') + { + unget_key(next); + break; + } + + if (*cursor >= 0 && *cursor < list->count) { history_record(list); - yank_register = list->items[*cursor]; - yank_full = 1; - for (int repeat = 0; repeat < count && *cursor < list->count; repeat++) - delete_item(list, *cursor); + 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); } @@ -1253,8 +2043,6 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit if (*cursor < 0 || *cursor >= list->count) break; - history_record(list); - if (is_section(&list->items[*cursor])) { int start = *cursor; @@ -1262,6 +2050,7 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit 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); @@ -1269,8 +2058,25 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit *cursor = start + (next_end - end); } } - else if (*cursor + 1 < list->count && !is_section(&list->items[*cursor + 1])) + else if (!is_subtask(&list->items[*cursor]) && !is_note(&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_subtask(&list->items[*cursor + 1]) || is_note(&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; @@ -1283,14 +2089,13 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit if (*cursor < 0 || *cursor >= list->count) break; - history_record(list); - 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])) @@ -1304,8 +2109,29 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit *cursor = previous_start; } } - else if (*cursor > 0 && !is_section(&list->items[*cursor - 1])) + else if (!is_subtask(&list->items[*cursor]) && !is_note(&list->items[*cursor])) { + int start = *cursor; + int previous = start - 1; + + while (previous >= 0 + && (is_subtask(&list->items[previous]) || is_note(&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_subtask(&list->items[*cursor - 1]) || is_note(&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; @@ -1318,10 +2144,23 @@ static int normal_key(struct List* list, int* cursor, enum Mode* mode, int* edit { history_record(list); int kept = 0; + int i = 0; - for (int i = 0; i < list->count; i++) - if (list->items[i].state != STATE_DONE) - list->items[kept++] = list->items[i]; + while (i < list->count) + { + int end = item_block_end(list, i); + + int drop = !is_section(&list->items[i]) + && !is_subtask(&list->items[i]) + && !is_note(&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); @@ -1360,8 +2199,9 @@ static void insert_key(struct List* list, int* cursor, enum Mode* mode, int* edi if (key == '\r' || key == '\n') { - insert_item(list, *cursor + 1); - (*cursor)++; + int at = item_block_end(list, *cursor); + insert_item(list, at); + *cursor = at; *edit_pos = 0; return; } @@ -1444,6 +2284,15 @@ static int command_key(struct List* list, const char* path, enum Mode* mode, cha return 1; } + if (strcmp(command, "reorder") == 0) + { + history_record(list); + commit(list); + command[0] = '\0'; + *mode = MODE_NORMAL; + return 1; + } + int write = strchr(command, 'w') != NULL || strcmp(command, "x") == 0; int quit = strchr(command, 'q') != NULL || strcmp(command, "x") == 0; @@ -1479,7 +2328,7 @@ static int command_key(struct List* list, const char* path, enum Mode* mode, cha return 1; } -static void search_key(const struct List* list, int* cursor, enum Mode* mode, char* command, char* search, int key) +static void search_key(const struct List* list, int* cursor, int* edit_pos, enum Mode* mode, char* command, char* search, int key) { size_t length = strlen(command); @@ -1499,7 +2348,13 @@ static void search_key(const struct List* list, int* cursor, enum Mode* mode, ch int match = find_match(list, *cursor, 1, search); if (match >= 0) + { *cursor = match; + int off = match_offset(list->items[match].text, search); + + if (off >= 0) + *edit_pos = off; + } return; } @@ -1563,37 +2418,40 @@ static void run(struct List* list, const char* path) 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) { const char* text = list->items[cursor].text; int text_length = (int) strlen(text); int caret_max = is_editing(mode) ? text_length : (text_length > 0 ? text_length - 1 : 0); + int caret_min = is_editing(mode) ? 0 : text_start(&list->items[cursor]); + + if (caret_min > caret_max) + caret_min = caret_max; if (edit_pos > caret_max) edit_pos = caret_max; - if (edit_pos < 0) - edit_pos = 0; + if (edit_pos < caret_min) + edit_pos = caret_min; - while (edit_pos > 0 && ((unsigned char) text[edit_pos] & 0xC0) == 0x80) + while (edit_pos > caret_min && ((unsigned char) text[edit_pos] & 0xC0) == 0x80) edit_pos--; } - render(list, cursor, scroll, mode, edit_pos, command); + if (cursor < scroll) + scroll = cursor; + + while (scroll < cursor + && render(list, cursor, scroll, mode, edit_pos, command, search, 1) < 0) + scroll++; + + render(list, cursor, scroll, mode, edit_pos, command, search, 0); int key = read_key(); @@ -1629,7 +2487,7 @@ static void run(struct List* list, const char* path) break; case MODE_SEARCH: - search_key(list, &cursor, &mode, command, search, key); + search_key(list, &cursor, &edit_pos, &mode, command, search, key); break; case MODE_CONFIRM: @@ -1654,15 +2512,21 @@ static void print_help(void) " %s -v | --version\n" "\n" "keys:\n" - " h j k l / arrows move w / b words { / } sections\n" - " gg / G top/bottom o new item i insert at cursor\n" - " A append text R overtype (replace) 3j 2dd counts\n" - " x toggle done space cycle state\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" + " 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" - " J / K move item or section / n N search u ctrl-r undo/redo\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 :wq :x write / quit :reset all back to todo :clear empty\n" + " :reorder re-sort the list\n" "\n" - "items: '=' section, '==' subsection, '-' subtask, '*' optional, else a task.\n" + "items: '=' section, '==' subsection, '-' subtask, '*' optional, '>' note.\n" "\n" "by hachem <im@hachem.wtf>\n", TODO_PROGRAM, TODO_VERSION, TODO_PROGRAM, TODO_PROGRAM, TODO_PROGRAM); |
