sdiagram

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | LICENSE

commit 82a2aab0db37be3d9eb84c3879c46e0b1634337b
Author: Kris Yotam <krisyotam@protonmail.com>
Date:   Thu, 12 Mar 2026 21:23:21 -0500

initial commit

Diffstat:
ALICENSE | 21+++++++++++++++++++++
AMakefile | 39+++++++++++++++++++++++++++++++++++++++
Acanvas.c | 761+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acanvas.o | 0
Aconfig.def.h | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aconfig.h | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aconfig.mk | 19+++++++++++++++++++
Adata.c | 649+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adata.o | 0
Asdiagram | 0
Asdiagram.c | 1109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asdiagram.h | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asdiagram.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, "&amp;"); + else if (*md == '<') + o += papp(o, end, "&lt;"); + 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, "&amp;"); + else if (*md == '<') + o += papp(o, end, "&lt;"); + 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, "&amp;"); + else if (*md == '<') + o += papp(o, end, "&lt;"); + 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, "&amp;"); + else if (*md == '<') + o += papp(o, end, "&lt;"); + 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.