From e95edef2f8d4671055456372bd8b350ad353e988 Mon Sep 17 00:00:00 2001 From: Kris Yotam Date: Sat, 21 Mar 2026 00:16:55 -0500 Subject: [PATCH] =?UTF-8?q?initial=20commit:=20stask=20v0.1=20=E2=80=94=20?= =?UTF-8?q?suckless=20task=20manager=20with=20CLI=20+=20GTK4=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + LICENSE | 21 ++ Makefile | 33 +++ config.def.h | 69 +++++ config.mk | 24 ++ data.c | 387 +++++++++++++++++++++++++++ gui.c | 731 +++++++++++++++++++++++++++++++++++++++++++++++++++ stask.c | 209 +++++++++++++++ stask.h | 63 +++++ 9 files changed, 1540 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 config.def.h create mode 100644 config.mk create mode 100644 data.c create mode 100644 gui.c create mode 100644 stask.c create mode 100644 stask.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..020af7a9910919717f3724ad0fd3f9b4a2eef268 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.o +stask +config.h diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..7d902e6acd57b45376f3727449f6f22c3c81cac3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kris Yotam + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..acd51c4b5fe6312d1192a1cdf37e89f902067227 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +# stask - simple task manager +# See LICENSE file for copyright and license details. + +include config.mk + +SRC = stask.c data.c gui.c +OBJ = ${SRC:.c=.o} + +all: stask + +.c.o: + ${CC} -c ${CFLAGS} $< + +${OBJ}: stask.h config.h + +config.h: + cp config.def.h config.h + +stask: ${OBJ} + ${CC} -o $@ ${OBJ} ${LDFLAGS} + +clean: + rm -f stask ${OBJ} + +install: stask + mkdir -p ${DESTDIR}${PREFIX}/bin + cp -f stask ${DESTDIR}${PREFIX}/bin + chmod 755 ${DESTDIR}${PREFIX}/bin/stask + +uninstall: + rm -f ${DESTDIR}${PREFIX}/bin/stask + +.PHONY: all clean install uninstall diff --git a/config.def.h b/config.def.h new file mode 100644 index 0000000000000000000000000000000000000000..767582af2f4fe24fdee1a8d1e8dd266d3fa72f0a --- /dev/null +++ b/config.def.h @@ -0,0 +1,69 @@ +/* stask - simple task manager + * See LICENSE file for copyright and license details. */ + +/* krisyotam.com monochromatic palette */ +/* all colors: {r, g, b, a} - pure grayscale (0 saturation) */ + +static const Theme theme_light = { + /* bg: hsl(0 0% 98%) */ + .bg = {0.98, 0.98, 0.98, 1.0}, + /* card: hsl(0 0% 99%) */ + .card = {0.99, 0.99, 0.99, 1.0}, + /* fg: hsl(0 0% 20%) */ + .fg = {0.20, 0.20, 0.20, 1.0}, + /* border: hsl(0 0% 88%) */ + .border = {0.88, 0.88, 0.88, 1.0}, + /* muted: hsl(0 0% 92%) */ + .muted = {0.92, 0.92, 0.92, 1.0}, + /* muted-fg: hsl(0 0% 50%) */ + .muted_fg = {0.50, 0.50, 0.50, 1.0}, + /* primary: hsl(0 0% 10%) */ + .primary = {0.10, 0.10, 0.10, 1.0}, + /* primary-fg: hsl(0 0% 95%) */ + .primary_fg = {0.95, 0.95, 0.95, 1.0}, + /* selection: hsl(0 0% 40%) */ + .sel = {0.40, 0.40, 0.40, 1.0}, + /* accent: hsl(0 0% 10%) - dark in light mode */ + .accent = {0.10, 0.10, 0.10, 1.0}, + /* accent-fg: hsl(0 0% 95%) */ + .accent_fg = {0.95, 0.95, 0.95, 1.0}, +}; + +static const Theme theme_dark = { + /* bg: hsl(0 0% 7%) */ + .bg = {0.07, 0.07, 0.07, 1.0}, + /* card: hsl(0 0% 9%) */ + .card = {0.09, 0.09, 0.09, 1.0}, + /* fg: hsl(0 0% 98%) */ + .fg = {0.98, 0.98, 0.98, 1.0}, + /* border: hsl(0 0% 15%) */ + .border = {0.15, 0.15, 0.15, 1.0}, + /* muted: hsl(0 0% 12%) */ + .muted = {0.12, 0.12, 0.12, 1.0}, + /* muted-fg: hsl(0 0% 70%) */ + .muted_fg = {0.70, 0.70, 0.70, 1.0}, + /* primary: hsl(0 0% 98%) */ + .primary = {0.98, 0.98, 0.98, 1.0}, + /* primary-fg: hsl(0 0% 9%) */ + .primary_fg = {0.09, 0.09, 0.09, 1.0}, + /* selection: hsl(0 0% 83%) */ + .sel = {0.83, 0.83, 0.83, 1.0}, + /* accent: hsl(0 0% 88%) - light in dark mode */ + .accent = {0.88, 0.88, 0.88, 1.0}, + /* accent-fg: hsl(0 0% 9%) */ + .accent_fg = {0.09, 0.09, 0.09, 1.0}, +}; + +/* default: 0=light, 1=dark */ +static const int default_dark = 0; + +/* GTK CSS for squared corners (krisyotam.com aesthetic) */ +static const char *app_css = + "* { border-radius: 0; }\n" + "button { border-radius: 0; padding: 4px 10px; }\n" + "popover > contents { border-radius: 0; padding: 0; }\n" + "entry { border-radius: 0; }\n" + "headerbar { border-radius: 0; }\n" + "window { border-radius: 0; }\n" + "separator { margin: 0 2px; }\n" + "checkbutton indicator { border-radius: 0; }\n"; diff --git a/config.mk b/config.mk new file mode 100644 index 0000000000000000000000000000000000000000..89195aeb96fc8bb7911a3b530be659a751559eba --- /dev/null +++ b/config.mk @@ -0,0 +1,24 @@ +# stask - simple task manager +# See LICENSE file for copyright and license details. + +VERSION = 0.1 + +PREFIX = /usr/local +MANPREFIX = ${PREFIX}/share/man + +PKG_CONFIG = pkg-config + +# GTK4 and SQLite3 +GTKINC = `$(PKG_CONFIG) --cflags gtk4` +GTKLIB = `$(PKG_CONFIG) --libs gtk4` +SQLITEINC = `$(PKG_CONFIG) --cflags sqlite3` +SQLITELIB = `$(PKG_CONFIG) --libs sqlite3` + +INCS = ${GTKINC} ${SQLITEINC} +LIBS = ${GTKLIB} ${SQLITELIB} + +CPPFLAGS = -D_POSIX_C_SOURCE=200809L -DVERSION=\"${VERSION}\" +CFLAGS = -std=c99 -pedantic -Wall -Wextra -Os ${INCS} ${CPPFLAGS} +LDFLAGS = ${LIBS} + +CC = cc diff --git a/data.c b/data.c new file mode 100644 index 0000000000000000000000000000000000000000..b4bb65fed2cd8fc0ca1c86e0568db28528f05ea6 --- /dev/null +++ b/data.c @@ -0,0 +1,387 @@ +/* stask - simple task manager + * See LICENSE file for copyright and license details. */ + +#include +#include +#include +#include +#include + +#include "stask.h" + +static void +now_str(char *buf, int len) +{ + time_t t; + struct tm *tm; + + t = time(NULL); + tm = localtime(&t); + strftime(buf, len, "%Y-%m-%d", tm); +} + +static int +ensure_dir(const char *path) +{ + char tmp[1024]; + char *p; + + snprintf(tmp, sizeof(tmp), "%s", path); + for (p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + mkdir(tmp, 0755); + *p = '/'; + } + } + return mkdir(tmp, 0755); +} + +int +db_open(TaskDB *tdb) +{ + const char *home; + char dir[1024]; + int rc; + const char *sql; + + home = getenv("HOME"); + if (!home) + home = "/tmp"; + + snprintf(dir, sizeof(dir), + "%s/.local/share/stask", home); + ensure_dir(dir); + + snprintf(tdb->dbpath, sizeof(tdb->dbpath), + "%s/stask.db", dir); + + rc = sqlite3_open(tdb->dbpath, &tdb->db); + if (rc != SQLITE_OK) { + fprintf(stderr, "stask: cannot open db: %s\n", + sqlite3_errmsg(tdb->db)); + return -1; + } + + sql = + "CREATE TABLE IF NOT EXISTS lists (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " name TEXT UNIQUE NOT NULL," + " created TEXT NOT NULL" + ");" + "CREATE TABLE IF NOT EXISTS tasks (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " list_id INTEGER NOT NULL," + " text TEXT NOT NULL," + " done INTEGER NOT NULL DEFAULT 0," + " added TEXT NOT NULL," + " FOREIGN KEY(list_id) REFERENCES lists(id)" + " ON DELETE CASCADE" + ");"; + + rc = sqlite3_exec(tdb->db, sql, NULL, NULL, NULL); + if (rc != SQLITE_OK) { + fprintf(stderr, "stask: schema error: %s\n", + sqlite3_errmsg(tdb->db)); + return -1; + } + + /* enable foreign keys */ + sqlite3_exec(tdb->db, + "PRAGMA foreign_keys = ON;", + NULL, NULL, NULL); + + return 0; +} + +void +db_close(TaskDB *tdb) +{ + if (tdb->db) + sqlite3_close(tdb->db); + tdb->db = NULL; +} + +int +list_create(TaskDB *tdb, const char *name) +{ + sqlite3_stmt *stmt; + char date[32]; + int rc; + + now_str(date, sizeof(date)); + rc = sqlite3_prepare_v2(tdb->db, + "INSERT INTO lists (name, created) VALUES (?, ?);", + -1, &stmt, NULL); + if (rc != SQLITE_OK) + return -1; + + sqlite3_bind_text(stmt, 1, name, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, date, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE ? 0 : -1; +} + +int +list_delete(TaskDB *tdb, const char *name) +{ + sqlite3_stmt *stmt; + int rc; + + /* delete tasks first (in case FK not enforced) */ + rc = sqlite3_prepare_v2(tdb->db, + "DELETE FROM tasks WHERE list_id = " + "(SELECT id FROM lists WHERE name = ?);", + -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, name, -1, + SQLITE_STATIC); + sqlite3_step(stmt); + sqlite3_finalize(stmt); + } + + rc = sqlite3_prepare_v2(tdb->db, + "DELETE FROM lists WHERE name = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) + return -1; + + sqlite3_bind_text(stmt, 1, name, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE ? 0 : -1; +} + +int +list_all(TaskDB *tdb, List **out, int *count) +{ + sqlite3_stmt *stmt; + int rc, n, cap; + List *arr; + + *out = NULL; + *count = 0; + + rc = sqlite3_prepare_v2(tdb->db, + "SELECT id, name, created FROM lists " + "ORDER BY name;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) + return -1; + + n = 0; + cap = 16; + arr = malloc(cap * sizeof(List)); + if (!arr) { + sqlite3_finalize(stmt); + return -1; + } + + while (sqlite3_step(stmt) == SQLITE_ROW) { + if (n >= cap) { + cap *= 2; + arr = realloc(arr, cap * sizeof(List)); + if (!arr) { + sqlite3_finalize(stmt); + return -1; + } + } + arr[n].id = sqlite3_column_int(stmt, 0); + snprintf(arr[n].name, sizeof(arr[n].name), + "%s", sqlite3_column_text(stmt, 1)); + snprintf(arr[n].created, + sizeof(arr[n].created), + "%s", sqlite3_column_text(stmt, 2)); + n++; + } + + sqlite3_finalize(stmt); + *out = arr; + *count = n; + return 0; +} + +int +list_by_name(TaskDB *tdb, const char *name, List *out) +{ + sqlite3_stmt *stmt; + int rc; + + rc = sqlite3_prepare_v2(tdb->db, + "SELECT id, name, created FROM lists " + "WHERE name = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) + return -1; + + sqlite3_bind_text(stmt, 1, name, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + if (rc != SQLITE_ROW) { + sqlite3_finalize(stmt); + return -1; + } + + out->id = sqlite3_column_int(stmt, 0); + snprintf(out->name, sizeof(out->name), + "%s", sqlite3_column_text(stmt, 1)); + snprintf(out->created, sizeof(out->created), + "%s", sqlite3_column_text(stmt, 2)); + sqlite3_finalize(stmt); + return 0; +} + +int +task_add(TaskDB *tdb, int list_id, const char *text) +{ + sqlite3_stmt *stmt; + char date[32]; + int rc; + + now_str(date, sizeof(date)); + rc = sqlite3_prepare_v2(tdb->db, + "INSERT INTO tasks (list_id, text, done, added)" + " VALUES (?, ?, 0, ?);", + -1, &stmt, NULL); + if (rc != SQLITE_OK) + return -1; + + sqlite3_bind_int(stmt, 1, list_id); + sqlite3_bind_text(stmt, 2, text, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 3, date, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE ? 0 : -1; +} + +int +task_del(TaskDB *tdb, int task_id) +{ + sqlite3_stmt *stmt; + int rc; + + rc = sqlite3_prepare_v2(tdb->db, + "DELETE FROM tasks WHERE id = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) + return -1; + + sqlite3_bind_int(stmt, 1, task_id); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE ? 0 : -1; +} + +int +task_done(TaskDB *tdb, int task_id) +{ + sqlite3_stmt *stmt; + int rc; + + rc = sqlite3_prepare_v2(tdb->db, + "UPDATE tasks SET done = 1 WHERE id = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) + return -1; + + sqlite3_bind_int(stmt, 1, task_id); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE ? 0 : -1; +} + +int +task_undo(TaskDB *tdb, int task_id) +{ + sqlite3_stmt *stmt; + int rc; + + rc = sqlite3_prepare_v2(tdb->db, + "UPDATE tasks SET done = 0 WHERE id = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) + return -1; + + sqlite3_bind_int(stmt, 1, task_id); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE ? 0 : -1; +} + +int +task_all(TaskDB *tdb, int list_id, Task **out, int *count) +{ + sqlite3_stmt *stmt; + int rc, n, cap; + Task *arr; + + *out = NULL; + *count = 0; + + rc = sqlite3_prepare_v2(tdb->db, + "SELECT id, list_id, text, done, added " + "FROM tasks WHERE list_id = ? " + "ORDER BY done ASC, id ASC;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) + return -1; + + sqlite3_bind_int(stmt, 1, list_id); + + n = 0; + cap = 32; + arr = malloc(cap * sizeof(Task)); + if (!arr) { + sqlite3_finalize(stmt); + return -1; + } + + while (sqlite3_step(stmt) == SQLITE_ROW) { + if (n >= cap) { + cap *= 2; + arr = realloc(arr, cap * sizeof(Task)); + if (!arr) { + sqlite3_finalize(stmt); + return -1; + } + } + arr[n].id = sqlite3_column_int(stmt, 0); + arr[n].list_id = sqlite3_column_int(stmt, 1); + snprintf(arr[n].text, sizeof(arr[n].text), + "%s", sqlite3_column_text(stmt, 2)); + arr[n].done = sqlite3_column_int(stmt, 3); + snprintf(arr[n].added, sizeof(arr[n].added), + "%s", sqlite3_column_text(stmt, 4)); + n++; + } + + sqlite3_finalize(stmt); + *out = arr; + *count = n; + return 0; +} + +int +task_count(TaskDB *tdb, int list_id, int *total, int *done) +{ + sqlite3_stmt *stmt; + int rc; + + *total = 0; + *done = 0; + + rc = sqlite3_prepare_v2(tdb->db, + "SELECT COUNT(*), SUM(done) FROM tasks " + "WHERE list_id = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) + return -1; + + sqlite3_bind_int(stmt, 1, list_id); + if (sqlite3_step(stmt) == SQLITE_ROW) { + *total = sqlite3_column_int(stmt, 0); + *done = sqlite3_column_int(stmt, 1); + } + sqlite3_finalize(stmt); + return 0; +} diff --git a/gui.c b/gui.c new file mode 100644 index 0000000000000000000000000000000000000000..bc46bd6cfa6de384c44814b8c97982a93bdadf3b --- /dev/null +++ b/gui.c @@ -0,0 +1,731 @@ +/* stask - simple task manager + * See LICENSE file for copyright and license details. */ + +#include +#include +#include + +#include +#include "stask.h" +#include "config.h" + +typedef struct { + TaskDB *tdb; + int dark; + const Theme *theme; + + GtkWidget *window; + GtkWidget *theme_btn; + GtkWidget *list_box; + GtkWidget *task_box; + GtkWidget *list_label; + GtkWidget *entry; + GtkWidget *status; + GtkCssProvider *theme_css; + + int cur_list; /* selected list id, -1 = none */ + char cur_name[128]; /* selected list name */ +} Gui; + +static Gui gui; + +/* theme CSS */ + +static void +apply_theme_css(Gui *g) +{ + char css[4096]; + const Theme *t; + int bg[3], fg[3], bd[3], mt[3], cd[3], mfg[3]; + int pr[3], pfg[3], ac[3], afg[3]; + + t = g->theme; + bg[0] = (int)(t->bg[0] * 255); + bg[1] = (int)(t->bg[1] * 255); + bg[2] = (int)(t->bg[2] * 255); + fg[0] = (int)(t->fg[0] * 255); + fg[1] = (int)(t->fg[1] * 255); + fg[2] = (int)(t->fg[2] * 255); + bd[0] = (int)(t->border[0] * 255); + bd[1] = (int)(t->border[1] * 255); + bd[2] = (int)(t->border[2] * 255); + mt[0] = (int)(t->muted[0] * 255); + mt[1] = (int)(t->muted[1] * 255); + mt[2] = (int)(t->muted[2] * 255); + cd[0] = (int)(t->card[0] * 255); + cd[1] = (int)(t->card[1] * 255); + cd[2] = (int)(t->card[2] * 255); + mfg[0] = (int)(t->muted_fg[0] * 255); + mfg[1] = (int)(t->muted_fg[1] * 255); + mfg[2] = (int)(t->muted_fg[2] * 255); + pr[0] = (int)(t->primary[0] * 255); + pr[1] = (int)(t->primary[1] * 255); + pr[2] = (int)(t->primary[2] * 255); + pfg[0] = (int)(t->primary_fg[0] * 255); + pfg[1] = (int)(t->primary_fg[1] * 255); + pfg[2] = (int)(t->primary_fg[2] * 255); + ac[0] = (int)(t->accent[0] * 255); + ac[1] = (int)(t->accent[1] * 255); + ac[2] = (int)(t->accent[2] * 255); + afg[0] = (int)(t->accent_fg[0] * 255); + afg[1] = (int)(t->accent_fg[1] * 255); + afg[2] = (int)(t->accent_fg[2] * 255); + + snprintf(css, sizeof(css), + "window, box, paned {" + " background: rgb(%d,%d,%d);" + " color: rgb(%d,%d,%d);" + "}" + "headerbar {" + " background: rgb(%d,%d,%d);" + " border-bottom: 1px solid rgb(%d,%d,%d);" + " color: rgb(%d,%d,%d);" + "}" + "headerbar button {" + " background: rgb(%d,%d,%d);" + " color: rgb(%d,%d,%d);" + " border: 1px solid rgb(%d,%d,%d);" + "}" + "headerbar button:hover {" + " background: rgb(%d,%d,%d);" + "}" + "label {" + " color: rgb(%d,%d,%d);" + "}" + "entry {" + " background: rgb(%d,%d,%d);" + " color: rgb(%d,%d,%d);" + " border: 1px solid rgb(%d,%d,%d);" + " caret-color: rgb(%d,%d,%d);" + "}" + "listboxrow {" + " background: rgb(%d,%d,%d);" + " color: rgb(%d,%d,%d);" + " padding: 6px 10px;" + " border-bottom: 1px solid rgb(%d,%d,%d);" + "}" + "listboxrow:selected {" + " background: rgb(%d,%d,%d);" + " color: rgb(%d,%d,%d);" + "}" + "separator {" + " background: rgb(%d,%d,%d);" + " min-width: 1px; min-height: 1px;" + "}" + "checkbutton {" + " color: rgb(%d,%d,%d);" + "}" + "checkbutton indicator {" + " border: 1px solid rgb(%d,%d,%d);" + " background: rgb(%d,%d,%d);" + "}" + ".task-done label {" + " color: rgb(%d,%d,%d);" + "}" + ".list-header {" + " font-weight: bold;" + " font-size: 14px;" + " padding: 8px 12px;" + "}" + ".task-date {" + " color: rgb(%d,%d,%d);" + " font-size: 11px;" + "}" + "popover > contents {" + " background: rgb(%d,%d,%d);" + " border: 1px solid rgb(%d,%d,%d);" + "}" + "popover button {" + " background: transparent;" + " color: rgb(%d,%d,%d);" + " padding: 6px 12px;" + "}" + "popover button:hover {" + " background: rgb(%d,%d,%d);" + "}", + /* window bg */ bg[0], bg[1], bg[2], + /* window fg */ fg[0], fg[1], fg[2], + /* headerbar bg */ bg[0], bg[1], bg[2], + /* headerbar border */ bd[0], bd[1], bd[2], + /* headerbar fg */ fg[0], fg[1], fg[2], + /* btn bg */ mt[0], mt[1], mt[2], + /* btn fg */ fg[0], fg[1], fg[2], + /* btn border */ bd[0], bd[1], bd[2], + /* btn hover */ bd[0], bd[1], bd[2], + /* label */ fg[0], fg[1], fg[2], + /* entry bg */ cd[0], cd[1], cd[2], + /* entry fg */ fg[0], fg[1], fg[2], + /* entry border */ bd[0], bd[1], bd[2], + /* entry caret */ pr[0], pr[1], pr[2], + /* listrow bg */ cd[0], cd[1], cd[2], + /* listrow fg */ fg[0], fg[1], fg[2], + /* listrow border */ bd[0], bd[1], bd[2], + /* listrow sel bg */ ac[0], ac[1], ac[2], + /* listrow sel fg */ afg[0], afg[1], afg[2], + /* separator */ bd[0], bd[1], bd[2], + /* checkbutton fg */ fg[0], fg[1], fg[2], + /* check border */ bd[0], bd[1], bd[2], + /* check bg */ cd[0], cd[1], cd[2], + /* done label */ mfg[0], mfg[1], mfg[2], + /* date label */ mfg[0], mfg[1], mfg[2], + /* popover bg */ cd[0], cd[1], cd[2], + /* popover border */ bd[0], bd[1], bd[2], + /* popover btn fg */ fg[0], fg[1], fg[2], + /* popover hover */ mt[0], mt[1], mt[2] + ); + + if (!g->theme_css) { + g->theme_css = gtk_css_provider_new(); + gtk_style_context_add_provider_for_display( + gdk_display_get_default(), + GTK_STYLE_PROVIDER(g->theme_css), + GTK_STYLE_PROVIDER_PRIORITY_USER); + } + gtk_css_provider_load_from_string(g->theme_css, css); +} + +static void +toggle_theme(Gui *g) +{ + g->dark = !g->dark; + g->theme = g->dark ? &theme_dark : &theme_light; + apply_theme_css(g); + if (g->theme_btn) + gtk_button_set_label( + GTK_BUTTON(g->theme_btn), + g->dark ? "Light" : "Dark"); +} + +/* refresh task list — forward declaration */ + +static void +refresh_tasks(Gui *g); + +static void +on_check_toggled(GtkCheckButton *cb, gpointer data) +{ + Gui *g; + int task_id; + gboolean active; + + g = &gui; + task_id = GPOINTER_TO_INT(data); + active = gtk_check_button_get_active(cb); + if (active) + task_done(g->tdb, task_id); + else + task_undo(g->tdb, task_id); + refresh_tasks(g); +} + +/* refresh list sidebar */ + +static void +on_list_selected(GtkListBox *box, GtkListBoxRow *row, + gpointer data) +{ + Gui *g; + const char *name; + GtkWidget *label; + List list; + + (void)box; + g = (Gui *)data; + + if (!row) { + g->cur_list = -1; + g->cur_name[0] = '\0'; + refresh_tasks(g); + return; + } + + label = gtk_widget_get_first_child( + GTK_WIDGET(row)); + if (!label) + return; + name = gtk_label_get_text(GTK_LABEL(label)); + if (list_by_name(g->tdb, name, &list) < 0) + return; + + g->cur_list = list.id; + snprintf(g->cur_name, sizeof(g->cur_name), + "%s", list.name); + refresh_tasks(g); +} + +static void +refresh_lists(Gui *g) +{ + List *lists; + GtkWidget *row, *label; + int i, n; + + while ((row = gtk_widget_get_first_child( + GTK_WIDGET(g->list_box)))) + gtk_list_box_remove( + GTK_LIST_BOX(g->list_box), row); + + if (list_all(g->tdb, &lists, &n) < 0) + return; + + for (i = 0; i < n; i++) { + label = gtk_label_new(lists[i].name); + gtk_label_set_xalign( + GTK_LABEL(label), 0.0); + gtk_list_box_append( + GTK_LIST_BOX(g->list_box), label); + + /* re-select current list */ + if (lists[i].id == g->cur_list) { + row = gtk_widget_get_parent(label); + gtk_list_box_select_row( + GTK_LIST_BOX(g->list_box), + GTK_LIST_BOX_ROW(row)); + } + } + free(lists); +} + +/* new list dialog */ + +static void +on_new_list_response(GtkDialog *dlg, int response, + gpointer data) +{ + Gui *g; + GtkWidget *entry; + const char *name; + + g = (Gui *)data; + if (response == GTK_RESPONSE_OK) { + entry = g_object_get_data( + G_OBJECT(dlg), "entry"); + name = gtk_editable_get_text( + GTK_EDITABLE(entry)); + if (name && name[0]) { + list_create(g->tdb, name); + refresh_lists(g); + } + } + gtk_window_destroy(GTK_WINDOW(dlg)); +} + +static void +on_new_list(GtkButton *btn, gpointer data) +{ + Gui *g; + GtkWidget *dlg, *box, *entry, *label; + + (void)btn; + g = (Gui *)data; + + dlg = gtk_dialog_new_with_buttons("New List", + GTK_WINDOW(g->window), + GTK_DIALOG_MODAL + | GTK_DIALOG_DESTROY_WITH_PARENT, + "Create", GTK_RESPONSE_OK, + "Cancel", GTK_RESPONSE_CANCEL, + NULL); + + box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_start(box, 16); + gtk_widget_set_margin_end(box, 16); + gtk_widget_set_margin_top(box, 12); + gtk_widget_set_margin_bottom(box, 12); + + label = gtk_label_new("List name:"); + gtk_label_set_xalign(GTK_LABEL(label), 0.0); + gtk_box_append(GTK_BOX(box), label); + + entry = gtk_entry_new(); + gtk_box_append(GTK_BOX(box), entry); + + g_object_set_data(G_OBJECT(dlg), "entry", entry); + + gtk_box_append( + GTK_BOX(gtk_dialog_get_content_area( + GTK_DIALOG(dlg))), + box); + + g_signal_connect(dlg, "response", + G_CALLBACK(on_new_list_response), g); + gtk_window_present(GTK_WINDOW(dlg)); +} + +/* delete list */ + +static void +on_del_list(GtkButton *btn, gpointer data) +{ + Gui *g; + + (void)btn; + g = (Gui *)data; + if (g->cur_list < 0) + return; + + list_delete(g->tdb, g->cur_name); + g->cur_list = -1; + g->cur_name[0] = '\0'; + refresh_lists(g); + refresh_tasks(g); +} + +/* add task via entry */ + +static void +on_entry_activate(GtkEntry *entry, gpointer data) +{ + Gui *g; + GtkEntryBuffer *buf; + const char *text; + + g = (Gui *)data; + if (g->cur_list < 0) + return; + + buf = gtk_entry_get_buffer(entry); + text = gtk_entry_buffer_get_text(buf); + if (!text || !text[0]) + return; + + task_add(g->tdb, g->cur_list, text); + gtk_entry_buffer_set_text(buf, "", 0); + refresh_tasks(g); +} + +/* delete selected task */ + +static void +on_del_task(GtkButton *btn, gpointer data) +{ + Gui *g; + GtkListBoxRow *row; + GtkWidget *hbox, *check; + int task_id; + + (void)btn; + g = (Gui *)data; + + row = gtk_list_box_get_selected_row( + GTK_LIST_BOX(g->task_box)); + if (!row) + return; + + hbox = gtk_widget_get_first_child( + GTK_WIDGET(row)); + if (!hbox) + return; + + /* first child of hbox is the checkbutton */ + check = gtk_widget_get_first_child(hbox); + if (!check) + return; + + /* get task id from the signal data — we need + * another approach: store id on the row */ + task_id = GPOINTER_TO_INT( + g_object_get_data( + G_OBJECT(check), "task-id")); + if (task_id > 0) { + task_del(g->tdb, task_id); + refresh_tasks(g); + } +} + +static void +refresh_tasks(Gui *g) +{ + Task *tasks; + GtkWidget *row, *hbox, *check, *label, *date; + char buf[64]; + int i, n, total, done; + + while ((row = gtk_widget_get_first_child( + GTK_WIDGET(g->task_box)))) + gtk_list_box_remove( + GTK_LIST_BOX(g->task_box), row); + + if (g->cur_list < 0) { + gtk_label_set_text( + GTK_LABEL(g->list_label), + "Select a list"); + gtk_label_set_text( + GTK_LABEL(g->status), ""); + return; + } + + gtk_label_set_text( + GTK_LABEL(g->list_label), g->cur_name); + + if (task_all(g->tdb, g->cur_list, &tasks, &n) < 0) + return; + + for (i = 0; i < n; i++) { + hbox = gtk_box_new( + GTK_ORIENTATION_HORIZONTAL, 8); + + check = gtk_check_button_new(); + gtk_check_button_set_active( + GTK_CHECK_BUTTON(check), + tasks[i].done); + g_object_set_data(G_OBJECT(check), + "task-id", + GINT_TO_POINTER(tasks[i].id)); + g_signal_connect(check, "toggled", + G_CALLBACK(on_check_toggled), + GINT_TO_POINTER(tasks[i].id)); + gtk_box_append(GTK_BOX(hbox), check); + + label = gtk_label_new(tasks[i].text); + gtk_label_set_xalign( + GTK_LABEL(label), 0.0); + gtk_widget_set_hexpand(label, TRUE); + gtk_label_set_ellipsize( + GTK_LABEL(label), + PANGO_ELLIPSIZE_END); + gtk_box_append(GTK_BOX(hbox), label); + + date = gtk_label_new(tasks[i].added); + gtk_widget_add_css_class(date, "task-date"); + gtk_box_append(GTK_BOX(hbox), date); + + gtk_list_box_append( + GTK_LIST_BOX(g->task_box), hbox); + + if (tasks[i].done) { + row = gtk_widget_get_parent(hbox); + gtk_widget_add_css_class( + row, "task-done"); + } + } + + task_count(g->tdb, g->cur_list, &total, &done); + snprintf(buf, sizeof(buf), + " %s | %d tasks, %d done", + g->cur_name, total, done); + gtk_label_set_text(GTK_LABEL(g->status), buf); + + free(tasks); +} + +/* toolbar button helper */ + +static GtkWidget * +make_btn(const char *label, GCallback cb, gpointer data) +{ + GtkWidget *btn; + + btn = gtk_button_new_with_label(label); + g_signal_connect(btn, "clicked", cb, data); + return btn; +} + +static void +on_theme_toggle(GtkButton *btn, gpointer data) +{ + (void)btn; + toggle_theme((Gui *)data); +} + +static void +activate(GtkApplication *app, gpointer data) +{ + GtkWidget *window, *header, *paned; + GtkWidget *left_box, *right_box; + GtkWidget *scroll_lists, *scroll_tasks; + GtkWidget *entry, *status, *list_label; + GtkWidget *entry_box; + GtkCssProvider *css; + Gui *g; + + (void)data; + g = &gui; + + /* squared corners CSS */ + css = gtk_css_provider_new(); + gtk_css_provider_load_from_string(css, app_css); + gtk_style_context_add_provider_for_display( + gdk_display_get_default(), + GTK_STYLE_PROVIDER(css), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + + apply_theme_css(g); + + /* window */ + window = gtk_application_window_new(app); + gtk_window_set_title(GTK_WINDOW(window), "stask"); + gtk_window_set_default_size(GTK_WINDOW(window), + 800, 600); + g->window = window; + + /* header bar */ + header = gtk_header_bar_new(); + + g->theme_btn = make_btn( + g->dark ? "Light" : "Dark", + G_CALLBACK(on_theme_toggle), g); + gtk_header_bar_pack_start( + GTK_HEADER_BAR(header), g->theme_btn); + gtk_header_bar_pack_start( + GTK_HEADER_BAR(header), + gtk_separator_new( + GTK_ORIENTATION_VERTICAL)); + gtk_header_bar_pack_start( + GTK_HEADER_BAR(header), + make_btn("New List", + G_CALLBACK(on_new_list), g)); + gtk_header_bar_pack_start( + GTK_HEADER_BAR(header), + make_btn("Delete List", + G_CALLBACK(on_del_list), g)); + gtk_header_bar_pack_end( + GTK_HEADER_BAR(header), + make_btn("Delete Task", + G_CALLBACK(on_del_task), g)); + gtk_window_set_titlebar( + GTK_WINDOW(window), header); + + /* main layout: paned */ + paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); + gtk_paned_set_position(GTK_PANED(paned), 200); + gtk_window_set_child(GTK_WINDOW(window), paned); + + /* left: list sidebar */ + left_box = gtk_box_new( + GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_size_request(left_box, 150, -1); + + scroll_lists = gtk_scrolled_window_new(); + gtk_widget_set_vexpand(scroll_lists, TRUE); + + g->list_box = gtk_list_box_new(); + gtk_list_box_set_selection_mode( + GTK_LIST_BOX(g->list_box), + GTK_SELECTION_SINGLE); + g_signal_connect(g->list_box, "row-selected", + G_CALLBACK(on_list_selected), g); + gtk_scrolled_window_set_child( + GTK_SCROLLED_WINDOW(scroll_lists), + g->list_box); + gtk_box_append(GTK_BOX(left_box), scroll_lists); + + gtk_paned_set_start_child( + GTK_PANED(paned), left_box); + gtk_paned_set_resize_start_child( + GTK_PANED(paned), FALSE); + gtk_paned_set_shrink_start_child( + GTK_PANED(paned), FALSE); + + /* right: task panel */ + right_box = gtk_box_new( + GTK_ORIENTATION_VERTICAL, 0); + + /* list name header */ + list_label = gtk_label_new("Select a list"); + gtk_widget_add_css_class( + list_label, "list-header"); + gtk_label_set_xalign( + GTK_LABEL(list_label), 0.0); + gtk_box_append(GTK_BOX(right_box), list_label); + gtk_box_append(GTK_BOX(right_box), + gtk_separator_new( + GTK_ORIENTATION_HORIZONTAL)); + g->list_label = list_label; + + /* task list */ + scroll_tasks = gtk_scrolled_window_new(); + gtk_widget_set_vexpand(scroll_tasks, TRUE); + + g->task_box = gtk_list_box_new(); + gtk_list_box_set_selection_mode( + GTK_LIST_BOX(g->task_box), + GTK_SELECTION_SINGLE); + gtk_scrolled_window_set_child( + GTK_SCROLLED_WINDOW(scroll_tasks), + g->task_box); + gtk_box_append(GTK_BOX(right_box), scroll_tasks); + + /* add task entry */ + gtk_box_append(GTK_BOX(right_box), + gtk_separator_new( + GTK_ORIENTATION_HORIZONTAL)); + + entry_box = gtk_box_new( + GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_set_margin_start(entry_box, 8); + gtk_widget_set_margin_end(entry_box, 8); + gtk_widget_set_margin_top(entry_box, 6); + gtk_widget_set_margin_bottom(entry_box, 6); + + entry = gtk_entry_new(); + gtk_entry_set_placeholder_text( + GTK_ENTRY(entry), + "Add task (press Enter)"); + gtk_widget_set_hexpand(entry, TRUE); + g_signal_connect(entry, "activate", + G_CALLBACK(on_entry_activate), g); + gtk_box_append(GTK_BOX(entry_box), entry); + g->entry = entry; + + gtk_box_append(GTK_BOX(right_box), entry_box); + + gtk_paned_set_end_child( + GTK_PANED(paned), right_box); + gtk_paned_set_resize_end_child( + GTK_PANED(paned), TRUE); + gtk_paned_set_shrink_end_child( + GTK_PANED(paned), FALSE); + + /* status bar */ + status = gtk_label_new(""); + gtk_widget_set_halign(status, GTK_ALIGN_START); + gtk_widget_set_margin_start(status, 6); + gtk_widget_set_margin_end(status, 6); + gtk_widget_set_margin_top(status, 3); + gtk_widget_set_margin_bottom(status, 3); + g->status = status; + /* status goes outside paned, at window bottom */ + + /* wrap paned + status in a vbox */ + { + GtkWidget *vbox; + + vbox = gtk_box_new( + GTK_ORIENTATION_VERTICAL, 0); + gtk_box_append(GTK_BOX(vbox), paned); + gtk_box_append(GTK_BOX(vbox), + gtk_separator_new( + GTK_ORIENTATION_HORIZONTAL)); + gtk_box_append(GTK_BOX(vbox), status); + gtk_window_set_child( + GTK_WINDOW(window), vbox); + } + + /* load lists */ + refresh_lists(g); + + gtk_window_present(GTK_WINDOW(window)); +} + +int +gui_run(TaskDB *tdb, int argc, char *argv[]) +{ + GtkApplication *app; + int ret; + + memset(&gui, 0, sizeof(gui)); + gui.tdb = tdb; + gui.dark = default_dark; + gui.theme = gui.dark ? &theme_dark : &theme_light; + gui.cur_list = -1; + + app = gtk_application_new("org.stask.app", + G_APPLICATION_NON_UNIQUE); + g_signal_connect(app, "activate", + G_CALLBACK(activate), NULL); + ret = g_application_run( + G_APPLICATION(app), 1, argv); + g_object_unref(app); + return ret; +} diff --git a/stask.c b/stask.c new file mode 100644 index 0000000000000000000000000000000000000000..ad47a30328822e1510ad62fa1b14a22ea747afb2 --- /dev/null +++ b/stask.c @@ -0,0 +1,209 @@ +/* stask - simple task manager + * See LICENSE file for copyright and license details. */ + +#include +#include +#include + +#include "stask.h" +#include "config.h" + +static void +usage(void) +{ + fprintf(stderr, + "usage: stask [-g]\n" + " stask [ls]\n" + " stask new \n" + " stask rm \n" + " stask \n" + " stask add \n" + " stask done \n" + " stask undo \n" + " stask del \n"); + exit(1); +} + +static void +cmd_ls(TaskDB *tdb) +{ + List *lists; + int i, n, total, done; + + if (list_all(tdb, &lists, &n) < 0) { + fprintf(stderr, "stask: cannot list\n"); + return; + } + + if (n == 0) { + printf("No lists. Create one with: stask new \n"); + free(lists); + return; + } + + printf("%-20s %10s %10s %s\n", + "LIST", "TASKS", "DONE", "CREATED"); + for (i = 0; i < n; i++) { + task_count(tdb, lists[i].id, &total, &done); + printf("%-20s %10d %10d %s\n", + lists[i].name, total, done, + lists[i].created); + } + free(lists); +} + +static void +cmd_show(TaskDB *tdb, const char *name) +{ + List list; + Task *tasks; + int i, n; + + if (list_by_name(tdb, name, &list) < 0) { + fprintf(stderr, + "stask: list '%s' not found\n", name); + return; + } + + if (task_all(tdb, list.id, &tasks, &n) < 0) { + fprintf(stderr, "stask: cannot read tasks\n"); + return; + } + + if (n == 0) { + printf("%s: empty\n", name); + free(tasks); + return; + } + + printf("%s:\n", name); + for (i = 0; i < n; i++) { + printf(" [%c] %-4d %-40s %s\n", + tasks[i].done ? 'x' : ' ', + tasks[i].id, + tasks[i].text, + tasks[i].added); + } + free(tasks); +} + +static void +cmd_new(TaskDB *tdb, const char *name) +{ + if (list_create(tdb, name) < 0) + fprintf(stderr, + "stask: cannot create '%s' " + "(already exists?)\n", name); + else + printf("Created list '%s'\n", name); +} + +static void +cmd_rm(TaskDB *tdb, const char *name) +{ + if (list_delete(tdb, name) < 0) + fprintf(stderr, + "stask: cannot remove '%s'\n", name); + else + printf("Removed list '%s'\n", name); +} + +static void +cmd_add(TaskDB *tdb, const char *listname, const char *text) +{ + List list; + + if (list_by_name(tdb, listname, &list) < 0) { + fprintf(stderr, + "stask: list '%s' not found\n", + listname); + return; + } + if (task_add(tdb, list.id, text) < 0) + fprintf(stderr, "stask: cannot add task\n"); + else + printf("Added to '%s'\n", listname); +} + +static void +cmd_done(TaskDB *tdb, int id) +{ + if (task_done(tdb, id) < 0) + fprintf(stderr, + "stask: cannot mark done: %d\n", id); +} + +static void +cmd_undo(TaskDB *tdb, int id) +{ + if (task_undo(tdb, id) < 0) + fprintf(stderr, + "stask: cannot undo: %d\n", id); +} + +static void +cmd_del(TaskDB *tdb, int id) +{ + if (task_del(tdb, id) < 0) + fprintf(stderr, + "stask: cannot delete: %d\n", id); +} + +int +main(int argc, char *argv[]) +{ + TaskDB tdb; + int id; + + memset(&tdb, 0, sizeof(tdb)); + + /* gui mode */ + if (argc >= 2 && strcmp(argv[1], "-g") == 0) { + if (db_open(&tdb) < 0) + return 1; + id = gui_run(&tdb, argc, argv); + db_close(&tdb); + return id; + } + + if (db_open(&tdb) < 0) + return 1; + + if (argc < 2 || strcmp(argv[1], "ls") == 0) { + cmd_ls(&tdb); + } else if (strcmp(argv[1], "new") == 0) { + if (argc < 3) usage(); + cmd_new(&tdb, argv[2]); + } else if (strcmp(argv[1], "rm") == 0) { + if (argc < 3) usage(); + cmd_rm(&tdb, argv[2]); + } else if (argc == 2) { + /* stask - show tasks */ + cmd_show(&tdb, argv[1]); + } else if (argc >= 3) { + /* stask [args] */ + if (strcmp(argv[2], "add") == 0) { + if (argc < 4) usage(); + cmd_add(&tdb, argv[1], argv[3]); + } else if (strcmp(argv[2], "done") == 0) { + if (argc < 4) usage(); + id = atoi(argv[3]); + cmd_done(&tdb, id); + } else if (strcmp(argv[2], "undo") == 0) { + if (argc < 4) usage(); + id = atoi(argv[3]); + cmd_undo(&tdb, id); + } else if (strcmp(argv[2], "del") == 0) { + if (argc < 4) usage(); + id = atoi(argv[3]); + cmd_del(&tdb, id); + } else { + usage(); + } + } else { + usage(); + } + + db_close(&tdb); + return 0; +} diff --git a/stask.h b/stask.h new file mode 100644 index 0000000000000000000000000000000000000000..9995fcb54504abb9ab77decc008b22c57bb3ab0d --- /dev/null +++ b/stask.h @@ -0,0 +1,63 @@ +/* stask - simple task manager + * See LICENSE file for copyright and license details. */ + +#ifndef STASK_H +#define STASK_H + +#include + +enum { TASK_TODO, TASK_DONE }; + +typedef struct { + double bg[4]; + double card[4]; + double fg[4]; + double border[4]; + double muted[4]; + double muted_fg[4]; + double primary[4]; + double primary_fg[4]; + double sel[4]; + double accent[4]; + double accent_fg[4]; +} Theme; + +typedef struct { + int id; + char name[128]; + char created[32]; +} List; + +typedef struct { + int id; + int list_id; + char text[512]; + int done; + char added[32]; +} Task; + +typedef struct { + sqlite3 *db; + char dbpath[1024]; +} TaskDB; + +/* data.c */ +int db_open(TaskDB *tdb); +void db_close(TaskDB *tdb); + +int list_create(TaskDB *tdb, const char *name); +int list_delete(TaskDB *tdb, const char *name); +int list_all(TaskDB *tdb, List **out, int *count); +int list_by_name(TaskDB *tdb, const char *name, List *out); + +int task_add(TaskDB *tdb, int list_id, const char *text); +int task_del(TaskDB *tdb, int task_id); +int task_done(TaskDB *tdb, int task_id); +int task_undo(TaskDB *tdb, int task_id); +int task_all(TaskDB *tdb, int list_id, Task **out, int *count); +int task_count(TaskDB *tdb, int list_id, int *total, int *done); + +/* gui.c */ +int gui_run(TaskDB *tdb, int argc, char *argv[]); + +#endif /* STASK_H */