hachem.wtf
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-09-29 15:45:10 +0200
committerhachem <im@hachem.wtf>2026-09-29 15:45:10 +0200
commit992f807b36d4f7cae5187110980d50c593967e82 (patch)
tree5b4c6964eee2337be09d0e108538030fdbbdfeb0
feat: basic modal ui
-rw-r--r--.gitignore3
-rw-r--r--Makefile50
-rw-r--r--todo.c319
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
diff --git a/todo.c b/todo.c
new file mode 100644
index 0000000..802c705
--- /dev/null
+++ b/todo.c
@@ -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;
+}