~kris/hacks

stask

e95edef2f8d4671055456372bd8b350ad353e988 — Kris Yotam 5 months ago
initial commit: stask v0.1 — suckless task manager with CLI + GTK4 GUI
9 files changed, 1540 insertions(+), 0 deletions(-)

A .gitignore
A LICENSE
A Makefile
A config.def.h
A config.mk
A data.c
A gui.c
A stask.c
A stask.h
A  => .gitignore +3 -0
@@ 1,3 @@
*.o
stask
config.h

A  => LICENSE +21 -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.

A  => Makefile +33 -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

A  => config.def.h +69 -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";

A  => config.mk +24 -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

A  => data.c +387 -0
@@ 1,387 @@
/* stask - simple task manager
 * See LICENSE file for copyright and license details. */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>

#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;
}

A  => gui.c +731 -0
@@ 1,731 @@
/* stask - simple task manager
 * See LICENSE file for copyright and license details. */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <gtk/gtk.h>
#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;
}

A  => stask.c +209 -0
@@ 1,209 @@
/* stask - simple task manager
 * See LICENSE file for copyright and license details. */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "stask.h"
#include "config.h"

static void
usage(void)
{
	fprintf(stderr,
		"usage: stask [-g]\n"
		"       stask [ls]\n"
		"       stask new <list>\n"
		"       stask rm <list>\n"
		"       stask <list>\n"
		"       stask <list> add <text>\n"
		"       stask <list> done <id>\n"
		"       stask <list> undo <id>\n"
		"       stask <list> del <id>\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 <name>\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 <list> - show tasks */
		cmd_show(&tdb, argv[1]);
	} else if (argc >= 3) {
		/* stask <list> <cmd> [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;
}

A  => stask.h +63 -0
@@ 1,63 @@
/* stask - simple task manager
 * See LICENSE file for copyright and license details. */

#ifndef STASK_H
#define STASK_H

#include <sqlite3.h>

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 */