diff options
| author | hachem <im@hachem.wtf> | 2026-09-29 15:45:10 +0200 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2026-09-29 15:45:10 +0200 |
| commit | 992f807b36d4f7cae5187110980d50c593967e82 (patch) | |
| tree | 5b4c6964eee2337be09d0e108538030fdbbdfeb0 | |
feat: basic modal ui
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Makefile | 50 | ||||
| -rw-r--r-- | todo.c | 319 |
3 files changed, 372 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b58d09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/todo +todo.dSYM/ +tests/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0a5bc8d --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +CC := clang +STD := c17 + +DEBUG ?= 1 +SANITIZE ?= 1 + +CFLAGS := -std=$(STD) \ + -Wall \ + -Wextra \ + -Wshadow \ + -Wpedantic \ + -Wconversion \ + -Wstrict-prototypes \ + -Wsign-conversion \ + -Wmissing-prototypes + +ifeq ($(DEBUG),1) +CFLAGS += -g -fno-omit-frame-pointer +else +CFLAGS += -O2 +endif + +ifeq ($(SANITIZE),1) +CFLAGS += -fsanitize=address,undefined +LDFLAGS += -fsanitize=address,undefined +endif + +TARGET := todo +SRC := todo.c + +PREFIX ?= /usr/local +bindir := $(DESTDIR)$(PREFIX)/bin + +all: $(TARGET) + +$(TARGET): $(SRC) + $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) + +clean: + rm -rf $(TARGET) $(TARGET).dSYM + +install: + make DEBUG=0 SANITIZE=0 clean all + install -d $(bindir) + install -m 755 $(TARGET) $(bindir)/$(TARGET) + +uninstall: + rm -f $(bindir)/$(TARGET) + +.PHONY: all clean install uninstall @@ -0,0 +1,319 @@ +/* + * todo - a little modal todo list. + * + * Author: hachem <im@hachem.wtf> + * Version: 0.1.0 + */ + +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <termios.h> +#include <unistd.h> + +#define todo_program "todo" +#define todo_version "0.1.0" + +#define max_items 4096 +#define max_text 256 + +struct Item +{ + int done; + char text[max_text]; +}; + +struct List +{ + struct Item items[max_items]; + int count; +}; + +enum Mode +{ + mode_normal +}; + +static const char* mode_name(enum Mode mode) +{ + switch (mode) + { + case mode_normal: return "NORMAL"; + } + + return "?"; +} + +static void todo_path(char* buf, size_t size) +{ + const char* env = getenv("TODO_FILE"); + + if (env != NULL && env[0] != '\0') + { + (void) snprintf(buf, size, "%s", env); + return; + } + + const char* home = getenv("HOME"); + + (void) snprintf(buf, size, "%s/.todo.txt", home != NULL ? home : "."); +} + +static void strip_newline(char* s) +{ + size_t len = strlen(s); + + while (len > 0 && (s[len - 1] == '\n' || s[len - 1] == '\r')) + s[--len] = '\0'; +} + +/* read the list file. each line is "[ ] text" or "[x] text"; anything else is + * treated as an undone item so hand-edited files still load. */ +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); + + int done = 0; + char* text = line; + + if (line[0] == '[' && line[1] != '\0' && line[2] == ']') + { + done = line[1] == 'x' || line[1] == 'X'; + text = line + 3; + + while (*text == ' ') + text++; + } + + if (*text == '\0') + continue; + + struct Item* item = &list->items[list->count++]; + item->done = done; + (void) snprintf(item->text, sizeof item->text, "%s", text); + } + + (void) fclose(file); +} + +static void save(const struct List* list, const char* path) +{ + FILE* file = fopen(path, "w"); + + if (file == NULL) + return; + + for (int i = 0; i < list->count; i++) + (void) fprintf(file, "[%c] %s\n", list->items[i].done ? 'x' : ' ', list->items[i].text); + + (void) fclose(file); +} + +static struct termios saved_termios; + +static void restore_terminal(void) +{ + (void) tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved_termios); + (void) write(STDOUT_FILENO, "\x1b[?25h\x1b[?1049l", 14); +} + +static void on_signal(int signum) +{ + (void) signum; + 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 ws; + + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_row > 0) + return ws.ws_row; + + return 24; +} + +static void render(const struct List* list, int cursor, int scroll, enum Mode mode) +{ + int rows = screen_rows(); + int visible = rows - 1; + + if (visible < 1) + visible = 1; + + char buf[64 * 1024]; + int n = 0; + + n += snprintf(buf + n, sizeof buf - (size_t) n, "\x1b[H"); + + for (int row = 0; row < visible; row++) + { + int i = scroll + row; + + if (i >= list->count) + { + n += snprintf(buf + n, sizeof buf - (size_t) n, "\x1b[K\r\n"); + continue; + } + + const struct Item* item = &list->items[i]; + const char* highlight = i == cursor ? "\x1b[7m" : ""; + const char* box = item->done ? "\x1b[32m[x]\x1b[0m" : "[ ]"; + const char* dim = item->done ? "\x1b[2m" : ""; + + n += snprintf(buf + n, sizeof buf - (size_t) n, + "%s %s %s%s\x1b[0m\x1b[K\r\n", highlight, box, dim, item->text); + } + + int done = 0; + + for (int i = 0; i < list->count; i++) + done += list->items[i].done; + + n += snprintf(buf + n, sizeof buf - (size_t) n, + "\x1b[2m-- %s -- %d/%d done\x1b[0m\x1b[K\x1b[J", mode_name(mode), done, list->count); + + (void) write(STDOUT_FILENO, buf, (size_t) n); +} + +static int normal_key(struct List* list, const char* path, int* cursor, char c) +{ + switch (c) + { + case 'q': + return 0; + + case 'j': + (*cursor)++; + break; + + case 'k': + (*cursor)--; + break; + + case ' ': + case 'x': + if (*cursor >= 0 && *cursor < list->count) + { + list->items[*cursor].done = !list->items[*cursor].done; + save(list, path); + } + + break; + + default: + break; + } + + return 1; +} + +static void run(struct List* list, const char* path) +{ + if (enter_raw_mode() != 0) + exit(EXIT_FAILURE); + + enum Mode mode = mode_normal; + int cursor = 0; + int scroll = 0; + + for (;;) + { + int visible = screen_rows() - 1; + + if (visible < 1) + visible = 1; + + 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; + + render(list, cursor, scroll, mode); + + char c; + + if (read(STDIN_FILENO, &c, 1) != 1) + break; + + int keep = 1; + + switch (mode) + { + case mode_normal: + keep = normal_key(list, path, &cursor, c); + break; + } + + if (!keep) + break; + } +} + +int main(void) +{ + 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; +} |
