commit 82a2aab0db37be3d9eb84c3879c46e0b1634337b
Author: Kris Yotam <krisyotam@protonmail.com>
Date: Thu, 12 Mar 2026 21:23:21 -0500
initial commit
Diffstat:
| A | LICENSE | | | 21 | +++++++++++++++++++++ |
| A | Makefile | | | 39 | +++++++++++++++++++++++++++++++++++++++ |
| A | canvas.c | | | 761 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | canvas.o | | | 0 | |
| A | config.def.h | | | 108 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | config.h | | | 108 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | config.mk | | | 19 | +++++++++++++++++++ |
| A | data.c | | | 649 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | data.o | | | 0 | |
| A | sdiagram | | | 0 | |
| A | sdiagram.c | | | 1109 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | sdiagram.h | | | 151 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | sdiagram.o | | | 0 | |
13 files changed, 2965 insertions(+), 0 deletions(-)
diff --git a/LICENSE 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
@@ -0,0 +1,39 @@
+# sdiagram - simple diagram tool
+# See LICENSE file for copyright and license details.
+
+include config.mk
+
+SRC = sdiagram.c canvas.c data.c
+OBJ = ${SRC:.c=.o}
+
+all: options sdiagram
+
+options:
+ @echo sdiagram build options:
+ @echo "CFLAGS = ${CFLAGS}"
+ @echo "LDFLAGS = ${LDFLAGS}"
+ @echo "CC = ${CC}"
+
+config.h:
+ cp config.def.h config.h
+
+.c.o:
+ ${CC} -c ${CFLAGS} $<
+
+${OBJ}: config.h sdiagram.h
+
+sdiagram: ${OBJ}
+ ${CC} -o $@ ${OBJ} ${LDFLAGS}
+
+clean:
+ rm -f sdiagram ${OBJ}
+
+install: all
+ mkdir -p ${DESTDIR}${PREFIX}/bin
+ cp -f sdiagram ${DESTDIR}${PREFIX}/bin
+ chmod 755 ${DESTDIR}${PREFIX}/bin/sdiagram
+
+uninstall:
+ rm -f ${DESTDIR}${PREFIX}/bin/sdiagram
+
+.PHONY: all options clean install uninstall
diff --git a/canvas.c b/canvas.c
@@ -0,0 +1,761 @@
+/* sdiagram - simple diagram tool
+ * See LICENSE file for copyright and license details. */
+
+#include <math.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "sdiagram.h"
+#include "config.h"
+
+#define PBUF_SZ 4096
+
+/* append to pango buffer with bounds check */
+static int
+papp(char *o, char *end, const char *s)
+{
+ int n;
+
+ n = snprintf(o, end - o, "%s", s);
+ return (n > 0 && o + n < end) ? n : 0;
+}
+
+/* convert markdown to Pango markup.
+ * supports: **bold**, *italic*, `code`,
+ * # h1, ## h2, - lists, --- rules */
+static void
+md_to_pango(const char *md, char *out, size_t outsz)
+{
+ char *o, *end;
+ int in_b, in_i, in_c, sol;
+
+ o = out;
+ end = out + outsz - 1;
+ in_b = in_i = in_c = 0;
+ sol = 1;
+
+ while (*md && o < end) {
+ /* line-level at start of line */
+ if (sol) {
+ if (md[0] == '#' && md[1] == '#'
+ && md[2] == ' ') {
+ o += papp(o, end, "<b>");
+ md += 3;
+ while (*md && *md != '\n' && o < end) {
+ if (*md == '&')
+ o += papp(o, end, "&");
+ else if (*md == '<')
+ o += papp(o, end, "<");
+ else
+ *o++ = *md;
+ md++;
+ }
+ o += papp(o, end, "</b>");
+ if (*md == '\n') { *o++ = '\n'; md++; }
+ sol = 1;
+ continue;
+ }
+ if (md[0] == '#' && md[1] == ' ') {
+ o += papp(o, end,
+ "<span size=\"large\">"
+ "<b>");
+ md += 2;
+ while (*md && *md != '\n' && o < end) {
+ if (*md == '&')
+ o += papp(o, end, "&");
+ else if (*md == '<')
+ o += papp(o, end, "<");
+ else
+ *o++ = *md;
+ md++;
+ }
+ o += papp(o, end,
+ "</b></span>");
+ if (*md == '\n') { *o++ = '\n'; md++; }
+ sol = 1;
+ continue;
+ }
+ if ((md[0] == '-' || md[0] == '*')
+ && md[1] == ' '
+ && !(md[0] == '*' && md[1] == '*')) {
+ o += papp(o, end, " \xe2\x80\xa2 ");
+ md += 2;
+ sol = 0;
+ continue;
+ }
+ if (md[0] == '-' && md[1] == '-'
+ && md[2] == '-') {
+ o += papp(o, end,
+ "\xe2\x94\x80\xe2\x94\x80"
+ "\xe2\x94\x80\xe2\x94\x80"
+ "\xe2\x94\x80\xe2\x94\x80"
+ "\xe2\x94\x80\xe2\x94\x80");
+ while (*md == '-') md++;
+ if (*md == '\n') { *o++ = '\n'; md++; }
+ sol = 1;
+ continue;
+ }
+ sol = 0;
+ }
+
+ /* inside code: no markdown processing */
+ if (in_c) {
+ if (*md == '`') {
+ o += papp(o, end, "</tt>");
+ in_c = 0;
+ md++;
+ continue;
+ }
+ if (*md == '&')
+ o += papp(o, end, "&");
+ else if (*md == '<')
+ o += papp(o, end, "<");
+ else
+ *o++ = *md;
+ if (*md == '\n') sol = 1;
+ md++;
+ continue;
+ }
+
+ /* backtick: start code */
+ if (*md == '`') {
+ o += papp(o, end, "<tt>");
+ in_c = 1;
+ md++;
+ continue;
+ }
+
+ /* bold ** */
+ if (md[0] == '*' && md[1] == '*') {
+ if (in_b)
+ o += papp(o, end, "</b>");
+ else
+ o += papp(o, end, "<b>");
+ in_b = !in_b;
+ md += 2;
+ continue;
+ }
+
+ /* italic * */
+ if (md[0] == '*' && md[1] != '*') {
+ if (in_i)
+ o += papp(o, end, "</i>");
+ else
+ o += papp(o, end, "<i>");
+ in_i = !in_i;
+ md++;
+ continue;
+ }
+
+ /* escape XML */
+ if (*md == '&')
+ o += papp(o, end, "&");
+ else if (*md == '<')
+ o += papp(o, end, "<");
+ else
+ *o++ = *md;
+
+ if (*md == '\n')
+ sol = 1;
+ md++;
+ }
+
+ /* close unclosed tags */
+ if (in_c) o += papp(o, end, "</tt>");
+ if (in_b) o += papp(o, end, "</b>");
+ if (in_i) o += papp(o, end, "</i>");
+ *o = '\0';
+}
+
+static void
+set_color(cairo_t *cr, const double *c)
+{
+ cairo_set_source_rgba(cr, c[0], c[1], c[2], c[3]);
+}
+
+static void
+rect_edge(double cx, double cy, double w, double h,
+ double tx, double ty, double *ex, double *ey)
+{
+ double dx, dy, sx, sy, s;
+
+ dx = tx - cx;
+ dy = ty - cy;
+ if (dx == 0 && dy == 0) {
+ *ex = cx;
+ *ey = cy;
+ return;
+ }
+ sx = (dx != 0) ? fabs((w / 2) / dx) : 1e9;
+ sy = (dy != 0) ? fabs((h / 2) / dy) : 1e9;
+ s = (sx < sy) ? sx : sy;
+ *ex = cx + dx * s;
+ *ey = cy + dy * s;
+}
+
+static void
+draw_grid(cairo_t *cr, Diagram *d, int w, int h)
+{
+ double gs, x0, y0, x, y;
+
+ if (!grid_on)
+ return;
+
+ gs = grid_size * d->zoom;
+ if (gs < 8.0)
+ return;
+
+ set_color(cr, d->theme->grid);
+ cairo_set_line_width(cr, 0.5);
+
+ x0 = fmod(d->panx * d->zoom, gs);
+ y0 = fmod(d->pany * d->zoom, gs);
+ if (x0 > 0) x0 -= gs;
+ if (y0 > 0) y0 -= gs;
+
+ for (x = x0; x < w; x += gs) {
+ cairo_move_to(cr, x, 0);
+ cairo_line_to(cr, x, h);
+ }
+ for (y = y0; y < h; y += gs) {
+ cairo_move_to(cr, 0, y);
+ cairo_line_to(cr, w, y);
+ }
+ cairo_stroke(cr);
+}
+
+static void
+draw_conn(cairo_t *cr, Diagram *d, Conn *c, int sel)
+{
+ int fi, ti;
+ Node *fn, *tn;
+ double x1, y1, x2, y2, fh, th;
+ double fcx, fcy, tcx, tcy;
+ const Theme *t;
+
+ fi = node_by_id(d, c->from);
+ ti = node_by_id(d, c->to);
+ if (fi < 0 || ti < 0)
+ return;
+
+ t = d->theme;
+ fn = &d->nodes[fi];
+ tn = &d->nodes[ti];
+ fh = node_height(fn);
+ th = node_height(tn);
+
+ fcx = fn->x + fn->w / 2;
+ fcy = fn->y + fh / 2;
+ tcx = tn->x + tn->w / 2;
+ tcy = tn->y + th / 2;
+
+ rect_edge(fcx, fcy, fn->w, fh, tcx, tcy, &x1, &y1);
+ rect_edge(tcx, tcy, tn->w, th, fcx, fcy, &x2, &y2);
+
+ if (sel)
+ set_color(cr, t->sel);
+ else
+ set_color(cr, t->conn);
+ cairo_set_line_width(cr, conn_lw);
+ cairo_move_to(cr, x1, y1);
+ cairo_line_to(cr, x2, y2);
+ cairo_stroke(cr);
+
+ /* arrowhead */
+ {
+ double dx, dy, len, ax, ay, px, py, sz;
+
+ dx = x2 - x1;
+ dy = y2 - y1;
+ len = hypot(dx, dy);
+ if (len < 1.0)
+ return;
+ dx /= len;
+ dy /= len;
+ sz = 8.0;
+ ax = x2 - dx * sz;
+ ay = y2 - dy * sz;
+ px = -dy;
+ py = dx;
+ cairo_move_to(cr, x2, y2);
+ cairo_line_to(cr,
+ ax + px * sz * 0.35,
+ ay + py * sz * 0.35);
+ cairo_line_to(cr,
+ ax - px * sz * 0.35,
+ ay - py * sz * 0.35);
+ cairo_close_path(cr);
+ cairo_fill(cr);
+ }
+
+ /* label */
+ if (c->label[0]) {
+ PangoLayout *layout;
+ PangoFontDescription *fd;
+ int tw, tth;
+
+ fd = pango_font_description_from_string(font_body);
+ layout = pango_cairo_create_layout(cr);
+ pango_layout_set_font_description(layout, fd);
+ pango_layout_set_text(layout, c->label, -1);
+ pango_layout_get_pixel_size(layout, &tw, &tth);
+ set_color(cr, t->fg);
+ cairo_move_to(cr, (x1 + x2) / 2 - tw / 2,
+ (y1 + y2) / 2 - tth / 2);
+ pango_cairo_show_layout(cr, layout);
+ g_object_unref(layout);
+ pango_font_description_free(fd);
+ }
+}
+
+/* draw a bento-style node: header | image | comment sections
+ * separated by borders, all sharp corners */
+static void
+draw_node(cairo_t *cr, Diagram *d, Node *n)
+{
+ PangoLayout *layout;
+ PangoFontDescription *fd;
+ const Theme *t;
+ double h, yoff;
+ int tw, th;
+
+ t = d->theme;
+ h = node_height(n);
+ n->h = h;
+
+ /* card background */
+ cairo_rectangle(cr, n->x, n->y, n->w, h);
+ set_color(cr, t->card);
+ cairo_fill(cr);
+
+ /* header background */
+ cairo_rectangle(cr, n->x, n->y, n->w, node_hdr_h);
+ cairo_set_source_rgb(cr, n->hdr[0], n->hdr[1], n->hdr[2]);
+ cairo_fill(cr);
+
+ /* title text */
+ fd = pango_font_description_from_string(font_title);
+ pango_font_description_set_weight(fd, PANGO_WEIGHT_MEDIUM);
+ layout = pango_cairo_create_layout(cr);
+ pango_layout_set_font_description(layout, fd);
+ pango_layout_set_text(layout, n->text, -1);
+ pango_layout_set_width(layout,
+ (int)((n->w - node_pad * 2) * PANGO_SCALE));
+ pango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_END);
+ pango_layout_get_pixel_size(layout, &tw, &th);
+ set_color(cr, t->primary_fg);
+ cairo_move_to(cr, n->x + node_pad,
+ n->y + (node_hdr_h - th) / 2);
+ pango_cairo_show_layout(cr, layout);
+ g_object_unref(layout);
+ pango_font_description_free(fd);
+
+ yoff = n->y + node_hdr_h;
+
+ /* header bottom border */
+ set_color(cr, t->border);
+ cairo_set_line_width(cr, 1.0);
+ cairo_move_to(cr, n->x, yoff);
+ cairo_line_to(cr, n->x + n->w, yoff);
+ cairo_stroke(cr);
+
+ /* image section: flush, natural size */
+ if (n->thumb) {
+ double ih;
+
+ ih = gdk_pixbuf_get_height(n->thumb);
+ gdk_cairo_set_source_pixbuf(cr,
+ n->thumb, n->x, yoff);
+ cairo_paint(cr);
+ yoff += ih;
+
+ /* border below image */
+ if (n->desc[0]) {
+ set_color(cr, t->border);
+ cairo_set_line_width(cr, 1.0);
+ cairo_move_to(cr, n->x, yoff);
+ cairo_line_to(cr, n->x + n->w, yoff);
+ cairo_stroke(cr);
+ }
+ }
+
+ /* description: rendered markdown */
+ if (n->desc[0]) {
+ char pbuf[PBUF_SZ];
+
+ md_to_pango(n->desc, pbuf, sizeof(pbuf));
+ fd = pango_font_description_from_string(font_body);
+ layout = pango_cairo_create_layout(cr);
+ pango_layout_set_font_description(layout, fd);
+ pango_layout_set_markup(layout, pbuf, -1);
+ pango_layout_set_width(layout,
+ (int)((n->w - node_pad * 2)
+ * PANGO_SCALE));
+ pango_layout_set_wrap(layout, PANGO_WRAP_WORD_CHAR);
+ set_color(cr, t->muted_fg);
+ cairo_move_to(cr, n->x + node_pad,
+ yoff + 4.0);
+ pango_cairo_show_layout(cr, layout);
+ g_object_unref(layout);
+ pango_font_description_free(fd);
+ }
+
+ /* outer border */
+ cairo_rectangle(cr, n->x, n->y, n->w, h);
+ if (n->selected) {
+ set_color(cr, t->sel);
+ cairo_set_line_width(cr, 2.0);
+ } else {
+ set_color(cr, t->border);
+ cairo_set_line_width(cr, 1.0);
+ }
+ cairo_stroke(cr);
+}
+
+void
+canvas_draw(GtkDrawingArea *area, cairo_t *cr,
+ int w, int h, gpointer data)
+{
+ Diagram *d;
+ int i;
+
+ (void)area;
+ d = (Diagram *)data;
+
+ /* background */
+ set_color(cr, d->theme->bg);
+ cairo_paint(cr);
+
+ draw_grid(cr, d, w, h);
+
+ /* pan + zoom */
+ cairo_save(cr);
+ cairo_translate(cr, d->panx * d->zoom,
+ d->pany * d->zoom);
+ cairo_scale(cr, d->zoom, d->zoom);
+
+ for (i = 0; i < d->nconns; i++)
+ draw_conn(cr, d, &d->conns[i],
+ i == d->selconn);
+
+ for (i = 0; i < d->nnodes; i++)
+ draw_node(cr, d, &d->nodes[i]);
+
+ cairo_restore(cr);
+
+ /* connect mode indicator */
+ if (d->mode == MODE_CONNECT) {
+ PangoLayout *layout;
+ PangoFontDescription *fd;
+
+ fd = pango_font_description_from_string(
+ "Sans Bold 10");
+ layout = pango_cairo_create_layout(cr);
+ pango_layout_set_font_description(layout, fd);
+ pango_layout_set_text(layout,
+ "CONNECT MODE click two nodes", -1);
+ set_color(cr, d->theme->muted_fg);
+ cairo_move_to(cr, 10, h - 24);
+ pango_cairo_show_layout(cr, layout);
+ g_object_unref(layout);
+ pango_font_description_free(fd);
+ }
+}
+
+void
+canvas_click(GtkGestureClick *g, int np,
+ double x, double y, gpointer data)
+{
+ Diagram *d;
+ double cx, cy;
+ int hit, i;
+
+ (void)g;
+ d = (Diagram *)data;
+ screen_to_canvas(d, x, y, &cx, &cy);
+
+ for (i = 0; i < d->nnodes; i++)
+ d->nodes[i].selected = 0;
+ d->sel = -1;
+ d->selconn = -1;
+
+ hit = node_at(d, cx, cy);
+
+ if (d->mode == MODE_CONNECT) {
+ if (hit >= 0) {
+ if (d->connfrom < 0) {
+ d->connfrom = hit;
+ d->nodes[hit].selected = 1;
+ } else if (hit != d->connfrom) {
+ conn_add(d,
+ d->nodes[d->connfrom].id,
+ d->nodes[hit].id, "");
+ d->mode = MODE_NORMAL;
+ d->connfrom = -1;
+ }
+ }
+ redraw(d);
+ status_update(d);
+ return;
+ }
+
+ if (hit >= 0) {
+ d->sel = hit;
+ d->nodes[hit].selected = 1;
+ if (np == 2)
+ edit_node_text(d, hit);
+ } else {
+ d->selconn = conn_at(d, cx, cy);
+ if (d->selconn < 0 && np == 2) {
+ hit = node_add(d, cx - node_min_w / 2,
+ cy - node_min_h / 2, NULL);
+ d->sel = hit;
+ d->nodes[hit].selected = 1;
+ edit_node_text(d, hit);
+ }
+ }
+ redraw(d);
+ status_update(d);
+}
+
+void
+canvas_rclick(GtkGestureClick *g, int np,
+ double x, double y, gpointer data)
+{
+ Diagram *d;
+ double cx, cy;
+ int hit, i;
+
+ (void)g;
+ (void)np;
+ d = (Diagram *)data;
+ screen_to_canvas(d, x, y, &cx, &cy);
+
+ /* select what was right-clicked */
+ for (i = 0; i < d->nnodes; i++)
+ d->nodes[i].selected = 0;
+ d->selconn = -1;
+
+ hit = node_at(d, cx, cy);
+ if (hit >= 0) {
+ d->sel = hit;
+ d->nodes[hit].selected = 1;
+ } else {
+ d->sel = -1;
+ d->selconn = conn_at(d, cx, cy);
+ }
+
+ d->ctx_cx = cx;
+ d->ctx_cy = cy;
+ show_context_menu(d, x, y);
+ redraw(d);
+}
+
+void
+canvas_drag_begin(GtkGestureDrag *g,
+ double x, double y, gpointer data)
+{
+ Diagram *d;
+ double cx, cy;
+ int hit;
+ GdkModifierType mods;
+ GdkEvent *ev;
+
+ d = (Diagram *)data;
+ screen_to_canvas(d, x, y, &cx, &cy);
+
+ ev = gtk_gesture_get_last_event(
+ GTK_GESTURE(g), NULL);
+ mods = ev ? gdk_event_get_modifier_state(ev) : 0;
+
+ hit = node_at(d, cx, cy);
+
+ if ((mods & GDK_CONTROL_MASK) || hit < 0) {
+ d->panning = 1;
+ d->pansx = d->panx;
+ d->pansy = d->pany;
+ } else {
+ d->dragging = 1;
+ d->dragox = cx - d->nodes[hit].x;
+ d->dragoy = cy - d->nodes[hit].y;
+ d->sel = hit;
+ {
+ int i;
+ for (i = 0; i < d->nnodes; i++)
+ d->nodes[i].selected = 0;
+ }
+ d->nodes[hit].selected = 1;
+ }
+}
+
+void
+canvas_drag_update(GtkGestureDrag *g,
+ double ox, double oy, gpointer data)
+{
+ Diagram *d;
+ double sx, sy, cx, cy;
+
+ d = (Diagram *)data;
+ sx = sy = cx = cy = 0;
+
+ if (d->panning) {
+ d->panx = d->pansx + ox / d->zoom;
+ d->pany = d->pansy + oy / d->zoom;
+ }
+
+ if (d->dragging && d->sel >= 0) {
+ gtk_gesture_drag_get_start_point(
+ GTK_GESTURE_DRAG(g), &sx, &sy);
+ screen_to_canvas(d, sx + ox, sy + oy,
+ &cx, &cy);
+ d->nodes[d->sel].x = cx - d->dragox;
+ d->nodes[d->sel].y = cy - d->dragoy;
+ d->modified = 1;
+ }
+
+ redraw(d);
+}
+
+void
+canvas_drag_end(GtkGestureDrag *g,
+ double ox, double oy, gpointer data)
+{
+ Diagram *d;
+
+ (void)g;
+ (void)ox;
+ (void)oy;
+ d = (Diagram *)data;
+ d->dragging = 0;
+ d->panning = 0;
+ status_update(d);
+}
+
+gboolean
+canvas_scroll(GtkEventControllerScroll *c,
+ double dx, double dy, gpointer data)
+{
+ Diagram *d;
+ double oz;
+
+ (void)c;
+ (void)dx;
+ d = (Diagram *)data;
+
+ oz = d->zoom;
+ d->zoom -= dy * zoom_step;
+ if (d->zoom < zoom_min) d->zoom = zoom_min;
+ if (d->zoom > zoom_max) d->zoom = zoom_max;
+
+ if (d->zoom != oz) {
+ redraw(d);
+ status_update(d);
+ }
+ return TRUE;
+}
+
+gboolean
+canvas_key(GtkEventControllerKey *c,
+ guint kv, guint kc, GdkModifierType st, gpointer data)
+{
+ Diagram *d;
+
+ (void)c;
+ (void)kc;
+ d = (Diagram *)data;
+
+ if (st & GDK_CONTROL_MASK) {
+ switch (kv) {
+ case GDK_KEY_s:
+ do_save(d);
+ return TRUE;
+ case GDK_KEY_o:
+ do_open(d);
+ return TRUE;
+ case GDK_KEY_n:
+ do_new(d);
+ return TRUE;
+ }
+ }
+
+ switch (kv) {
+ case GDK_KEY_n:
+ if (!(st & GDK_CONTROL_MASK)) {
+ int idx;
+ idx = node_add(d, -d->panx + 200,
+ -d->pany + 200, NULL);
+ d->sel = idx;
+ d->nodes[idx].selected = 1;
+ edit_node_text(d, idx);
+ redraw(d);
+ }
+ return TRUE;
+ case GDK_KEY_c:
+ if (d->mode == MODE_CONNECT) {
+ d->mode = MODE_NORMAL;
+ d->connfrom = -1;
+ } else {
+ d->mode = MODE_CONNECT;
+ d->connfrom = -1;
+ }
+ redraw(d);
+ status_update(d);
+ return TRUE;
+ case GDK_KEY_e:
+ if (d->sel >= 0)
+ edit_node_text(d, d->sel);
+ return TRUE;
+ case GDK_KEY_i:
+ if (d->sel >= 0)
+ do_import_image(d);
+ return TRUE;
+ case GDK_KEY_Delete: /* FALLTHROUGH */
+ case GDK_KEY_x:
+ if (d->sel >= 0) {
+ node_del(d, d->sel);
+ redraw(d);
+ status_update(d);
+ } else if (d->selconn >= 0) {
+ conn_del(d, d->selconn);
+ redraw(d);
+ status_update(d);
+ }
+ return TRUE;
+ case GDK_KEY_Escape:
+ d->mode = MODE_NORMAL;
+ d->connfrom = -1;
+ {
+ int i;
+ for (i = 0; i < d->nnodes; i++)
+ d->nodes[i].selected = 0;
+ }
+ d->sel = -1;
+ d->selconn = -1;
+ redraw(d);
+ status_update(d);
+ return TRUE;
+ case GDK_KEY_plus: /* FALLTHROUGH */
+ case GDK_KEY_equal:
+ d->zoom += zoom_step;
+ if (d->zoom > zoom_max)
+ d->zoom = zoom_max;
+ redraw(d);
+ status_update(d);
+ return TRUE;
+ case GDK_KEY_minus:
+ d->zoom -= zoom_step;
+ if (d->zoom < zoom_min)
+ d->zoom = zoom_min;
+ redraw(d);
+ status_update(d);
+ return TRUE;
+ case GDK_KEY_0:
+ d->zoom = 1.0;
+ d->panx = 0;
+ d->pany = 0;
+ redraw(d);
+ status_update(d);
+ return TRUE;
+ }
+ return FALSE;
+}
diff --git a/canvas.o b/canvas.o
Binary files differ.
diff --git a/config.def.h b/config.def.h
@@ -0,0 +1,108 @@
+/* sdiagram - simple diagram tool
+ * See LICENSE file for copyright and license details. */
+
+/* appearance */
+static const char *font_title = "Sans 11";
+static const char *font_body = "Sans 9";
+static const double node_min_w = 180.0;
+static const double node_min_h = 44.0;
+static const double node_max_w = 320.0;
+static const double node_img_h = 160.0;
+static const double node_pad = 10.0;
+static const double node_hdr_h = 32.0;
+static const double node_comment_lh = 16.0;
+static const double conn_lw = 1.5;
+static const double zoom_step = 0.1;
+static const double zoom_min = 0.1;
+static const double zoom_max = 5.0;
+static const double grid_size = 50.0;
+static const int grid_on = 1;
+
+/* themes - 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},
+ /* connections */
+ .conn = {0.55, 0.55, 0.55, 0.70},
+ /* selection ring: hsl(0 0% 40%) */
+ .sel = {0.40, 0.40, 0.40, 1.0},
+ /* grid */
+ .grid = {0.92, 0.92, 0.92, 1.0},
+ /* light mode headers: dark, text is primary_fg (0.95) */
+ .hdr = {
+ {0.10, 0.10, 0.10},
+ {0.18, 0.18, 0.18},
+ {0.14, 0.14, 0.14},
+ {0.22, 0.22, 0.22},
+ {0.12, 0.12, 0.12},
+ {0.16, 0.16, 0.16},
+ },
+ .nhdr = 6,
+};
+
+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},
+ /* connections */
+ .conn = {0.45, 0.45, 0.45, 0.70},
+ /* selection ring: hsl(0 0% 83%) */
+ .sel = {0.83, 0.83, 0.83, 1.0},
+ /* grid */
+ .grid = {0.11, 0.11, 0.11, 1.0},
+ /* dark mode headers: light, text is primary_fg (0.09) */
+ .hdr = {
+ {0.88, 0.88, 0.88},
+ {0.80, 0.80, 0.80},
+ {0.84, 0.84, 0.84},
+ {0.76, 0.76, 0.76},
+ {0.90, 0.90, 0.90},
+ {0.82, 0.82, 0.82},
+ },
+ .nhdr = 6,
+};
+
+/* default: 0=light, 1=dark */
+static const int default_dark = 0;
+
+/* default asset mode: 0=copy, 1=symlink */
+static const int default_asset_mode = 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";
diff --git a/config.h b/config.h
@@ -0,0 +1,108 @@
+/* sdiagram - simple diagram tool
+ * See LICENSE file for copyright and license details. */
+
+/* appearance */
+static const char *font_title = "Sans 11";
+static const char *font_body = "Sans 9";
+static const double node_min_w = 180.0;
+static const double node_min_h = 44.0;
+static const double node_max_w = 320.0;
+static const double node_img_h = 160.0;
+static const double node_pad = 10.0;
+static const double node_hdr_h = 32.0;
+static const double node_comment_lh = 16.0;
+static const double conn_lw = 1.5;
+static const double zoom_step = 0.1;
+static const double zoom_min = 0.1;
+static const double zoom_max = 5.0;
+static const double grid_size = 50.0;
+static const int grid_on = 1;
+
+/* themes - 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},
+ /* connections */
+ .conn = {0.55, 0.55, 0.55, 0.70},
+ /* selection ring: hsl(0 0% 40%) */
+ .sel = {0.40, 0.40, 0.40, 1.0},
+ /* grid */
+ .grid = {0.92, 0.92, 0.92, 1.0},
+ /* node header presets */
+ .hdr = {
+ {0.10, 0.10, 0.10},
+ {0.20, 0.20, 0.20},
+ {0.15, 0.15, 0.15},
+ {0.25, 0.25, 0.25},
+ {0.12, 0.12, 0.12},
+ {0.18, 0.18, 0.18},
+ },
+ .nhdr = 6,
+};
+
+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},
+ /* connections */
+ .conn = {0.45, 0.45, 0.45, 0.70},
+ /* selection ring: hsl(0 0% 83%) */
+ .sel = {0.83, 0.83, 0.83, 1.0},
+ /* grid */
+ .grid = {0.11, 0.11, 0.11, 1.0},
+ /* node header presets */
+ .hdr = {
+ {0.22, 0.22, 0.22},
+ {0.28, 0.28, 0.28},
+ {0.18, 0.18, 0.18},
+ {0.32, 0.32, 0.32},
+ {0.25, 0.25, 0.25},
+ {0.20, 0.20, 0.20},
+ },
+ .nhdr = 6,
+};
+
+/* default: 0=light, 1=dark */
+static const int default_dark = 0;
+
+/* default asset mode: 0=copy, 1=symlink */
+static const int default_asset_mode = 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";
diff --git a/config.mk b/config.mk
@@ -0,0 +1,19 @@
+# sdiagram - simple diagram tool
+# See LICENSE file for copyright and license details.
+
+VERSION = 0.1
+
+PREFIX = /usr/local
+MANPREFIX = ${PREFIX}/share/man
+
+PKG_CONFIG = pkg-config
+
+GTK4_CFLAGS = $(shell ${PKG_CONFIG} --cflags gtk4)
+GTK4_LIBS = $(shell ${PKG_CONFIG} --libs gtk4)
+
+INCS = ${GTK4_CFLAGS}
+LIBS = ${GTK4_LIBS} -lsqlite3 -lm
+
+CC = cc
+CFLAGS = -std=c99 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -pedantic -O2 ${INCS}
+LDFLAGS = ${LIBS}
diff --git a/data.c b/data.c
@@ -0,0 +1,649 @@
+/* sdiagram - simple diagram tool
+ * See LICENSE file for copyright and license details. */
+
+#include <errno.h>
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#include "sdiagram.h"
+#include "config.h"
+
+#define INIT_CAP 32
+
+static int
+count_lines(const char *s)
+{
+ int n;
+
+ if (!s || !s[0])
+ return 0;
+ n = 1;
+ while (*s) {
+ if (*s == '\n')
+ n++;
+ s++;
+ }
+ return n;
+}
+
+void
+data_init(Diagram *d)
+{
+ memset(d, 0, sizeof(*d));
+ d->nodes = calloc(INIT_CAP, sizeof(Node));
+ d->ncap = INIT_CAP;
+ d->conns = calloc(INIT_CAP, sizeof(Conn));
+ d->ccap = INIT_CAP;
+ d->zoom = 1.0;
+ d->sel = -1;
+ d->selconn = -1;
+ d->connfrom = -1;
+ d->nextid = 1;
+ d->amode = default_asset_mode;
+ d->dark = default_dark;
+ d->theme = d->dark ? &theme_dark : &theme_light;
+ d->editing = -1;
+}
+
+void
+data_free(Diagram *d)
+{
+ int i;
+
+ for (i = 0; i < d->nnodes; i++) {
+ if (d->nodes[i].thumb)
+ g_object_unref(d->nodes[i].thumb);
+ }
+ free(d->nodes);
+ free(d->conns);
+}
+
+static void
+grow_nodes(Diagram *d)
+{
+ if (d->nnodes < d->ncap)
+ return;
+ d->ncap *= 2;
+ d->nodes = realloc(d->nodes, d->ncap * sizeof(Node));
+}
+
+static void
+grow_conns(Diagram *d)
+{
+ if (d->nconns < d->ccap)
+ return;
+ d->ccap *= 2;
+ d->conns = realloc(d->conns, d->ccap * sizeof(Conn));
+}
+
+int
+node_add(Diagram *d, double x, double y, const char *text)
+{
+ Node *n;
+ const Theme *t;
+ int ci;
+
+ grow_nodes(d);
+ n = &d->nodes[d->nnodes];
+ memset(n, 0, sizeof(*n));
+ n->id = d->nextid++;
+ n->x = x;
+ n->y = y;
+ n->w = node_min_w;
+ n->h = node_min_h;
+ if (text)
+ snprintf(n->text, sizeof(n->text), "%s", text);
+ else
+ snprintf(n->text, sizeof(n->text), "Node %d", n->id);
+
+ t = d->theme;
+ ci = d->hdr_idx % t->nhdr;
+ n->hdr[0] = t->hdr[ci][0];
+ n->hdr[1] = t->hdr[ci][1];
+ n->hdr[2] = t->hdr[ci][2];
+ d->hdr_idx++;
+
+ d->modified = 1;
+ return d->nnodes++;
+}
+
+void
+node_del(Diagram *d, int idx)
+{
+ int i, id;
+
+ if (idx < 0 || idx >= d->nnodes)
+ return;
+ id = d->nodes[idx].id;
+ if (d->nodes[idx].thumb)
+ g_object_unref(d->nodes[idx].thumb);
+
+ for (i = d->nconns - 1; i >= 0; i--) {
+ if (d->conns[i].from == id || d->conns[i].to == id)
+ conn_del(d, i);
+ }
+
+ memmove(&d->nodes[idx], &d->nodes[idx + 1],
+ (d->nnodes - idx - 1) * sizeof(Node));
+ d->nnodes--;
+ d->sel = -1;
+ d->modified = 1;
+}
+
+int
+node_by_id(Diagram *d, int id)
+{
+ int i;
+
+ for (i = 0; i < d->nnodes; i++) {
+ if (d->nodes[i].id == id)
+ return i;
+ }
+ return -1;
+}
+
+double
+node_height(Node *n)
+{
+ double h;
+
+ h = node_hdr_h;
+ if (n->thumb) {
+ h += gdk_pixbuf_get_height(n->thumb);
+ } else if (!n->desc[0]) {
+ h += node_pad * 2;
+ }
+ if (n->desc[0])
+ h += count_lines(n->desc) * node_comment_lh
+ + 8.0;
+ return h < node_min_h ? node_min_h : h;
+}
+
+int
+node_at(Diagram *d, double x, double y)
+{
+ int i;
+ Node *n;
+
+ for (i = d->nnodes - 1; i >= 0; i--) {
+ n = &d->nodes[i];
+ n->h = node_height(n);
+ if (x >= n->x && x <= n->x + n->w &&
+ y >= n->y && y <= n->y + n->h)
+ return i;
+ }
+ return -1;
+}
+
+int
+conn_add(Diagram *d, int from, int to, const char *label)
+{
+ Conn *c;
+ int i;
+
+ for (i = 0; i < d->nconns; i++) {
+ if (d->conns[i].from == from && d->conns[i].to == to)
+ return -1;
+ if (d->conns[i].from == to && d->conns[i].to == from)
+ return -1;
+ }
+
+ grow_conns(d);
+ c = &d->conns[d->nconns];
+ memset(c, 0, sizeof(*c));
+ c->id = d->nextid++;
+ c->from = from;
+ c->to = to;
+ if (label)
+ snprintf(c->label, sizeof(c->label), "%s", label);
+
+ d->modified = 1;
+ return d->nconns++;
+}
+
+void
+conn_del(Diagram *d, int idx)
+{
+ if (idx < 0 || idx >= d->nconns)
+ return;
+ memmove(&d->conns[idx], &d->conns[idx + 1],
+ (d->nconns - idx - 1) * sizeof(Conn));
+ d->nconns--;
+ d->selconn = -1;
+ d->modified = 1;
+}
+
+static double
+point_line_dist(double px, double py,
+ double x1, double y1, double x2, double y2)
+{
+ double dx, dy, t, cx, cy;
+
+ dx = x2 - x1;
+ dy = y2 - y1;
+ if (dx == 0 && dy == 0)
+ return hypot(px - x1, py - y1);
+
+ t = ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy);
+ if (t < 0) t = 0;
+ if (t > 1) t = 1;
+ cx = x1 + t * dx;
+ cy = y1 + t * dy;
+ return hypot(px - cx, py - cy);
+}
+
+int
+conn_at(Diagram *d, double x, double y)
+{
+ int i, fi, ti;
+ Node *fn, *tn;
+ double x1, y1, x2, y2, dist;
+
+ for (i = 0; i < d->nconns; i++) {
+ fi = node_by_id(d, d->conns[i].from);
+ ti = node_by_id(d, d->conns[i].to);
+ if (fi < 0 || ti < 0)
+ continue;
+ fn = &d->nodes[fi];
+ tn = &d->nodes[ti];
+ x1 = fn->x + fn->w / 2;
+ y1 = fn->y + node_height(fn) / 2;
+ x2 = tn->x + tn->w / 2;
+ y2 = tn->y + node_height(tn) / 2;
+ dist = point_line_dist(x, y, x1, y1, x2, y2);
+ if (dist < 8.0)
+ return i;
+ }
+ return -1;
+}
+
+/* asset management */
+
+static int
+mkdirp(const char *path)
+{
+ struct stat st;
+
+ if (stat(path, &st) == 0)
+ return S_ISDIR(st.st_mode) ? 0 : -1;
+ return mkdir(path, 0755);
+}
+
+static int
+copy_file(const char *src, const char *dst)
+{
+ FILE *in, *out;
+ char buf[8192];
+ size_t n;
+
+ in = fopen(src, "rb");
+ if (!in)
+ return -1;
+ out = fopen(dst, "wb");
+ if (!out) {
+ fclose(in);
+ return -1;
+ }
+ while ((n = fread(buf, 1, sizeof(buf), in)) > 0)
+ fwrite(buf, 1, n, out);
+ fclose(in);
+ fclose(out);
+ return 0;
+}
+
+int
+asset_import(Diagram *d, int nidx, const char *filepath)
+{
+ Node *n;
+ const char *base;
+ char dst[1024];
+
+ if (nidx < 0 || nidx >= d->nnodes)
+ return -1;
+ if (!d->adir[0])
+ return -1;
+
+ mkdirp(d->adir);
+
+ n = &d->nodes[nidx];
+ base = strrchr(filepath, '/');
+ base = base ? base + 1 : filepath;
+ snprintf(dst, sizeof(dst), "%s/%s", d->adir, base);
+
+ if (d->amode == ASSET_SYMLINK) {
+ if (symlink(filepath, dst) < 0 && errno != EEXIST)
+ return -1;
+ } else {
+ if (copy_file(filepath, dst) < 0)
+ return -1;
+ }
+
+ snprintf(n->asset, sizeof(n->asset), "%s", base);
+
+ if (n->thumb) {
+ g_object_unref(n->thumb);
+ n->thumb = NULL;
+ }
+ thumb_load(n, d->adir);
+
+ /* expand node to fit image */
+ if (n->thumb) {
+ double tw;
+ tw = gdk_pixbuf_get_width(n->thumb);
+ if (tw > n->w)
+ n->w = tw;
+ }
+
+ d->modified = 1;
+ return 0;
+}
+
+void
+asset_remove(Diagram *d, int nidx)
+{
+ Node *n;
+
+ if (nidx < 0 || nidx >= d->nnodes)
+ return;
+ n = &d->nodes[nidx];
+ if (n->thumb) {
+ g_object_unref(n->thumb);
+ n->thumb = NULL;
+ }
+ n->asset[0] = '\0';
+ d->modified = 1;
+}
+
+void
+thumb_load(Node *n, const char *adir)
+{
+ char path[1024];
+
+ if (!n->asset[0] || n->thumb)
+ return;
+
+ snprintf(path, sizeof(path), "%s/%s", adir, n->asset);
+ n->thumb = gdk_pixbuf_new_from_file_at_scale(
+ path, (int)node_max_w, (int)node_img_h,
+ TRUE, NULL);
+}
+
+/* SQLite persistence */
+
+int
+db_new(Diagram *d, const char *path)
+{
+ sqlite3 *db;
+ int rc;
+
+ snprintf(d->path, sizeof(d->path), "%s", path);
+ snprintf(d->dbpath, sizeof(d->dbpath),
+ "%s/diagram.db", path);
+ snprintf(d->adir, sizeof(d->adir),
+ "%s/assets", path);
+
+ if (mkdirp(path) < 0)
+ return -1;
+ if (mkdirp(d->adir) < 0)
+ return -1;
+
+ rc = sqlite3_open(d->dbpath, &db);
+ if (rc != SQLITE_OK)
+ return -1;
+
+ sqlite3_exec(db,
+ "CREATE TABLE IF NOT EXISTS nodes ("
+ " id INTEGER PRIMARY KEY,"
+ " x REAL, y REAL, w REAL,"
+ " text TEXT,"
+ " asset TEXT,"
+ " desc TEXT,"
+ " hdr_r REAL, hdr_g REAL, hdr_b REAL"
+ ");"
+ "CREATE TABLE IF NOT EXISTS conns ("
+ " id INTEGER PRIMARY KEY,"
+ " src INTEGER, dst INTEGER,"
+ " label TEXT"
+ ");"
+ "CREATE TABLE IF NOT EXISTS meta ("
+ " key TEXT PRIMARY KEY,"
+ " val TEXT"
+ ");",
+ NULL, NULL, NULL);
+
+ sqlite3_close(db);
+ d->modified = 0;
+ return 0;
+}
+
+int
+db_save(Diagram *d)
+{
+ sqlite3 *db;
+ sqlite3_stmt *stmt;
+ int i, rc;
+ Node *n;
+ Conn *c;
+ char buf[64];
+
+ if (!d->dbpath[0])
+ return -1;
+
+ rc = sqlite3_open(d->dbpath, &db);
+ if (rc != SQLITE_OK)
+ return -1;
+
+ sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL);
+ sqlite3_exec(db, "DELETE FROM nodes;", NULL, NULL, NULL);
+ sqlite3_exec(db, "DELETE FROM conns;", NULL, NULL, NULL);
+ sqlite3_exec(db, "DELETE FROM meta;", NULL, NULL, NULL);
+
+ sqlite3_prepare_v2(db,
+ "INSERT INTO nodes VALUES(?,?,?,?,?,?,?,?,?,?);",
+ -1, &stmt, NULL);
+ for (i = 0; i < d->nnodes; i++) {
+ n = &d->nodes[i];
+ sqlite3_bind_int(stmt, 1, n->id);
+ sqlite3_bind_double(stmt, 2, n->x);
+ sqlite3_bind_double(stmt, 3, n->y);
+ sqlite3_bind_double(stmt, 4, n->w);
+ sqlite3_bind_text(stmt, 5, n->text,
+ -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 6, n->asset,
+ -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 7, n->desc,
+ -1, SQLITE_STATIC);
+ sqlite3_bind_double(stmt, 8, n->hdr[0]);
+ sqlite3_bind_double(stmt, 9, n->hdr[1]);
+ sqlite3_bind_double(stmt, 10, n->hdr[2]);
+ sqlite3_step(stmt);
+ sqlite3_reset(stmt);
+ }
+ sqlite3_finalize(stmt);
+
+ sqlite3_prepare_v2(db,
+ "INSERT INTO conns VALUES(?,?,?,?);",
+ -1, &stmt, NULL);
+ for (i = 0; i < d->nconns; i++) {
+ c = &d->conns[i];
+ sqlite3_bind_int(stmt, 1, c->id);
+ sqlite3_bind_int(stmt, 2, c->from);
+ sqlite3_bind_int(stmt, 3, c->to);
+ sqlite3_bind_text(stmt, 4, c->label,
+ -1, SQLITE_STATIC);
+ sqlite3_step(stmt);
+ sqlite3_reset(stmt);
+ }
+ sqlite3_finalize(stmt);
+
+ sqlite3_prepare_v2(db,
+ "INSERT INTO meta VALUES(?,?);",
+ -1, &stmt, NULL);
+
+ snprintf(buf, sizeof(buf), "%.2f", d->panx);
+ sqlite3_bind_text(stmt, 1, "panx", -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 2, buf, -1, SQLITE_TRANSIENT);
+ sqlite3_step(stmt); sqlite3_reset(stmt);
+
+ snprintf(buf, sizeof(buf), "%.2f", d->pany);
+ sqlite3_bind_text(stmt, 1, "pany", -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 2, buf, -1, SQLITE_TRANSIENT);
+ sqlite3_step(stmt); sqlite3_reset(stmt);
+
+ snprintf(buf, sizeof(buf), "%.4f", d->zoom);
+ sqlite3_bind_text(stmt, 1, "zoom", -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 2, buf, -1, SQLITE_TRANSIENT);
+ sqlite3_step(stmt); sqlite3_reset(stmt);
+
+ snprintf(buf, sizeof(buf), "%d", d->nextid);
+ sqlite3_bind_text(stmt, 1, "nextid", -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 2, buf, -1, SQLITE_TRANSIENT);
+ sqlite3_step(stmt); sqlite3_reset(stmt);
+
+ snprintf(buf, sizeof(buf), "%d", d->amode);
+ sqlite3_bind_text(stmt, 1, "amode", -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 2, buf, -1, SQLITE_TRANSIENT);
+ sqlite3_step(stmt); sqlite3_reset(stmt);
+
+ snprintf(buf, sizeof(buf), "%d", d->dark);
+ sqlite3_bind_text(stmt, 1, "dark", -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 2, buf, -1, SQLITE_TRANSIENT);
+ sqlite3_step(stmt); sqlite3_reset(stmt);
+
+ sqlite3_finalize(stmt);
+ sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL);
+ sqlite3_close(db);
+
+ d->modified = 0;
+ return 0;
+}
+
+static void
+load_meta(Diagram *d, sqlite3 *db)
+{
+ sqlite3_stmt *stmt;
+ const char *key, *val;
+
+ sqlite3_prepare_v2(db, "SELECT key, val FROM meta;",
+ -1, &stmt, NULL);
+ while (sqlite3_step(stmt) == SQLITE_ROW) {
+ key = (const char *)sqlite3_column_text(stmt, 0);
+ val = (const char *)sqlite3_column_text(stmt, 1);
+ if (!key || !val)
+ continue;
+ if (strcmp(key, "panx") == 0)
+ d->panx = atof(val);
+ else if (strcmp(key, "pany") == 0)
+ d->pany = atof(val);
+ else if (strcmp(key, "zoom") == 0)
+ d->zoom = atof(val);
+ else if (strcmp(key, "nextid") == 0)
+ d->nextid = atoi(val);
+ else if (strcmp(key, "amode") == 0)
+ d->amode = atoi(val);
+ else if (strcmp(key, "dark") == 0)
+ d->dark = atoi(val);
+ }
+ sqlite3_finalize(stmt);
+}
+
+int
+db_load(Diagram *d, const char *path)
+{
+ sqlite3 *db;
+ sqlite3_stmt *stmt;
+ int rc, i;
+ Node *n;
+ Conn *c;
+
+ snprintf(d->path, sizeof(d->path), "%s", path);
+ snprintf(d->dbpath, sizeof(d->dbpath),
+ "%s/diagram.db", path);
+ snprintf(d->adir, sizeof(d->adir),
+ "%s/assets", path);
+
+ rc = sqlite3_open(d->dbpath, &db);
+ if (rc != SQLITE_OK)
+ return -1;
+
+ for (i = 0; i < d->nnodes; i++) {
+ if (d->nodes[i].thumb) {
+ g_object_unref(d->nodes[i].thumb);
+ d->nodes[i].thumb = NULL;
+ }
+ }
+ d->nnodes = 0;
+ d->nconns = 0;
+ d->sel = -1;
+ d->selconn = -1;
+
+ sqlite3_prepare_v2(db,
+ "SELECT id, x, y, w, text, asset, desc,"
+ " hdr_r, hdr_g, hdr_b "
+ "FROM nodes;", -1, &stmt, NULL);
+ while (sqlite3_step(stmt) == SQLITE_ROW) {
+ grow_nodes(d);
+ n = &d->nodes[d->nnodes];
+ memset(n, 0, sizeof(*n));
+ n->id = sqlite3_column_int(stmt, 0);
+ n->x = sqlite3_column_double(stmt, 1);
+ n->y = sqlite3_column_double(stmt, 2);
+ n->w = sqlite3_column_double(stmt, 3);
+ if (sqlite3_column_text(stmt, 4))
+ snprintf(n->text, sizeof(n->text), "%s",
+ (const char *)
+ sqlite3_column_text(stmt, 4));
+ if (sqlite3_column_text(stmt, 5))
+ snprintf(n->asset, sizeof(n->asset), "%s",
+ (const char *)
+ sqlite3_column_text(stmt, 5));
+ if (sqlite3_column_text(stmt, 6))
+ snprintf(n->desc, sizeof(n->desc),
+ "%s", (const char *)
+ sqlite3_column_text(stmt, 6));
+ n->hdr[0] = sqlite3_column_double(stmt, 7);
+ n->hdr[1] = sqlite3_column_double(stmt, 8);
+ n->hdr[2] = sqlite3_column_double(stmt, 9);
+ thumb_load(n, d->adir);
+ if (n->thumb) {
+ double tw;
+ tw = gdk_pixbuf_get_width(n->thumb);
+ if (tw > n->w)
+ n->w = tw;
+ }
+ n->h = node_height(n);
+ d->nnodes++;
+ }
+ sqlite3_finalize(stmt);
+
+ sqlite3_prepare_v2(db,
+ "SELECT id, src, dst, label FROM conns;",
+ -1, &stmt, NULL);
+ while (sqlite3_step(stmt) == SQLITE_ROW) {
+ grow_conns(d);
+ c = &d->conns[d->nconns];
+ memset(c, 0, sizeof(*c));
+ c->id = sqlite3_column_int(stmt, 0);
+ c->from = sqlite3_column_int(stmt, 1);
+ c->to = sqlite3_column_int(stmt, 2);
+ if (sqlite3_column_text(stmt, 3))
+ snprintf(c->label, sizeof(c->label), "%s",
+ (const char *)
+ sqlite3_column_text(stmt, 3));
+ d->nconns++;
+ }
+ sqlite3_finalize(stmt);
+
+ load_meta(d, db);
+ sqlite3_close(db);
+
+ d->theme = d->dark ? &theme_dark : &theme_light;
+ if (d->zoom < zoom_min)
+ d->zoom = 1.0;
+ d->modified = 0;
+ return 0;
+}
diff --git a/data.o b/data.o
Binary files differ.
diff --git a/sdiagram b/sdiagram
Binary files differ.
diff --git a/sdiagram.c b/sdiagram.c
@@ -0,0 +1,1109 @@
+/* sdiagram - simple diagram tool
+ * See LICENSE file for copyright and license details. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "sdiagram.h"
+#include "config.h"
+
+static Diagram dia;
+
+void
+screen_to_canvas(Diagram *d, double sx, double sy,
+ double *cx, double *cy)
+{
+ *cx = sx / d->zoom - d->panx;
+ *cy = sy / d->zoom - d->pany;
+}
+
+void
+canvas_to_screen(Diagram *d, double cx, double cy,
+ double *sx, double *sy)
+{
+ *sx = (cx + d->panx) * d->zoom;
+ *sy = (cy + d->pany) * d->zoom;
+}
+
+void
+redraw(Diagram *d)
+{
+ if (d->canvas)
+ gtk_widget_queue_draw(d->canvas);
+}
+
+void
+status_update(Diagram *d)
+{
+ char buf[256];
+ const char *mode;
+
+ if (!d->status)
+ return;
+ mode = d->mode == MODE_CONNECT ? "CONNECT" : "NORMAL";
+ snprintf(buf, sizeof(buf),
+ " %s | Nodes: %d | Conns: %d | Zoom: %.0f%%"
+ " | %s%s",
+ mode, d->nnodes, d->nconns, d->zoom * 100,
+ d->amode == ASSET_SYMLINK ? "Symlink" : "Copy",
+ d->modified ? " [modified]" : "");
+ gtk_label_set_text(GTK_LABEL(d->status), buf);
+}
+
+static void
+apply_theme_css(Diagram *d)
+{
+ char css[6144];
+ const Theme *t;
+ int bg[3], fg[3], bd[3], mt[3], cd[3], mfg[3], pr[3];
+
+ t = d->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);
+
+ snprintf(css, sizeof(css),
+ "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);"
+ "}"
+ "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);"
+ "}"
+ "entry {"
+ " background: rgb(%d,%d,%d);"
+ " color: rgb(%d,%d,%d);"
+ " border: 1px solid rgb(%d,%d,%d);"
+ " caret-color: rgb(%d,%d,%d);"
+ "}"
+ "separator {"
+ " background: rgb(%d,%d,%d);"
+ "}"
+ "textview {"
+ " background: rgb(%d,%d,%d);"
+ " color: rgb(%d,%d,%d);"
+ " border: 1px solid rgb(%d,%d,%d);"
+ " caret-color: rgb(%d,%d,%d);"
+ "}"
+ "textview text {"
+ " background: rgb(%d,%d,%d);"
+ " color: rgb(%d,%d,%d);"
+ "}",
+ /* headerbar bg */ bg[0], bg[1], bg[2],
+ /* headerbar border */ bd[0], bd[1], bd[2],
+ /* headerbar fg */ fg[0], fg[1], fg[2],
+ /* button bg */ mt[0], mt[1], mt[2],
+ /* button fg */ fg[0], fg[1], fg[2],
+ /* button border */ bd[0], bd[1], bd[2],
+ /* button hover */ bd[0], bd[1], bd[2],
+ /* label fg */ fg[0], fg[1], fg[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 btn hover */ mt[0], mt[1], mt[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],
+ /* separator */ bd[0], bd[1], bd[2],
+ /* textview bg */ cd[0], cd[1], cd[2],
+ /* textview fg */ mfg[0], mfg[1], mfg[2],
+ /* textview border */ bd[0], bd[1], bd[2],
+ /* textview caret */ fg[0], fg[1], fg[2],
+ /* textview text bg */ cd[0], cd[1], cd[2],
+ /* textview text fg */ mfg[0], mfg[1], mfg[2]
+ );
+
+ if (!d->theme_css) {
+ d->theme_css = gtk_css_provider_new();
+ gtk_style_context_add_provider_for_display(
+ gdk_display_get_default(),
+ GTK_STYLE_PROVIDER(d->theme_css),
+ GTK_STYLE_PROVIDER_PRIORITY_USER);
+ }
+ gtk_css_provider_load_from_string(d->theme_css, css);
+}
+
+void
+toggle_theme(Diagram *d)
+{
+ int i, ci;
+ const Theme *t;
+
+ d->dark = !d->dark;
+ d->theme = d->dark ? &theme_dark : &theme_light;
+ d->modified = 1;
+
+ /* re-color existing node headers from new theme */
+ t = d->theme;
+ for (i = 0; i < d->nnodes; i++) {
+ ci = i % t->nhdr;
+ d->nodes[i].hdr[0] = t->hdr[ci][0];
+ d->nodes[i].hdr[1] = t->hdr[ci][1];
+ d->nodes[i].hdr[2] = t->hdr[ci][2];
+ }
+
+ apply_theme_css(d);
+
+ if (d->theme_btn)
+ gtk_button_set_label(
+ GTK_BUTTON(d->theme_btn),
+ d->dark ? "Light" : "Dark");
+
+ redraw(d);
+ status_update(d);
+}
+
+/* popover text editing */
+
+static void
+close_popover(Diagram *d)
+{
+ if (d->popover) {
+ gtk_popover_popdown(GTK_POPOVER(d->popover));
+ gtk_widget_unparent(d->popover);
+ d->popover = NULL;
+ d->popentry = NULL;
+ }
+}
+
+static void
+edit_done(GtkEntry *entry, gpointer data)
+{
+ Diagram *d;
+ GtkEntryBuffer *buf;
+ const char *text;
+ Node *n;
+
+ d = (Diagram *)data;
+ buf = gtk_entry_get_buffer(entry);
+ text = gtk_entry_buffer_get_text(buf);
+
+ if (d->edit_mode == EDIT_TITLE) {
+ if (d->editing >= 0 && d->editing < d->nnodes
+ && text && text[0])
+ snprintf(d->nodes[d->editing].text,
+ sizeof(d->nodes[d->editing].text),
+ "%s", text);
+ } else if (d->edit_mode == EDIT_DESC) {
+ if (d->editing >= 0 && d->editing < d->nnodes) {
+ n = &d->nodes[d->editing];
+ if (text)
+ snprintf(n->desc, sizeof(n->desc),
+ "%s", text);
+ else
+ n->desc[0] = '\0';
+ }
+ } else if (d->edit_mode == EDIT_CONN_LABEL) {
+ if (d->editing >= 0 && d->editing < d->nconns
+ && text)
+ snprintf(d->conns[d->editing].label,
+ sizeof(d->conns[d->editing].label),
+ "%s", text);
+ }
+
+ d->modified = 1;
+ d->editing = -1;
+ close_popover(d);
+ redraw(d);
+ gtk_widget_grab_focus(d->canvas);
+}
+
+static void
+show_edit_popover(Diagram *d, double sx, double sy,
+ const char *initial)
+{
+ GdkRectangle rect;
+
+ close_popover(d);
+
+ d->popentry = gtk_entry_new();
+ if (initial)
+ gtk_entry_buffer_set_text(
+ gtk_entry_get_buffer(
+ GTK_ENTRY(d->popentry)),
+ initial, -1);
+ g_signal_connect(d->popentry, "activate",
+ G_CALLBACK(edit_done), d);
+
+ d->popover = gtk_popover_new();
+ gtk_popover_set_child(GTK_POPOVER(d->popover),
+ d->popentry);
+ gtk_popover_set_autohide(GTK_POPOVER(d->popover),
+ TRUE);
+
+ rect.x = (int)sx;
+ rect.y = (int)sy;
+ rect.width = 1;
+ rect.height = 1;
+
+ gtk_widget_set_parent(d->popover, d->canvas);
+ gtk_popover_set_pointing_to(GTK_POPOVER(d->popover),
+ &rect);
+ gtk_popover_popup(GTK_POPOVER(d->popover));
+ gtk_widget_grab_focus(d->popentry);
+}
+
+void
+edit_node_text(Diagram *d, int idx)
+{
+ double sx, sy;
+
+ if (idx < 0 || idx >= d->nnodes)
+ return;
+
+ d->edit_mode = EDIT_TITLE;
+ d->editing = idx;
+
+ canvas_to_screen(d,
+ d->nodes[idx].x + d->nodes[idx].w / 2,
+ d->nodes[idx].y, &sx, &sy);
+ show_edit_popover(d, sx, sy, d->nodes[idx].text);
+}
+
+/* inline description editing */
+
+static void
+desc_save_text(Diagram *d)
+{
+ GtkTextBuffer *tbuf;
+ GtkTextIter start, end;
+ char *text;
+
+ if (!d->desc_edit)
+ return;
+ if (d->editing < 0 || d->editing >= d->nnodes)
+ return;
+
+ tbuf = gtk_text_view_get_buffer(
+ GTK_TEXT_VIEW(d->desc_edit));
+ gtk_text_buffer_get_bounds(tbuf, &start, &end);
+ text = gtk_text_buffer_get_text(tbuf,
+ &start, &end, FALSE);
+ if (text) {
+ snprintf(d->nodes[d->editing].desc,
+ sizeof(d->nodes[d->editing].desc),
+ "%s", text);
+ g_free(text);
+ }
+ d->modified = 1;
+}
+
+/* deferred cleanup: runs after event processing */
+static gboolean
+desc_cleanup_idle(gpointer data)
+{
+ Diagram *d;
+ GtkWidget *tv;
+
+ d = (Diagram *)data;
+ tv = d->desc_edit;
+ if (!tv)
+ return G_SOURCE_REMOVE;
+
+ d->desc_edit = NULL;
+ d->editing = -1;
+ gtk_overlay_remove_overlay(
+ GTK_OVERLAY(d->overlay), tv);
+ redraw(d);
+ gtk_widget_grab_focus(d->canvas);
+ return G_SOURCE_REMOVE;
+}
+
+static void
+desc_finish(Diagram *d, int save)
+{
+ if (!d->desc_edit)
+ return;
+ if (save)
+ desc_save_text(d);
+ /* defer widget removal to avoid crash
+ * during focus-out signal */
+ g_idle_add(desc_cleanup_idle, d);
+}
+
+static gboolean
+desc_key(GtkEventControllerKey *c, guint kv,
+ guint kc, GdkModifierType st, gpointer data)
+{
+ (void)c;
+ (void)kc;
+
+ if (kv == GDK_KEY_Escape) {
+ desc_finish((Diagram *)data, 0);
+ return TRUE;
+ }
+ if ((st & GDK_CONTROL_MASK)
+ && kv == GDK_KEY_Return) {
+ desc_finish((Diagram *)data, 1);
+ return TRUE;
+ }
+ return FALSE;
+}
+
+static void
+desc_focus_out(GtkEventControllerFocus *c, gpointer data)
+{
+ (void)c;
+ desc_finish((Diagram *)data, 1);
+}
+
+void
+edit_node_desc(Diagram *d, int idx)
+{
+ GtkWidget *tv;
+ GtkTextBuffer *tbuf;
+ GtkEventController *key, *focus;
+ Node *n;
+ double sx, sy, desc_y, desc_h;
+
+ if (idx < 0 || idx >= d->nnodes)
+ return;
+
+ /* close existing edit */
+ if (d->desc_edit)
+ desc_finish(d, 1);
+
+ n = &d->nodes[idx];
+ d->edit_mode = EDIT_DESC;
+ d->editing = idx;
+
+ /* desc area starts below header + image */
+ desc_y = n->y + node_hdr_h;
+ if (n->thumb)
+ desc_y += gdk_pixbuf_get_height(n->thumb);
+
+ canvas_to_screen(d, n->x, desc_y, &sx, &sy);
+
+ desc_h = n->h - (desc_y - n->y);
+ if (desc_h < 80.0)
+ desc_h = 80.0;
+
+ /* create text view */
+ tv = gtk_text_view_new();
+ gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(tv),
+ GTK_WRAP_WORD_CHAR);
+ gtk_text_view_set_left_margin(GTK_TEXT_VIEW(tv), 6);
+ gtk_text_view_set_right_margin(GTK_TEXT_VIEW(tv), 6);
+ gtk_text_view_set_top_margin(GTK_TEXT_VIEW(tv), 4);
+ gtk_text_view_set_bottom_margin(GTK_TEXT_VIEW(tv), 4);
+
+ tbuf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(tv));
+ gtk_text_buffer_set_text(tbuf, n->desc, -1);
+
+ /* position over the desc section */
+ gtk_widget_set_halign(tv, GTK_ALIGN_START);
+ gtk_widget_set_valign(tv, GTK_ALIGN_START);
+ gtk_widget_set_margin_start(tv, (int)sx);
+ gtk_widget_set_margin_top(tv, (int)sy);
+ gtk_widget_set_size_request(tv,
+ (int)(n->w * d->zoom),
+ (int)(desc_h * d->zoom));
+
+ /* key handler: Ctrl+Enter to save, Escape to cancel */
+ key = gtk_event_controller_key_new();
+ g_signal_connect(key, "key-pressed",
+ G_CALLBACK(desc_key), d);
+ gtk_widget_add_controller(tv, key);
+
+ /* save on focus out */
+ focus = gtk_event_controller_focus_new();
+ g_signal_connect(focus, "leave",
+ G_CALLBACK(desc_focus_out), d);
+ gtk_widget_add_controller(tv, focus);
+
+ gtk_overlay_add_overlay(
+ GTK_OVERLAY(d->overlay), tv);
+ d->desc_edit = tv;
+
+ gtk_widget_grab_focus(tv);
+}
+
+/* context menu */
+
+static void
+close_ctx(Diagram *d)
+{
+ if (d->ctxmenu) {
+ gtk_popover_popdown(GTK_POPOVER(d->ctxmenu));
+ gtk_widget_unparent(d->ctxmenu);
+ d->ctxmenu = NULL;
+ }
+}
+
+static void
+ctx_edit_title(GtkButton *btn, gpointer data)
+{
+ Diagram *d;
+
+ (void)btn;
+ d = (Diagram *)data;
+ close_ctx(d);
+ if (d->sel >= 0)
+ edit_node_text(d, d->sel);
+}
+
+static void
+ctx_edit_desc(GtkButton *btn, gpointer data)
+{
+ Diagram *d;
+
+ (void)btn;
+ d = (Diagram *)data;
+ close_ctx(d);
+ if (d->sel >= 0)
+ edit_node_desc(d, d->sel);
+}
+
+static void
+ctx_add_image(GtkButton *btn, gpointer data)
+{
+ Diagram *d;
+
+ (void)btn;
+ d = (Diagram *)data;
+ close_ctx(d);
+ do_import_image(d);
+}
+
+static void
+ctx_remove_image(GtkButton *btn, gpointer data)
+{
+ Diagram *d;
+
+ (void)btn;
+ d = (Diagram *)data;
+ close_ctx(d);
+ if (d->sel >= 0) {
+ asset_remove(d, d->sel);
+ redraw(d);
+ }
+}
+
+static void
+ctx_delete_node(GtkButton *btn, gpointer data)
+{
+ Diagram *d;
+
+ (void)btn;
+ d = (Diagram *)data;
+ close_ctx(d);
+ if (d->sel >= 0) {
+ node_del(d, d->sel);
+ redraw(d);
+ status_update(d);
+ }
+}
+
+static void
+ctx_delete_conn(GtkButton *btn, gpointer data)
+{
+ Diagram *d;
+
+ (void)btn;
+ d = (Diagram *)data;
+ close_ctx(d);
+ if (d->selconn >= 0) {
+ conn_del(d, d->selconn);
+ redraw(d);
+ status_update(d);
+ }
+}
+
+static void
+ctx_edit_label(GtkButton *btn, gpointer data)
+{
+ Diagram *d;
+ double sx, sy;
+ int fi;
+ Node *fn;
+
+ (void)btn;
+ d = (Diagram *)data;
+ close_ctx(d);
+ if (d->selconn < 0 || d->selconn >= d->nconns)
+ return;
+
+ d->edit_mode = EDIT_CONN_LABEL;
+ d->editing = d->selconn;
+
+ fi = node_by_id(d, d->conns[d->selconn].from);
+ if (fi < 0)
+ return;
+ fn = &d->nodes[fi];
+ canvas_to_screen(d,
+ fn->x + fn->w / 2,
+ fn->y + node_height(fn) / 2,
+ &sx, &sy);
+ show_edit_popover(d, sx, sy,
+ d->conns[d->selconn].label);
+}
+
+static void
+ctx_add_node(GtkButton *btn, gpointer data)
+{
+ Diagram *d;
+ int idx;
+
+ (void)btn;
+ d = (Diagram *)data;
+ close_ctx(d);
+ idx = node_add(d,
+ d->ctx_cx - node_min_w / 2,
+ d->ctx_cy - node_min_h / 2, NULL);
+ d->sel = idx;
+ d->nodes[idx].selected = 1;
+ edit_node_text(d, idx);
+ redraw(d);
+ status_update(d);
+}
+
+static GtkWidget *
+ctx_btn(const char *label, GCallback cb, gpointer data)
+{
+ GtkWidget *btn;
+
+ btn = gtk_button_new_with_label(label);
+ gtk_button_set_has_frame(GTK_BUTTON(btn), FALSE);
+ gtk_widget_set_halign(btn, GTK_ALIGN_FILL);
+ g_signal_connect(btn, "clicked", cb, data);
+ return btn;
+}
+
+void
+show_context_menu(Diagram *d, double sx, double sy)
+{
+ GtkWidget *vbox;
+ GdkRectangle rect;
+
+ close_ctx(d);
+
+ vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+ gtk_widget_set_size_request(vbox, 140, -1);
+
+ if (d->sel >= 0) {
+ gtk_box_append(GTK_BOX(vbox),
+ ctx_btn("Edit Title",
+ G_CALLBACK(ctx_edit_title), d));
+ gtk_box_append(GTK_BOX(vbox),
+ ctx_btn("Edit Description",
+ G_CALLBACK(ctx_edit_desc), d));
+ gtk_box_append(GTK_BOX(vbox),
+ gtk_separator_new(
+ GTK_ORIENTATION_HORIZONTAL));
+ gtk_box_append(GTK_BOX(vbox),
+ ctx_btn("Add Image",
+ G_CALLBACK(ctx_add_image), d));
+ if (d->nodes[d->sel].asset[0])
+ gtk_box_append(GTK_BOX(vbox),
+ ctx_btn("Remove Image",
+ G_CALLBACK(ctx_remove_image),
+ d));
+ gtk_box_append(GTK_BOX(vbox),
+ gtk_separator_new(
+ GTK_ORIENTATION_HORIZONTAL));
+ gtk_box_append(GTK_BOX(vbox),
+ ctx_btn("Delete Node",
+ G_CALLBACK(ctx_delete_node), d));
+ } else if (d->selconn >= 0) {
+ gtk_box_append(GTK_BOX(vbox),
+ ctx_btn("Edit Label",
+ G_CALLBACK(ctx_edit_label), d));
+ gtk_box_append(GTK_BOX(vbox),
+ gtk_separator_new(
+ GTK_ORIENTATION_HORIZONTAL));
+ gtk_box_append(GTK_BOX(vbox),
+ ctx_btn("Delete Connection",
+ G_CALLBACK(ctx_delete_conn), d));
+ } else {
+ gtk_box_append(GTK_BOX(vbox),
+ ctx_btn("Add Node Here",
+ G_CALLBACK(ctx_add_node), d));
+ }
+
+ d->ctxmenu = gtk_popover_new();
+ gtk_popover_set_child(GTK_POPOVER(d->ctxmenu), vbox);
+ gtk_popover_set_autohide(GTK_POPOVER(d->ctxmenu), TRUE);
+
+ rect.x = (int)sx;
+ rect.y = (int)sy;
+ rect.width = 1;
+ rect.height = 1;
+
+ gtk_widget_set_parent(d->ctxmenu, d->canvas);
+ gtk_popover_set_pointing_to(
+ GTK_POPOVER(d->ctxmenu), &rect);
+ gtk_popover_popup(GTK_POPOVER(d->ctxmenu));
+}
+
+/* file dialogs */
+
+static void
+open_cb(GObject *src, GAsyncResult *res, gpointer data)
+{
+ GtkFileDialog *dlg;
+ GFile *file;
+ Diagram *d;
+ char *path;
+
+ d = (Diagram *)data;
+ dlg = GTK_FILE_DIALOG(src);
+ file = gtk_file_dialog_select_folder_finish(
+ dlg, res, NULL);
+ if (!file)
+ return;
+
+ path = g_file_get_path(file);
+ if (path) {
+ db_load(d, path);
+ apply_theme_css(d);
+ if (d->theme_btn)
+ gtk_button_set_label(
+ GTK_BUTTON(d->theme_btn),
+ d->dark ? "Light" : "Dark");
+ redraw(d);
+ status_update(d);
+ g_free(path);
+ }
+ g_object_unref(file);
+}
+
+void
+do_open(Diagram *d)
+{
+ GtkFileDialog *dlg;
+
+ dlg = gtk_file_dialog_new();
+ gtk_file_dialog_set_title(dlg, "Open Diagram");
+ gtk_file_dialog_select_folder(dlg,
+ GTK_WINDOW(d->window), NULL, open_cb, d);
+}
+
+static void
+save_as_cb(GObject *src, GAsyncResult *res, gpointer data)
+{
+ GtkFileDialog *dlg;
+ GFile *file;
+ Diagram *d;
+ char *path;
+
+ d = (Diagram *)data;
+ dlg = GTK_FILE_DIALOG(src);
+ file = gtk_file_dialog_select_folder_finish(
+ dlg, res, NULL);
+ if (!file)
+ return;
+
+ path = g_file_get_path(file);
+ if (path) {
+ db_new(d, path);
+ db_save(d);
+ status_update(d);
+ g_free(path);
+ }
+ g_object_unref(file);
+}
+
+void
+do_save(Diagram *d)
+{
+ if (!d->dbpath[0]) {
+ do_save_as(d);
+ return;
+ }
+ db_save(d);
+ status_update(d);
+}
+
+void
+do_save_as(Diagram *d)
+{
+ GtkFileDialog *dlg;
+
+ dlg = gtk_file_dialog_new();
+ gtk_file_dialog_set_title(dlg,
+ "Save Diagram (select directory)");
+ gtk_file_dialog_select_folder(dlg,
+ GTK_WINDOW(d->window), NULL, save_as_cb, d);
+}
+
+void
+do_new(Diagram *d)
+{
+ int i;
+
+ for (i = 0; i < d->nnodes; i++) {
+ if (d->nodes[i].thumb)
+ g_object_unref(d->nodes[i].thumb);
+ }
+ d->nnodes = 0;
+ d->nconns = 0;
+ d->sel = -1;
+ d->selconn = -1;
+ d->panx = 0;
+ d->pany = 0;
+ d->zoom = 1.0;
+ d->nextid = 1;
+ d->hdr_idx = 0;
+ d->modified = 0;
+ d->path[0] = '\0';
+ d->dbpath[0] = '\0';
+ d->adir[0] = '\0';
+ d->mode = MODE_NORMAL;
+ d->connfrom = -1;
+ redraw(d);
+ status_update(d);
+}
+
+static void
+import_cb(GObject *src, GAsyncResult *res, gpointer data)
+{
+ GtkFileDialog *dlg;
+ GFile *file;
+ Diagram *d;
+ char *path;
+
+ d = (Diagram *)data;
+ dlg = GTK_FILE_DIALOG(src);
+ file = gtk_file_dialog_open_finish(dlg, res, NULL);
+ if (!file)
+ return;
+
+ path = g_file_get_path(file);
+ if (path && d->sel >= 0) {
+ if (!d->adir[0])
+ snprintf(d->adir, sizeof(d->adir),
+ "/tmp/sdiagram-assets");
+ asset_import(d, d->sel, path);
+ redraw(d);
+ g_free(path);
+ }
+ g_object_unref(file);
+}
+
+void
+do_import_image(Diagram *d)
+{
+ GtkFileDialog *dlg;
+ GtkFileFilter *filter;
+ GListStore *filters;
+
+ if (d->sel < 0)
+ return;
+
+ filter = gtk_file_filter_new();
+ gtk_file_filter_set_name(filter, "Images");
+ gtk_file_filter_add_mime_type(filter, "image/*");
+
+ filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
+ g_list_store_append(filters, filter);
+ g_object_unref(filter);
+
+ dlg = gtk_file_dialog_new();
+ gtk_file_dialog_set_title(dlg, "Import Image");
+ gtk_file_dialog_set_filters(dlg,
+ G_LIST_MODEL(filters));
+ gtk_file_dialog_open(dlg,
+ GTK_WINDOW(d->window), NULL, import_cb, d);
+ g_object_unref(filters);
+}
+
+/* toolbar callbacks */
+
+static void
+on_new(GtkButton *btn, gpointer data)
+{
+ (void)btn;
+ do_new((Diagram *)data);
+}
+
+static void
+on_open(GtkButton *btn, gpointer data)
+{
+ (void)btn;
+ do_open((Diagram *)data);
+}
+
+static void
+on_save(GtkButton *btn, gpointer data)
+{
+ (void)btn;
+ do_save((Diagram *)data);
+}
+
+static void
+on_add(GtkButton *btn, gpointer data)
+{
+ Diagram *d;
+ int idx;
+
+ (void)btn;
+ d = (Diagram *)data;
+ idx = node_add(d, -d->panx + 300,
+ -d->pany + 200, NULL);
+ d->sel = idx;
+ d->nodes[idx].selected = 1;
+ edit_node_text(d, idx);
+ redraw(d);
+ status_update(d);
+}
+
+static void
+on_connect(GtkButton *btn, gpointer data)
+{
+ Diagram *d;
+
+ (void)btn;
+ d = (Diagram *)data;
+ if (d->mode == MODE_CONNECT) {
+ d->mode = MODE_NORMAL;
+ d->connfrom = -1;
+ } else {
+ d->mode = MODE_CONNECT;
+ d->connfrom = -1;
+ }
+ redraw(d);
+ status_update(d);
+}
+
+static void
+on_image(GtkButton *btn, gpointer data)
+{
+ (void)btn;
+ do_import_image((Diagram *)data);
+}
+
+static void
+on_mode_toggle(GtkButton *btn, gpointer data)
+{
+ Diagram *d;
+
+ (void)btn;
+ d = (Diagram *)data;
+ d->amode = d->amode == ASSET_COPY
+ ? ASSET_SYMLINK : ASSET_COPY;
+ status_update(d);
+}
+
+static void
+on_theme_toggle(GtkButton *btn, gpointer data)
+{
+ (void)btn;
+ toggle_theme((Diagram *)data);
+}
+
+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
+activate(GtkApplication *app, gpointer data)
+{
+ GtkWidget *window, *vbox, *canvas, *status;
+ GtkWidget *header;
+ GtkGesture *click, *drag, *rclick;
+ GtkEventController *scroll, *key;
+ GtkCssProvider *css;
+ Diagram *d;
+
+ (void)data;
+ d = &dia;
+
+ /* 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);
+
+ /* theme colors for GTK chrome */
+ apply_theme_css(d);
+
+ /* window */
+ window = gtk_application_window_new(app);
+ gtk_window_set_title(GTK_WINDOW(window), "sdiagram");
+ gtk_window_set_default_size(GTK_WINDOW(window),
+ 1200, 800);
+ d->window = window;
+
+ /* header bar */
+ header = gtk_header_bar_new();
+
+ /* theme toggle - top left */
+ d->theme_btn = make_btn(
+ d->dark ? "Light" : "Dark",
+ G_CALLBACK(on_theme_toggle), d);
+ gtk_header_bar_pack_start(GTK_HEADER_BAR(header),
+ d->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", G_CALLBACK(on_new), d));
+ gtk_header_bar_pack_start(GTK_HEADER_BAR(header),
+ make_btn("Open", G_CALLBACK(on_open), d));
+ gtk_header_bar_pack_start(GTK_HEADER_BAR(header),
+ make_btn("Save", G_CALLBACK(on_save), d));
+ 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("+ Node", G_CALLBACK(on_add), d));
+ gtk_header_bar_pack_start(GTK_HEADER_BAR(header),
+ make_btn("Connect",
+ G_CALLBACK(on_connect), d));
+ gtk_header_bar_pack_start(GTK_HEADER_BAR(header),
+ make_btn("Image", G_CALLBACK(on_image), d));
+ gtk_header_bar_pack_end(GTK_HEADER_BAR(header),
+ make_btn("Copy/Link",
+ G_CALLBACK(on_mode_toggle), d));
+ gtk_window_set_titlebar(GTK_WINDOW(window), header);
+
+ /* layout */
+ vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+ gtk_window_set_child(GTK_WINDOW(window), vbox);
+
+ /* overlay wraps canvas for inline editing */
+ d->overlay = gtk_overlay_new();
+ gtk_widget_set_hexpand(d->overlay, TRUE);
+ gtk_widget_set_vexpand(d->overlay, TRUE);
+
+ /* canvas */
+ canvas = gtk_drawing_area_new();
+ gtk_widget_set_hexpand(canvas, TRUE);
+ gtk_widget_set_vexpand(canvas, TRUE);
+ gtk_widget_set_focusable(canvas, TRUE);
+ gtk_drawing_area_set_draw_func(
+ GTK_DRAWING_AREA(canvas),
+ canvas_draw, d, NULL);
+ d->canvas = canvas;
+ gtk_overlay_set_child(
+ GTK_OVERLAY(d->overlay), canvas);
+
+ /* left click */
+ click = gtk_gesture_click_new();
+ g_signal_connect(click, "pressed",
+ G_CALLBACK(canvas_click), d);
+ gtk_widget_add_controller(canvas,
+ GTK_EVENT_CONTROLLER(click));
+
+ /* right click */
+ rclick = gtk_gesture_click_new();
+ gtk_gesture_single_set_button(
+ GTK_GESTURE_SINGLE(rclick), 3);
+ g_signal_connect(rclick, "pressed",
+ G_CALLBACK(canvas_rclick), d);
+ gtk_widget_add_controller(canvas,
+ GTK_EVENT_CONTROLLER(rclick));
+
+ /* drag */
+ drag = gtk_gesture_drag_new();
+ g_signal_connect(drag, "drag-begin",
+ G_CALLBACK(canvas_drag_begin), d);
+ g_signal_connect(drag, "drag-update",
+ G_CALLBACK(canvas_drag_update), d);
+ g_signal_connect(drag, "drag-end",
+ G_CALLBACK(canvas_drag_end), d);
+ gtk_widget_add_controller(canvas,
+ GTK_EVENT_CONTROLLER(drag));
+
+ /* scroll */
+ scroll = gtk_event_controller_scroll_new(
+ GTK_EVENT_CONTROLLER_SCROLL_VERTICAL);
+ g_signal_connect(scroll, "scroll",
+ G_CALLBACK(canvas_scroll), d);
+ gtk_widget_add_controller(canvas, scroll);
+
+ /* keyboard */
+ key = gtk_event_controller_key_new();
+ g_signal_connect(key, "key-pressed",
+ G_CALLBACK(canvas_key), d);
+ gtk_widget_add_controller(canvas, key);
+
+ gtk_box_append(GTK_BOX(vbox), d->overlay);
+
+ /* 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);
+ d->status = status;
+ gtk_box_append(GTK_BOX(vbox), status);
+
+ status_update(d);
+ gtk_window_present(GTK_WINDOW(window));
+ gtk_widget_grab_focus(canvas);
+}
+
+int
+main(int argc, char *argv[])
+{
+ GtkApplication *app;
+ int ret;
+
+ data_init(&dia);
+
+ if (argc > 1) {
+ if (db_load(&dia, argv[1]) < 0)
+ fprintf(stderr,
+ "sdiagram: cannot open '%s'\n",
+ argv[1]);
+ }
+
+ app = gtk_application_new("org.sdiagram.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);
+ data_free(&dia);
+ return ret;
+}
diff --git a/sdiagram.h b/sdiagram.h
@@ -0,0 +1,151 @@
+/* sdiagram - simple diagram tool
+ * See LICENSE file for copyright and license details. */
+
+#ifndef SDIAGRAM_H
+#define SDIAGRAM_H
+
+#include <gtk/gtk.h>
+#include <sqlite3.h>
+
+enum { ASSET_COPY, ASSET_SYMLINK };
+enum { MODE_NORMAL, MODE_CONNECT };
+enum { EDIT_TITLE, EDIT_DESC, EDIT_CONN_LABEL };
+
+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 conn[4];
+ double sel[4];
+ double grid[4];
+ double hdr[6][3];
+ int nhdr;
+} Theme;
+
+typedef struct {
+ int id;
+ double x, y, w, h;
+ char text[256];
+ char asset[512]; /* relative path in assets/ dir */
+ char desc[1024]; /* editable description */
+ double hdr[3]; /* header color rgb */
+ int selected;
+ GdkPixbuf *thumb; /* cached image thumbnail */
+} Node;
+
+typedef struct {
+ int id;
+ int from; /* node id */
+ int to; /* node id */
+ char label[128];
+} Conn;
+
+typedef struct {
+ Node *nodes;
+ int nnodes;
+ int ncap;
+
+ Conn *conns;
+ int nconns;
+ int ccap;
+
+ char path[1024]; /* diagram directory */
+ char dbpath[1024]; /* diagram.db path */
+ char adir[1024]; /* assets/ path */
+ int amode; /* ASSET_COPY or ASSET_SYMLINK */
+
+ double panx, pany;
+ double zoom;
+
+ int sel; /* selected node index, -1 = none */
+ int selconn; /* selected conn index, -1 = none */
+ int mode; /* MODE_NORMAL or MODE_CONNECT */
+ int connfrom; /* source node idx for connecting */
+ int dragging;
+ double dragox, dragoy;
+ int panning;
+ double pansx, pansy;
+
+ int nextid;
+ int modified;
+ int hdr_idx; /* cycles through header presets */
+
+ int dark; /* 0=light, 1=dark */
+ const Theme *theme; /* current theme pointer */
+
+ int edit_mode; /* EDIT_TITLE, EDIT_COMMENT, etc */
+ int editing; /* node/conn index being edited */
+
+ double ctx_cx, ctx_cy; /* right-click canvas coords */
+
+ GtkWidget *window;
+ GtkWidget *overlay;
+ GtkWidget *canvas;
+ GtkWidget *status;
+ GtkWidget *popover;
+ GtkWidget *popentry;
+ GtkWidget *ctxmenu;
+ GtkWidget *theme_btn;
+ GtkWidget *desc_edit; /* inline text view for desc */
+ GtkCssProvider *theme_css;
+} Diagram;
+
+/* data.c */
+void data_init(Diagram *d);
+void data_free(Diagram *d);
+int node_add(Diagram *d, double x, double y, const char *text);
+void node_del(Diagram *d, int idx);
+int node_by_id(Diagram *d, int id);
+int node_at(Diagram *d, double x, double y);
+double node_height(Node *n);
+int conn_add(Diagram *d, int from, int to, const char *label);
+void conn_del(Diagram *d, int idx);
+int conn_at(Diagram *d, double x, double y);
+int asset_import(Diagram *d, int nidx, const char *filepath);
+void asset_remove(Diagram *d, int nidx);
+void thumb_load(Node *n, const char *adir);
+int db_save(Diagram *d);
+int db_load(Diagram *d, const char *path);
+int db_new(Diagram *d, const char *path);
+
+/* canvas.c */
+void canvas_draw(GtkDrawingArea *area, cairo_t *cr,
+ int w, int h, gpointer data);
+void canvas_click(GtkGestureClick *g, int np,
+ double x, double y, gpointer data);
+void canvas_rclick(GtkGestureClick *g, int np,
+ double x, double y, gpointer data);
+void canvas_drag_begin(GtkGestureDrag *g,
+ double x, double y, gpointer data);
+void canvas_drag_update(GtkGestureDrag *g,
+ double ox, double oy, gpointer data);
+void canvas_drag_end(GtkGestureDrag *g,
+ double ox, double oy, gpointer data);
+gboolean canvas_scroll(GtkEventControllerScroll *c,
+ double dx, double dy, gpointer data);
+gboolean canvas_key(GtkEventControllerKey *c,
+ guint kv, guint kc, GdkModifierType st, gpointer data);
+
+/* sdiagram.c */
+void screen_to_canvas(Diagram *d, double sx, double sy,
+ double *cx, double *cy);
+void canvas_to_screen(Diagram *d, double cx, double cy,
+ double *sx, double *sy);
+void status_update(Diagram *d);
+void edit_node_text(Diagram *d, int idx);
+void edit_node_desc(Diagram *d, int idx);
+void show_context_menu(Diagram *d, double sx, double sy);
+void do_open(Diagram *d);
+void do_save(Diagram *d);
+void do_save_as(Diagram *d);
+void do_new(Diagram *d);
+void do_import_image(Diagram *d);
+void redraw(Diagram *d);
+void toggle_theme(Diagram *d);
+
+#endif /* SDIAGRAM_H */
diff --git a/sdiagram.o b/sdiagram.o
Binary files differ.