From 71b675d4ca1bfddd451f5e0a5a107bd883b7a4a2 Mon Sep 17 00:00:00 2001 From: Monish Meher Date: Mon, 9 Nov 2026 18:14:27 +0530 Subject: [PATCH 1/4] Add ShapeKit, a shared shape recognizer Plain C99 so both the macOS app (SwiftPM C target) and the Windows port compile the same code. Recognizes lines, arrows (one stroke, or a line followed by a V), circles, ellipses, triangles, squares, rectangles, diamonds, pentagons, hexagons, stars and hearts, and returns clean polylines. Scribbles and handwriting stay freehand. Tests/shapes/shape_test.c draws thousands of synthetic hand-drawn strokes and checks accuracy per shape, false positives, two-stroke arrows and orientation. scripts/test-shapes.sh runs it. Co-Authored-By: Claude Opus 5.5 --- Sources/ShapeKit/include/shapekit.h | 93 ++ Sources/ShapeKit/shapekit.c | 1411 +++++++++++++++++++++++++++ Tests/shapes/shape_test.c | 1048 ++++++++++++++++++++ scripts/test-shapes.sh | 9 + 4 files changed, 2561 insertions(+) create mode 100644 Sources/ShapeKit/include/shapekit.h create mode 100644 Sources/ShapeKit/shapekit.c create mode 100644 Tests/shapes/shape_test.c create mode 100755 scripts/test-shapes.sh diff --git a/Sources/ShapeKit/include/shapekit.h b/Sources/ShapeKit/include/shapekit.h new file mode 100644 index 0000000..89324ea --- /dev/null +++ b/Sources/ShapeKit/include/shapekit.h @@ -0,0 +1,93 @@ +#ifndef TINYHAND_SHAPEKIT_H +#define TINYHAND_SHAPEKIT_H + +/* + * ShapeKit: turns a rough freehand stroke into a clean shape. + * + * Plain C99 with no dependencies, so the macOS app (through SwiftPM) and the + * Windows port (through the Makefile) share one recognizer and behave the same. + * + * Coordinates are in whatever unit the caller draws in (points on macOS, + * pixels on Windows). Pass y_down = 1 when +y points down the screen (Windows), + * 0 when +y points up (macOS). It only matters for shapes that have an + * "upright" pose: a triangle, pentagon or star pointing up, a heart with its + * point at the bottom. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + float x; + float y; +} th_point; + +typedef enum { + TH_SHAPE_NONE = 0, + TH_SHAPE_LINE, + TH_SHAPE_ARROW, + TH_SHAPE_CIRCLE, + TH_SHAPE_ELLIPSE, + TH_SHAPE_TRIANGLE, + TH_SHAPE_SQUARE, + TH_SHAPE_RECTANGLE, + TH_SHAPE_DIAMOND, + TH_SHAPE_PENTAGON, + TH_SHAPE_HEXAGON, + TH_SHAPE_STAR, + TH_SHAPE_HEART, + TH_SHAPE_KIND_COUNT +} th_shape_kind; + +#define TH_SHAPE_MAX_POINTS 256 +#define TH_SHAPE_MAX_PATHS 4 + +/* + * The clean shape as one or more polylines. Path i uses + * points[path_start[i] .. path_start[i] + path_length[i] - 1] and is closed + * when path_closed[i] is non-zero. Curves (circle, ellipse, heart) come back + * finely sampled, so callers only ever draw straight segments. + */ +typedef struct { + th_shape_kind kind; + int path_count; + int path_start[TH_SHAPE_MAX_PATHS]; + int path_length[TH_SHAPE_MAX_PATHS]; + int path_closed[TH_SHAPE_MAX_PATHS]; + int point_count; + th_point points[TH_SHAPE_MAX_POINTS]; +} th_shape; + +/* + * Recognise one stroke, points in drawing order. Returns the kind found and + * fills *out, or returns TH_SHAPE_NONE (and sets out->kind to it) when the + * stroke should stay freehand: scribbles, handwriting, tiny marks. + */ +th_shape_kind th_recognize(const th_point *points, int count, int y_down, th_shape *out); + +/* + * Two-stroke arrows: call this for a new stroke when the previous stroke was + * just snapped to a line. If the new stroke is a small "V" or "<" drawn at + * either end of that line, *out becomes the finished arrow (pointing at that + * end) and TH_SHAPE_ARROW is returned; the caller replaces the line with it + * and drops the new stroke. Otherwise returns TH_SHAPE_NONE. + */ +th_shape_kind th_recognize_arrowhead(const th_shape *line, const th_point *points, int count, + int y_down, th_shape *out); + +/* "Line", "Arrow", "Circle", ... ; "None" for TH_SHAPE_NONE. */ +const char *th_shape_name(th_shape_kind kind); + +/* Small accessors so Swift does not have to walk C fixed-size arrays. */ +int th_shape_path_count(const th_shape *shape); +int th_shape_path_start(const th_shape *shape, int path); +int th_shape_path_length(const th_shape *shape, int path); +int th_shape_path_closed(const th_shape *shape, int path); +th_point th_shape_point(const th_shape *shape, int index); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/Sources/ShapeKit/shapekit.c b/Sources/ShapeKit/shapekit.c new file mode 100644 index 0000000..d42f3ca --- /dev/null +++ b/Sources/ShapeKit/shapekit.c @@ -0,0 +1,1411 @@ +#include "shapekit.h" + +#include +#include +#include + +/* + * How it works, in short: + * 1. Resample the stroke to evenly spaced points (so speed and input rate + * do not matter) in an internal frame where +y points down. + * 2. Straight enough end to end: a line. + * 3. Ends meet (small gap or an overshoot): a closed loop. Fit both a + * polygon (corner detection plus line fits per side) and an ellipse, + * match against a heart template, and pick what explains it best. + * Five or more self-crossings with five straight sides: a pentagram. + * 4. Otherwise look for an arrow: a straight shaft plus a small head that + * folds back on both sides of the tip. + * 5. Anything else stays freehand. + */ + +#define SK_PI 3.14159265358979323846 +#define SK_TAU (2.0 * SK_PI) +#define SK_OPEN_N 128 +#define SK_LOOP_N 96 +#define SK_TPL_N 64 +#define SK_CURVE_N 96 +#define SK_HEAD_N 32 +#define SK_MAX_V 48 +#define SK_MIN_SIZE 16.0 + +typedef struct { + double x; + double y; +} vec2; + +typedef struct { + double minx, miny, maxx, maxy; +} box2; + +static vec2 v2(double x, double y) +{ + vec2 r; + r.x = x; + r.y = y; + return r; +} + +static vec2 vadd(vec2 a, vec2 b) { return v2(a.x + b.x, a.y + b.y); } +static vec2 vsub(vec2 a, vec2 b) { return v2(a.x - b.x, a.y - b.y); } +static vec2 vscale(vec2 a, double s) { return v2(a.x * s, a.y * s); } +static double vdot(vec2 a, vec2 b) { return a.x * b.x + a.y * b.y; } +static double vcross(vec2 a, vec2 b) { return a.x * b.y - a.y * b.x; } +static double vlen(vec2 a) { return sqrt(a.x * a.x + a.y * a.y); } +static double vdist(vec2 a, vec2 b) { return vlen(vsub(a, b)); } +static vec2 vpolar(double r, double ang) { return v2(r * cos(ang), r * sin(ang)); } + +static vec2 vnorm(vec2 a) +{ + double l = vlen(a); + return l > 1e-12 ? vscale(a, 1.0 / l) : v2(0, 0); +} + +static vec2 vrot(vec2 a, double ang) +{ + double c = cos(ang), s = sin(ang); + return v2(a.x * c - a.y * s, a.x * s + a.y * c); +} + +static double rad(double deg) { return deg * SK_PI / 180.0; } + +static double wrap(double a) +{ + a = fmod(a + SK_PI, SK_TAU); + if (a < 0) { + a += SK_TAU; + } + return a - SK_PI; +} + +static double clampd(double v, double lo, double hi) +{ + return v < lo ? lo : (v > hi ? hi : v); +} + +static double turn_between(vec2 a, vec2 b) { return atan2(vcross(a, b), vdot(a, b)); } + +static box2 bounds(const vec2 *p, int n) +{ + box2 b; + int i; + b.minx = b.maxx = p[0].x; + b.miny = b.maxy = p[0].y; + for (i = 1; i < n; i++) { + if (p[i].x < b.minx) b.minx = p[i].x; + if (p[i].x > b.maxx) b.maxx = p[i].x; + if (p[i].y < b.miny) b.miny = p[i].y; + if (p[i].y > b.maxy) b.maxy = p[i].y; + } + return b; +} + +static double box_diag(box2 b) { return hypot(b.maxx - b.minx, b.maxy - b.miny); } + +static vec2 mean_of(const vec2 *p, int n) +{ + vec2 m = v2(0, 0); + int i; + for (i = 0; i < n; i++) { + m = vadd(m, p[i]); + } + return vscale(m, 1.0 / n); +} + +static double polyline_length(const vec2 *p, int n, int closed) +{ + double l = 0; + int i; + for (i = 1; i < n; i++) { + l += vdist(p[i - 1], p[i]); + } + if (closed && n > 1) { + l += vdist(p[n - 1], p[0]); + } + return l; +} + +/* Principal axis through the mean: centre, unit direction, and the spread along / across it. */ +static void principal_axis(const vec2 *p, int n, vec2 *center, vec2 *dir) +{ + vec2 m = mean_of(p, n); + double sxx = 0, syy = 0, sxy = 0, ang; + int i; + for (i = 0; i < n; i++) { + vec2 d = vsub(p[i], m); + sxx += d.x * d.x; + syy += d.y * d.y; + sxy += d.x * d.y; + } + ang = 0.5 * atan2(2 * sxy, sxx - syy); + *center = m; + *dir = v2(cos(ang), sin(ang)); +} + +static vec2 raw_point(const th_point *p, int i, double sy) +{ + return v2((double)p[i].x, (double)p[i].y * sy); +} + +/* Evenly spaced points along the raw stroke, y flipped into the internal y-down frame. */ +static int resample_raw(const th_point *p, int n, double sy, vec2 *out, int m, double *length) +{ + double total = 0, step, acc = 0; + vec2 prev; + int i, k = 1; + for (i = 1; i < n; i++) { + total += vdist(raw_point(p, i - 1, sy), raw_point(p, i, sy)); + } + *length = total; + if (total <= 1e-9 || m < 2) { + return 0; + } + step = total / (m - 1); + prev = raw_point(p, 0, sy); + out[0] = prev; + for (i = 1; i < n && k < m; i++) { + vec2 cur = raw_point(p, i, sy); + double d = vdist(prev, cur); + while (d > 0 && acc + d >= step && k < m) { + double t = (step - acc) / d; + vec2 q = vadd(prev, vscale(vsub(cur, prev), t)); + out[k++] = q; + prev = q; + d = vdist(prev, cur); + acc = 0; + } + acc += d; + prev = cur; + } + while (k < m) { + out[k++] = raw_point(p, n - 1, sy); + } + return 1; +} + +/* Evenly spaced points along a polyline. Closed: m points around the loop, starting at p[0]. */ +static int resample(const vec2 *p, int n, int closed, vec2 *out, int m) +{ + double total = polyline_length(p, n, closed), step, acc = 0; + vec2 prev; + int i, k = 1, segs = closed ? n : n - 1; + if (total <= 1e-9 || n < 2 || m < 2) { + return 0; + } + step = closed ? total / m : total / (m - 1); + prev = p[0]; + out[0] = prev; + for (i = 1; i <= segs && k < m; i++) { + vec2 cur = p[i % n]; + double d = vdist(prev, cur); + while (d > 0 && acc + d >= step && k < m) { + double t = (step - acc) / d; + vec2 q = vadd(prev, vscale(vsub(cur, prev), t)); + out[k++] = q; + prev = q; + d = vdist(prev, cur); + acc = 0; + } + acc += d; + prev = cur; + } + while (k < m) { + out[k++] = closed ? p[0] : p[n - 1]; + } + return 1; +} + +static void shape_clear(th_shape *s) +{ + memset(s, 0, sizeof *s); + s->kind = TH_SHAPE_NONE; +} + +static int shape_add(th_shape *s, double sy, const vec2 *p, int n, int closed) +{ + int i, start; + if (n <= 0 || s->path_count >= TH_SHAPE_MAX_PATHS || s->point_count + n > TH_SHAPE_MAX_POINTS) { + return 0; + } + start = s->point_count; + for (i = 0; i < n; i++) { + s->points[start + i].x = (float)p[i].x; + s->points[start + i].y = (float)(p[i].y * sy); + } + s->path_start[s->path_count] = start; + s->path_length[s->path_count] = n; + s->path_closed[s->path_count] = closed ? 1 : 0; + s->path_count++; + s->point_count += n; + return 1; +} + +static th_shape_kind finish(th_shape *o, th_shape_kind kind) +{ + o->kind = kind; + return kind; +} + +/* Snap an angle to the nearest of base + m * step when within tol. */ +static double snap_angle(double ang, double base, double step, double tol) +{ + double rel = ang - base; + double m = floor(rel / step + 0.5); + if (fabs(rel - m * step) < tol) { + return base + m * step; + } + return ang; +} + +/* Best-fit rotation of an n-fold symmetric set of vertex angles around c. */ +static double nfold_rotation(const vec2 *v, int count, vec2 c, int fold) +{ + double sx = 0, sy = 0; + int i; + for (i = 0; i < count; i++) { + double a = atan2(v[i].y - c.y, v[i].x - c.x); + sx += cos(fold * a); + sy += sin(fold * a); + } + return atan2(sy, sx) / fold; +} + +static void regular_polygon(vec2 c, double r, int n, double rot, vec2 *out) +{ + int j; + for (j = 0; j < n; j++) { + out[j] = vadd(c, vpolar(r, rot + SK_TAU * j / n)); + } +} + +static void star_points(vec2 c, double outer, double inner, double rot, vec2 *out) +{ + int j; + for (j = 0; j < 10; j++) { + out[j] = vadd(c, vpolar(j % 2 ? inner : outer, rot + SK_PI * j / 5)); + } +} + +/* ---------- Lines and arrows ---------- */ + +static vec2 snap_segment_end(vec2 fixed, vec2 moving, double tol) +{ + vec2 d = vsub(moving, fixed); + double ang = atan2(d.y, d.x); + double snapped = snap_angle(ang, 0, SK_PI / 2, tol); + return vadd(fixed, vpolar(vlen(d), snapped)); +} + +static th_shape_kind build_arrow(vec2 tail, vec2 tip, double barb, double sy, th_shape *o) +{ + vec2 shaft[2], head[3], back; + tail = snap_segment_end(tip, tail, rad(6)); + back = vnorm(vsub(tail, tip)); + shaft[0] = tail; + shaft[1] = tip; + head[0] = vadd(tip, vscale(vrot(back, rad(30)), barb)); + head[1] = tip; + head[2] = vadd(tip, vscale(vrot(back, -rad(30)), barb)); + shape_add(o, sy, shaft, 2, 0); + shape_add(o, sy, head, 3, 0); + return finish(o, TH_SHAPE_ARROW); +} + +static th_shape_kind try_line(const vec2 *s, int n, double length, double sy, th_shape *o) +{ + vec2 c, d, p0, p1, mid, half; + double chord = vdist(s[0], s[n - 1]), maxdev = 0, ang, snapped; + int i; + double backtrack = 0, coarse = 0; + for (i = 4; i < n; i += 4) { + coarse += vdist(s[i - 4], s[i]); + } + coarse += vdist(s[(n - 1) / 4 * 4], s[n - 1]); + /* Length measured on every fourth point, so hand jitter does not count but a zigzag does. */ + if (chord < 0.92 * coarse || chord < 0.75 * length || chord < SK_MIN_SIZE) { + return TH_SHAPE_NONE; + } + principal_axis(s, n, &c, &d); + if (vdot(d, vsub(s[n - 1], s[0])) < 0) { + d = vscale(d, -1); + } + for (i = 0; i < n; i++) { + double dev = fabs(vcross(d, vsub(s[i], c))); + if (dev > maxdev) maxdev = dev; + if (i > 0) { + double step = vdot(d, vsub(s[i], s[i - 1])); + if (step < 0) backtrack -= step; + } + } + /* Straight, and never doubling back (a scribbled-over line is not a line). */ + if (maxdev > 0.06 * chord || backtrack > 0.08 * chord) { + return TH_SHAPE_NONE; + } + p0 = vadd(c, vscale(d, vdot(vsub(s[0], c), d))); + p1 = vadd(c, vscale(d, vdot(vsub(s[n - 1], c), d))); + mid = vscale(vadd(p0, p1), 0.5); + half = vsub(p1, mid); + ang = atan2(half.y, half.x); + snapped = snap_angle(ang, 0, SK_PI / 2, rad(6)); + if (snapped != ang) { + half = vpolar(vlen(half), snapped); + p0 = vsub(mid, half); + p1 = vadd(mid, half); + } + { + vec2 seg[2]; + seg[0] = p0; + seg[1] = p1; + shape_add(o, sy, seg, 2, 0); + } + return finish(o, TH_SHAPE_LINE); +} + +static th_shape_kind try_arrow(const vec2 *s, int n, double size, double sy, th_shape *o) +{ + vec2 p[SK_OPEN_N]; + int pass, i; + for (pass = 0; pass < 2; pass++) { + int tip = 0, ok = 1; + double shaft = 0, maxdev = 0, reach = 0, side_pos = 0, side_neg = 0, fwd = -1e9, head_len = 0; + vec2 d, nrm, tail, tp; + for (i = 0; i < n; i++) { + p[i] = pass ? s[n - 1 - i] : s[i]; + } + for (i = 1; i < n; i++) { + double dd = vdist(p[0], p[i]); + if (dd > shaft) { + shaft = dd; + } + } + /* The head of a triangle-headed arrow returns to the tip: take the first visit. */ + for (i = 1; i < n; i++) { + if (vdist(p[0], p[i]) >= 0.97 * shaft) { + tip = i; + break; + } + } + for (i = tip + 1; i < n && i <= tip + 4; i++) { + if (vdist(p[0], p[i]) > vdist(p[0], p[tip])) { + tip = i; + } + } + shaft = vdist(p[0], p[tip]); + if (tip < 0.45 * (n - 1) || tip > 0.93 * (n - 1) || shaft < 0.5 * size) { + continue; + } + tail = p[0]; + tp = p[tip]; + d = vnorm(vsub(tp, tail)); + nrm = v2(-d.y, d.x); + for (i = 0; i <= tip; i++) { + double dev = fabs(vcross(d, vsub(p[i], tail))); + if (dev > maxdev) maxdev = dev; + } + if (maxdev > 0.07 * shaft) { + continue; + } + for (i = tip + 1; i < n; i++) { + vec2 w = vsub(p[i], tp); + double u = vdot(w, d), v = vdot(w, nrm), r = vlen(w); + if (r > reach) reach = r; + if (v > side_pos) side_pos = v; + if (v < side_neg) side_neg = v; + if (u > fwd) fwd = u; + head_len += vdist(p[i - 1], p[i]); + } + if (reach < 0.08 * shaft || reach > 0.6 * shaft || fwd > 0.25 * reach) { + continue; + } + if (side_pos < 0.2 * reach || -side_neg < 0.2 * reach) { + continue; + } + if (head_len < 1.5 * reach || head_len > 5.0 * reach) { + continue; + } + for (i = tip + 1; i < n && ok; i++) { + vec2 w = vsub(p[i], tp); + double r = vlen(w); + if (r > 0.35 * reach && vdot(w, d) / r > -0.2) { + ok = 0; + } + } + if (!ok) { + continue; + } + return build_arrow(tail, tp, clampd(reach, 0.1 * shaft, 0.4 * shaft), sy, o); + } + return TH_SHAPE_NONE; +} + +/* ---------- Closed loops ---------- */ + +/* + * Finds where the stroke closes on itself and resamples that loop. One end of + * the stroke has to come back to the other end's neighbourhood: either the two + * ends nearly meet (a gap) or one end runs past the other (an overshoot). + */ +static int find_loop(const vec2 *s, int n, double size, double length, vec2 *loop) +{ + int win = (int)(0.2 * (n - 1)), i, bi = 0, bj = n - 1; + double best = vdist(s[0], s[n - 1]), minor, lo = 1e300, hi = -1e300; + vec2 c, d, nrm; + for (i = n - 1 - win; i < n; i++) { + double dd = vdist(s[0], s[i]); + if (dd < best) { + best = dd; + bi = 0; + bj = i; + } + } + for (i = 0; i <= win; i++) { + double dd = vdist(s[i], s[n - 1]); + if (dd < best) { + best = dd; + bi = i; + bj = n - 1; + } + } + if (bj - bi < 0.6 * (n - 1)) { + return 0; + } + principal_axis(s + bi, bj - bi + 1, &c, &d); + nrm = v2(-d.y, d.x); + for (i = bi; i <= bj; i++) { + double v = vdot(vsub(s[i], c), nrm); + if (v < lo) lo = v; + if (v > hi) hi = v; + } + minor = hi - lo; + if ((best > 0.2 * size && best > 0.07 * length) || best > 0.6 * minor) { + return 0; + } + return resample(s + bi, bj - bi + 1, 1, loop, SK_LOOP_N); +} + +static int segments_cross(vec2 a, vec2 b, vec2 c, vec2 d) +{ + double d1 = vcross(vsub(b, a), vsub(c, a)); + double d2 = vcross(vsub(b, a), vsub(d, a)); + double d3 = vcross(vsub(d, c), vsub(a, c)); + double d4 = vcross(vsub(d, c), vsub(b, c)); + return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); +} + +static int count_crossings(const vec2 *p, int m) +{ + int i, j, count = 0; + for (i = 0; i < m; i++) { + for (j = i + 2; j < m; j++) { + if (i == 0 && j == m - 1) { + continue; + } + if (segments_cross(p[i], p[(i + 1) % m], p[j], p[(j + 1) % m])) { + count++; + } + } + } + return count; +} + +/* + * Cuts out small self-loops (an overshoot that curls back over the start, a + * scribble at a heart's cusp) so they do not count as crossings or turns. + */ +static void drop_small_loops(vec2 *p, int m) +{ + vec2 tmp[SK_LOOP_N]; + int pass; + for (pass = 0; pass < 3; pass++) { + int i, j, t, keep = 0, found = 0; + for (i = 0; i < m && !found; i++) { + for (j = i + 2; j < m && !found; j++) { + int inner = j - i; + if (i == 0 && j == m - 1) { + continue; + } + if (!segments_cross(p[i], p[(i + 1) % m], p[j], p[(j + 1) % m])) { + continue; + } + if (inner <= 0.15 * m) { + for (t = 0; t <= i; t++) tmp[keep++] = p[t]; + for (t = j + 1; t < m; t++) tmp[keep++] = p[t]; + found = 1; + } else if (m - inner <= 0.15 * m) { + for (t = i + 1; t <= j; t++) tmp[keep++] = p[t]; + found = 1; + } + } + } + if (!found || keep < 8 || !resample(tmp, keep, 1, p, m)) { + return; + } + } +} + +static double total_turning(const vec2 *p, int m) +{ + double sum = 0; + int i; + for (i = 0; i < m; i++) { + vec2 a = vsub(p[i], p[(i - 1 + m) % m]); + vec2 b = vsub(p[(i + 1) % m], p[i]); + sum += turn_between(a, b); + } + return sum; +} + +static void dp_mark(const vec2 *p, int a, int b, double tol, unsigned char *keep) +{ + vec2 d; + double len, best = -1; + int i, at = -1; + if (b <= a + 1) { + return; + } + d = vsub(p[b], p[a]); + len = vlen(d); + for (i = a + 1; i < b; i++) { + double dev = len > 1e-9 ? fabs(vcross(d, vsub(p[i], p[a]))) / len : vdist(p[i], p[a]); + if (dev > best) { + best = dev; + at = i; + } + } + if (best > tol) { + keep[at] = 1; + dp_mark(p, a, at, tol, keep); + dp_mark(p, at, b, tol, keep); + } +} + +/* Douglas-Peucker around a closed loop; returns loop indices of the corners in order. */ +static int closed_dp(const vec2 *p, int m, double tol, int *idx) +{ + vec2 r[SK_LOOP_N + 1], c = mean_of(p, m); + unsigned char keep[SK_LOOP_N + 1]; + int i, a = 0, b = 0, bb, k = 0; + double far = -1; + for (i = 0; i < m; i++) { + double dd = vdist(p[i], c); + if (dd > far) { + far = dd; + a = i; + } + } + far = -1; + for (i = 0; i < m; i++) { + double dd = vdist(p[i], p[a]); + if (dd > far) { + far = dd; + b = i; + } + } + for (i = 0; i <= m; i++) { + r[i] = p[(a + i) % m]; + keep[i] = 0; + } + bb = (b - a + m) % m; + if (bb == 0) { + bb = m / 2; + } + keep[0] = keep[bb] = 1; + dp_mark(r, 0, bb, tol, keep); + dp_mark(r, bb, m, tol, keep); + for (i = 0; i < m && k < SK_MAX_V; i++) { + if (keep[i]) { + idx[k++] = (a + i) % m; + } + } + return k; +} + +static double vertex_turn(const vec2 *p, const int *idx, int k, int j) +{ + vec2 a = p[idx[(j - 1 + k) % k]], b = p[idx[j]], c = p[idx[(j + 1) % k]]; + return turn_between(vsub(b, a), vsub(c, b)); +} + +static void remove_at(int *idx, int *k, int j) +{ + int i; + for (i = j; i < *k - 1; i++) { + idx[i] = idx[i + 1]; + } + (*k)--; +} + +/* Drops corners that barely turn and merges corners split in two by a rounded drawing. */ +static int simplify_corners(const vec2 *p, int m, double perim, int *idx, int k) +{ + int guard; + for (guard = 0; guard < 4 * SK_MAX_V && k > 3; guard++) { + int j, jmin = -1, emin = -1; + double tmin = 1e9, lmin = 1e300; + for (j = 0; j < k; j++) { + double t = fabs(vertex_turn(p, idx, k, j)); + if (t < tmin) { + tmin = t; + jmin = j; + } + } + if (tmin < rad(22)) { + remove_at(idx, &k, jmin); + continue; + } + for (j = 0; j < k; j++) { + double l = vdist(p[idx[j]], p[idx[(j + 1) % k]]); + /* Only a corner split in two turns the same way at both ends (a star's tip and notch do not). */ + if (l < lmin && vertex_turn(p, idx, k, j) * vertex_turn(p, idx, k, (j + 1) % k) > 0) { + lmin = l; + emin = j; + } + } + if (emin >= 0 && lmin < 0.07 * perim) { + int j2 = (emin + 1) % k; + int span = (idx[j2] - idx[emin] + m) % m; + idx[emin] = (idx[emin] + span / 2) % m; + remove_at(idx, &k, j2); + continue; + } + break; + } + return k; +} + +typedef struct { + int k; + int idx[SK_MAX_V]; + vec2 v[SK_MAX_V]; + double sharp_mean; + double sharp_min; + double straight_max; +} poly_fit; + +static vec2 loop_tangent(const vec2 *p, int m, int i) +{ + int a = ((i - 2) % m + m) % m, b = ((i + 2) % m + m) % m; + return vnorm(vsub(p[b], p[a])); +} + +static double loop_turn(const vec2 *p, int m, int center, int half) +{ + return turn_between(loop_tangent(p, m, center - half), loop_tangent(p, m, center + half)); +} + +/* Samples skipped at each end of a side, so rounded corners do not count as bent sides. */ +static int edge_trim(int span) +{ + int t = (int)(0.1 * span); + return t < 2 ? 2 : t; +} + +/* + * Fits a straight line to the middle of each side and puts each corner where + * neighbouring sides meet, which also restores corners a drawing rounded off. + * Returns how far the most bent side strays from straight, relative to its length. + */ +static double fit_sides(const vec2 *p, int m, double size, const int *idx, int k, double reach, vec2 *v) +{ + vec2 lc[SK_MAX_V], ld[SK_MAX_V], tmp[SK_LOOP_N + 1]; + double straight = 0; + int j; + for (j = 0; j < k; j++) { + int a = idx[j], b = idx[(j + 1) % k]; + int span = (b - a + m) % m, t0, t1, t, cnt = 0; + vec2 pa = p[a], pb = p[b], chord = vsub(pb, pa); + double clen = vlen(chord); + if (span == 0) { + span = m; + } + t0 = (int)floor(0.15 * span); + t1 = span - t0; + if (t1 - t0 < 2) { + t0 = 0; + t1 = span; + } + for (t = t0; t <= t1; t++) { + tmp[cnt++] = p[(a + t) % m]; + } + principal_axis(tmp, cnt, &lc[j], &ld[j]); + for (t = edge_trim(span); t <= span - edge_trim(span); t++) { + double dev = clen > 1e-9 ? fabs(vcross(chord, vsub(p[(a + t) % m], pa))) / clen : 0; + if (clen > 1e-9 && dev / clen > straight) { + straight = dev / clen; + } + } + } + for (j = 0; j < k; j++) { + int pj = (j - 1 + k) % k; + vec2 corner = p[idx[j]]; + double den = vcross(ld[pj], ld[j]); + if (fabs(den) > sin(rad(10))) { + double t = vcross(vsub(lc[j], lc[pj]), ld[j]) / den; + vec2 x = vadd(lc[pj], vscale(ld[pj], t)); + if (vdist(x, corner) < reach * size) { + corner = x; + } + } + v[j] = corner; + } + return straight; +} + +static int fit_polygon(const vec2 *p, int m, double size, double perim, poly_fit *f) +{ + int j, k; + k = closed_dp(p, m, 0.04 * size, f->idx); + k = simplify_corners(p, m, perim, f->idx, k); + f->k = k; + f->sharp_mean = 0; + f->sharp_min = 1e9; + f->straight_max = 0; + if (k < 3) { + return 0; + } + f->straight_max = fit_sides(p, m, size, f->idx, k, 0.2, f->v); + for (j = 0; j < k; j++) { + int i = f->idx[j]; + int sp = (f->idx[j] - f->idx[(j - 1 + k) % k] + m) % m; + int sn = (f->idx[(j + 1) % k] - f->idx[j] + m) % m; + int span = sp < sn ? sp : sn; + int wide = span / 2, c; + double narrow = 0, wturn, sharp; + if (wide > 8) wide = 8; + if (wide < 4) wide = 4; + wturn = loop_turn(p, m, i, wide); + for (c = -1; c <= 1; c++) { + double t = loop_turn(p, m, i + c, 3); + if (t * wturn > 0 && fabs(t) > fabs(narrow)) { + narrow = t; + } + } + sharp = fabs(wturn) > rad(10) ? fabs(narrow) / fabs(wturn) : 1.0; + f->sharp_mean += sharp; + if (sharp < f->sharp_min) { + f->sharp_min = sharp; + } + } + f->sharp_mean /= k; + return 1; +} + +typedef struct { + vec2 c; + double a, b, phi; + double err_mean, err_max; +} ellipse_fit; + +static void fit_ellipse(const vec2 *p, int m, ellipse_fit *e) +{ + vec2 c, u, v; + double umin = 1e300, umax = -1e300, vmin = 1e300, vmax = -1e300; + int i; + principal_axis(p, m, &c, &u); + v = v2(-u.y, u.x); + for (i = 0; i < m; i++) { + vec2 d = vsub(p[i], c); + double pu = vdot(d, u), pv = vdot(d, v); + if (pu < umin) umin = pu; + if (pu > umax) umax = pu; + if (pv < vmin) vmin = pv; + if (pv > vmax) vmax = pv; + } + e->c = vadd(c, vadd(vscale(u, 0.5 * (umin + umax)), vscale(v, 0.5 * (vmin + vmax)))); + e->a = 0.5 * (umax - umin); + e->b = 0.5 * (vmax - vmin); + e->phi = atan2(u.y, u.x); + e->err_mean = 0; + e->err_max = 0; + if (e->a < 1e-6 || e->b < 1e-6) { + e->err_mean = e->err_max = 1e9; + return; + } + for (i = 0; i < m; i++) { + vec2 d = vsub(p[i], e->c); + double r = hypot(vdot(d, u) / e->a, vdot(d, v) / e->b); + double err = fabs(r - 1); + e->err_mean += err; + if (err > e->err_max) e->err_max = err; + } + e->err_mean /= m; + if (e->a < e->b) { + double t = e->a; + e->a = e->b; + e->b = t; + e->phi += SK_PI / 2; + } +} + +static void unit_box(vec2 *p, int n) +{ + box2 b = bounds(p, n); + double w = b.maxx - b.minx, h = b.maxy - b.miny; + int i; + if (w < 1e-9) w = 1; + if (h < 1e-9) h = 1; + for (i = 0; i < n; i++) { + p[i] = v2((p[i].x - b.minx) / w, (p[i].y - b.miny) / h); + } +} + +/* Mean point distance between two closed curves, best over start point and direction. */ +static double loop_distance(const vec2 *a, const vec2 *b, int n) +{ + double best = 1e300; + int dir, s, i; + for (dir = -1; dir <= 1; dir += 2) { + for (s = 0; s < n; s++) { + double sum = 0; + for (i = 0; i < n && sum < best; i++) { + int j = ((s + dir * i) % n + n) % n; + sum += vdist(a[i], b[j]); + } + if (sum < best) { + best = sum; + } + } + } + return best / n; +} + +/* Classic heart curve, lobes up and point down in the y-down frame. */ +static void heart_curve(vec2 *out, int n) +{ + vec2 dense[256]; + int i; + for (i = 0; i < 256; i++) { + double t = SK_TAU * i / 256, s = sin(t); + dense[i] = v2(16 * s * s * s, -(13 * cos(t) - 5 * cos(2 * t) - 2 * cos(3 * t) - cos(4 * t))); + } + resample(dense, 256, 1, out, n); +} + +static void circle_curve(vec2 *out, int n) +{ + int i; + for (i = 0; i < n; i++) { + out[i] = vpolar(1, SK_TAU * i / n); + } +} + +static th_shape_kind make_ellipse(const ellipse_fit *e, double sy, th_shape *o) +{ + vec2 pts[SK_CURVE_N]; + int i; + if (e->b / e->a >= 0.82) { + double r = 0.5 * (e->a + e->b); + for (i = 0; i < SK_CURVE_N; i++) { + pts[i] = vadd(e->c, vpolar(r, SK_TAU * i / SK_CURVE_N)); + } + shape_add(o, sy, pts, SK_CURVE_N, 1); + return finish(o, TH_SHAPE_CIRCLE); + } + { + double phi = snap_angle(e->phi, 0, SK_PI / 2, rad(10)); + for (i = 0; i < SK_CURVE_N; i++) { + double t = SK_TAU * i / SK_CURVE_N; + vec2 q = v2(e->a * cos(t), e->b * sin(t)); + pts[i] = vadd(e->c, vrot(q, phi)); + } + } + shape_add(o, sy, pts, SK_CURVE_N, 1); + return finish(o, TH_SHAPE_ELLIPSE); +} + +static th_shape_kind make_heart(const vec2 *loop, int m, double sy, th_shape *o) +{ + vec2 pts[SK_CURVE_N]; + box2 lb = bounds(loop, m), hb; + double w = lb.maxx - lb.minx, h = lb.maxy - lb.miny; + int i; + heart_curve(pts, SK_CURVE_N); + hb = bounds(pts, SK_CURVE_N); + for (i = 0; i < SK_CURVE_N; i++) { + pts[i] = v2(lb.minx + (pts[i].x - hb.minx) / (hb.maxx - hb.minx) * w, + lb.miny + (pts[i].y - hb.miny) / (hb.maxy - hb.miny) * h); + } + shape_add(o, sy, pts, SK_CURVE_N, 1); + return finish(o, TH_SHAPE_HEART); +} + +static double min_interior_angle(const vec2 *v, int k) +{ + double best = 1e9; + int j; + for (j = 0; j < k; j++) { + vec2 a = vsub(v[(j - 1 + k) % k], v[j]), b = vsub(v[(j + 1) % k], v[j]); + double ang = fabs(turn_between(a, b)); + if (ang < best) best = ang; + } + return best; +} + +static th_shape_kind make_triangle(const vec2 *v, double sy, th_shape *o) +{ + vec2 out[3], c = mean_of(v, 3); + double l[3], lmin, lmax; + int j; + if (min_interior_angle(v, 3) < rad(12)) { + return TH_SHAPE_NONE; + } + for (j = 0; j < 3; j++) { + l[j] = vdist(v[j], v[(j + 1) % 3]); + } + lmin = fmin(l[0], fmin(l[1], l[2])); + lmax = fmax(l[0], fmax(l[1], l[2])); + if (lmax / lmin < 1.2) { + double r = (vdist(v[0], c) + vdist(v[1], c) + vdist(v[2], c)) / 3; + double rot = snap_angle(nfold_rotation(v, 3, c, 3), -SK_PI / 2, SK_PI / 3, rad(12)); + regular_polygon(c, r, 3, rot, out); + } else { + double best = 1e9, turn = 0; + for (j = 0; j < 3; j++) { + vec2 e = vsub(v[(j + 1) % 3], v[j]); + double ang = atan2(e.y, e.x); + double off = ang - snap_angle(ang, 0, SK_PI / 2, SK_PI); + if (fabs(off) < best) { + best = fabs(off); + turn = -off; + } + } + for (j = 0; j < 3; j++) { + out[j] = best < rad(8) ? vadd(c, vrot(vsub(v[j], c), turn)) : v[j]; + } + } + shape_add(o, sy, out, 3, 1); + return finish(o, TH_SHAPE_TRIANGLE); +} + +static int near_axis(vec2 d, double axis, double tol) +{ + double ang = atan2(d.y, d.x); + double rel = wrap(2 * (ang - axis)) / 2; + return fabs(rel) < tol; +} + +static th_shape_kind make_quad(const vec2 *v, double sy, th_shape *o) +{ + vec2 out[4], c = mean_of(v, 4), d02 = vsub(v[2], v[0]), d13 = vsub(v[3], v[1]); + double side[4], smin = 1e300, smax = 0, ang_dev = 0; + int j, rhombus, diag_axis_tight, diag_axis_loose, is_rect; + for (j = 0; j < 4; j++) { + vec2 a = vsub(v[(j + 3) % 4], v[j]), b = vsub(v[(j + 1) % 4], v[j]); + double interior = fabs(turn_between(a, b)); + double dev = fabs(interior - SK_PI / 2); + if (dev > ang_dev) ang_dev = dev; + side[j] = vdist(v[j], v[(j + 1) % 4]); + if (side[j] < smin) smin = side[j]; + if (side[j] > smax) smax = side[j]; + } + if (smin < 1e-6) { + return TH_SHAPE_NONE; + } + is_rect = ang_dev < rad(22); + rhombus = smax / smin < 1.3; + diag_axis_tight = (near_axis(d02, 0, rad(12)) && near_axis(d13, SK_PI / 2, rad(12))) + || (near_axis(d02, SK_PI / 2, rad(12)) && near_axis(d13, 0, rad(12))); + diag_axis_loose = (near_axis(d02, 0, rad(18)) && near_axis(d13, SK_PI / 2, rad(18))) + || (near_axis(d02, SK_PI / 2, rad(18)) && near_axis(d13, 0, rad(18))); + if ((diag_axis_tight && rhombus) || (!is_rect && diag_axis_loose && smax / smin < 1.35)) { + int d02_flat = fabs(d02.x) > fabs(d02.y); + double hw = 0.5 * fabs(d02_flat ? d02.x : d13.x); + double hh = 0.5 * fabs(d02_flat ? d13.y : d02.y); + if (hw < 1e-6 || hh < 1e-6) { + return TH_SHAPE_NONE; + } + out[0] = v2(c.x, c.y - hh); + out[1] = v2(c.x + hw, c.y); + out[2] = v2(c.x, c.y + hh); + out[3] = v2(c.x - hw, c.y); + shape_add(o, sy, out, 4, 1); + return finish(o, TH_SHAPE_DIAMOND); + } + if (is_rect) { + double sx = 0, syy = 0, theta, hu = 0, hv = 0; + vec2 u, w; + th_shape_kind kind = TH_SHAPE_RECTANGLE; + for (j = 0; j < 4; j++) { + vec2 e = vsub(v[(j + 1) % 4], v[j]); + double a = atan2(e.y, e.x); + double len = vlen(e); + sx += len * cos(4 * a); + syy += len * sin(4 * a); + } + theta = snap_angle(atan2(syy, sx) / 4, 0, SK_PI / 2, rad(10)); + u = vpolar(1, theta); + w = v2(-u.y, u.x); + for (j = 0; j < 4; j++) { + vec2 d = vsub(v[j], c); + hu += fabs(vdot(d, u)); + hv += fabs(vdot(d, w)); + } + hu /= 4; + hv /= 4; + if (fmin(hu, hv) / fmax(hu, hv) >= 0.85) { + hu = hv = 0.5 * (hu + hv); + kind = TH_SHAPE_SQUARE; + } + out[0] = vadd(c, vadd(vscale(u, -hu), vscale(w, -hv))); + out[1] = vadd(c, vadd(vscale(u, hu), vscale(w, -hv))); + out[2] = vadd(c, vadd(vscale(u, hu), vscale(w, hv))); + out[3] = vadd(c, vadd(vscale(u, -hu), vscale(w, hv))); + shape_add(o, sy, out, 4, 1); + return finish(o, kind); + } + return TH_SHAPE_NONE; +} + +/* Pentagon or hexagon: regular when close to it, otherwise the straightened outline (a house, say). */ +static th_shape_kind make_ngon(const vec2 *v, int k, double sy, th_shape *o) +{ + vec2 out[6], c = mean_of(v, k); + double rmean = 0, rvar = 0, smin = 1e300, smax = 0; + int j; + for (j = 0; j < k; j++) { + double s = vdist(v[j], v[(j + 1) % k]); + rmean += vdist(v[j], c); + if (s < smin) smin = s; + if (s > smax) smax = s; + } + rmean /= k; + for (j = 0; j < k; j++) { + double d = vdist(v[j], c) - rmean; + rvar += d * d; + } + if (sqrt(rvar / k) / rmean < 0.15 && smax / smin < 1.6) { + double rot = nfold_rotation(v, k, c, k); + rot = snap_angle(rot, -SK_PI / 2, SK_PI / k, k == 5 ? rad(10) : rad(8)); + regular_polygon(c, rmean, k, rot, out); + } else { + if (min_interior_angle(v, k) < rad(25)) { + return TH_SHAPE_NONE; + } + for (j = 0; j < k; j++) { + out[j] = v[j]; + } + } + shape_add(o, sy, out, k, 1); + return finish(o, k == 5 ? TH_SHAPE_PENTAGON : TH_SHAPE_HEXAGON); +} + +static th_shape_kind make_star(vec2 c, double outer, double inner, double rot, double sy, th_shape *o) +{ + vec2 out[10]; + rot = snap_angle(rot, -SK_PI / 2, SK_TAU / 5, rad(12)); + star_points(c, outer, inner, rot, out); + shape_add(o, sy, out, 10, 1); + return finish(o, TH_SHAPE_STAR); +} + +static th_shape_kind make_star_outline(const vec2 *v, double sy, th_shape *o) +{ + vec2 c = mean_of(v, 10), outer[5]; + double r[10], even = 0, odd = 0, omin = 1e300, imax = 0, ro = 0, ri = 0; + int j, first; + for (j = 0; j < 10; j++) { + r[j] = vdist(v[j], c); + if (j % 2) { + odd += r[j]; + } else { + even += r[j]; + } + } + first = even > odd ? 0 : 1; + for (j = 0; j < 10; j++) { + if (j % 2 == first) { + outer[j / 2] = v[j]; + ro += r[j]; + if (r[j] < omin) omin = r[j]; + } else { + ri += r[j]; + if (r[j] > imax) imax = r[j]; + } + } + ro /= 5; + ri /= 5; + if (imax > 0.9 * omin || ri / ro > 0.75) { + return TH_SHAPE_NONE; + } + c = mean_of(outer, 5); + return make_star(c, ro, ro * clampd(ri / ro, 0.32, 0.55), nfold_rotation(outer, 5, c, 5), sy, o); +} + +static th_shape_kind try_pentagram(const vec2 *p, int m, double size, double sy, th_shape *o) +{ + int idx[SK_MAX_V], k, j, sign = 0; + vec2 v[5], c; + double perim = polyline_length(p, m, 1), rmean = 0; + k = closed_dp(p, m, 0.05 * size, idx); + k = simplify_corners(p, m, perim, idx, k); + if (k != 5) { + return TH_SHAPE_NONE; + } + /* Five long crossing strokes: a gap at the start can clip a tip, so let the sides rebuild it. */ + if (fit_sides(p, m, size, idx, 5, 0.35, v) > 0.25) { + return TH_SHAPE_NONE; + } + c = mean_of(v, 5); + for (j = 0; j < 5; j++) { + double a0 = atan2(v[j].y - c.y, v[j].x - c.x); + double a1 = atan2(v[(j + 1) % 5].y - c.y, v[(j + 1) % 5].x - c.x); + double step = wrap(a1 - a0); + int s = step > 0 ? 1 : -1; + if (fabs(step) < rad(108)) { + return TH_SHAPE_NONE; + } + if (sign == 0) { + sign = s; + } else if (s != sign) { + return TH_SHAPE_NONE; + } + rmean += vdist(v[j], c); + } + rmean /= 5; + for (j = 0; j < 5; j++) { + if (fabs(vdist(v[j], c) - rmean) > 0.3 * rmean) { + return TH_SHAPE_NONE; + } + } + return make_star(c, rmean, rmean * 0.382, nfold_rotation(v, 5, c, 5), sy, o); +} + +static th_shape_kind classify_loop(vec2 *p, int m, double sy, th_shape *o) +{ + box2 b = bounds(p, m); + double size = box_diag(b), perim = polyline_length(p, m, 1), turning; + int crossings; + poly_fit poly; + ellipse_fit ell; + vec2 cand[SK_TPL_N], heart[SK_TPL_N], circle[SK_TPL_N]; + double d_heart, d_circle; + int poly_good, ell_good, simple; + + if (size < SK_MIN_SIZE) { + return TH_SHAPE_NONE; + } + drop_small_loops(p, m); + perim = polyline_length(p, m, 1); + crossings = count_crossings(p, m); + if (crossings >= 3) { + return try_pentagram(p, m, size, sy, o); + } + /* One full turn for a simple outline. A heart's cusp can read as +-180, so it is checked apart. */ + turning = fabs(total_turning(p, m)); + simple = turning > rad(250) && turning < rad(470); + fit_ellipse(p, m, &ell); + if (ell.b / ell.a < 0.12) { + return TH_SHAPE_NONE; + } + + fit_polygon(p, m, size, perim, &poly); + /* Corners decide it: a polygon turns all at once at each corner, a round shape turns everywhere. */ + poly_good = simple && (poly.k == 3 || poly.k == 4 || poly.k == 5 || poly.k == 6 || poly.k == 10) + && poly.sharp_mean >= 0.82 && poly.sharp_min >= 0.5 && poly.straight_max < (poly.k == 10 ? 0.3 : 0.14) + && !(ell.err_mean < 0.03 && poly.sharp_mean < 0.9); + if (poly_good) { + th_shape_kind kind = TH_SHAPE_NONE; + switch (poly.k) { + case 3: kind = make_triangle(poly.v, sy, o); break; + case 4: kind = make_quad(poly.v, sy, o); break; + case 5: + case 6: kind = make_ngon(poly.v, poly.k, sy, o); break; + case 10: kind = make_star_outline(poly.v, sy, o); break; + default: break; + } + if (kind != TH_SHAPE_NONE) { + return kind; + } + shape_clear(o); + } + + ell_good = simple && ell.err_mean < 0.07 && ell.err_max < 0.25; + + resample(p, m, 1, cand, SK_TPL_N); + unit_box(cand, SK_TPL_N); + heart_curve(heart, SK_TPL_N); + unit_box(heart, SK_TPL_N); + circle_curve(circle, SK_TPL_N); + unit_box(circle, SK_TPL_N); + d_heart = loop_distance(cand, heart, SK_TPL_N); + d_circle = loop_distance(cand, circle, SK_TPL_N); + if (d_heart < 0.065 && d_heart < 0.6 * d_circle && crossings <= 2) { + return make_heart(p, m, sy, o); + } + if (ell_good && crossings <= 2) { + return make_ellipse(&ell, sy, o); + } + return TH_SHAPE_NONE; +} + +th_shape_kind th_recognize(const th_point *points, int count, int y_down, th_shape *out) +{ + th_shape scratch; + th_shape *o = out ? out : &scratch; + double sy = y_down ? 1.0 : -1.0, raw_len = 0, length, size; + vec2 s[SK_OPEN_N], loop[SK_LOOP_N]; + th_shape_kind kind; + + shape_clear(o); + if (!points || count < 8) { + return TH_SHAPE_NONE; + } + if (!resample_raw(points, count, sy, s, SK_OPEN_N, &raw_len)) { + return TH_SHAPE_NONE; + } + size = box_diag(bounds(s, SK_OPEN_N)); + if (size < SK_MIN_SIZE) { + return TH_SHAPE_NONE; + } + length = polyline_length(s, SK_OPEN_N, 0); + kind = try_line(s, SK_OPEN_N, length, sy, o); + if (kind == TH_SHAPE_NONE) { + shape_clear(o); + if (find_loop(s, SK_OPEN_N, size, length, loop)) { + kind = classify_loop(loop, SK_LOOP_N, sy, o); + } else { + kind = try_arrow(s, SK_OPEN_N, size, sy, o); + } + } + if (kind == TH_SHAPE_NONE) { + shape_clear(o); + } + o->kind = kind; + return kind; +} + +th_shape_kind th_recognize_arrowhead(const th_shape *line, const th_point *points, int count, + int y_down, th_shape *out) +{ + th_shape scratch; + th_shape *o = out ? out : &scratch; + double sy = y_down ? 1.0 : -1.0, raw_len, len, ssize, best = -1, arm1, arm2, maxdev = 0; + vec2 s[SK_HEAD_N], a, b, apex, e, t, d, w1, w2, chord; + int i, m = 0, n = SK_HEAD_N, first, last; + + shape_clear(o); + if (!line || line->kind != TH_SHAPE_LINE || line->path_count < 1 || line->path_length[0] < 2 + || !points || count < 3) { + return TH_SHAPE_NONE; + } + first = line->path_start[0]; + last = first + line->path_length[0] - 1; + if (last >= line->point_count || last >= TH_SHAPE_MAX_POINTS) { + return TH_SHAPE_NONE; + } + a = v2(line->points[first].x, line->points[first].y * sy); + b = v2(line->points[last].x, line->points[last].y * sy); + len = vdist(a, b); + if (len < SK_MIN_SIZE || !resample_raw(points, count, sy, s, n, &raw_len)) { + return TH_SHAPE_NONE; + } + ssize = box_diag(bounds(s, n)); + if (ssize < 6 || ssize < 0.04 * len || ssize > 0.7 * len) { + return TH_SHAPE_NONE; + } + chord = vsub(s[n - 1], s[0]); + for (i = 1; i < n - 1; i++) { + double dev = vlen(chord) > 1e-9 ? fabs(vcross(chord, vsub(s[i], s[0]))) / vlen(chord) : vdist(s[i], s[0]); + if (dev > best) { + best = dev; + m = i; + } + } + if (m < 0.2 * (n - 1) || m > 0.8 * (n - 1)) { + return TH_SHAPE_NONE; + } + apex = s[m]; + arm1 = vdist(s[0], apex); + arm2 = vdist(s[n - 1], apex); + if (arm1 < 1e-6 || arm2 < 1e-6 || arm1 / arm2 > 3 || arm2 / arm1 > 3) { + return TH_SHAPE_NONE; + } + for (i = 0; i < n; i++) { + vec2 from = i <= m ? s[0] : apex, to = i <= m ? apex : s[n - 1]; + vec2 seg = vsub(to, from); + double sl = vlen(seg); + double dev = sl > 1e-9 ? fabs(vcross(seg, vsub(s[i], from))) / sl : 0; + double rel = dev / (i <= m ? arm1 : arm2); + if (rel > maxdev) maxdev = rel; + } + if (maxdev > 0.18) { + return TH_SHAPE_NONE; + } + w1 = vnorm(vsub(s[0], apex)); + w2 = vnorm(vsub(s[n - 1], apex)); + { + double opening = fabs(turn_between(w1, w2)); + if (opening < rad(15) || opening > rad(140)) { + return TH_SHAPE_NONE; + } + } + if (vdist(apex, a) < vdist(apex, b)) { + e = a; + t = b; + } else { + e = b; + t = a; + } + if (vdist(apex, e) > fmax(0.6 * fmax(arm1, arm2), 0.12 * len)) { + return TH_SHAPE_NONE; + } + d = vnorm(vsub(e, t)); + if (vdot(w1, d) > -0.25 || vdot(w2, d) > -0.25) { + return TH_SHAPE_NONE; + } + if (vcross(d, w1) * vcross(d, w2) >= 0) { + return TH_SHAPE_NONE; + } + return build_arrow(t, e, clampd(0.5 * (arm1 + arm2), 0.1 * len, 0.4 * len), sy, o); +} + +const char *th_shape_name(th_shape_kind kind) +{ + switch (kind) { + case TH_SHAPE_LINE: return "Line"; + case TH_SHAPE_ARROW: return "Arrow"; + case TH_SHAPE_CIRCLE: return "Circle"; + case TH_SHAPE_ELLIPSE: return "Ellipse"; + case TH_SHAPE_TRIANGLE: return "Triangle"; + case TH_SHAPE_SQUARE: return "Square"; + case TH_SHAPE_RECTANGLE: return "Rectangle"; + case TH_SHAPE_DIAMOND: return "Diamond"; + case TH_SHAPE_PENTAGON: return "Pentagon"; + case TH_SHAPE_HEXAGON: return "Hexagon"; + case TH_SHAPE_STAR: return "Star"; + case TH_SHAPE_HEART: return "Heart"; + case TH_SHAPE_NONE: + case TH_SHAPE_KIND_COUNT: + default: return "None"; + } +} + +int th_shape_path_count(const th_shape *shape) +{ + return shape ? shape->path_count : 0; +} + +int th_shape_path_start(const th_shape *shape, int path) +{ + return shape && path >= 0 && path < shape->path_count ? shape->path_start[path] : 0; +} + +int th_shape_path_length(const th_shape *shape, int path) +{ + return shape && path >= 0 && path < shape->path_count ? shape->path_length[path] : 0; +} + +int th_shape_path_closed(const th_shape *shape, int path) +{ + return shape && path >= 0 && path < shape->path_count ? shape->path_closed[path] : 0; +} + +th_point th_shape_point(const th_shape *shape, int index) +{ + th_point zero; + zero.x = 0; + zero.y = 0; + if (!shape || index < 0 || index >= shape->point_count) { + return zero; + } + return shape->points[index]; +} diff --git a/Tests/shapes/shape_test.c b/Tests/shapes/shape_test.c new file mode 100644 index 0000000..1bb9d80 --- /dev/null +++ b/Tests/shapes/shape_test.c @@ -0,0 +1,1048 @@ +/* + * Synthetic hand-drawn strokes for ShapeKit. + * + * Every shape is drawn many times with a seeded PRNG: random size, rotation, + * aspect, start point and direction, wobble, jitter, rounded corners, gaps and + * overshoot on closed shapes, uneven point spacing, sometimes whole-pixel + * input. Half the strokes are fed in a y-up frame (macOS) and half y-down + * (Windows). Exit code is non-zero when any check falls below its bar. + */ + +#include "shapekit.h" + +#include +#include +#include +#include + +#define PI 3.14159265358979323846 +#define DENSE 6000 +#define MAXP 4000 +#define SAMPLES 300 + +typedef struct { + double x, y; +} P2; + +typedef struct { + P2 p[DENSE]; + double s[DENSE]; + int n; + int closed; + double len; +} Path; + +typedef struct { + double wob1, wob2, f1, f2, ph1, ph2; + double jitter; + double speed_var, speed_f, speed_ph; + double cover; + double drift; + double start; + int dir; + int count; + int pixels; +} Noise; + +static unsigned long long rng_state = 0x9E3779B97F4A7C15ULL; + +static double rnd(void) +{ + rng_state ^= rng_state >> 12; + rng_state ^= rng_state << 25; + rng_state ^= rng_state >> 27; + return (double)((rng_state * 2685821657736338717ULL) >> 11) * (1.0 / 9007199254740992.0); +} + +static double ur(double a, double b) { return a + (b - a) * rnd(); } + +static int ri(int a, int b) +{ + int v = a + (int)(rnd() * (b - a + 1)); + return v > b ? b : v; +} + +static double gauss(void) +{ + double u1 = rnd(), u2 = rnd(); + if (u1 < 1e-12) { + u1 = 1e-12; + } + return sqrt(-2 * log(u1)) * cos(2 * PI * u2); +} + +static P2 p2(double x, double y) +{ + P2 r; + r.x = x; + r.y = y; + return r; +} + +static P2 rot(P2 a, double ang) +{ + double c = cos(ang), s = sin(ang); + return p2(a.x * c - a.y * s, a.x * s + a.y * c); +} + +static double rad(double d) { return d * PI / 180; } + +/* ---------- ideal paths ---------- */ + +static void path_begin(Path *path, int closed) +{ + path->n = 0; + path->closed = closed; + path->len = 0; +} + +static void path_add(Path *path, P2 q) +{ + if (path->n >= DENSE) { + return; + } + if (path->n > 0) { + P2 last = path->p[path->n - 1]; + double d = hypot(q.x - last.x, q.y - last.y); + if (d < 1e-9) { + return; + } + path->len += d; + } + path->s[path->n] = path->len; + path->p[path->n++] = q; +} + +static void path_end(Path *path) +{ + if (path->closed && path->n > 1) { + P2 a = path->p[path->n - 1], b = path->p[0]; + path->len += hypot(a.x - b.x, a.y - b.y); + } +} + +static void add_segment(Path *path, P2 a, P2 b, int steps) +{ + int i; + for (i = 0; i <= steps; i++) { + double t = (double)i / steps; + path_add(path, p2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)); + } +} + +/* Polyline through vertices, each corner rounded with a quadratic curve of radius r. */ +static void path_polygon(Path *path, const P2 *v, int nv, int closed, double r) +{ + int j, first = closed ? 0 : 1, last = closed ? nv - 1 : nv - 2; + P2 prev_end = v[0]; + path_begin(path, closed); + if (!closed) { + path_add(path, v[0]); + } + for (j = first; j <= last; j++) { + P2 a = v[(j - 1 + nv) % nv], c = v[j], b = v[(j + 1) % nv]; + double la = hypot(c.x - a.x, c.y - a.y), lb = hypot(b.x - c.x, b.y - c.y); + double rr = r; + P2 s, e; + int i; + if (rr > 0.4 * la) rr = 0.4 * la; + if (rr > 0.4 * lb) rr = 0.4 * lb; + s = p2(c.x - (c.x - a.x) / la * rr, c.y - (c.y - a.y) / la * rr); + e = p2(c.x + (b.x - c.x) / lb * rr, c.y + (b.y - c.y) / lb * rr); + if (path->n > 0) { + add_segment(path, prev_end, s, 60); + } + for (i = 0; i <= 12; i++) { + double t = i / 12.0, u = 1 - t; + path_add(path, p2(u * u * s.x + 2 * u * t * c.x + t * t * e.x, u * u * s.y + 2 * u * t * c.y + t * t * e.y)); + } + prev_end = e; + } + if (closed) { + add_segment(path, prev_end, path->p[0], 60); + } else { + add_segment(path, prev_end, v[nv - 1], 60); + } + path_end(path); +} + +static P2 path_at(const Path *path, double s) +{ + int lo = 0, hi = path->n - 1, mid; + if (path->closed) { + s = fmod(s, path->len); + if (s < 0) { + s += path->len; + } + if (s >= path->s[path->n - 1]) { + P2 a = path->p[path->n - 1], b = path->p[0]; + double seg = path->len - path->s[path->n - 1]; + double t = seg > 1e-12 ? (s - path->s[path->n - 1]) / seg : 0; + return p2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t); + } + } else { + if (s <= 0) return path->p[0]; + if (s >= path->len) return path->p[path->n - 1]; + } + while (hi - lo > 1) { + mid = (lo + hi) / 2; + if (path->s[mid] <= s) { + lo = mid; + } else { + hi = mid; + } + } + { + double seg = path->s[hi] - path->s[lo]; + double t = seg > 1e-12 ? (s - path->s[lo]) / seg : 0; + return p2(path->p[lo].x + (path->p[hi].x - path->p[lo].x) * t, + path->p[lo].y + (path->p[hi].y - path->p[lo].y) * t); + } +} + +/* ---------- drawing a path like a hand would ---------- */ + +static void noise_init(Noise *nz, double size, int sloppy, int closed) +{ + nz->wob1 = size * (sloppy ? ur(0.006, 0.014) : ur(0, 0.007)); + nz->wob2 = size * (sloppy ? ur(0.001, 0.004) : ur(0, 0.002)); + nz->f1 = ur(0.6, 2.5); + nz->f2 = ur(4, 9); + nz->ph1 = ur(0, 2 * PI); + nz->ph2 = ur(0, 2 * PI); + nz->jitter = size * (sloppy ? ur(0.001, 0.003) : ur(0, 0.0015)); + nz->speed_var = ur(0, 0.7); + nz->speed_f = ur(0.5, 3); + nz->speed_ph = ur(0, 2 * PI); + nz->cover = closed ? (sloppy ? ur(0.93, 1.15) : ur(0.97, 1.06)) : 1; + nz->drift = closed ? size * ur(-0.03, 0.03) : 0; + nz->start = closed ? ur(0, 1) : 0; + nz->dir = rnd() < 0.5 ? 1 : -1; + nz->count = ri(30, 260); + nz->pixels = rnd() < 0.5; +} + +static int draw_path(const Path *path, const Noise *nz, th_point *out) +{ + double total = path->len * nz->cover, base = total / nz->count, t = 0; + int n = 0, done = 0; + while (!done && n < MAXP) { + double s, off, eps = path->len * 1e-3; + P2 q, a, b, nrm; + double nl; + if (t >= total) { + t = total; + done = 1; + } + s = path->closed ? nz->start * path->len + nz->dir * t : t; + q = path_at(path, s); + a = path_at(path, s - eps); + b = path_at(path, s + eps); + nrm = p2(-(b.y - a.y), b.x - a.x); + nl = hypot(nrm.x, nrm.y); + if (nl > 1e-12) { + nrm.x /= nl; + nrm.y /= nl; + } + off = nz->wob1 * sin(2 * PI * nz->f1 * t / total + nz->ph1) + nz->wob2 * sin(2 * PI * nz->f2 * t / total + nz->ph2); + if (path->closed && t > path->len) { + off += nz->drift * (t - path->len) / (0.15 * path->len); + } + q.x += nrm.x * off + gauss() * nz->jitter; + q.y += nrm.y * off + gauss() * nz->jitter; + if (nz->pixels) { + q.x = floor(q.x + 0.5); + q.y = floor(q.y + 0.5); + } + out[n].x = (float)q.x; + out[n].y = (float)q.y; + n++; + { + double step = base * (1 + nz->speed_var * sin(2 * PI * nz->speed_f * t / total + nz->speed_ph)); + t += step < 0.2 * base ? 0.2 * base : step; + } + } + return n; +} + +static void reverse_points(th_point *p, int n) +{ + int i; + for (i = 0; i < n / 2; i++) { + th_point t = p[i]; + p[i] = p[n - 1 - i]; + p[n - 1 - i] = t; + } +} + +static void flip_y(th_point *p, int n) +{ + int i; + for (i = 0; i < n; i++) { + p[i].y = -p[i].y; + } +} + +/* ---------- shape generators (y-down: "up" is -y) ---------- */ + +enum { + G_LINE, G_ARROW, G_CIRCLE, G_ELLIPSE, G_TRIANGLE, G_SQUARE, G_RECTANGLE, G_DIAMOND, + G_PENTAGON, G_HEXAGON, G_STAR, G_PENTAGRAM, G_HEART, G_COUNT +}; + +static const char *gen_names[G_COUNT] = { + "line", "arrow", "circle", "ellipse", "triangle", "square", "rectangle", "diamond", + "pentagon", "hexagon", "star", "pentagram", "heart", +}; + +static const th_shape_kind gen_expect[G_COUNT] = { + TH_SHAPE_LINE, TH_SHAPE_ARROW, TH_SHAPE_CIRCLE, TH_SHAPE_ELLIPSE, TH_SHAPE_TRIANGLE, TH_SHAPE_SQUARE, + TH_SHAPE_RECTANGLE, TH_SHAPE_DIAMOND, TH_SHAPE_PENTAGON, TH_SHAPE_HEXAGON, TH_SHAPE_STAR, TH_SHAPE_STAR, + TH_SHAPE_HEART, +}; + +enum { + N_SCRIBBLE, N_ZIGZAG, N_CURSIVE, N_SPIRAL, N_SCURVE, N_FIGURE8, N_CARC, N_CHECK, N_LSHAPE, N_USHAPE, N_TWICE, + N_TINY, N_COUNT +}; + +static const char *neg_names[N_COUNT] = { + "scribble", "zigzag", "cursive loops", "spiral", "s-curve", "figure eight", "open arc", "check mark", + "L shape", "U shape", "circle twice", "tiny mark", +}; + +static Path g_path; + +static void transform(P2 *v, int n, double ang, P2 c) +{ + int i; + for (i = 0; i < n; i++) { + P2 q = rot(v[i], ang); + v[i] = p2(q.x + c.x, q.y + c.y); + } +} + +static void path_curve(Path *path, int closed, P2 (*fn)(double, const double *), const double *args, double ang, P2 c) +{ + int i, steps = 2000; + path_begin(path, closed); + for (i = 0; i <= (closed ? steps - 1 : steps); i++) { + P2 q = rot(fn((double)i / steps, args), ang); + path_add(path, p2(q.x + c.x, q.y + c.y)); + } + path_end(path); +} + +static P2 f_ellipse(double t, const double *a) +{ + return p2(a[0] * cos(2 * PI * t), a[1] * sin(2 * PI * t)); +} + +static P2 f_heart(double t, const double *a) +{ + double u = 2 * PI * t, s = sin(u); + return p2(a[0] * a[2] * 16 * s * s * s, -a[0] * (13 * cos(u) - 5 * cos(2 * u) - 2 * cos(3 * u) - cos(4 * u))); +} + +static P2 f_spiral(double t, const double *a) +{ + double th = 2 * PI * a[1] * t, r = a[0] * (0.08 + t); + return p2(r * cos(th), r * sin(th)); +} + +static P2 f_scurve(double t, const double *a) +{ + return p2(a[0] * t, a[1] * sin(2 * PI * a[2] * t)); +} + +static P2 f_cursive(double t, const double *a) +{ + double u = 2 * PI * a[2] * t; + return p2(a[0] * (u - a[1] * sin(u)), -a[0] * a[1] * cos(u)); +} + +static P2 f_figure8(double t, const double *a) +{ + double u = 2 * PI * t; + return p2(a[0] * sin(u), a[1] * sin(u) * cos(u)); +} + +static P2 f_twice(double t, const double *a) +{ + double u = 2 * PI * a[2] * t, grow = 1 + 0.08 * t; + return p2(a[0] * grow * cos(u), a[1] * grow * sin(u)); +} + +static P2 f_arc(double t, const double *a) +{ + double u = a[1] * t; + return p2(a[0] * cos(u), a[0] * sin(u)); +} + +static P2 catmull(P2 p0, P2 p1, P2 p2_, P2 p3, double t) +{ + double t2 = t * t, t3 = t2 * t; + return p2(0.5 * (2 * p1.x + (-p0.x + p2_.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2_.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2_.x + p3.x) * t3), + 0.5 * (2 * p1.y + (-p0.y + p2_.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2_.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2_.y + p3.y) * t3)); +} + +/* Builds one random stroke. Returns the point count; *closed says whether it was a closed shape. */ +static int make_positive(int g, th_point *out, int sloppy) +{ + P2 v[16], c = p2(ur(300, 1500), ur(300, 1100)); + double r = ur(40, 380), size = 2 * r, ang = ur(0, 2 * PI), round_r = size * (sloppy ? ur(0.02, 0.06) : ur(0, 0.03)); + double args[4]; + int closed = 1, i, n, reverse = 0; + Noise nz; + switch (g) { + case G_LINE: + v[0] = p2(-r, 0); + v[1] = p2(r, 0); + transform(v, 2, ang, c); + path_begin(&g_path, 0); + add_segment(&g_path, v[0], v[1], 400); + path_end(&g_path); + closed = 0; + break; + case G_ARROW: { + double barb = ur(0.15, 0.35) * size, spread = rad(ur(20, 45)); + P2 tip = p2(size, 0), back = p2(-1, 0); + P2 b1 = p2(tip.x + barb * rot(back, spread).x, barb * rot(back, spread).y); + P2 b2 = p2(tip.x + barb * rot(back, -spread).x, barb * rot(back, -spread).y); + v[0] = p2(0, 0); + v[1] = tip; + if (rnd() < 0.5) { + v[2] = b1; + v[3] = tip; + v[4] = b2; + } else { + v[2] = b1; + v[3] = b2; + v[4] = tip; + } + transform(v, 5, ang, c); + path_polygon(&g_path, v, 5, 0, size * ur(0, 0.008)); + closed = 0; + reverse = rnd() < 0.5; + break; + } + case G_CIRCLE: + args[0] = r; + args[1] = r * ur(0.94, 1.0); + path_curve(&g_path, 1, f_ellipse, args, ang, c); + break; + case G_ELLIPSE: + args[0] = r; + args[1] = r * ur(0.38, 0.7); + path_curve(&g_path, 1, f_ellipse, args, ang, c); + break; + case G_TRIANGLE: { + double minang = 0; + do { + double base = ur(0, 2 * PI); + for (i = 0; i < 3; i++) { + double a = base + i * 2 * PI / 3 + rad(ur(-28, 28)), rr = r * ur(0.8, 1.2); + v[i] = p2(rr * cos(a), rr * sin(a)); + } + minang = 1e9; + for (i = 0; i < 3; i++) { + P2 a = v[(i + 2) % 3], b = v[i], d = v[(i + 1) % 3]; + double t = acos(((a.x - b.x) * (d.x - b.x) + (a.y - b.y) * (d.y - b.y)) / + (hypot(a.x - b.x, a.y - b.y) * hypot(d.x - b.x, d.y - b.y))); + if (t < minang) minang = t; + } + } while (minang < rad(30)); + transform(v, 3, 0, c); + path_polygon(&g_path, v, 3, 1, round_r); + break; + } + case G_SQUARE: + case G_RECTANGLE: { + double w = r, h = g == G_SQUARE ? r / ur(1.0, 1.08) : r / ur(1.4, 3.0); + double a = rad(g == G_SQUARE ? ur(-25, 25) : ur(-40, 40)); + v[0] = p2(-w, -h); + v[1] = p2(w, -h); + v[2] = p2(w, h); + v[3] = p2(-w, h); + if (rnd() < 0.5 && g == G_RECTANGLE) { + for (i = 0; i < 4; i++) { + v[i] = p2(v[i].y, v[i].x); + } + } + transform(v, 4, a, c); + path_polygon(&g_path, v, 4, 1, round_r); + break; + } + case G_DIAMOND: { + double hw = r, hh = r * ur(0.65, 1.5); + v[0] = p2(0, -hh); + v[1] = p2(hw, 0); + v[2] = p2(0, hh); + v[3] = p2(-hw, 0); + transform(v, 4, rad(ur(-6, 6)), c); + path_polygon(&g_path, v, 4, 1, round_r); + break; + } + case G_PENTAGON: + case G_HEXAGON: { + int k = g == G_PENTAGON ? 5 : 6; + for (i = 0; i < k; i++) { + double a = ang + i * 2 * PI / k + rad(ur(-3, 3)), rr = r * ur(0.96, 1.04); + v[i] = p2(rr * cos(a), rr * sin(a)); + } + transform(v, k, 0, c); + path_polygon(&g_path, v, k, 1, round_r * 0.7); + break; + } + case G_STAR: { + double inner = ur(0.36, 0.5); + for (i = 0; i < 10; i++) { + double a = ang + i * PI / 5 + rad(ur(-3, 3)), rr = r * (i % 2 ? inner : 1) * ur(0.95, 1.05); + v[i] = p2(rr * cos(a), rr * sin(a)); + } + transform(v, 10, 0, c); + path_polygon(&g_path, v, 10, 1, size * ur(0, 0.015)); + break; + } + case G_PENTAGRAM: + for (i = 0; i < 5; i++) { + double a = ang + (i * 2 % 5) * 2 * PI / 5 + rad(ur(-3, 3)), rr = r * ur(0.95, 1.05); + v[i] = p2(rr * cos(a), rr * sin(a)); + } + transform(v, 5, 0, c); + path_polygon(&g_path, v, 5, 1, size * ur(0, 0.015)); + break; + case G_HEART: + args[0] = r / 16; + args[2] = ur(0.85, 1.15); + path_curve(&g_path, 1, f_heart, args, rad(ur(-8, 8)), c); + break; + default: + return 0; + } + noise_init(&nz, size, sloppy, closed); + if (g == G_HEART && rnd() < 0.5) { + nz.start = 0; + } + if (g == G_ARROW) { + nz.wob1 = size * ur(0, 0.006); + nz.wob2 = size * ur(0, 0.002); + } + n = draw_path(&g_path, &nz, out); + if (reverse) { + reverse_points(out, n); + } + return n; +} + +static int make_negative(int k, th_point *out) +{ + P2 c = p2(ur(300, 1500), ur(300, 1100)), v[32]; + double r = ur(60, 380), size = 2 * r, ang = ur(0, 2 * PI), args[4]; + int closed = 0, i, n; + Noise nz; + switch (k) { + case N_SCRIBBLE: { + int cnt = ri(8, 14); + for (i = 0; i < cnt; i++) { + v[i] = p2(c.x + ur(-r, r), c.y + ur(-r, r)); + } + path_begin(&g_path, 0); + for (i = 0; i < cnt - 1; i++) { + int s; + P2 p0 = v[i > 0 ? i - 1 : 0], p3 = v[i + 2 < cnt ? i + 2 : cnt - 1]; + for (s = 0; s < 80; s++) { + path_add(&g_path, catmull(p0, v[i], v[i + 1], p3, s / 80.0)); + } + } + path_end(&g_path); + break; + } + case N_ZIGZAG: { + int teeth = ri(4, 9); + double w = size / teeth, h = w * ur(0.4, 1.2); + for (i = 0; i <= teeth; i++) { + v[i] = p2(i * w - size / 2, i % 2 ? h : -h); + } + transform(v, teeth + 1, ang, c); + path_polygon(&g_path, v, teeth + 1, 0, w * 0.05); + break; + } + case N_CURSIVE: + args[0] = r / 12; + args[1] = ur(1.4, 2.4); + args[2] = ri(3, 6); + path_curve(&g_path, 0, f_cursive, args, rad(ur(-15, 15)), c); + break; + case N_SPIRAL: + args[0] = r; + args[1] = ur(2, 4); + path_curve(&g_path, 0, f_spiral, args, ang, c); + break; + case N_SCURVE: + args[0] = size; + args[1] = size * ur(0.15, 0.35); + args[2] = ur(0.8, 1.5); + path_curve(&g_path, 0, f_scurve, args, ang, c); + break; + case N_FIGURE8: + args[0] = r; + args[1] = r * ur(0.6, 1.2); + path_curve(&g_path, 1, f_figure8, args, ang, c); + closed = 1; + break; + case N_CARC: + args[0] = r; + args[1] = rad(ur(160, 260)); + path_curve(&g_path, 0, f_arc, args, ang, c); + break; + case N_CHECK: + v[0] = p2(0, 0); + v[1] = p2(0.3 * size, 0.35 * size); + v[2] = p2(size, -0.45 * size); + transform(v, 3, rad(ur(-15, 15)), c); + path_polygon(&g_path, v, 3, 0, size * 0.02); + break; + case N_LSHAPE: + v[0] = p2(0, -size * ur(0.5, 1)); + v[1] = p2(0, 0); + v[2] = p2(size * ur(0.5, 1), 0); + transform(v, 3, ang, c); + path_polygon(&g_path, v, 3, 0, size * 0.02); + break; + case N_USHAPE: { + double w = size * ur(0.3, 0.7), h = size * ur(0.5, 1); + v[0] = p2(-w / 2, -h); + v[1] = p2(-w / 2, 0); + v[2] = p2(w / 2, 0); + v[3] = p2(w / 2, -h); + transform(v, 4, ang, c); + path_polygon(&g_path, v, 4, 0, w * ur(0.1, 0.5)); + break; + } + case N_TWICE: + args[0] = r; + args[1] = r * ur(0.9, 1.0); + args[2] = ur(1.7, 2.1); + path_curve(&g_path, 0, f_twice, args, ang, c); + break; + case N_TINY: { + int g = ri(0, G_COUNT - 1); + th_point tmp[MAXP]; + int m = make_positive(g, tmp, 0); + double minx = 1e9, miny = 1e9, maxx = -1e9, maxy = -1e9, scale; + for (i = 0; i < m; i++) { + if (tmp[i].x < minx) minx = tmp[i].x; + if (tmp[i].y < miny) miny = tmp[i].y; + if (tmp[i].x > maxx) maxx = tmp[i].x; + if (tmp[i].y > maxy) maxy = tmp[i].y; + } + scale = ur(4, 10) / hypot(maxx - minx, maxy - miny); + for (i = 0; i < m; i++) { + out[i].x = (float)((tmp[i].x - minx) * scale + 500); + out[i].y = (float)((tmp[i].y - miny) * scale + 500); + } + return m; + } + default: + return 0; + } + noise_init(&nz, size, rnd() < 0.5, closed); + n = draw_path(&g_path, &nz, out); + if (rnd() < 0.5) { + reverse_points(out, n); + } + return n; +} + +/* ---------- checks ---------- */ + +static int failures = 0; + +static void check(int ok, const char *what) +{ + if (!ok) { + failures++; + printf(" FAIL: %s\n", what); + } +} + +static int layout_ok(const th_shape *s) +{ + int expect_pts = 0; + switch (s->kind) { + case TH_SHAPE_LINE: + return s->path_count == 1 && s->path_length[0] == 2 && !s->path_closed[0]; + case TH_SHAPE_ARROW: + return s->path_count == 2 && s->path_length[0] == 2 && s->path_length[1] == 3 && !s->path_closed[0] && !s->path_closed[1]; + case TH_SHAPE_CIRCLE: + case TH_SHAPE_ELLIPSE: + case TH_SHAPE_HEART: expect_pts = -1; break; + case TH_SHAPE_TRIANGLE: expect_pts = 3; break; + case TH_SHAPE_SQUARE: + case TH_SHAPE_RECTANGLE: + case TH_SHAPE_DIAMOND: expect_pts = 4; break; + case TH_SHAPE_PENTAGON: expect_pts = 5; break; + case TH_SHAPE_HEXAGON: expect_pts = 6; break; + case TH_SHAPE_STAR: expect_pts = 10; break; + default: return 0; + } + if (s->path_count != 1 || !s->path_closed[0]) { + return 0; + } + return expect_pts < 0 ? s->path_length[0] >= 32 && s->path_length[0] <= 128 : s->path_length[0] == expect_pts; +} + +static double overlap(const th_point *in, int n, const th_shape *s) +{ + double a0 = 1e9, b0 = 1e9, a1 = -1e9, b1 = -1e9, c0 = 1e9, d0 = 1e9, c1 = -1e9, d1 = -1e9; + double ix, iy, inter, uni; + int i; + for (i = 0; i < n; i++) { + if (in[i].x < a0) a0 = in[i].x; + if (in[i].x > a1) a1 = in[i].x; + if (in[i].y < b0) b0 = in[i].y; + if (in[i].y > b1) b1 = in[i].y; + } + for (i = 0; i < s->point_count; i++) { + if (s->points[i].x < c0) c0 = s->points[i].x; + if (s->points[i].x > c1) c1 = s->points[i].x; + if (s->points[i].y < d0) d0 = s->points[i].y; + if (s->points[i].y > d1) d1 = s->points[i].y; + } + ix = fmin(a1, c1) - fmax(a0, c0); + iy = fmin(b1, d1) - fmax(b0, d0); + if (ix <= 0 || iy <= 0) { + return 0; + } + inter = ix * iy; + uni = (a1 - a0) * (b1 - b0) + (c1 - c0) * (d1 - d0) - inter; + return uni > 0 ? inter / uni : 0; +} + +static th_point pts[MAXP]; + +static const char *only = NULL; +static int verbose = 0; + +static void run_positives(void) +{ + int g, i; + printf("Positive shapes (%d each, half y-up, 40%% sloppy)\n", SAMPLES); + printf(" %-10s %7s %s\n", "shape", "correct", "misses"); + for (g = 0; g < G_COUNT; g++) { + int correct = 0, miss[TH_SHAPE_KIND_COUNT] = {0}, bad_layout = 0, bad_place = 0, k; + if (only && strcmp(only, gen_names[g]) != 0) { + continue; + } + char line[256]; + int off = 0; + for (i = 0; i < SAMPLES; i++) { + th_shape shape; + int up = i % 2, n = make_positive(g, pts, rnd() < 0.4); + th_shape_kind kind; + if (up) { + flip_y(pts, n); + } + if (verbose) { + fprintf(stderr, "---\n"); + } + kind = th_recognize(pts, n, up ? 0 : 1, &shape); + if (verbose && kind != gen_expect[g]) { + fprintf(stderr, "MISS %s -> %s\n", gen_names[g], th_shape_name(kind)); + } + if (kind == gen_expect[g]) { + correct++; + if (!layout_ok(&shape)) { + bad_layout++; + } + if (g != G_LINE && g != G_ARROW && overlap(pts, n, &shape) < 0.6) { + bad_place++; + } + } else { + miss[kind]++; + } + if (shape.kind != kind) { + bad_layout++; + } + } + line[0] = 0; + for (k = 0; k < TH_SHAPE_KIND_COUNT; k++) { + if (miss[k]) { + off += snprintf(line + off, sizeof line - off, "%s %d ", th_shape_name((th_shape_kind)k), miss[k]); + } + } + printf(" %-10s %6.1f%% %s\n", gen_names[g], 100.0 * correct / SAMPLES, line); + check(correct >= 0.9 * SAMPLES, gen_names[g]); + check(bad_layout == 0, "output path layout"); + check(bad_place == 0, "output sits on the drawn stroke"); + } +} + +static void run_negatives(void) +{ + int k, i, total_fp = 0, total = 0; + printf("\nStrokes that must stay freehand (%d each)\n", SAMPLES); + for (k = 0; k < N_COUNT; k++) { + int fp = 0, hits[TH_SHAPE_KIND_COUNT] = {0}, j, off = 0; + char line[256]; + for (i = 0; i < SAMPLES; i++) { + int up = i % 2, n = make_negative(k, pts); + th_shape_kind kind; + if (up) { + flip_y(pts, n); + } + kind = th_recognize(pts, n, up ? 0 : 1, NULL); + if (kind != TH_SHAPE_NONE) { + fp++; + hits[kind]++; + } + } + line[0] = 0; + for (j = 0; j < TH_SHAPE_KIND_COUNT; j++) { + if (hits[j]) { + off += snprintf(line + off, sizeof line - off, "%s %d ", th_shape_name((th_shape_kind)j), hits[j]); + } + } + printf(" %-14s %5.1f%% snapped %s\n", neg_names[k], 100.0 * fp / SAMPLES, line); + check(k == N_TINY ? fp == 0 : fp <= 0.1 * SAMPLES, neg_names[k]); + total_fp += fp; + total += SAMPLES; + } + printf(" overall false positives: %.1f%%\n", 100.0 * total_fp / total); + check(total_fp <= 0.05 * total, "overall false positive rate"); +} + +static th_shape make_line_shape(double x0, double y0, double x1, double y1) +{ + th_shape s; + memset(&s, 0, sizeof s); + s.kind = TH_SHAPE_LINE; + s.path_count = 1; + s.path_start[0] = 0; + s.path_length[0] = 2; + s.point_count = 2; + s.points[0].x = (float)x0; + s.points[0].y = (float)y0; + s.points[1].x = (float)x1; + s.points[1].y = (float)y1; + return s; +} + +static int draw_v(P2 apex, P2 dir, double arm, double spread, th_point *out) +{ + P2 v[3]; + Noise nz; + P2 back = p2(-dir.x, -dir.y); + v[0] = p2(apex.x + arm * rot(back, spread).x, apex.y + arm * rot(back, spread).y); + v[1] = apex; + v[2] = p2(apex.x + arm * ur(0.8, 1.2) * rot(back, -spread).x, apex.y + arm * ur(0.8, 1.2) * rot(back, -spread).y); + path_polygon(&g_path, v, 3, 0, arm * 0.03); + noise_init(&nz, arm * 2, 0, 0); + nz.count = ri(8, 40); + return draw_path(&g_path, &nz, out); +} + +static void run_arrowheads(void) +{ + int i, ok = 0, rejected = 0; + printf("\nTwo-stroke arrows (line, then a V at one end)\n"); + for (i = 0; i < SAMPLES; i++) { + double ang = ur(0, 2 * PI), L = ur(120, 900); + P2 a = p2(ur(200, 800), ur(200, 800)), b = p2(a.x + L * cos(ang), a.y + L * sin(ang)); + int at_b = rnd() < 0.5, up = i % 2, n, j; + P2 end = at_b ? b : a, other = at_b ? a : b; + P2 dir = p2((end.x - other.x) / L, (end.y - other.y) / L); + P2 apex = p2(end.x + ur(-0.04, 0.04) * L, end.y + ur(-0.04, 0.04) * L); + th_shape line = make_line_shape(a.x, a.y, b.x, b.y), out; + th_shape_kind kind; + n = draw_v(apex, dir, L * ur(0.08, 0.3), rad(ur(22, 45)), pts); + if (up) { + flip_y(pts, n); + line.points[0].y = -line.points[0].y; + line.points[1].y = -line.points[1].y; + } + kind = th_recognize_arrowhead(&line, pts, n, up ? 0 : 1, &out); + if (kind == TH_SHAPE_ARROW && layout_ok(&out)) { + th_point tip = out.points[1]; + double ey = up ? -end.y : end.y; + if (hypot(tip.x - end.x, tip.y - ey) < 0.02 * L) { + ok++; + } + } + for (j = 0; j < 4; j++) { + th_shape_kind bad = TH_SHAPE_NONE; + P2 mid = p2((a.x + b.x) / 2, (a.y + b.y) / 2); + th_shape l2 = make_line_shape(a.x, a.y, b.x, b.y); + int m = 0; + if (j == 0) { + m = draw_v(mid, dir, L * 0.15, rad(30), pts); + } else if (j == 1) { + m = draw_v(end, p2(-dir.x, -dir.y), L * 0.15, rad(30), pts); + { + int q; + for (q = 0; q < m; q++) { + pts[q].x += (float)(dir.x * L * 0.15); + pts[q].y += (float)(dir.y * L * 0.15); + } + } + } else if (j == 2) { + m = draw_v(p2(end.x + dir.x * L * 0.5, end.y + dir.y * L * 0.5), dir, L * 0.15, rad(30), pts); + } else { + Path *gp = &g_path; + double args2[2]; + Noise nz; + args2[0] = L * 0.1; + args2[1] = L * 0.1; + path_curve(gp, 1, f_ellipse, args2, 0, end); + noise_init(&nz, L * 0.2, 0, 1); + m = draw_path(gp, &nz, pts); + } + bad = th_recognize_arrowhead(&l2, pts, m, 1, NULL); + if (bad == TH_SHAPE_NONE) { + rejected++; + } + } + } + printf(" V at an end -> arrow: %5.1f%%\n", 100.0 * ok / SAMPLES); + printf(" V elsewhere / wrong way: %5.1f%% rejected\n", 100.0 * rejected / (4 * SAMPLES)); + check(ok >= 0.9 * SAMPLES, "arrowhead accepted"); + check(rejected >= 0.95 * 4 * SAMPLES, "arrowhead rejected when wrong"); +} + +/* Upright shapes drawn in a y-up frame must come back upright for y_down = 0. */ +static void run_orientation(void) +{ + int i, tri_ok = 0, star_ok = 0, heart_up = 0, heart_wrong = 0; + printf("\nOrientation\n"); + for (i = 0; i < SAMPLES; i++) { + P2 v[10], c = p2(800, 600); + double r = ur(60, 300); + Noise nz; + th_shape s; + int n, j, top = 0; + double ymax = -1e9; + for (j = 0; j < 3; j++) { + double a = PI / 2 + j * 2 * PI / 3 + rad(ur(-4, 4)); + v[j] = p2(c.x + r * cos(a), c.y + r * sin(a)); + } + path_polygon(&g_path, v, 3, 1, r * 0.03); + noise_init(&nz, 2 * r, 0, 1); + n = draw_path(&g_path, &nz, pts); + if (th_recognize(pts, n, 0, &s) == TH_SHAPE_TRIANGLE) { + for (j = 0; j < 3; j++) { + if (s.points[j].y > ymax) { + ymax = s.points[j].y; + top = j; + } + } + { + int b0 = (top + 1) % 3, b1 = (top + 2) % 3; + if (fabs(s.points[b0].y - s.points[b1].y) < 0.01 * r && fabs(s.points[top].x - c.x) < 0.1 * r) { + tri_ok++; + } + } + } + for (j = 0; j < 10; j++) { + double a = PI / 2 + j * PI / 5 + rad(ur(-3, 3)); + double rr = r * (j % 2 ? 0.42 : 1); + v[j] = p2(c.x + rr * cos(a), c.y + rr * sin(a)); + } + path_polygon(&g_path, v, 10, 1, r * 0.01); + noise_init(&nz, 2 * r, 0, 1); + n = draw_path(&g_path, &nz, pts); + if (th_recognize(pts, n, 0, &s) == TH_SHAPE_STAR) { + ymax = -1e9; + for (j = 0; j < 10; j++) { + if (s.points[j].y > ymax) { + ymax = s.points[j].y; + top = j; + } + } + { + double mx = 0; + for (j = 0; j < 10; j++) { + mx += s.points[j].x / 10; + } + if (fabs(s.points[top].x - mx) < 0.01 * r) { + star_ok++; + } + } + } + { + double args[3]; + args[0] = r / 16; + args[2] = ur(0.9, 1.1); + path_curve(&g_path, 1, f_heart, args, 0, p2(0, 0)); + noise_init(&nz, 2 * r, 0, 1); + n = draw_path(&g_path, &nz, pts); + flip_y(pts, n); + if (th_recognize(pts, n, 0, NULL) == TH_SHAPE_HEART) { + heart_up++; + } + if (th_recognize(pts, n, 1, NULL) == TH_SHAPE_HEART) { + heart_wrong++; + } + } + } + printf(" upright triangle stays point-up (y-up input): %5.1f%%\n", 100.0 * tri_ok / SAMPLES); + printf(" upright star stays point-up (y-up input): %5.1f%%\n", 100.0 * star_ok / SAMPLES); + printf(" heart in y-up input read with y_down=0: %5.1f%%\n", 100.0 * heart_up / SAMPLES); + printf(" same heart read upside down (y_down=1): %5.1f%%\n", 100.0 * heart_wrong / SAMPLES); + check(tri_ok >= 0.9 * SAMPLES, "triangle orientation"); + check(star_ok >= 0.9 * SAMPLES, "star orientation"); + check(heart_up >= 0.9 * SAMPLES, "heart y-up"); + check(heart_wrong <= 0.1 * SAMPLES, "upside-down heart rejected"); +} + +static void run_edges(void) +{ + th_point few[7]; + th_shape s; + int i; + printf("\nEdge cases\n"); + for (i = 0; i < 7; i++) { + few[i].x = (float)(i * 40); + few[i].y = 0; + } + check(th_recognize(few, 7, 1, &s) == TH_SHAPE_NONE && s.kind == TH_SHAPE_NONE, "fewer than 8 points"); + check(th_recognize(NULL, 0, 1, &s) == TH_SHAPE_NONE, "null input"); + for (i = 0; i < 7; i++) { + few[i].x = 10; + few[i].y = 10; + } + check(th_recognize(few, 7, 1, NULL) == TH_SHAPE_NONE, "all points equal"); + for (i = 0; i < 2000; i++) { + pts[i].x = (float)(100 + i * 0.4); + pts[i].y = (float)(300 + 0.2 * sin(i * 0.3)); + } + check(th_recognize(pts, 2000, 1, &s) == TH_SHAPE_LINE && fabs(s.points[0].y - s.points[1].y) < 1e-3, "long dense horizontal line snaps level"); + check(strcmp(th_shape_name(TH_SHAPE_HEART), "Heart") == 0 && strcmp(th_shape_name(TH_SHAPE_NONE), "None") == 0, "names"); + check(th_shape_path_count(&s) == 1 && th_shape_path_length(&s, 0) == 2 && th_shape_path_closed(&s, 0) == 0 + && th_shape_path_length(&s, 5) == 0 && th_shape_point(&s, 999).x == 0, "accessors"); + printf(" done\n"); +} + +int main(int argc, char **argv) +{ + if (argc > 1) { + rng_state = strtoull(argv[1], NULL, 10) | 1; + } + verbose = getenv("SHAPE_VERBOSE") != NULL; + if (argc > 2) { + only = argv[2]; + run_positives(); + return failures ? 1 : 0; + } + run_positives(); + run_negatives(); + run_arrowheads(); + run_orientation(); + run_edges(); + if (failures) { + printf("\n%d check(s) failed\n", failures); + return 1; + } + printf("\nAll shape checks passed\n"); + return 0; +} diff --git a/scripts/test-shapes.sh b/scripts/test-shapes.sh new file mode 100755 index 0000000..746ffd0 --- /dev/null +++ b/scripts/test-shapes.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Builds and runs the ShapeKit synthetic stroke tests natively. Pass a number to change the seed. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="$ROOT/.build/shape-test" +mkdir -p "$ROOT/.build" +cc -std=c99 -O2 -Wall -Wextra -Werror -I"$ROOT/Sources/ShapeKit/include" \ + "$ROOT/Sources/ShapeKit/shapekit.c" "$ROOT/Tests/shapes/shape_test.c" -lm -o "$OUT" +"$OUT" "$@" From d2db2444805f92a5d13654bfbd3029176ea10a36 Mon Sep 17 00:00:00 2001 From: Monish Meher Date: Mon, 9 Nov 2026 18:14:27 +0530 Subject: [PATCH 2/4] Snap rough shapes into clean ones on macOS When a stroke ends it goes through ShapeKit and, if it reads as a shape, is redrawn as that shape with a short crossfade. A V drawn at the end of a freshly snapped line turns it into an arrow. On by default, with a Snap shapes switch in Settings, Drawing and in the menu bar menu. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 6 +- Sources/TinyHand/AppDelegate.swift | 9 +++ Sources/TinyHand/Ink.swift | 87 ++++++++++++++++++++++++++++- Sources/TinyHand/Preferences.swift | 7 ++- Sources/TinyHand/SettingsView.swift | 4 ++ 5 files changed, 110 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index c536a5e..76e9dbd 100644 --- a/Package.swift +++ b/Package.swift @@ -8,9 +8,13 @@ let package = Package( .package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.10.0"), ], targets: [ + .target( + name: "ShapeKit", + path: "Sources/ShapeKit" + ), .executableTarget( name: "TinyHand", - dependencies: [.product(name: "Sparkle", package: "Sparkle")], + dependencies: ["ShapeKit", .product(name: "Sparkle", package: "Sparkle")], path: "Sources/TinyHand", linkerSettings: [.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"])] ) diff --git a/Sources/TinyHand/AppDelegate.swift b/Sources/TinyHand/AppDelegate.swift index aa6dd5f..5a2e26e 100644 --- a/Sources/TinyHand/AppDelegate.swift +++ b/Sources/TinyHand/AppDelegate.swift @@ -13,6 +13,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var toggleItem: NSMenuItem? private var clearItem: NSMenuItem? private var soundItem: NSMenuItem? + private var snapItem: NSMenuItem? private var toggleHotKey: UInt32? private var clearHotKey: UInt32? @@ -67,12 +68,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let toggle = NSMenuItem(title: "", action: #selector(toggle), keyEquivalent: "") let clear = NSMenuItem(title: "", action: #selector(clearInk), keyEquivalent: "") let sound = NSMenuItem(title: "Tap Sound", action: #selector(toggleSound), keyEquivalent: "") + let snap = NSMenuItem(title: "Snap Shapes", action: #selector(toggleSnap), keyEquivalent: "") toggleItem = toggle clearItem = clear soundItem = sound + snapItem = snap menu.addItem(toggle) menu.addItem(clear) menu.addItem(sound) + menu.addItem(snap) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",")) if Updater.isAvailable { @@ -127,6 +131,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { toggleItem?.title = "\(on ? "Turn Pointer Off" : "Turn Pointer On") \(Preferences.toggleShortcut.display)" clearItem?.title = "Clear Drawing \(Preferences.clearShortcut.display)" soundItem?.state = overlay.soundOn ? .on : .off + snapItem?.state = Preferences.snapShapes ? .on : .off permissionItem?.isHidden = AXIsProcessTrusted() } @@ -191,6 +196,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { overlay.soundOn.toggle() } + @objc private func toggleSnap() { + Preferences.snapShapes.toggle() + } + @objc private func openSettings() { overlay.exit() settings.show() diff --git a/Sources/TinyHand/Ink.swift b/Sources/TinyHand/Ink.swift index edfcb28..97bc5a1 100644 --- a/Sources/TinyHand/Ink.swift +++ b/Sources/TinyHand/Ink.swift @@ -1,4 +1,5 @@ import AppKit +import ShapeKit final class Ink { let layer = CALayer() @@ -18,6 +19,8 @@ final class Ink { private var stroke: CAShapeLayer? private var points: [CGPoint] = [] + private var lastLine: (layer: CAShapeLayer, shape: th_shape, at: Date)? + private var fades = 0 var isDrawing: Bool { stroke != nil } @@ -57,8 +60,17 @@ final class Ink { guard let s = stroke else { return } + let drawn = points stroke = nil points = [] + if Preferences.snapShapes { + if snapArrowhead(drawn, stroke: s) { + return + } + snap(drawn, stroke: s) + } else { + lastLine = nil + } freeze(s) if keep { return @@ -69,9 +81,75 @@ final class Ink { func clear() { stroke = nil points = [] + lastLine = nil layer.sublayers?.forEach { $0.removeFromSuperlayer() } } + private func snap(_ drawn: [CGPoint], stroke s: CAShapeLayer) { + lastLine = nil + let input = drawn.map { th_point(x: Float($0.x), y: Float($0.y)) } + var shape = th_shape() + let kind = th_recognize(input, Int32(input.count), 0, &shape) + guard kind != TH_SHAPE_NONE else { + return + } + show(shape, in: s) + Log.write("snapped stroke to \(String(cString: th_shape_name(kind)))") + if kind == TH_SHAPE_LINE { + lastLine = (s, shape, Date()) + } + } + + private func snapArrowhead(_ drawn: [CGPoint], stroke s: CAShapeLayer) -> Bool { + guard let last = lastLine, Date().timeIntervalSince(last.at) < 3, last.layer.superlayer != nil, + last.layer.strokeColor == s.strokeColor else { + return false + } + let input = drawn.map { th_point(x: Float($0.x), y: Float($0.y)) } + var line = last.shape + var arrow = th_shape() + guard th_recognize_arrowhead(&line, input, Int32(input.count), 0, &arrow) == TH_SHAPE_ARROW else { + return false + } + lastLine = nil + s.removeFromSuperlayer() + show(arrow, in: last.layer) + freeze(last.layer) + if !keep { + fade(last.layer, after: fadeDelay, over: fadeDuration) + } + Log.write("snapped arrowhead onto line") + return true + } + + private func show(_ shape: th_shape, in s: CAShapeLayer) { + var shape = shape + let path = CGMutablePath() + for i in 0.. 1 else { + continue + } + let pts = (0.. CGPoint in + let p = th_shape_point(&shape, start + j) + return CGPoint(x: CGFloat(p.x), y: CGFloat(p.y)) + } + path.addLines(between: pts) + if th_shape_path_closed(&shape, i) != 0 { + path.closeSubpath() + } + } + let crossfade = CATransition() + crossfade.type = .fade + crossfade.duration = 0.12 + s.add(crossfade, forKey: "snap") + CATransaction.begin() + CATransaction.setDisableActions(true) + s.path = path + CATransaction.commit() + } + private func freeze(_ s: CAShapeLayer) { guard let path = s.path else { return @@ -88,6 +166,10 @@ final class Ink { } private func fade(_ s: CAShapeLayer, after delay: CFTimeInterval, over duration: CFTimeInterval) { + fades += 1 + let token = fades + s.setValue(token, forKey: "fadeToken") + s.removeAnimation(forKey: "fade") let anim = CABasicAnimation(keyPath: "opacity") anim.fromValue = 1 anim.toValue = 0 @@ -98,7 +180,10 @@ final class Ink { anim.isRemovedOnCompletion = false s.add(anim, forKey: "fade") DispatchQueue.main.asyncAfter(deadline: .now() + delay + duration) { [weak s] in - s?.removeFromSuperlayer() + guard let s = s, s.value(forKey: "fadeToken") as? Int == token else { + return + } + s.removeFromSuperlayer() } } diff --git a/Sources/TinyHand/Preferences.swift b/Sources/TinyHand/Preferences.swift index 5c04cdc..d3c1843 100644 --- a/Sources/TinyHand/Preferences.swift +++ b/Sources/TinyHand/Preferences.swift @@ -144,6 +144,11 @@ enum Preferences { set { set(newValue, "showInkButton") } } + static var snapShapes: Bool { + get { defaults.object(forKey: "snapShapes") as? Bool ?? true } + set { set(newValue, "snapShapes") } + } + static let maxPinned = 8 static var pinnedStyles: [PointerStyle] { @@ -177,7 +182,7 @@ enum Preferences { static func resetAll() { for key in ["shortcut.toggle", "shortcut.clear", "pointerSize", "showShadow", "fadeSeconds", "sound.hands", "sound.laser", "interactKey", "interactKeepsHand", "pinnedStyles", "pointerStyle", - "inkChoice", "showInkButton", "tapSound"] { + "inkChoice", "showInkButton", "tapSound", "snapShapes"] { defaults.removeObject(forKey: key) } NotificationCenter.default.post(name: changed, object: nil) diff --git a/Sources/TinyHand/SettingsView.swift b/Sources/TinyHand/SettingsView.swift index f2b0ddf..c2bec97 100644 --- a/Sources/TinyHand/SettingsView.swift +++ b/Sources/TinyHand/SettingsView.swift @@ -983,6 +983,10 @@ private struct DrawingPage: View { .fixedSize() .focusable(false) } + Row("Snap shapes", detail: "Rough lines, arrows, circles, boxes, triangles, stars and hearts turn into clean shapes.") { + Toggle("", isOn: model.binding({ Preferences.snapShapes }, { Preferences.snapShapes = $0 })) + .toggleStyle(.switch).labelsHidden().focusable(false) + } Row("Ink button in the toolbar", detail: "Change the colour while you present.") { Toggle("", isOn: model.binding({ Preferences.showInkButton }, { Preferences.showInkButton = $0 })) .toggleStyle(.switch).labelsHidden().focusable(false) From ed06b4f015b10cd148e78d79ae41ee256bc615cc Mon Sep 17 00:00:00 2001 From: Monish Meher Date: Mon, 9 Nov 2026 18:14:27 +0530 Subject: [PATCH 3/4] Port TinyHand to Windows A native Win32 + GDI+ app in windows/, built with mingw-w64 into one static TinyHand.exe. It brings the hand on a stick, all 36 hands and the laser dot, taps, drawing with fades, shape snapping (same ShapeKit recognizer), tap sounds, the toolbar with ink tray and first-run hint, a tray icon and menu, global shortcuts, Alt to click for real, and a Settings window with the same pages as the macOS one. Clicks become taps through a WH_MOUSE_LL hook on its own thread; the taskbar, Start and open menus pass through. The overlay is a per-pixel alpha layered window updated by dirty rectangles. Self-test switches (--render-test, --toolbar-test, --settings-test, --settings-ui-test, --welcome-test, --e2e-test) run on Windows and under Wine. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + windows/Makefile | 69 ++ windows/README.md | 93 ++ windows/res/appicon.png | Bin 0 -> 22434 bytes windows/res/make-ico.py | 14 + windows/res/tinyhand.ico | Bin 0 -> 45858 bytes windows/res/tinyhand.manifest | 29 + windows/res/tinyhand.rc | 44 + windows/src/app.cpp | 312 +++++++ windows/src/app.h | 44 + windows/src/arm.cpp | 328 +++++++ windows/src/arm.h | 56 ++ windows/src/common.h | 94 ++ windows/src/hands.cpp | 295 ++++++ windows/src/hands.h | 35 + windows/src/ink.cpp | 244 +++++ windows/src/ink.h | 53 ++ windows/src/main.cpp | 93 ++ windows/src/overlay.cpp | 803 +++++++++++++++++ windows/src/overlay.h | 94 ++ windows/src/prefs.cpp | 379 ++++++++ windows/src/prefs.h | 95 ++ windows/src/resource.h | 14 + windows/src/selftest.cpp | 713 +++++++++++++++ windows/src/selftest.h | 19 + windows/src/settings.cpp | 1579 +++++++++++++++++++++++++++++++++ windows/src/settings.h | 152 ++++ windows/src/sound.cpp | 26 + windows/src/sound.h | 11 + windows/src/toolbar.cpp | 596 +++++++++++++ windows/src/toolbar.h | 81 ++ windows/src/util.cpp | 333 +++++++ windows/src/welcome.cpp | 239 +++++ windows/src/welcome.h | 27 + 34 files changed, 6965 insertions(+) create mode 100644 windows/Makefile create mode 100644 windows/README.md create mode 100644 windows/res/appicon.png create mode 100644 windows/res/make-ico.py create mode 100644 windows/res/tinyhand.ico create mode 100644 windows/res/tinyhand.manifest create mode 100644 windows/res/tinyhand.rc create mode 100644 windows/src/app.cpp create mode 100644 windows/src/app.h create mode 100644 windows/src/arm.cpp create mode 100644 windows/src/arm.h create mode 100644 windows/src/common.h create mode 100644 windows/src/hands.cpp create mode 100644 windows/src/hands.h create mode 100644 windows/src/ink.cpp create mode 100644 windows/src/ink.h create mode 100644 windows/src/main.cpp create mode 100644 windows/src/overlay.cpp create mode 100644 windows/src/overlay.h create mode 100644 windows/src/prefs.cpp create mode 100644 windows/src/prefs.h create mode 100644 windows/src/resource.h create mode 100644 windows/src/selftest.cpp create mode 100644 windows/src/selftest.h create mode 100644 windows/src/settings.cpp create mode 100644 windows/src/settings.h create mode 100644 windows/src/sound.cpp create mode 100644 windows/src/sound.h create mode 100644 windows/src/toolbar.cpp create mode 100644 windows/src/toolbar.h create mode 100644 windows/src/util.cpp create mode 100644 windows/src/welcome.cpp create mode 100644 windows/src/welcome.h diff --git a/.gitignore b/.gitignore index b7af770..89e048e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ *.dmg *.zip assets/unused/ +windows/build/ diff --git a/windows/Makefile b/windows/Makefile new file mode 100644 index 0000000..8609cc5 --- /dev/null +++ b/windows/Makefile @@ -0,0 +1,69 @@ +# TinyHand for Windows. +# +# Cross-compile from macOS or Linux with mingw-w64 (brew install mingw-w64, +# apt install mingw-w64): make +# Build natively in an MSYS2 MinGW64 shell: make CROSS= +# +# Targets: all (build/TinyHand.exe), test (build/shape_test.exe), dist (zip), clean. + +CROSS ?= x86_64-w64-mingw32- +CC := $(CROSS)gcc +CXX := $(CROSS)g++ +WINDRES := $(CROSS)windres + +comma := , +VERSION := $(shell tr -d '[:space:]' < ../VERSION) +VERSION_NUM := $(subst .,$(comma),$(VERSION))$(comma)0 +BUILD := build +NAME := TinyHand-$(VERSION)-windows-x64 +DIST := dist/$(NAME) + +CPPFLAGS := -DUNICODE -D_UNICODE -DNOMINMAX -D_WIN32_WINNT=0x0A00 -I../Sources/ShapeKit/include -Isrc -I$(BUILD) +CFLAGS := -std=c99 -O2 -Wall -Wextra +CXXFLAGS := -std=c++17 -O2 -Wall -Wextra -municode +LDFLAGS := -municode -mwindows -static -static-libgcc -static-libstdc++ -s +LDLIBS := -lgdiplus -lcomctl32 -lshlwapi -lshell32 -lole32 -luuid -lwinmm -lgdi32 -luser32 -ladvapi32 + +SOURCES := $(wildcard src/*.cpp) +HEADERS := $(wildcard src/*.h) ../Sources/ShapeKit/include/shapekit.h +OBJECTS := $(SOURCES:src/%.cpp=$(BUILD)/%.o) $(BUILD)/shapekit.o $(BUILD)/tinyhand.res.o + +.PHONY: all test dist clean + +all: $(BUILD)/TinyHand.exe + +$(BUILD): + mkdir -p $(BUILD) + +$(BUILD)/version.h: ../VERSION | $(BUILD) + printf '#pragma once\n#define TH_VERSION L"%s"\n#define TH_VERSION_A "%s"\n#define TH_VERSION_NUM %s\n' \ + '$(VERSION)' '$(VERSION)' '$(VERSION_NUM)' > $@ + +$(BUILD)/%.o: src/%.cpp $(HEADERS) $(BUILD)/version.h + $(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@ + +$(BUILD)/shapekit.o: ../Sources/ShapeKit/shapekit.c ../Sources/ShapeKit/include/shapekit.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@ + +$(BUILD)/tinyhand.res.o: res/tinyhand.rc res/tinyhand.manifest res/tinyhand.ico res/appicon.png src/resource.h $(BUILD)/version.h + $(WINDRES) -I$(BUILD) -Isrc -O coff $< $@ + +$(BUILD)/TinyHand.exe: $(OBJECTS) + $(CXX) $(LDFLAGS) -o $@ $^ $(LDLIBS) + +test: $(BUILD)/shape_test.exe + +$(BUILD)/shape_test.exe: ../Tests/shapes/shape_test.c ../Sources/ShapeKit/shapekit.c ../Sources/ShapeKit/include/shapekit.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -static -o $@ ../Tests/shapes/shape_test.c ../Sources/ShapeKit/shapekit.c + +dist: all + rm -rf $(DIST) dist/$(NAME).zip + mkdir -p $(DIST)/assets + cp $(BUILD)/TinyHand.exe $(DIST)/ + cp ../assets/hand_*.png $(DIST)/assets/ + cp ../LICENSE $(DIST)/LICENSE.txt + cd dist && zip -qr $(NAME).zip $(NAME) + @echo "Built dist/$(NAME).zip" + +clean: + rm -rf $(BUILD) dist diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..769fafb --- /dev/null +++ b/windows/README.md @@ -0,0 +1,93 @@ +# TinyHand for Windows + +A native Win32 port of TinyHand: the same tiny hand on a stick, the same 36 hands and laser dot, +the same drawing and tap sounds, now on Windows 10 and 11 (x64). + +It is a single static `TinyHand.exe` (no installer, no runtime to install) plus the `assets` folder +with the hand pictures. + +## Use + +| | | +|---|---| +| Click the tray icon, or **Ctrl+Alt+H** | Turn the pointer on or off | +| Right-click the tray icon | Menu and Settings | +| Click | Tap (nothing underneath gets clicked) | +| Hold and drag | Draw on the screen | +| Draw a rough shape | It snaps into a clean line, arrow, circle, ellipse, triangle, square, rectangle, diamond, pentagon, hexagon, star or heart | +| Draw a small **V** at the end of a line | The line becomes an arrow | +| Hold **Alt** and click | Click for real, through the hand | +| **Ctrl+Alt+X** | Clear the drawing | +| **Esc** | Clear everything and turn the pointer off | + +The taskbar and open menus always get real clicks, and the real cursor comes back while you are over them. + +**Settings** (right-click the tray icon) has the same pages as on the Mac: General (shortcuts, the +click-through key, open at login), Pointers (which hands sit in the toolbar: drag to reorder, drag out or +press x to remove, + to add), Appearance (size, shadow), Drawing (ink colour, fade, shape snapping, ink +button), Sound and About. It follows the Windows light or dark app theme. On first run a small welcome +card shows the shortcut. + +Unlike macOS, Windows needs no special permission for TinyHand to turn clicks into taps. +Settings live in `HKEY_CURRENT_USER\Software\TinyHand`; the log is `%LOCALAPPDATA%\TinyHand\TinyHand.log`. + +## Build + +From macOS or Linux, with [mingw-w64](https://www.mingw-w64.org/) (`brew install mingw-w64` or +`sudo apt install mingw-w64`): + +```bash +make -C windows # windows/build/TinyHand.exe +make -C windows dist # windows/dist/TinyHand--windows-x64.zip +``` + +On Windows, in an [MSYS2](https://www.msys2.org/) MinGW64 shell (`pacman -S mingw-w64-x86_64-gcc make zip`): + +```bash +make -C windows CROSS= +``` + +`windows/build/TinyHand.exe` finds the hand pictures in the repository's `assets` folder, so it runs +straight from the build folder. + +## Checks + +The exe has the same kind of self-test switches as the macOS app. They work on Windows and under Wine: + +| | | +|---|---| +| `TinyHand.exe --render-test out.png` | Draws every shape through the snapper and all 37 pointers into one picture; exit code is the number of misses | +| `TinyHand.exe --toolbar-test out.png` | Renders the toolbar, the first-run hint and the ink tray | +| `TinyHand.exe --settings-test out.png` | Renders every Settings page (plus the pointer picker and dark mode) to `out-.png` | +| `TinyHand.exe --settings-ui-test` | Clicks and drags through Settings with window messages and checks each control changes its setting. The real mouse does not move | +| `TinyHand.exe --welcome-test out.png` | Renders the first-run card, light and dark | +| `TinyHand.exe --e2e-test` | Drives real mouse and keyboard input through the hook: taps, drawing and snapping, two-stroke arrows, Alt-click, shortcuts and Esc. It moves the mouse for a few seconds | +| `make -C windows test` | Builds `shape_test.exe`, the shared shape recognizer test | + +To try it on a Mac with [Wine](https://www.winehq.org/) (64-bit Wine 9 or later): + +```bash +make -C windows all test +wine windows/build/shape_test.exe +wine windows/build/TinyHand.exe --render-test /tmp/tinyhand-render.png +wine windows/build/TinyHand.exe --e2e-test +wine windows/build/TinyHand.exe +``` + +## How it maps to the macOS app + +| macOS | Windows | +|---|---| +| Menu bar item | Tray icon (white or black to match the taskbar) | +| `CGEvent` tap on its own thread | `WH_MOUSE_LL` hook on its own thread | +| Transparent `NSWindow` with Core Animation layers | Per-pixel-alpha layered window, GDI+ drawing, dirty-rect updates | +| `CGDisplayHideCursor` | System cursors swapped for a blank one, restored on exit and after a crash | +| Carbon hot keys | `RegisterHotKey` | +| Option to click for real | Alt (or Ctrl, Shift, off) | +| Menu bar and Dock pass-through | Taskbar, tray overflow, Start and open menus pass through | +| `UserDefaults` | Registry, `HKCU\Software\TinyHand` | +| Open at login | `HKCU\...\CurrentVersion\Run` | + +Shape snapping is shared: both apps compile the same C recognizer in `Sources/ShapeKit`. + +Not ported yet: making your own hand from a picture, and automatic updates. diff --git a/windows/res/appicon.png b/windows/res/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..53896ba7d774f150eb9e96955842496327966bd9 GIT binary patch literal 22434 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 LZJ<@5VITH?B46vG literal 0 HcmV?d00001 diff --git a/windows/res/make-ico.py b/windows/res/make-ico.py new file mode 100644 index 0000000..e84d79f --- /dev/null +++ b/windows/res/make-ico.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Rebuild windows/res/tinyhand.ico and appicon.png from the macOS app icon (needs Pillow).""" + +from pathlib import Path + +from PIL import Image + +root = Path(__file__).resolve().parents[2] +source = Image.open(root / "assets" / "AppIcon.icns") +icon = source.convert("RGBA") +sizes = [(s, s) for s in (16, 20, 24, 32, 40, 48, 64, 128, 256)] +icon.save(Path(__file__).with_name("tinyhand.ico"), sizes=sizes) +icon.resize((256, 256), Image.LANCZOS).save(Path(__file__).with_name("appicon.png")) +print("wrote tinyhand.ico and appicon.png in", Path(__file__).parent) diff --git a/windows/res/tinyhand.ico b/windows/res/tinyhand.ico new file mode 100644 index 0000000000000000000000000000000000000000..7daf6fac336bf2ecff4451b8624172b2495a7e55 GIT binary patch literal 45858 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 z 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 + + + TinyHand: a tiny hand on a stick that points at things on your screen. + + + + + + + + + + + + + + + + + + + + true/pm + PerMonitorV2 + + + diff --git a/windows/res/tinyhand.rc b/windows/res/tinyhand.rc new file mode 100644 index 0000000..38969e5 --- /dev/null +++ b/windows/res/tinyhand.rc @@ -0,0 +1,44 @@ +#include +#include "../src/resource.h" +#include "version.h" + +IDI_APP ICON "res/tinyhand.ico" + +IDR_SOUND_THOCK RCDATA "../assets/sounds/tap_thock.wav" +IDR_SOUND_CLICK RCDATA "../assets/sounds/tap_click.wav" +IDR_SOUND_POP RCDATA "../assets/sounds/tap_pop.wav" +IDR_SOUND_WOOD RCDATA "../assets/sounds/tap_wood.wav" +IDR_SOUND_BOOP RCDATA "../assets/sounds/tap_boop.wav" +IDR_SOUND_SQUEAK RCDATA "../assets/sounds/tap_squeak.wav" + +IDR_TRAY_ON RCDATA "../assets/menubar_on@3x.png" +IDR_TRAY_OFF RCDATA "../assets/menubar_off@3x.png" +IDR_APP_PNG RCDATA "res/appicon.png" + +1 24 "res/tinyhand.manifest" + +VS_VERSION_INFO VERSIONINFO +FILEVERSION TH_VERSION_NUM +PRODUCTVERSION TH_VERSION_NUM +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_APP +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "TinyHand" + VALUE "FileDescription", "TinyHand" + VALUE "FileVersion", TH_VERSION_A + VALUE "InternalName", "TinyHand" + VALUE "LegalCopyright", "MIT License" + VALUE "OriginalFilename", "TinyHand.exe" + VALUE "ProductName", "TinyHand" + VALUE "ProductVersion", TH_VERSION_A + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/windows/src/app.cpp b/windows/src/app.cpp new file mode 100644 index 0000000..3ac2018 --- /dev/null +++ b/windows/src/app.cpp @@ -0,0 +1,312 @@ +#include "app.h" + +#include + +namespace { + +const wchar_t *kClass = L"TinyHandApp"; +constexpr UINT kTrayId = 1; +constexpr int kToggleHotkey = 1; +constexpr int kClearHotkey = 2; + +enum : UINT { kMenuToggle = 100, kMenuClear, kMenuSound, kMenuSnap, kMenuSettings, kMenuQuit }; + +bool light_taskbar() { + DWORD v = 0, size = sizeof(v); + if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + L"SystemUsesLightTheme", RRF_RT_REG_DWORD, nullptr, &v, &size) == ERROR_SUCCESS) { + return v != 0; + } + // No Windows 10/11 theme key (older Windows or Wine): the classic taskbar is light. + return true; +} + +HICON make_icon(int resource, bool dark) { + std::unique_ptr src(th::load_png_resource(resource)); + if (!src) { + return nullptr; + } + int size = GetSystemMetrics(SM_CXSMICON); + Gdiplus::Bitmap dst(size, size, PixelFormat32bppARGB); + { + Gdiplus::Graphics g(&dst); + g.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic); + g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); + float c = dark ? 0.0f : 1.0f; + Gdiplus::ColorMatrix m = {{{0, 0, 0, 0, 0}, {0, 0, 0, 0, 0}, {0, 0, 0, 0, 0}, {0, 0, 0, 1, 0}, {c, c, c, 0, 1}}}; + Gdiplus::ImageAttributes attrs; + attrs.SetColorMatrix(&m); + g.DrawImage(src.get(), Gdiplus::Rect(0, 0, size, size), 0, 0, INT(src->GetWidth()), INT(src->GetHeight()), + Gdiplus::UnitPixel, &attrs); + } + HICON icon = nullptr; + dst.GetHICON(&icon); + return icon; +} + +} // namespace + +bool App::init(HINSTANCE instance, bool testing) { + instance_ = instance; + testing_ = testing; + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = WndProc; + wc.hInstance = instance; + wc.lpszClassName = kClass; + RegisterClassExW(&wc); + hwnd_ = CreateWindowExW(WS_EX_TOOLWINDOW, kClass, L"TinyHand", WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, instance, + this); + if (!hwnd_) { + return false; + } + taskbarCreated_ = RegisterWindowMessageW(L"TaskbarCreated"); + + if (!toolbar_.create(instance) || !overlay_.create(instance, &toolbar_)) { + return false; + } + overlay_.onActiveChanged = [this] { updateTray(); }; + settings_.applyHotkey = [this](const Hotkey &h, bool isToggle) { return applyHotkey(h, isToggle); }; + settings_.onRecording = [this](bool recording) { + recording_ = recording; + if (recording) { + unregisterHotkeys(); + } else { + registerHotkeys(); + } + }; + loadIcons(); + addTray(); + if (!registerHotkeys()) { + th::log(L"could not register shortcuts %ls / %ls", prefs::get().toggleKey.display().c_str(), + prefs::get().clearKey.display().c_str()); + } + th::log(L"launch %ls", th::exe_path().c_str()); + + welcome_.onStart = [this] { overlay_.activate(); }; + if (!testing_ && !prefs::get().welcomeShown) { + prefs::get().welcomeShown = true; + prefs::save(); + welcome_.show(instance_); + } + return true; +} + +void App::shutdown() { + overlay_.deactivate(); + unregisterHotkeys(); + if (trayAdded_) { + NOTIFYICONDATAW nid{}; + nid.cbSize = sizeof(nid); + nid.hWnd = hwnd_; + nid.uID = kTrayId; + Shell_NotifyIconW(NIM_DELETE, &nid); + trayAdded_ = false; + } + if (hwnd_) { + DestroyWindow(hwnd_); + hwnd_ = nullptr; + } +} + +LRESULT CALLBACK App::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_NCCREATE) { + auto *cs = reinterpret_cast(lp); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(cs->lpCreateParams)); + reinterpret_cast(cs->lpCreateParams)->hwnd_ = hwnd; + } + auto *self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + return self ? self->proc(msg, wp, lp) : DefWindowProcW(hwnd, msg, wp, lp); +} + +LRESULT App::proc(UINT msg, WPARAM wp, LPARAM lp) { + if (msg == taskbarCreated_ && taskbarCreated_) { + trayAdded_ = false; + addTray(); + return 0; + } + switch (msg) { + case WM_TH_TRAY: + switch (LOWORD(lp)) { + case WM_LBUTTONUP: + overlay_.toggle(); + break; + case WM_RBUTTONUP: + case WM_CONTEXTMENU: + showMenu(); + break; + default: + break; + } + return 0; + case WM_TH_SHOW_SETTINGS: + openSettings(); + return 0; + case WM_HOTKEY: + if (wp == kToggleHotkey) { + overlay_.toggle(); + } else if (wp == kClearHotkey) { + overlay_.clearInk(); + } + return 0; + case WM_SETTINGCHANGE: + if (lp && wcscmp(reinterpret_cast(lp), L"ImmersiveColorSet") == 0) { + loadIcons(); + updateTray(); + } + return 0; + case WM_QUERYENDSESSION: + return TRUE; + case WM_ENDSESSION: + if (wp) { + overlay_.deactivate(); + } + return 0; + default: + break; + } + return DefWindowProcW(hwnd_, msg, wp, lp); +} + +void App::loadIcons() { + if (iconOn_) { + DestroyIcon(iconOn_); + } + if (iconOff_) { + DestroyIcon(iconOff_); + } + bool dark = light_taskbar(); + iconOn_ = make_icon(IDR_TRAY_ON, dark); + iconOff_ = make_icon(IDR_TRAY_OFF, dark); +} + +void App::addTray() { + NOTIFYICONDATAW nid{}; + nid.cbSize = sizeof(nid); + nid.hWnd = hwnd_; + nid.uID = kTrayId; + nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; + nid.uCallbackMessage = WM_TH_TRAY; + nid.hIcon = iconOff_ ? iconOff_ : LoadIconW(instance_, MAKEINTRESOURCEW(IDI_APP)); + wcscpy_s(nid.szTip, L"TinyHand: click to turn on, right-click for the menu"); + trayAdded_ = Shell_NotifyIconW(NIM_ADD, &nid) != FALSE; + if (!trayAdded_) { + th::log(L"tray icon not added (error %lu)", GetLastError()); + } +} + +void App::updateTray() { + if (!trayAdded_) { + return; + } + bool on = overlay_.active(); + NOTIFYICONDATAW nid{}; + nid.cbSize = sizeof(nid); + nid.hWnd = hwnd_; + nid.uID = kTrayId; + nid.uFlags = NIF_ICON | NIF_TIP; + nid.hIcon = on ? iconOn_ : iconOff_; + wcscpy_s(nid.szTip, on ? L"TinyHand is on: click to turn off, right-click for the menu" + : L"TinyHand: click to turn on, right-click for the menu"); + Shell_NotifyIconW(NIM_MODIFY, &nid); +} + +void App::showMenu() { + HMENU menu = CreatePopupMenu(); + std::wstring toggle = std::wstring(overlay_.active() ? L"Turn Pointer Off" : L"Turn Pointer On") + L"\t" + + prefs::get().toggleKey.display(); + std::wstring clear = L"Clear Drawing\t" + prefs::get().clearKey.display(); + AppendMenuW(menu, MF_STRING, kMenuToggle, toggle.c_str()); + AppendMenuW(menu, MF_STRING, kMenuClear, clear.c_str()); + AppendMenuW(menu, MF_STRING | (overlay_.soundOn() ? MF_CHECKED : 0), kMenuSound, L"Tap Sound"); + AppendMenuW(menu, MF_STRING | (prefs::get().snapShapes ? MF_CHECKED : 0), kMenuSnap, L"Snap Shapes"); + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(menu, MF_STRING, kMenuSettings, L"Settings..."); + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(menu, MF_STRING, kMenuQuit, L"Quit TinyHand"); + + POINT p; + GetCursorPos(&p); + overlay_.suspendInput(true); + SetForegroundWindow(hwnd_); + UINT cmd = UINT(TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN, p.x, p.y, 0, hwnd_, + nullptr)); + PostMessageW(hwnd_, WM_NULL, 0, 0); + overlay_.suspendInput(false); + DestroyMenu(menu); + + switch (cmd) { + case kMenuToggle: + overlay_.toggle(); + break; + case kMenuClear: + overlay_.clearInk(); + break; + case kMenuSound: + overlay_.setSoundOn(!overlay_.soundOn()); + break; + case kMenuSnap: + prefs::get().snapShapes = !prefs::get().snapShapes; + prefs::changed(); + break; + case kMenuSettings: + openSettings(); + break; + case kMenuQuit: + shutdown(); + PostQuitMessage(0); + break; + default: + break; + } +} + +void App::openSettings() { + overlay_.exitPointer(); + settings_.show(instance_); +} + +bool App::registerHotkeys() { + if (toggleRegistered_ && clearRegistered_) { + return true; + } + const Hotkey &t = prefs::get().toggleKey; + const Hotkey &c = prefs::get().clearKey; + if (!toggleRegistered_) { + toggleRegistered_ = RegisterHotKey(hwnd_, kToggleHotkey, t.mods | MOD_NOREPEAT, t.vk) != FALSE; + } + if (!clearRegistered_) { + clearRegistered_ = RegisterHotKey(hwnd_, kClearHotkey, c.mods | MOD_NOREPEAT, c.vk) != FALSE; + } + return toggleRegistered_ && clearRegistered_; +} + +void App::unregisterHotkeys() { + if (toggleRegistered_) { + UnregisterHotKey(hwnd_, kToggleHotkey); + } + if (clearRegistered_) { + UnregisterHotKey(hwnd_, kClearHotkey); + } + toggleRegistered_ = clearRegistered_ = false; +} + +bool App::applyHotkey(const Hotkey &h, bool isToggle) { + unregisterHotkeys(); + Hotkey &slot = isToggle ? prefs::get().toggleKey : prefs::get().clearKey; + Hotkey old = slot; + slot = h; + if (registerHotkeys()) { + if (recording_) { + unregisterHotkeys(); + } + prefs::changed(); + return true; + } + unregisterHotkeys(); + slot = old; + if (!recording_) { + registerHotkeys(); + } + return false; +} diff --git a/windows/src/app.h b/windows/src/app.h new file mode 100644 index 0000000..d9e5bb3 --- /dev/null +++ b/windows/src/app.h @@ -0,0 +1,44 @@ +#pragma once + +#include "overlay.h" +#include "settings.h" +#include "toolbar.h" +#include "welcome.h" + +// The tray icon, its menu and the global shortcuts (AppDelegate.swift). +class App { +public: + bool init(HINSTANCE instance, bool testing = false); + void shutdown(); + Overlay &overlay() { return overlay_; } + SettingsWindow &settings() { return settings_; } + HWND hwnd() const { return hwnd_; } + +private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); + LRESULT proc(UINT msg, WPARAM wp, LPARAM lp); + void addTray(); + void updateTray(); + void loadIcons(); + void showMenu(); + void openSettings(); + bool registerHotkeys(); + void unregisterHotkeys(); + bool applyHotkey(const Hotkey &h, bool isToggle); + + HINSTANCE instance_ = nullptr; + HWND hwnd_ = nullptr; + UINT taskbarCreated_ = 0; + bool trayAdded_ = false; + HICON iconOn_ = nullptr; + HICON iconOff_ = nullptr; + bool toggleRegistered_ = false; + bool clearRegistered_ = false; + bool testing_ = false; + bool recording_ = false; + + Toolbar toolbar_; + Overlay overlay_; + SettingsWindow settings_; + WelcomeWindow welcome_; +}; diff --git a/windows/src/arm.cpp b/windows/src/arm.cpp new file mode 100644 index 0000000..ce3c3a2 --- /dev/null +++ b/windows/src/arm.cpp @@ -0,0 +1,328 @@ +#include "arm.h" + +#include "prefs.h" + +namespace { + +constexpr float kMinRodFrac = 0.05f; +constexpr float kGripLeanFrac = 0.30f; +constexpr float kGripDepthFrac = 0.12f; +constexpr float kGripFollow = 0.08f; +constexpr float kLiftFrac = 0.06f; +constexpr float kShadowOpacity = 0.27f; + +// The macOS view steps at 120 Hz. Scale per-step easing so the feel is the +// same whatever rate the Windows timer manages. +float ease(float perStep, double dt) { + return 1.0f - float(std::pow(1.0 - perStep, std::clamp(dt, 0.0, 0.25) * 120.0)); +} + +void box_blur(std::vector &a, int w, int h, int r) { + std::vector tmp(a.size()); + for (int pass = 0; pass < 3; pass++) { + for (int y = 0; y < h; y++) { + float sum = 0; + const float *row = &a[size_t(y) * w]; + for (int x = -r; x <= r; x++) { + sum += row[std::clamp(x, 0, w - 1)] * (x >= 0 && x < w); + } + for (int x = 0; x < w; x++) { + tmp[size_t(y) * w + x] = sum / float(2 * r + 1); + int add = x + r + 1, sub = x - r; + sum += (add < w ? row[add] : 0) - (sub >= 0 ? row[sub] : 0); + } + } + for (int x = 0; x < w; x++) { + float sum = 0; + for (int y = 0; y <= r && y < h; y++) { + sum += tmp[size_t(y) * w + x]; + } + for (int y = 0; y < h; y++) { + a[size_t(y) * w + x] = sum / float(2 * r + 1); + int add = y + r + 1, sub = y - r; + sum += (add < h ? tmp[size_t(add) * w + x] : 0) - (sub >= 0 ? tmp[size_t(sub) * w + x] : 0); + } + } + } +} + +} // namespace + +void Arm::setStyle(const std::wstring &style) { + style_ = style; + laser_ = style_is_laser(style); + asset_.reset(); + display_.reset(); + shadow_.reset(); + displayHeight_ = 0; + if (!laser_) { + asset_ = hands::load(style); + if (!asset_) { + style_ = L"laser"; + laser_ = true; + } + } + lastKey_.clear(); +} + +void Arm::setSize(int width, int height) { + if (width == width_ && height == height_) { + return; + } + width_ = std::max(1, width); + height_ = std::max(1, height); + gripX_ = -1; + lastKey_.clear(); +} + +float Arm::inkWidth() const { return std::max(3.0f, float(height_) * 0.0055f); } + +bool Arm::settling() const { + if (press_ > 0.002f) { + return true; + } + return gripX_ >= 0 && gripTarget_ >= 0 && std::fabs(gripTarget_ - gripX_) > 0.5f; +} + +void Arm::updateDisplayImage() { + if (!asset_) { + return; + } + float sizeFrac = float(prefs::get().pointerSize); + int full = asset_->height; + float restPts = sizeFrac * float(height_) / (asset_->wrist - asset_->handTop); + const int step = 64; + int needed = (int(std::ceil(restPts)) + step - 1) / step * step; + int h = needed >= full * 9 / 10 ? full : std::max(step, needed); + if (h == displayHeight_ && display_) { + return; + } + displayHeight_ = h; + display_.reset(hands::resized(asset_->image.get(), h)); + shadow_.reset(); + if (!display_) { + return; + } + + int dw = int(display_->GetWidth()), dh = int(display_->GetHeight()); + float radius = std::max(1.0f, kLiftFrac * float(height_) * 0.35f); + int blur = std::max(1, int(std::lround(radius / 2))); + shadowPad_ = blur * 3; + int sw = dw + 2 * shadowPad_, sh = dh + 2 * shadowPad_; + std::vector alpha(size_t(sw) * sh, 0.0f); + Gdiplus::BitmapData data{}; + Gdiplus::Rect all(0, 0, dw, dh); + if (display_->LockBits(&all, Gdiplus::ImageLockModeRead, PixelFormat32bppPARGB, &data) == Gdiplus::Ok) { + for (int y = 0; y < dh; y++) { + const uint8_t *row = static_cast(data.Scan0) + ptrdiff_t(y) * data.Stride; + for (int x = 0; x < dw; x++) { + alpha[size_t(y + shadowPad_) * sw + x + shadowPad_] = row[x * 4 + 3] / 255.0f; + } + } + display_->UnlockBits(&data); + } + box_blur(alpha, sw, sh, blur); + shadow_.reset(th::new_pargb(sw, sh)); + if (!shadow_) { + return; + } + Gdiplus::Rect sall(0, 0, sw, sh); + if (shadow_->LockBits(&sall, Gdiplus::ImageLockModeWrite, PixelFormat32bppPARGB, &data) == Gdiplus::Ok) { + for (int y = 0; y < sh; y++) { + uint8_t *row = static_cast(data.Scan0) + ptrdiff_t(y) * data.Stride; + for (int x = 0; x < sw; x++) { + uint8_t a = uint8_t(std::clamp(alpha[size_t(y) * sw + x] * kShadowOpacity, 0.0f, 1.0f) * 255.0f); + row[x * 4 + 0] = 0; + row[x * 4 + 1] = 0; + row[x * 4 + 2] = 0; + row[x * 4 + 3] = a; + } + } + shadow_->UnlockBits(&data); + } +} + +bool Arm::aim(float cursorX, float cursorY, double dt) { + const float W = float(width_), H = float(height_); + const float cx = cursorX, cy = H - cursorY; + float targetX = std::clamp(cx + kGripLeanFrac * W, W * 0.15f, W * 1.05f); + float gx = gripX_ < 0 ? targetX : gripX_ + (targetX - gripX_) * ease(kGripFollow, dt); + gripX_ = gx; + gripTarget_ = targetX; + float gy = -kGripDepthFrac * H; + if (!held) { + press_ += (0 - press_) * ease(0.18f, dt); + } + float sizeFrac = float(prefs::get().pointerSize); + bool shadow = prefs::get().showShadow; + + std::vector key; + if (laser_) { + float r = std::max(4.0f, H * 0.0038f * sizeFrac / 0.13f) * (1 + 0.2f * press_); + pose_.laserR = r; + pose_.cursorX = cursorX; + pose_.cursorY = cursorY; + pose_.press = press_; + key = {1, std::lround(r * 100), std::lround(cursorX * 100), std::lround(cursorY * 100), hidden}; + } else { + if (!asset_) { + return false; + } + updateDisplayImage(); + const HandAsset &a = *asset_; + float dx = cx - gx, dy = cy - gy; + float reach = std::hypot(dx, dy); + float aimAngle = std::atan2(dy, dx); + float handPts = sizeFrac * H * (1 - 0.06f * press_); + float s = handPts / (a.wrist - a.handTop); + float widthPts = s * a.aspect; + float handTipPts = (a.wrist - a.tip) * s; + float tipOffX = (a.tipX - a.rodX) * widthPts; + float along = std::sqrt(std::max(0.0f, reach * reach - tipOffX * tipOffX)); + float rodLen = std::max(kMinRodFrac * H, along - handTipPts); + float tipAngle = std::atan2(rodLen + handTipPts, tipOffX); + pose_.rotation = aimAngle - tipAngle; + pose_.rodLen = rodLen; + pose_.handPts = handPts; + pose_.widthPts = widthPts; + pose_.gripX = gx; + pose_.gripY = H - gy; + pose_.lift = kLiftFrac * H * (1 - 0.8f * press_); + pose_.press = press_; + key = {0, + std::lround(pose_.rotation * 100), + std::lround(rodLen * 100), + std::lround(handPts * 100), + std::lround(widthPts * 100), + std::lround(gx * 100), + std::lround(pose_.lift * 100), + std::lround(press_ * 100), + shadow, + hidden}; + } + if (key == lastKey_) { + return false; + } + lastKey_ = key; + bounds_ = computeBounds(); + return true; +} + +RECT Arm::computeBounds() const { + RECT r{0, 0, 0, 0}; + if (hidden) { + return r; + } + if (laser_) { + float glow = pose_.laserR + pose_.laserR * (2.2f + pose_.press) * 1.2f + 3; + r.left = LONG(std::floor(pose_.cursorX - glow)); + r.top = LONG(std::floor(pose_.cursorY - glow)); + r.right = LONG(std::ceil(pose_.cursorX + glow)); + r.bottom = LONG(std::ceil(pose_.cursorY + glow)); + return r; + } + if (!asset_) { + return r; + } + float pad = float(shadowPad_) * (display_ ? pose_.widthPts / float(display_->GetWidth()) : 1.0f) + 4; + float x0 = -asset_->rodX * pose_.widthPts - pad; + float x1 = (1 - asset_->rodX) * pose_.widthPts + pad; + float y0 = -(pose_.rodLen + pose_.handPts) - pad; + float y1 = pad; + float angle = -pose_.rotation; + float c = std::cos(angle), s = std::sin(angle); + float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f; + float shadowX = prefs::get().showShadow ? pose_.lift * 0.6f : 0; + float shadowY = prefs::get().showShadow ? pose_.lift : 0; + const float xs[2] = {x0, x1}, ys[2] = {y0, y1}; + for (float lx : xs) { + for (float ly : ys) { + float px = pose_.gripX + lx * c - ly * s; + float py = pose_.gripY + lx * s + ly * c; + for (int k = 0; k < 2; k++) { + float ox = k ? shadowX : 0, oy = k ? shadowY : 0; + minX = std::min(minX, px + ox); + maxX = std::max(maxX, px + ox); + minY = std::min(minY, py + oy); + maxY = std::max(maxY, py + oy); + } + } + } + r.left = LONG(std::floor(minX)); + r.top = LONG(std::floor(minY)); + r.right = LONG(std::ceil(maxX)); + r.bottom = LONG(std::ceil(maxY)); + return r; +} + +void Arm::draw(Gdiplus::Graphics &g) const { + if (hidden) { + return; + } + if (laser_) { + drawLaser(g); + } else { + drawHand(g); + } +} + +void Arm::drawHand(Gdiplus::Graphics &g) const { + if (!asset_ || !display_) { + return; + } + const HandAsset &a = *asset_; + const float dw = float(display_->GetWidth()), dh = float(display_->GetHeight()); + const float wPts = pose_.widthPts, rodLen = pose_.rodLen, handPts = pose_.handPts; + const float x0 = -a.rodX * wPts; + const float degrees = -pose_.rotation * 180.0f / float(th::kPi); + + Gdiplus::GraphicsState outer = g.Save(); + g.SetInterpolationMode(Gdiplus::InterpolationModeBilinear); + g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); + + if (prefs::get().showShadow && shadow_) { + const float P = float(shadowPad_); + const float sx = wPts / dw; + const float handScale = handPts / ((a.wrist - a.handTop) * dh); + const float rodScale = rodLen / std::max(1.0f, (a.rodEnd - a.wrist) * dh); + Gdiplus::GraphicsState beforeShadow = g.Save(); + g.TranslateTransform(pose_.gripX + pose_.lift * 0.6f, pose_.gripY + pose_.lift); + g.RotateTransform(degrees); + g.DrawImage(shadow_.get(), + Gdiplus::RectF(x0 - P * sx, -(rodLen + handPts) - P * handScale, wPts + 2 * P * sx, + handPts + P * handScale), + 0, a.handTop * dh, dw + 2 * P, (a.wrist - a.handTop) * dh + P, Gdiplus::UnitPixel); + g.DrawImage(shadow_.get(), Gdiplus::RectF(x0 - P * sx, -rodLen, wPts + 2 * P * sx, rodLen + P * rodScale), 0, + a.wrist * dh + P, dw + 2 * P, (a.rodEnd - a.wrist) * dh + P, Gdiplus::UnitPixel); + g.Restore(beforeShadow); + } + + g.TranslateTransform(pose_.gripX, pose_.gripY); + g.RotateTransform(degrees); + g.DrawImage(display_.get(), Gdiplus::RectF(x0, -rodLen, wPts, rodLen), 0, a.wrist * dh, dw, + (a.rodEnd - a.wrist) * dh, Gdiplus::UnitPixel); + g.DrawImage(display_.get(), Gdiplus::RectF(x0, -(rodLen + handPts), wPts, handPts), 0, a.handTop * dh, dw, + (a.wrist - a.handTop) * dh, Gdiplus::UnitPixel); + g.Restore(outer); +} + +void Arm::drawLaser(Gdiplus::Graphics &g) const { + const float r = pose_.laserR; + const float x = pose_.cursorX, y = pose_.cursorY; + const float glow = r + r * (2.2f + pose_.press) * 1.2f; + Gdiplus::GraphicsPath halo; + halo.AddEllipse(x - glow, y - glow, 2 * glow, 2 * glow); + Gdiplus::PathGradientBrush brush(&halo); + brush.SetCenterPoint(Gdiplus::PointF(x, y)); + brush.SetCenterColor(th::rgba(kInkRed, 0.85f)); + Gdiplus::Color edge = th::rgba(kInkRed, 0.0f); + INT count = 1; + brush.SetSurroundColors(&edge, &count); + brush.SetFocusScales(r / glow, r / glow); + g.FillPath(&brush, &halo); + Gdiplus::SolidBrush red(th::rgba(kInkRed)); + g.FillEllipse(&red, x - r, y - r, 2 * r, 2 * r); + float core = r * 0.45f; + Gdiplus::SolidBrush light(Gdiplus::Color(242, 255, 219, 209)); + g.FillEllipse(&light, x - core, y - core, 2 * core, 2 * core); +} diff --git a/windows/src/arm.h b/windows/src/arm.h new file mode 100644 index 0000000..4ea8000 --- /dev/null +++ b/windows/src/arm.h @@ -0,0 +1,56 @@ +#pragma once + +#include "hands.h" + +// The hand on a stick (or the laser dot), ported from ArmView.swift. +// The pose math runs in a y-up frame exactly like the macOS view, then is +// drawn into a y-down GDI+ surface. +class Arm { +public: + void setStyle(const std::wstring &style); + const std::wstring &style() const { return style_; } + bool isLaser() const { return laser_; } + + void setSize(int width, int height); + void press() { press_ = 1; } + bool held = false; + bool hidden = false; + + // Advances the pose towards the cursor (local, y-down pixels). + // Returns true when what is on screen changed. + bool aim(float cursorX, float cursorY, double dt); + bool settling() const; + + // Area the arm covers right now, including its shadow (local pixels). + RECT bounds() const { return bounds_; } + void draw(Gdiplus::Graphics &g) const; + float inkWidth() const; + +private: + void updateDisplayImage(); + void drawHand(Gdiplus::Graphics &g) const; + void drawLaser(Gdiplus::Graphics &g) const; + RECT computeBounds() const; + + std::wstring style_ = L"lavender"; + bool laser_ = false; + std::shared_ptr asset_; + std::unique_ptr display_; + std::unique_ptr shadow_; + int shadowPad_ = 0; + int displayHeight_ = 0; + float displaySizeFrac_ = 0; + + int width_ = 1280; + int height_ = 800; + float press_ = 0; + float gripX_ = -1; + float gripTarget_ = -1; + + struct Pose { + float rotation = 0, rodLen = 0, handPts = 0, widthPts = 0, gripX = 0, gripY = 0, lift = 0, press = 0; + float cursorX = 0, cursorY = 0, laserR = 0; + } pose_; + std::vector lastKey_; + RECT bounds_{0, 0, 0, 0}; +}; diff --git a/windows/src/common.h b/windows/src/common.h new file mode 100644 index 0000000..da5f774 --- /dev/null +++ b/windows/src/common.h @@ -0,0 +1,94 @@ +#pragma once + +#ifndef UNICODE +#define UNICODE +#endif +#ifndef _UNICODE +#define _UNICODE +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0A00 +#endif + +#include +#include +#include +#include +namespace Gdiplus { +using std::max; +using std::min; +} +#include + +#include +#include +#include +#include +#include +#include + +#include "shapekit.h" +#include "resource.h" + +enum : UINT { + WM_TH_MOUSEDOWN = WM_APP + 1, + WM_TH_MOUSEUP, + WM_TH_DRAG, + WM_TH_INTERACT_DOWN, + WM_TH_INTERACT_UP, + WM_TH_PASSTHROUGH, + WM_TH_WAKE, + WM_TH_TRAY, + WM_TH_SHOW_SETTINGS, + WM_TH_THUMBS, +}; + +namespace th { + +constexpr double kPi = 3.14159265358979323846; + +double now(); +void log(const wchar_t *fmt, ...); +std::wstring exe_path(); +std::wstring exe_dir(); +std::wstring data_dir(); +bool file_exists(const std::wstring &path); + +bool apps_use_dark(); +float dpi_scale_at(POINT pt); +float dpi_scale_window(HWND hwnd); + +Gdiplus::Color rgba(uint32_t rgb, float alpha = 1.0f); +Gdiplus::Color white(float alpha); +Gdiplus::Color black(float alpha); + +Gdiplus::Bitmap *load_png_file(const std::wstring &path); +Gdiplus::Bitmap *load_png_resource(int id); +Gdiplus::Bitmap *new_pargb(int width, int height); +bool save_png(Gdiplus::Bitmap *bitmap, const std::wstring &path); +const void *resource_bytes(int id, DWORD *size); + +void add_rounded_rect(Gdiplus::GraphicsPath &path, const Gdiplus::RectF &r, float radius); + +// A 32-bit premultiplied DIB that GDI+ draws into and UpdateLayeredWindow +// reads from, so layered windows need no extra copies. +struct Surface { + HDC dc = nullptr; + HBITMAP dib = nullptr; + HGDIOBJ old = nullptr; + uint8_t *bits = nullptr; + int width = 0; + int height = 0; + std::unique_ptr bitmap; + + ~Surface(); + bool resize(int w, int h); + void clear(); + void clear(const RECT &r); + bool present(HWND hwnd, POINT origin, BYTE alpha, const RECT *dirty = nullptr); +}; + +} // namespace th diff --git a/windows/src/hands.cpp b/windows/src/hands.cpp new file mode 100644 index 0000000..4b2f797 --- /dev/null +++ b/windows/src/hands.cpp @@ -0,0 +1,295 @@ +#include "hands.h" + +#include "prefs.h" + +#include + +namespace hands { + +static std::map> cache; +static std::vector recent; +static std::map> thumbs; +static SRWLOCK thumbsLock = SRWLOCK_INIT; + +struct SharedLock { + SharedLock() { AcquireSRWLockExclusive(&thumbsLock); } + ~SharedLock() { ReleaseSRWLockExclusive(&thumbsLock); } +}; + +std::wstring asset_dir() { + static std::wstring dir = [] { + std::wstring exe = th::exe_dir(); + const wchar_t *candidates[] = {L"\\assets", L"\\..\\assets", L"\\..\\..\\assets"}; + for (const wchar_t *c : candidates) { + std::wstring d = exe + c; + if (th::file_exists(d + L"\\hand_lavender.png")) { + return d; + } + } + if (th::file_exists(L"assets\\hand_lavender.png")) { + return std::wstring(L"assets"); + } + return exe + L"\\assets"; + }(); + return dir; +} + +static std::unique_ptr measure(Gdiplus::Bitmap *image) { + int w = int(image->GetWidth()); + int h = int(image->GetHeight()); + Gdiplus::BitmapData data{}; + Gdiplus::Rect all(0, 0, w, h); + if (image->LockBits(&all, Gdiplus::ImageLockModeRead, PixelFormat32bppARGB, &data) != Gdiplus::Ok) { + return nullptr; + } + std::vector alpha(size_t(w) * size_t(h)); + for (int y = 0; y < h; y++) { + const uint8_t *row = static_cast(data.Scan0) + ptrdiff_t(y) * data.Stride; + for (int x = 0; x < w; x++) { + alpha[size_t(y) * w + x] = row[x * 4 + 3]; + } + } + image->UnlockBits(&data); + + std::vector counts(h, 0); + for (int y = 0; y < h; y++) { + int n = 0; + for (int x = 0; x < w; x++) { + n += alpha[size_t(y) * w + x] > 128; + } + counts[y] = n; + } + auto center = [&](int y) { + int lo = -1, hi = -1; + for (int x = 0; x < w; x++) { + if (alpha[size_t(y) * w + x] > 128) { + if (lo < 0) { + lo = x; + } + hi = x; + } + } + return lo < 0 ? 0.5f : float(lo + hi) / 2.0f / float(w); + }; + + int top = -1; + for (int y = 0; y < h; y++) { + if (counts[y] > 0) { + top = y; + break; + } + } + if (top < 0) { + return nullptr; + } + int searchEnd = std::min(h, top + int(0.45 * h)); + int maxW = 0; + for (int y = top; y < searchEnd; y++) { + maxW = std::max(maxW, counts[y]); + } + if (maxW <= 0) { + return nullptr; + } + int fist = -1; + for (int y = top; y < h; y++) { + if (counts[y] > 0.6 * maxW) { + fist = y; + break; + } + } + if (fist < 0) { + return nullptr; + } + int wrist = -1; + for (int y = fist; y < h; y++) { + if (counts[y] < 0.35 * maxW) { + wrist = y; + break; + } + } + if (wrist < 0) { + return nullptr; + } + int rodW = counts[std::min(h - 1, wrist + h / 50)]; + int lastRow = h - 1; + for (int y = h - 1; y >= 0; y--) { + if (counts[y] > 0) { + lastRow = y; + break; + } + } + int from = std::min(h - 1, wrist + h / 33); + int rodEnd = lastRow; + for (int y = from; y < h; y++) { + if (counts[y] > 1.8 * rodW) { + rodEnd = y; + break; + } + } + int minX = w, maxX = 0; + for (int y = top; y < wrist; y++) { + for (int x = 0; x < w; x++) { + if (alpha[size_t(y) * w + x] > 128) { + minX = std::min(minX, x); + maxX = std::max(maxX, x); + } + } + } + + auto a = std::make_unique(); + float fh = float(h); + a->width = w; + a->height = h; + a->aspect = float(w) / fh; + a->handTop = std::max(0.0f, float(top) - 0.004f * fh) / fh; + a->tip = float(top) / fh; + a->wrist = float(wrist) / fh; + a->rodEnd = float(rodEnd) / fh; + a->tipX = center(std::min(h - 1, top + h / 200)); + a->rodX = center((wrist + rodEnd) / 2); + a->handMinX = float(minX) / float(w); + a->handMaxX = float(maxX) / float(w); + return a; +} + +static std::unique_ptr read(const std::wstring &style) { + if (style_is_laser(style)) { + return nullptr; + } + std::wstring path = asset_dir() + L"\\hand_" + style + L".png"; + Gdiplus::Bitmap *image = th::load_png_file(path); + if (!image) { + th::log(L"missing hand picture %ls", path.c_str()); + return nullptr; + } + auto asset = measure(image); + if (!asset) { + delete image; + th::log(L"could not measure %ls", path.c_str()); + return nullptr; + } + asset->image.reset(image); + return asset; +} + +std::shared_ptr load(const std::wstring &style) { + auto hit = cache.find(style); + if (hit == cache.end()) { + std::unique_ptr fresh = read(style); + if (!fresh) { + return nullptr; + } + hit = cache.emplace(style, std::shared_ptr(std::move(fresh))).first; + } + recent.erase(std::remove(recent.begin(), recent.end(), style), recent.end()); + recent.push_back(style); + while (recent.size() > 2) { + cache.erase(recent.front()); + recent.erase(recent.begin()); + } + return hit->second; +} + +Gdiplus::Bitmap *resized(Gdiplus::Bitmap *image, int height) { + int w = std::max(1, int(std::lround(double(image->GetWidth()) * height / double(image->GetHeight())))); + Gdiplus::Bitmap *out = th::new_pargb(w, height); + if (!out) { + return nullptr; + } + Gdiplus::Graphics g(out); + g.SetCompositingMode(Gdiplus::CompositingModeSourceCopy); + g.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic); + g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); + Gdiplus::ImageAttributes attrs; + attrs.SetWrapMode(Gdiplus::WrapModeTileFlipXY); + g.DrawImage(image, Gdiplus::Rect(0, 0, w, height), 0, 0, INT(image->GetWidth()), INT(image->GetHeight()), + Gdiplus::UnitPixel, &attrs); + return out; +} + +static Gdiplus::Bitmap *make_thumbnail(const std::wstring &style) { + std::unique_ptr a = read(style); + if (!a) { + return nullptr; + } + float w = float(a->width), h = float(a->height); + float handW = (a->handMaxX - a->handMinX) * w; + float handH = (a->wrist - a->handTop) * h; + float side = std::max(handW, handH) * 1.06f; + float cx = (a->handMinX + a->handMaxX) / 2 * w; + float cy = (a->handTop + a->wrist) / 2 * h; + float x0 = std::max(0.0f, cx - side / 2), y0 = std::max(0.0f, cy - side / 2); + float x1 = std::min(w, cx + side / 2), y1 = std::min(h, cy + side / 2); + const int px = 128; + Gdiplus::Bitmap *thumb = th::new_pargb(px, px); + if (!thumb) { + return nullptr; + } + Gdiplus::Graphics g(thumb); + g.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic); + g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); + g.DrawImage(a->image.get(), Gdiplus::RectF(0, 0, px, px), x0, y0, x1 - x0, y1 - y0, Gdiplus::UnitPixel); + return thumb; +} + +Gdiplus::Bitmap *cached_thumbnail(const std::wstring &style) { + SharedLock lock; + auto hit = thumbs.find(style); + return hit == thumbs.end() ? nullptr : hit->second.get(); +} + +Gdiplus::Bitmap *thumbnail(const std::wstring &style) { + if (Gdiplus::Bitmap *hit = cached_thumbnail(style)) { + return hit; + } + Gdiplus::Bitmap *made = make_thumbnail(style); + if (!made) { + return nullptr; + } + SharedLock lock; + auto &slot = thumbs[style]; + if (!slot) { + slot.reset(made); + } else { + delete made; + } + return slot.get(); +} + +struct PreloadJob { + std::vector styles; + HWND notify; + UINT msg; +}; + +static DWORD WINAPI preload_thread(LPVOID param) { + std::unique_ptr job(static_cast(param)); + for (size_t i = 0; i < job->styles.size(); i++) { + thumbnail(job->styles[i]); + if (job->notify && (i % 6 == 5 || i + 1 == job->styles.size())) { + PostMessageW(job->notify, job->msg, 0, 0); + } + } + return 0; +} + +void preload_thumbnails(const std::vector &styles, HWND notify, UINT msg) { + auto job = std::make_unique(); + for (const auto &s : styles) { + if (!style_is_laser(s) && !cached_thumbnail(s)) { + job->styles.push_back(s); + } + } + if (job->styles.empty()) { + return; + } + job->notify = notify; + job->msg = msg; + HANDLE t = CreateThread(nullptr, 0, preload_thread, job.get(), 0, nullptr); + if (t) { + job.release(); + CloseHandle(t); + } +} + +} // namespace hands diff --git a/windows/src/hands.h b/windows/src/hands.h new file mode 100644 index 0000000..6a9d424 --- /dev/null +++ b/windows/src/hands.h @@ -0,0 +1,35 @@ +#pragma once + +#include "common.h" + +// One hand-on-a-stick picture plus the landmarks the arm needs, measured from +// its alpha channel the same way the macOS app does (HandAsset.measure). +// All landmarks are fractions of the picture: y from the top, x from the left. +struct HandAsset { + std::unique_ptr image; + int width = 0; + int height = 0; + float aspect = 0; + float handTop = 0; + float tip = 0; + float wrist = 0; + float rodEnd = 0; + float tipX = 0; + float rodX = 0; + float handMinX = 0; + float handMaxX = 0; +}; + +namespace hands { + +std::shared_ptr load(const std::wstring &style); +// 128 px square crop of the hand, cached for the toolbar and settings. +Gdiplus::Bitmap *thumbnail(const std::wstring &style); +// The thumbnail if it is ready, without decoding anything. +Gdiplus::Bitmap *cached_thumbnail(const std::wstring &style); +// Builds missing thumbnails on a worker thread, then posts msg to notify. +void preload_thumbnails(const std::vector &styles, HWND notify, UINT msg); +Gdiplus::Bitmap *resized(Gdiplus::Bitmap *image, int height); +std::wstring asset_dir(); + +} // namespace hands diff --git a/windows/src/ink.cpp b/windows/src/ink.cpp new file mode 100644 index 0000000..b022bf0 --- /dev/null +++ b/windows/src/ink.cpp @@ -0,0 +1,244 @@ +#include "ink.h" + +#include "prefs.h" + +namespace { + +constexpr double kFadeDuration = 0.6; +constexpr double kArrowheadWindow = 3.0; + +RECT union_rect(const RECT &a, const RECT &b) { + if (a.right <= a.left || a.bottom <= a.top) { + return b; + } + if (b.right <= b.left || b.bottom <= b.top) { + return a; + } + return RECT{std::min(a.left, b.left), std::min(a.top, b.top), std::max(a.right, b.right), + std::max(a.bottom, b.bottom)}; +} + +} // namespace + +void Ink::markDirty(const RECT &r) { dirty_ = union_rect(dirty_, r); } + +RECT Ink::takeDirty() { + RECT d = dirty_; + dirty_ = RECT{0, 0, 0, 0}; + return d; +} + +Ink::Stroke *Ink::find(int id) { + for (auto &s : strokes_) { + if (s->id == id) { + return s.get(); + } + } + return nullptr; +} + +void Ink::begin(Gdiplus::PointF p, float width, uint32_t color) { + auto s = std::make_unique(); + s->id = nextId_++; + s->raw.push_back(p); + s->width = width; + s->color = color; + current_ = s.get(); + strokes_.push_back(std::move(s)); + rebuild(*current_); +} + +void Ink::extend(Gdiplus::PointF p) { + if (!current_) { + return; + } + const Gdiplus::PointF &last = current_->raw.back(); + if (std::hypot(p.X - last.X, p.Y - last.Y) < 1.5f) { + return; + } + current_->raw.push_back(p); + rebuild(*current_); +} + +th_shape_kind Ink::end(double now) { + Stroke *s = current_; + if (!s) { + return TH_SHAPE_NONE; + } + current_ = nullptr; + s->endTime = now; + th_shape_kind kind = TH_SHAPE_NONE; + if (prefs::get().snapShapes && s->raw.size() >= 2) { + std::vector pts; + pts.reserve(s->raw.size()); + for (const auto &p : s->raw) { + pts.push_back(th_point{p.X, p.Y}); + } + Stroke *line = lastLineId_ ? find(lastLineId_) : nullptr; + if (line && now - lastLineTime_ < kArrowheadWindow) { + th_shape arrow; + if (th_recognize_arrowhead(&line->shape, pts.data(), int(pts.size()), 1, &arrow) == TH_SHAPE_ARROW) { + markDirty(s->bounds); + strokes_.erase(std::find_if(strokes_.begin(), strokes_.end(), + [s](const std::unique_ptr &x) { return x.get() == s; })); + line->shape = arrow; + line->endTime = now; + rebuild(*line); + lastLineId_ = 0; + lastKind_ = TH_SHAPE_ARROW; + th::log(L"snapped two strokes to an arrow"); + return TH_SHAPE_ARROW; + } + } + th_shape shape; + kind = th_recognize(pts.data(), int(pts.size()), 1, &shape); + if (kind != TH_SHAPE_NONE) { + s->shape = shape; + s->snapped = true; + rebuild(*s); + th::log(L"snapped stroke of %d points to %hs", int(pts.size()), th_shape_name(kind)); + } + } + lastKind_ = kind; + if (kind == TH_SHAPE_LINE) { + lastLineId_ = s->id; + lastLineTime_ = now; + } else { + lastLineId_ = 0; + } + return kind; +} + +void Ink::clear() { + for (auto &s : strokes_) { + markDirty(s->bounds); + } + strokes_.clear(); + current_ = nullptr; + lastLineId_ = 0; +} + +bool Ink::fading(double now) const { + double fade = prefs::get().fadeSeconds; + if (fade <= 0) { + return false; + } + double delay = std::max(0.5, fade); + for (const auto &s : strokes_) { + if (s->endTime >= 0 && now >= s->endTime + delay - 0.1) { + return true; + } + } + return false; +} + +bool Ink::update(double now) { + double fade = prefs::get().fadeSeconds; + if (fade <= 0) { + return false; + } + double delay = std::max(0.5, fade); + bool changed = false; + for (size_t i = 0; i < strokes_.size();) { + Stroke &s = *strokes_[i]; + if (s.endTime < 0) { + i++; + continue; + } + double t = (now - s.endTime - delay) / kFadeDuration; + if (t <= 0) { + i++; + continue; + } + markDirty(s.bounds); + changed = true; + if (t >= 1) { + if (s.id == lastLineId_) { + lastLineId_ = 0; + } + strokes_.erase(strokes_.begin() + ptrdiff_t(i)); + continue; + } + s.alpha = float(1 - t * t); + i++; + } + return changed; +} + +void Ink::rebuild(Stroke &s) { + markDirty(s.bounds); + auto path = std::make_unique(); + float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f; + auto grow = [&](float x, float y) { + minX = std::min(minX, x); + maxX = std::max(maxX, x); + minY = std::min(minY, y); + maxY = std::max(maxY, y); + }; + if (s.snapped) { + const th_shape &sh = s.shape; + for (int i = 0; i < sh.path_count; i++) { + std::vector pts; + for (int k = 0; k < sh.path_length[i]; k++) { + const th_point &p = sh.points[sh.path_start[i] + k]; + pts.emplace_back(p.x, p.y); + grow(p.x, p.y); + } + if (pts.size() < 2) { + continue; + } + path->StartFigure(); + path->AddLines(pts.data(), INT(pts.size())); + if (sh.path_closed[i]) { + path->CloseFigure(); + } + } + } else { + const auto &p = s.raw; + for (const auto &q : p) { + grow(q.X, q.Y); + } + if (p.size() == 1) { + path->AddLine(p[0], Gdiplus::PointF(p[0].X + 0.01f, p[0].Y)); + } else { + Gdiplus::PointF cur = p[0]; + for (size_t i = 1; i < p.size(); i++) { + const Gdiplus::PointF &ctrl = p[i - 1]; + Gdiplus::PointF mid((p[i - 1].X + p[i].X) / 2, (p[i - 1].Y + p[i].Y) / 2); + Gdiplus::PointF c1(cur.X + 2.0f / 3 * (ctrl.X - cur.X), cur.Y + 2.0f / 3 * (ctrl.Y - cur.Y)); + Gdiplus::PointF c2(mid.X + 2.0f / 3 * (ctrl.X - mid.X), mid.Y + 2.0f / 3 * (ctrl.Y - mid.Y)); + path->AddBezier(cur, c1, c2, mid); + cur = mid; + } + path->AddLine(cur, p.back()); + } + } + s.path = std::move(path); + float pad = s.width / 2 + 6; + s.bounds = RECT{LONG(std::floor(minX - pad)), LONG(std::floor(minY - pad)), LONG(std::ceil(maxX + pad)), + LONG(std::ceil(maxY + pad))}; + markDirty(s.bounds); +} + +void Ink::draw(Gdiplus::Graphics &g) const { + Gdiplus::RectF clip; + g.GetClipBounds(&clip); + for (const auto &s : strokes_) { + if (!s->path || s->bounds.right < clip.X || s->bounds.left > clip.X + clip.Width || + s->bounds.bottom < clip.Y || s->bounds.top > clip.Y + clip.Height) { + continue; + } + Gdiplus::Pen shadow(th::black(0.18f * s->alpha), s->width + 1.0f); + shadow.SetLineCap(Gdiplus::LineCapRound, Gdiplus::LineCapRound, Gdiplus::DashCapRound); + shadow.SetLineJoin(Gdiplus::LineJoinRound); + Gdiplus::GraphicsState saved = g.Save(); + g.TranslateTransform(1.0f, 1.5f); + g.DrawPath(&shadow, s->path.get()); + g.Restore(saved); + + Gdiplus::Pen pen(th::rgba(s->color, s->alpha), s->width); + pen.SetLineCap(Gdiplus::LineCapRound, Gdiplus::LineCapRound, Gdiplus::DashCapRound); + pen.SetLineJoin(Gdiplus::LineJoinRound); + g.DrawPath(&pen, s->path.get()); + } +} diff --git a/windows/src/ink.h b/windows/src/ink.h new file mode 100644 index 0000000..0a54585 --- /dev/null +++ b/windows/src/ink.h @@ -0,0 +1,53 @@ +#pragma once + +#include "common.h" + +// Strokes drawn with the hand, ported from Ink.swift, plus shape snapping: +// when a stroke ends it goes through the shared ShapeKit recognizer and, if +// it reads as a shape, is redrawn as that clean shape. +class Ink { +public: + void begin(Gdiplus::PointF p, float width, uint32_t color); + void extend(Gdiplus::PointF p); + // Ends the current stroke. Returns the shape it snapped to, if any. + th_shape_kind end(double now); + void clear(); + + bool drawing() const { return current_ != nullptr; } + // True while a stroke is fading out (or about to), so frames must keep coming. + bool fading(double now) const; + int count() const { return int(strokes_.size()); } + th_shape_kind lastKind() const { return lastKind_; } + + // Steps fades; returns true if anything on screen changed. + bool update(double now); + // Everything that changed since the last call (local pixels). + RECT takeDirty(); + void draw(Gdiplus::Graphics &g) const; + +private: + struct Stroke { + int id = 0; + std::vector raw; + th_shape shape{}; + bool snapped = false; + uint32_t color = 0; + float width = 4; + double endTime = -1; + float alpha = 1; + RECT bounds{0, 0, 0, 0}; + std::unique_ptr path; + }; + + void rebuild(Stroke &s); + void markDirty(const RECT &r); + Stroke *find(int id); + + std::vector> strokes_; + Stroke *current_ = nullptr; + int nextId_ = 1; + int lastLineId_ = 0; + double lastLineTime_ = 0; + th_shape_kind lastKind_ = TH_SHAPE_NONE; + RECT dirty_{0, 0, 0, 0}; +}; diff --git a/windows/src/main.cpp b/windows/src/main.cpp new file mode 100644 index 0000000..cf2a3a4 --- /dev/null +++ b/windows/src/main.cpp @@ -0,0 +1,93 @@ +#include "app.h" +#include "selftest.h" + +#include +#include + +namespace { + +LONG WINAPI on_crash(EXCEPTION_POINTERS *) { + cursor::show(); + return EXCEPTION_CONTINUE_SEARCH; +} + +void enable_per_monitor_dpi() { + using SetContext = BOOL(WINAPI *)(HANDLE); + auto fn = reinterpret_cast( + reinterpret_cast(GetProcAddress(GetModuleHandleW(L"user32.dll"), "SetProcessDpiAwarenessContext"))); + if (fn) { + fn(reinterpret_cast(static_cast(-4))); + } +} + +int run_app(HINSTANCE instance) { + HANDLE mutex = CreateMutexW(nullptr, TRUE, L"Local\\TinyHand.Instance"); + if (GetLastError() == ERROR_ALREADY_EXISTS) { + if (HWND other = FindWindowW(L"TinyHandApp", nullptr)) { + PostMessageW(other, WM_TH_SHOW_SETTINGS, 0, 0); + } + return 0; + } + SetUnhandledExceptionFilter(on_crash); + cursor::repair_if_needed(); + + App app; + if (!app.init(instance)) { + th::log(L"startup failed"); + return 1; + } + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + app.shutdown(); + if (mutex) { + CloseHandle(mutex); + } + return 0; +} + +} // namespace + +int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int) { + enable_per_monitor_dpi(); + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + Gdiplus::GdiplusStartupInput input; + ULONG_PTR token = 0; + Gdiplus::GdiplusStartup(&token, &input, nullptr); + INITCOMMONCONTROLSEX icc{sizeof(icc), ICC_STANDARD_CLASSES | ICC_BAR_CLASSES | ICC_HOTKEY_CLASS | ICC_WIN95_CLASSES}; + InitCommonControlsEx(&icc); + prefs::load(); + + int argc = 0; + LPWSTR *argv = CommandLineToArgvW(GetCommandLineW(), &argc); + auto arg = [&](int i, const wchar_t *fallback) { return std::wstring(i < argc ? argv[i] : fallback); }; + int code = -1; + for (int i = 1; i < argc && code < 0; i++) { + std::wstring a = argv[i]; + if (a == L"--render-test") { + code = selftest::render(arg(i + 1, L"tinyhand-render.png")); + } else if (a == L"--toolbar-test") { + code = selftest::toolbar(arg(i + 1, L"tinyhand-toolbar.png")); + } else if (a == L"--settings-test") { + code = selftest::settings(instance, arg(i + 1, L"tinyhand-settings.png")); + } else if (a == L"--settings-ui-test") { + code = selftest::settings_ui(instance); + } else if (a == L"--welcome-test") { + code = selftest::welcome(arg(i + 1, L"tinyhand-welcome.png")); + } else if (a == L"--e2e-test") { + code = selftest::e2e(instance); + } else if (a == L"--version") { + selftest::print("TinyHand for Windows"); + code = 0; + } + } + if (code < 0) { + code = run_app(instance); + } + LocalFree(argv); + Gdiplus::GdiplusShutdown(token); + CoUninitialize(); + return code; +} diff --git a/windows/src/overlay.cpp b/windows/src/overlay.cpp new file mode 100644 index 0000000..1e323c8 --- /dev/null +++ b/windows/src/overlay.cpp @@ -0,0 +1,803 @@ +#include "overlay.h" + +#include "prefs.h" + +#include + +namespace { + +const wchar_t *kClass = L"TinyHandOverlay"; +constexpr UINT kBusyMs = 8; +constexpr UINT kIdleMs = 50; +constexpr UINT_PTR kTimerId = 1; +constexpr int kEscHotkey = 1; + +// State shared with the mouse hook thread. The hook runs on its own thread +// (like the macOS event tap) so a busy frame never delays the user's mouse. +struct HookState { + CRITICAL_SECTION cs; + HWND target = nullptr; + bool active = false; + int interactVk = 0; + bool interactPress = false; + bool passingThrough = false; + bool pressing = false; + bool idle = false; + bool dragPosted = false; + std::vector passRects; + std::vector points; + HHOOK hook = nullptr; + HANDLE thread = nullptr; + DWORD threadId = 0; + HookState() { InitializeCriticalSection(&cs); } +}; + +HookState hs; + +struct Lock { + Lock() { EnterCriticalSection(&hs.cs); } + ~Lock() { LeaveCriticalSection(&hs.cs); } +}; + +bool in_rects(const std::vector &rects, POINT p) { + for (const RECT &r : rects) { + if (PtInRect(&r, p)) { + return true; + } + } + return false; +} + +LRESULT CALLBACK mouse_proc(int code, WPARAM wp, LPARAM lp) { + if (code != HC_ACTION) { + return CallNextHookEx(nullptr, code, wp, lp); + } + const auto *m = reinterpret_cast(lp); + const bool down = wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_XBUTTONDOWN; + const bool up = wp == WM_LBUTTONUP || wp == WM_RBUTTONUP || wp == WM_MBUTTONUP || wp == WM_XBUTTONUP; + bool swallow = false; + UINT post = 0; + LPARAM postL = MAKELPARAM(m->pt.x, m->pt.y); + HWND target; + { + Lock lock; + target = hs.target; + if (wp == WM_MOUSEMOVE) { + if (hs.active && hs.pressing) { + if (hs.points.size() < 16384) { + hs.points.push_back(m->pt); + } + if (!hs.dragPosted) { + hs.dragPosted = true; + post = WM_TH_DRAG; + } + } else if (hs.idle) { + hs.idle = false; + post = WM_TH_WAKE; + } + } else if (hs.active && (down || up)) { + if (down && !hs.interactPress && !hs.pressing && !hs.passingThrough && hs.interactVk && + (GetAsyncKeyState(hs.interactVk) & 0x8000)) { + hs.interactPress = true; + post = WM_TH_INTERACT_DOWN; + } else if (hs.interactPress) { + if (up) { + hs.interactPress = false; + post = WM_TH_INTERACT_UP; + } + } else { + if (down && !hs.pressing && !hs.passingThrough && in_rects(hs.passRects, m->pt)) { + hs.passingThrough = true; + post = WM_TH_PASSTHROUGH; + } + if (hs.passingThrough) { + if (up) { + hs.passingThrough = false; + } + } else if (down) { + swallow = true; + if (!hs.pressing) { + hs.pressing = true; + hs.points.clear(); + hs.points.push_back(m->pt); + post = WM_TH_MOUSEDOWN; + } + } else { + swallow = true; + if (hs.pressing) { + hs.pressing = false; + post = WM_TH_MOUSEUP; + } + } + } + } + } + if (post && target) { + PostMessageW(target, post, 0, postL); + } + return swallow ? 1 : CallNextHookEx(nullptr, code, wp, lp); +} + +DWORD WINAPI hook_thread(LPVOID ready) { + MSG msg; + PeekMessageW(&msg, nullptr, 0, 0, PM_NOREMOVE); + SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL); + HHOOK hook = SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, GetModuleHandleW(nullptr), 0); + { + Lock lock; + hs.hook = hook; + } + SetEvent(static_cast(ready)); + if (!hook) { + return 1; + } + while (GetMessageW(&msg, nullptr, 0, 0) > 0) { + } + UnhookWindowsHookEx(hook); + Lock lock; + hs.hook = nullptr; + return 0; +} + +bool hook_install() { + if (hs.thread) { + return hs.hook != nullptr; + } + HANDLE ready = CreateEventW(nullptr, TRUE, FALSE, nullptr); + hs.thread = CreateThread(nullptr, 0, hook_thread, ready, 0, &hs.threadId); + if (!hs.thread) { + CloseHandle(ready); + return false; + } + WaitForSingleObject(ready, 2000); + CloseHandle(ready); + if (!hs.hook) { + WaitForSingleObject(hs.thread, 1000); + CloseHandle(hs.thread); + hs.thread = nullptr; + return false; + } + return true; +} + +void hook_remove() { + if (!hs.thread) { + return; + } + PostThreadMessageW(hs.threadId, WM_QUIT, 0, 0); + WaitForSingleObject(hs.thread, 1000); + CloseHandle(hs.thread); + hs.thread = nullptr; +} + +void hook_set_active(bool active) { + Lock lock; + hs.active = active; + hs.interactPress = false; + hs.passingThrough = false; + hs.pressing = false; + hs.points.clear(); +} + +RECT union_rect(const RECT &a, const RECT &b) { + if (a.right <= a.left || a.bottom <= a.top) { + return b; + } + if (b.right <= b.left || b.bottom <= b.top) { + return a; + } + return RECT{std::min(a.left, b.left), std::min(a.top, b.top), std::max(a.right, b.right), + std::max(a.bottom, b.bottom)}; +} + +bool class_is(HWND hwnd, std::initializer_list names) { + wchar_t cls[96]; + if (!GetClassNameW(hwnd, cls, int(std::size(cls)))) { + return false; + } + for (const wchar_t *n : names) { + if (wcscmp(cls, n) == 0) { + return true; + } + } + return false; +} + +struct ShellScan { + std::vector menus; + std::vector shell; + HWND foreground; +}; + +BOOL CALLBACK scan_window(HWND hwnd, LPARAM lp) { + auto *scan = reinterpret_cast(lp); + if (!IsWindowVisible(hwnd)) { + return TRUE; + } + RECT r; + if (!GetWindowRect(hwnd, &r) || r.right <= r.left || r.bottom <= r.top) { + return TRUE; + } + if (class_is(hwnd, {L"#32768"})) { + scan->menus.push_back(r); + } else if (class_is(hwnd, {L"Shell_TrayWnd", L"Shell_SecondaryTrayWnd", L"NotifyIconOverflowWindow", + L"TopLevelWindowForOverflowXamlIsland"})) { + scan->shell.push_back(r); + } else if (hwnd == scan->foreground && + class_is(hwnd, {L"Windows.UI.Core.CoreWindow", L"XamlExplorerHostIslandWindow", + L"Shell_InputSwitchTopLevelWindow"})) { + scan->menus.push_back(r); + } + return TRUE; +} + +} // namespace + +namespace cursor { + +static bool hidden = false; + +void hide() { + if (hidden) { + return; + } + hidden = true; + prefs::set_cursor_hidden_flag(true); + BYTE andMask[32 * 4]; + BYTE xorMask[32 * 4]; + memset(andMask, 0xFF, sizeof(andMask)); + memset(xorMask, 0x00, sizeof(xorMask)); + HCURSOR blank = CreateCursor(GetModuleHandleW(nullptr), 0, 0, 32, 32, andMask, xorMask); + const DWORD ids[] = {32512, 32513, 32514, 32515, 32516, 32642, 32643, 32644, + 32645, 32646, 32648, 32649, 32650, 32651, 32671, 32672}; + int failed = 0; + for (DWORD id : ids) { + if (!SetSystemCursor(CopyCursor(blank), id)) { + failed++; + } + } + DestroyCursor(blank); + if (failed) { + th::log(L"could not hide %d system cursors (error %lu)", failed, GetLastError()); + } +} + +void show() { + if (!hidden) { + return; + } + hidden = false; + SystemParametersInfoW(SPI_SETCURSORS, 0, nullptr, 0); + prefs::set_cursor_hidden_flag(false); +} + +void repair_if_needed() { + if (prefs::cursor_hidden_flag()) { + th::log(L"restoring cursors left hidden by a previous run"); + SystemParametersInfoW(SPI_SETCURSORS, 0, nullptr, 0); + prefs::set_cursor_hidden_flag(false); + } +} + +} // namespace cursor + +bool Overlay::create(HINSTANCE instance, Toolbar *toolbar) { + toolbar_ = toolbar; + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = WndProc; + wc.hInstance = instance; + wc.lpszClassName = kClass; + RegisterClassExW(&wc); + hwnd_ = CreateWindowExW(WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + kClass, L"TinyHand", WS_POPUP, 0, 0, 10, 10, nullptr, nullptr, instance, this); + if (!hwnd_) { + return false; + } + { + Lock lock; + hs.target = hwnd_; + } + arm_.setStyle(prefs::get().pointerStyle); + if (arm_.style() != prefs::get().pointerStyle) { + prefs::get().pointerStyle = arm_.style(); + } + toolbar_->onPick = [this](const std::wstring &s) { pick(s); }; + toolbar_->onToggleSound = [this] { setSoundOn(!soundOn()); }; + toolbar_->onExit = [this] { exitPointer(); }; + toolbar_->select(arm_.style()); + syncSound(); + prefs::listen([this] { syncPrefs(); }); + return true; +} + +LRESULT CALLBACK Overlay::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_NCCREATE) { + auto *cs = reinterpret_cast(lp); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(cs->lpCreateParams)); + reinterpret_cast(cs->lpCreateParams)->hwnd_ = hwnd; + } + auto *self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + return self ? self->proc(msg, wp, lp) : DefWindowProcW(hwnd, msg, wp, lp); +} + +LRESULT Overlay::proc(UINT msg, WPARAM wp, LPARAM lp) { + double t = th::now(); + switch (msg) { + case WM_TIMER: + if (wp == kTimerId && active_) { + tick(); + } + return 0; + case WM_TH_MOUSEDOWN: { + if (!active_) { + return 0; + } + presses_++; + arm_.press(); + arm_.held = true; + sound_.play(); + toolbar_->hideHint(t); + down_ = true; + downPoint_ = local(POINT{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}); + lastButtonEvent_ = t; + wake(); + return 0; + } + case WM_TH_DRAG: + drainPoints(); + wake(); + return 0; + case WM_TH_MOUSEUP: + drainPoints(); + release(); + lastButtonEvent_ = t; + wake(); + return 0; + case WM_TH_INTERACT_DOWN: + arm_.press(); + arm_.held = true; + if (prefs::get().interactKey == InteractKey::Alt) { + // Stops the Alt release from opening the focused app's menu bar. + INPUT in[2] = {}; + in[0].type = in[1].type = INPUT_KEYBOARD; + in[0].ki.wVk = in[1].ki.wVk = 0xE8; + in[1].ki.dwFlags = KEYEVENTF_KEYUP; + SendInput(2, in, sizeof(INPUT)); + } + lastButtonEvent_ = t; + wake(); + return 0; + case WM_TH_INTERACT_UP: + arm_.held = false; + lastButtonEvent_ = t; + wake(); + return 0; + case WM_TH_PASSTHROUGH: { + POINT p{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}; + if (in_rects(shellRects_, p)) { + menuOpeningUntil_ = t + 1; + } + lastButtonEvent_ = t; + wake(); + return 0; + } + case WM_TH_WAKE: + wake(); + return 0; + case WM_HOTKEY: + if (wp == kEscHotkey) { + exitPointer(); + } + return 0; + case WM_DISPLAYCHANGE: + case WM_DPICHANGED: + monitor_ = nullptr; + return 0; + default: + break; + } + return DefWindowProcW(hwnd_, msg, wp, lp); +} + +bool Overlay::hookInstalled() const { + Lock lock; + return hs.hook != nullptr; +} + +Gdiplus::PointF Overlay::local(POINT p) const { + return Gdiplus::PointF(float(p.x - monRect_.left), float(p.y - monRect_.top)); +} + +void Overlay::syncSound() { + sound_.enabled = soundOn(); + toolbar_->setSoundOn(soundOn()); + if (onSoundChanged) { + onSoundChanged(); + } +} + +void Overlay::syncPrefs() { + { + Lock lock; + hs.interactVk = interact_vk(prefs::get().interactKey); + } + if (arm_.style() != prefs::get().pointerStyle) { + pick(prefs::get().pointerStyle); + } + toolbar_->refresh(); + syncSound(); +} + +bool Overlay::soundOn() const { + return arm_.isLaser() ? prefs::get().laserSound : prefs::get().handSound; +} + +void Overlay::setSoundOn(bool on) { + if (arm_.isLaser()) { + prefs::get().laserSound = on; + } else { + prefs::get().handSound = on; + } + prefs::changed(); +} + +void Overlay::pick(const std::wstring &style) { + arm_.setStyle(style); + prefs::get().pointerStyle = arm_.style(); + prefs::save(); + toolbar_->select(arm_.style()); + syncSound(); +} + +void Overlay::toggle() { + if (active_) { + deactivate(); + } else { + activate(); + } +} + +void Overlay::exitPointer() { + clearInk(); + deactivate(); +} + +void Overlay::clearInk() { + ink_.clear(); + if (active_) { + render(false, false); + } +} + +void Overlay::suspendInput(bool suspended) { + if (!active_) { + return; + } + ownMenuOpen_ = suspended; + Lock lock; + hs.active = !suspended && hs.hook != nullptr; +} + +bool Overlay::activate() { + if (active_) { + return hookInstalled(); + } + bool installed = hook_install(); + active_ = true; + monitor_ = nullptr; + lastTick_ = 0; + normalCursor_ = false; + arm_.hidden = false; + arm_.held = false; + down_ = false; + { + Lock lock; + hs.interactVk = interact_vk(prefs::get().interactKey); + hs.idle = false; + } + hook_set_active(installed); + if (!periodRaised_) { + timeBeginPeriod(1); + periodRaised_ = true; + } + cursor::hide(); + tick(); + ShowWindow(hwnd_, SW_SHOWNOACTIVATE); + SetWindowPos(hwnd_, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + toolbar_->show(); + render(true, true); + + double t = th::now(); + stillSince_ = t; + timerMs_ = 0; + runTimer(kBusyMs); + revealUntil_ = t + 3; + if (installed && !prefs::get().hintShown) { + prefs::get().hintShown = true; + prefs::save(); + std::vector> parts = {{L"Click", L"to tap"}, {L"Drag", L"to draw"}}; + if (prefs::get().interactKey != InteractKey::Off) { + parts.push_back({std::wstring(L"Hold ") + interact_key_name(prefs::get().interactKey), L"to click for real"}); + } + parts.push_back({L"Esc", L"to exit"}); + toolbar_->showHint(parts, t); + revealUntil_ = t + 10; + hintHideAt_ = t + 10; + } + setEscEnabled(true); + th::log(L"activate hookInstalled=%d monitor=%ldx%ld scale=%.2f", installed, monRect_.right - monRect_.left, + monRect_.bottom - monRect_.top, scale_); + if (onActiveChanged) { + onActiveChanged(); + } + return installed; +} + +void Overlay::deactivate() { + if (!active_) { + return; + } + active_ = false; + KillTimer(hwnd_, kTimerId); + timerMs_ = 0; + hook_set_active(false); + hook_remove(); + arm_.held = false; + down_ = false; + ink_.clear(); + ink_.takeDirty(); + surface_.clear(); + surface_.present(hwnd_, POINT{monRect_.left, monRect_.top}, 255); + ShowWindow(hwnd_, SW_HIDE); + toolbar_->hide(); + normalCursor_ = false; + arm_.hidden = false; + { + Lock lock; + hs.passRects.clear(); + } + setEscEnabled(false); + ownMenuOpen_ = false; + cursor::show(); + if (periodRaised_) { + timeEndPeriod(1); + periodRaised_ = false; + } + monitor_ = nullptr; + th::log(L"deactivate"); + if (onActiveChanged) { + onActiveChanged(); + } +} + +void Overlay::setEscEnabled(bool enabled) { + if (enabled && active_ && !escRegistered_) { + escRegistered_ = RegisterHotKey(hwnd_, kEscHotkey, MOD_NOREPEAT, VK_ESCAPE) != FALSE; + } + if (!enabled && escRegistered_) { + UnregisterHotKey(hwnd_, kEscHotkey); + escRegistered_ = false; + } +} + +void Overlay::drainPoints() { + std::vector pts; + { + Lock lock; + pts.swap(hs.points); + hs.dragPosted = false; + } + if (!down_) { + return; + } + for (POINT p : pts) { + Gdiplus::PointF lp = local(p); + if (!ink_.drawing()) { + if (std::hypot(lp.X - downPoint_.X, lp.Y - downPoint_.Y) < 4 * scale_) { + continue; + } + ink_.begin(downPoint_, arm_.inkWidth(), ink_for(arm_.style())); + } + ink_.extend(lp); + } +} + +void Overlay::release() { + arm_.held = false; + down_ = false; + ink_.end(th::now()); +} + +void Overlay::scanShell(double now) { + ShellScan scan; + scan.foreground = GetForegroundWindow(); + EnumWindows(scan_window, reinterpret_cast(&scan)); + menuRects_ = std::move(scan.menus); + shellRects_ = std::move(scan.shell); + lastScan_ = now; +} + +void Overlay::updateZones(POINT mouse, double now) { + const float s = scale_; + RECT bar = toolbar_->frameRect(); + RECT wide = bar; + InflateRect(&wide, LONG(80 * s), 0); + RECT above = bar; + OffsetRect(&above, 0, -LONG(90 * s)); + RECT nearZone = union_rect(wide, above); + InflateRect(&nearZone, 0, LONG(10 * s)); + bool drawing = ink_.drawing(); + toolbar_->setRevealed(!drawing && (PtInRect(&nearZone, mouse) || now < revealUntil_)); + + bool nearShell = false; + for (RECT r : shellRects_) { + InflateRect(&r, 0, LONG(40 * s)); + nearShell = nearShell || PtInRect(&r, mouse); + } + bool menuLikely = !menuRects_.empty() || ownMenuOpen_ || now < menuOpeningUntil_ || + now - lastButtonEvent_ < 2 || nearShell; + if (now - lastScan_ > (menuLikely ? 0.05 : 0.25)) { + scanShell(now); + } + bool overMenu = in_rects(menuRects_, mouse) || in_rects(shellRects_, mouse) || now < menuOpeningUntil_; + int vk = interact_vk(prefs::get().interactKey); + bool interactPress; + { + Lock lock; + interactPress = hs.interactPress; + } + bool interacting = (vk && (GetAsyncKeyState(vk) & 0x8000)) || interactPress; + bool normal = !drawing && !prefs::get().interactKeepsHand && (interacting || overMenu || ownMenuOpen_); + setEscEnabled(menuRects_.empty() && !ownMenuOpen_); + + if (normal != normalCursor_) { + th::log(normal ? L"real cursor (%ls) at %ld,%ld" : L"pointer back%ls at %ld,%ld", + normal ? (interacting ? L"interact key" : L"menu or taskbar") : L"", mouse.x, mouse.y); + normalCursor_ = normal; + arm_.hidden = normal; + if (normal) { + cursor::show(); + } else { + cursor::hide(); + } + } + + std::vector rects = shellRects_; + rects.insert(rects.end(), menuRects_.begin(), menuRects_.end()); + if (toolbar_->revealed()) { + rects.push_back(toolbar_->frameRect()); + } + Lock lock; + hs.passRects = std::move(rects); +} + +void Overlay::render(bool armChanged, bool full) { + const int w = monRect_.right - monRect_.left, h = monRect_.bottom - monRect_.top; + if (w <= 0 || h <= 0 || !surface_.bitmap) { + return; + } + RECT dirty{0, 0, 0, 0}; + if (full) { + dirty = RECT{0, 0, w, h}; + } + if (armChanged || full) { + dirty = union_rect(dirty, armBounds_); + armBounds_ = arm_.bounds(); + dirty = union_rect(dirty, armBounds_); + } + dirty = union_rect(dirty, ink_.takeDirty()); + if (dirty.right <= dirty.left || dirty.bottom <= dirty.top) { + return; + } + InflateRect(&dirty, 2, 2); + dirty.left = std::max(0L, dirty.left); + dirty.top = std::max(0L, dirty.top); + dirty.right = std::min(LONG(w), dirty.right); + dirty.bottom = std::min(LONG(h), dirty.bottom); + if (dirty.right <= dirty.left || dirty.bottom <= dirty.top) { + return; + } + surface_.clear(dirty); + { + Gdiplus::Graphics g(surface_.bitmap.get()); + g.SetClip(Gdiplus::Rect(dirty.left, dirty.top, dirty.right - dirty.left, dirty.bottom - dirty.top)); + g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); + ink_.draw(g); + arm_.draw(g); + } + surface_.present(hwnd_, POINT{monRect_.left, monRect_.top}, 255, full ? nullptr : &dirty); +} + +void Overlay::runTimer(UINT ms) { + if (timerMs_ == ms) { + return; + } + SetTimer(hwnd_, kTimerId, ms, nullptr); + timerMs_ = ms; +} + +void Overlay::wake() { + double t = th::now(); + stillSince_ = t; + if (!active_ || timerMs_ == kBusyMs) { + return; + } + { + Lock lock; + hs.idle = false; + } + runTimer(kBusyMs); + tick(); +} + +void Overlay::updatePace(bool busy, double now) { + if (timerMs_ == kIdleMs) { + if (busy) { + wake(); + } + return; + } + bool still = now - stillSince_ > 0.5; + if (!still || busy || arm_.held || arm_.settling() || ink_.drawing() || down_) { + return; + } + { + Lock lock; + hs.idle = true; + } + runTimer(kIdleMs); +} + +void Overlay::tick() { + if (!active_) { + return; + } + double t = th::now(); + double dt = lastTick_ > 0 ? t - lastTick_ : 1.0 / 120.0; + lastTick_ = t; + POINT p; + GetCursorPos(&p); + HMONITOR m = MonitorFromPoint(p, MONITOR_DEFAULTTONEAREST); + bool full = false; + if (m != monitor_) { + bool moving = monitor_ != nullptr; + monitor_ = m; + MONITORINFO mi{}; + mi.cbSize = sizeof(mi); + GetMonitorInfoW(m, &mi); + monRect_ = mi.rcMonitor; + workRect_ = mi.rcWork; + scale_ = th::dpi_scale_at(p); + if (moving) { + release(); + revealUntil_ = t + 1.5; + } + ink_.clear(); + ink_.takeDirty(); + int w = monRect_.right - monRect_.left, h = monRect_.bottom - monRect_.top; + surface_.resize(w, h); + surface_.clear(); + armBounds_ = RECT{0, 0, 0, 0}; + SetWindowPos(hwnd_, HWND_TOPMOST, monRect_.left, monRect_.top, w, h, SWP_NOACTIVATE); + arm_.setSize(w, h); + toolbar_->place(monRect_, workRect_, scale_); + full = true; + } + if (p.x != lastMouse_.x || p.y != lastMouse_.y) { + lastMouse_ = p; + stillSince_ = t; + } + updateZones(p, t); + if (hintHideAt_ > 0 && t >= hintHideAt_) { + hintHideAt_ = 0; + toolbar_->hideHint(t); + } + Gdiplus::PointF l = local(p); + bool armChanged = arm_.aim(l.X, l.Y, dt); + ink_.update(t); + toolbar_->animate(t); + render(armChanged, full); + updatePace(toolbar_->animating() || ink_.fading(t) || hintHideAt_ > 0, t); +} diff --git a/windows/src/overlay.h b/windows/src/overlay.h new file mode 100644 index 0000000..7ff18e4 --- /dev/null +++ b/windows/src/overlay.h @@ -0,0 +1,94 @@ +#pragma once + +#include "arm.h" +#include "ink.h" +#include "sound.h" +#include "toolbar.h" + +namespace cursor { +void hide(); +void show(); +// Puts the user's cursors back if a previous run died while they were hidden. +void repair_if_needed(); +} // namespace cursor + +// The full-screen, click-through layer the hand and ink are drawn on, plus +// the low-level mouse hook that turns clicks into taps (OverlayController.swift +// and InputTap.swift). +class Overlay { +public: + bool create(HINSTANCE instance, Toolbar *toolbar); + + void toggle(); + bool activate(); + void deactivate(); + void exitPointer(); + void clearInk(); + bool active() const { return active_; } + bool hookInstalled() const; + + void pick(const std::wstring &style); + bool soundOn() const; + void setSoundOn(bool on); + void suspendInput(bool suspended); + + std::function onActiveChanged; + std::function onSoundChanged; + + // For --e2e-test. + int strokeCount() const { return ink_.count(); } + th_shape_kind lastShape() const { return ink_.lastKind(); } + int presses() const { return presses_; } + bool drawing() const { return ink_.drawing(); } + void tick(); + +private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); + LRESULT proc(UINT msg, WPARAM wp, LPARAM lp); + void syncPrefs(); + void syncSound(); + void updateZones(POINT mouse, double now); + void render(bool armChanged, bool full); + void drainPoints(); + void release(); + void runTimer(UINT ms); + void wake(); + void updatePace(bool busy, double now); + void setEscEnabled(bool enabled); + void scanShell(double now); + Gdiplus::PointF local(POINT p) const; + + HWND hwnd_ = nullptr; + Toolbar *toolbar_ = nullptr; + th::Surface surface_; + Arm arm_; + Ink ink_; + TapSound sound_; + + bool active_ = false; + HMONITOR monitor_ = nullptr; + RECT monRect_{0, 0, 0, 0}; + RECT workRect_{0, 0, 0, 0}; + float scale_ = 1; + RECT armBounds_{0, 0, 0, 0}; + + bool down_ = false; + Gdiplus::PointF downPoint_{0, 0}; + int presses_ = 0; + + UINT timerMs_ = 0; + double lastTick_ = 0; + double stillSince_ = 0; + POINT lastMouse_{-1, -1}; + double lastButtonEvent_ = -10; + double revealUntil_ = 0; + double hintHideAt_ = 0; + double menuOpeningUntil_ = 0; + double lastScan_ = -10; + std::vector menuRects_; + std::vector shellRects_; + bool normalCursor_ = false; + bool ownMenuOpen_ = false; + bool escRegistered_ = false; + bool periodRaised_ = false; +}; diff --git a/windows/src/prefs.cpp b/windows/src/prefs.cpp new file mode 100644 index 0000000..bf82f0a --- /dev/null +++ b/windows/src/prefs.cpp @@ -0,0 +1,379 @@ +#include "prefs.h" + +const std::vector kStyles = { + {L"lavender", L"Lavender toy", kInkPurple}, + {L"glove", L"Cursor glove", kInkPurple}, + {L"coral", L"Coral vinyl", kInkRed}, + {L"plush", L"Plush", 0x2F86F0}, + {L"robot", L"Robot", 0x16A87A}, + {L"pink", L"Bubblegum", 0xEE4F98}, + {L"blue", L"Sky", 0x2F86F0}, + {L"mint", L"Mint", 0x16A87A}, + {L"lemon", L"Lemon", 0xF2B705}, + {L"black", L"Midnight", 0x1D1D21}, + {L"chrome", L"Chrome", 0x6B7380}, + {L"holo", L"Holo", kInkPurple}, + {L"fuzzy", L"Fuzzy", kInkPurple}, + {L"wood", L"Wood", 0x9A5E2C}, + {L"marble", L"Marble", 0x6B7380}, + {L"terrazzo", L"Terrazzo", 0xF0561D}, + {L"glass", L"Glass", 0x1597E5}, + {L"jelly", L"Jelly", 0xA33FE6}, + {L"pastel", L"Pastel", 0xEE4F98}, + {L"pixel", L"Pixel", 0x1D1D21}, + {L"lowpoly", L"Low poly", kInkPurple}, + {L"knit", L"Knit", kInkPurple}, + {L"skeleton", L"Skeleton", 0x9A5E2C}, + {L"astro", L"Astronaut", 0x2F86F0}, + {L"space", L"Space", kInkRed}, + {L"monster", L"Monster", 0x3E9E2E}, + {L"water", L"Water", 0x1597E5}, + {L"lava", L"Lava", 0xF0561D}, + {L"grass", L"Grass", 0x3E9E2E}, + {L"cactus", L"Cactus", 0x3E9E2E}, + {L"choco", L"Chocolate", 0x9A5E2C}, + {L"cloud", L"Cloud", 0x2F86F0}, + {L"mech", L"Mech", 0xF0561D}, + {L"neon", L"Neon", 0xE0309A}, + {L"leaf", L"Leaf", 0x3E9E2E}, + {L"cookie", L"Gingerbread", 0x9A5E2C}, + {L"laser", L"Laser dot", kInkRed}, +}; + +const std::vector kInkChoices = { + {L"match", L"Match the pointer", 0, true}, + {L"red", L"Red", kInkRed, false}, + {L"orange", L"Orange", 0xFF8A00, false}, + {L"yellow", L"Yellow", 0xFFCC00, false}, + {L"green", L"Green", 0x30C25A, false}, + {L"blue", L"Blue", 0x0A84FF, false}, + {L"purple", L"Purple", 0x8754F2, false}, + {L"white", L"White", 0xFFFFFF, false}, + {L"black", L"Black", 0x1D1D21, false}, +}; + +const std::vector kTapSounds = { + {L"thock", L"Thock", IDR_SOUND_THOCK}, {L"click", L"Click", IDR_SOUND_CLICK}, + {L"pop", L"Pop", IDR_SOUND_POP}, {L"wood", L"Wood block", IDR_SOUND_WOOD}, + {L"boop", L"Boop", IDR_SOUND_BOOP}, {L"squeak", L"Squeak", IDR_SOUND_SQUEAK}, +}; + +bool style_is_laser(const std::wstring &id) { return id == L"laser"; } + +const StyleInfo *style_info(const std::wstring &id) { + for (const auto &s : kStyles) { + if (id == s.id) { + return &s; + } + } + return nullptr; +} + +std::wstring style_title(const std::wstring &id) { + const StyleInfo *s = style_info(id); + return s ? s->title : id; +} + +uint32_t style_ink(const std::wstring &id) { + const StyleInfo *s = style_info(id); + return s ? s->ink : kInkPurple; +} + +uint32_t ink_for(const std::wstring &style) { + for (const auto &c : kInkChoices) { + if (prefs::get().inkChoice == c.id && !c.match) { + return c.color; + } + } + return style_ink(style); +} + +const wchar_t *interact_key_name(InteractKey key) { + switch (key) { + case InteractKey::Alt: + return L"Alt"; + case InteractKey::Ctrl: + return L"Ctrl"; + case InteractKey::Shift: + return L"Shift"; + case InteractKey::Off: + break; + } + return L""; +} + +int interact_vk(InteractKey key) { + switch (key) { + case InteractKey::Alt: + return VK_MENU; + case InteractKey::Ctrl: + return VK_CONTROL; + case InteractKey::Shift: + return VK_SHIFT; + case InteractKey::Off: + break; + } + return 0; +} + +std::wstring Hotkey::display() const { + std::wstring s; + if (mods & MOD_CONTROL) { + s += L"Ctrl+"; + } + if (mods & MOD_ALT) { + s += L"Alt+"; + } + if (mods & MOD_SHIFT) { + s += L"Shift+"; + } + if (mods & MOD_WIN) { + s += L"Win+"; + } + if ((vk >= '0' && vk <= '9') || (vk >= 'A' && vk <= 'Z')) { + s += wchar_t(vk); + return s; + } + UINT scan = MapVirtualKeyW(vk, MAPVK_VK_TO_VSC); + LONG lparam = LONG(scan << 16); + if (vk == VK_LEFT || vk == VK_RIGHT || vk == VK_UP || vk == VK_DOWN || vk == VK_PRIOR || vk == VK_NEXT || + vk == VK_HOME || vk == VK_END || vk == VK_INSERT || vk == VK_DELETE) { + lparam |= 1 << 24; + } + wchar_t name[64] = {}; + if (GetKeyNameTextW(lparam, name, int(std::size(name))) > 0) { + s += name; + } else { + wchar_t buf[16]; + _snwprintf(buf, std::size(buf), L"0x%02X", vk); + s += buf; + } + return s; +} + +namespace prefs { + +static const wchar_t *kKey = L"Software\\TinyHand"; +static const wchar_t *kRunKey = L"Software\\Microsoft\\Windows\\CurrentVersion\\Run"; + +static Prefs g; +static bool persist = true; + +void set_persist(bool on) { persist = on; } +static std::vector> listeners; + +Prefs &get() { return g; } + +std::vector default_pinned() { return {L"lavender", L"glove", L"coral", L"plush", L"robot", L"laser"}; } + +static DWORD read_dword(HKEY k, const wchar_t *name, DWORD fallback) { + DWORD v = 0, size = sizeof(v), type = 0; + if (RegQueryValueExW(k, name, nullptr, &type, reinterpret_cast(&v), &size) == ERROR_SUCCESS && + type == REG_DWORD) { + return v; + } + return fallback; +} + +static std::wstring read_string(HKEY k, const wchar_t *name, const std::wstring &fallback) { + wchar_t buf[1024]; + DWORD size = sizeof(buf) - sizeof(wchar_t), type = 0; + if (RegQueryValueExW(k, name, nullptr, &type, reinterpret_cast(buf), &size) == ERROR_SUCCESS && + type == REG_SZ) { + buf[size / sizeof(wchar_t)] = 0; + return buf; + } + return fallback; +} + +static void write_dword(HKEY k, const wchar_t *name, DWORD v) { + RegSetValueExW(k, name, 0, REG_DWORD, reinterpret_cast(&v), sizeof(v)); +} + +static void write_string(HKEY k, const wchar_t *name, const std::wstring &v) { + RegSetValueExW(k, name, 0, REG_SZ, reinterpret_cast(v.c_str()), + DWORD((v.size() + 1) * sizeof(wchar_t))); +} + +static std::vector split(const std::wstring &s) { + std::vector out; + size_t start = 0; + while (start <= s.size()) { + size_t comma = s.find(L',', start); + std::wstring part = s.substr(start, comma == std::wstring::npos ? std::wstring::npos : comma - start); + if (!part.empty() && style_info(part) && std::find(out.begin(), out.end(), part) == out.end()) { + out.push_back(part); + } + if (comma == std::wstring::npos) { + break; + } + start = comma + 1; + } + return out; +} + +static InteractKey parse_key(const std::wstring &s) { + if (s == L"ctrl") { + return InteractKey::Ctrl; + } + if (s == L"shift") { + return InteractKey::Shift; + } + if (s == L"off") { + return InteractKey::Off; + } + return InteractKey::Alt; +} + +static const wchar_t *key_id(InteractKey k) { + switch (k) { + case InteractKey::Ctrl: + return L"ctrl"; + case InteractKey::Shift: + return L"shift"; + case InteractKey::Off: + return L"off"; + case InteractKey::Alt: + break; + } + return L"alt"; +} + +void load() { + g = Prefs(); + g.pinned = default_pinned(); + HKEY k = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kKey, 0, KEY_READ, &k) != ERROR_SUCCESS) { + return; + } + std::wstring style = read_string(k, L"pointerStyle", g.pointerStyle); + g.pointerStyle = style_info(style) ? style : L"lavender"; + g.pointerSize = std::clamp(read_dword(k, L"pointerSize", 130) / 1000.0, kMinSize, kMaxSize); + g.showShadow = read_dword(k, L"showShadow", 1) != 0; + g.fadeSeconds = read_dword(k, L"fadeSeconds", 3); + g.handSound = read_dword(k, L"handSound", 1) != 0; + g.laserSound = read_dword(k, L"laserSound", 0) != 0; + g.tapSound = read_string(k, L"tapSound", g.tapSound); + if (std::none_of(kTapSounds.begin(), kTapSounds.end(), [](const TapSoundInfo &t) { return g.tapSound == t.id; })) { + g.tapSound = L"thock"; + } + g.interactKey = parse_key(read_string(k, L"interactKey", L"alt")); + g.interactKeepsHand = read_dword(k, L"interactKeepsHand", 0) != 0; + g.inkChoice = read_string(k, L"inkChoice", g.inkChoice); + g.showInkButton = read_dword(k, L"showInkButton", 1) != 0; + g.snapShapes = read_dword(k, L"snapShapes", 1) != 0; + std::vector pinned = split(read_string(k, L"pinnedStyles", L"")); + if (!pinned.empty()) { + if (pinned.size() > kMaxPinned) { + pinned.resize(kMaxPinned); + } + g.pinned = pinned; + } + g.toggleKey = Hotkey::unpack(read_dword(k, L"toggleHotkey", g.toggleKey.pack())); + g.clearKey = Hotkey::unpack(read_dword(k, L"clearHotkey", g.clearKey.pack())); + g.hintShown = read_dword(k, L"hintShown", 0) != 0; + g.welcomeShown = read_dword(k, L"welcomeShown", 0) != 0; + RegCloseKey(k); +} + +void save() { + if (!persist) { + return; + } + HKEY k = nullptr; + if (RegCreateKeyExW(HKEY_CURRENT_USER, kKey, 0, nullptr, 0, KEY_WRITE, nullptr, &k, nullptr) != ERROR_SUCCESS) { + return; + } + write_string(k, L"pointerStyle", g.pointerStyle); + write_dword(k, L"pointerSize", DWORD(std::lround(g.pointerSize * 1000))); + write_dword(k, L"showShadow", g.showShadow); + write_dword(k, L"fadeSeconds", DWORD(std::lround(g.fadeSeconds))); + write_dword(k, L"handSound", g.handSound); + write_dword(k, L"laserSound", g.laserSound); + write_string(k, L"tapSound", g.tapSound); + write_string(k, L"interactKey", key_id(g.interactKey)); + write_dword(k, L"interactKeepsHand", g.interactKeepsHand); + write_string(k, L"inkChoice", g.inkChoice); + write_dword(k, L"showInkButton", g.showInkButton); + write_dword(k, L"snapShapes", g.snapShapes); + std::wstring pinned; + for (const auto &p : g.pinned) { + pinned += (pinned.empty() ? L"" : L",") + p; + } + write_string(k, L"pinnedStyles", pinned); + write_dword(k, L"toggleHotkey", g.toggleKey.pack()); + write_dword(k, L"clearHotkey", g.clearKey.pack()); + write_dword(k, L"hintShown", g.hintShown); + write_dword(k, L"welcomeShown", g.welcomeShown); + RegCloseKey(k); +} + +void reset() { + bool hint = g.hintShown; + bool welcome = g.welcomeShown; + g = Prefs(); + g.pinned = default_pinned(); + g.hintShown = hint; + g.welcomeShown = welcome; + changed(); +} + +void changed() { + save(); + auto copy = listeners; + for (auto &fn : copy) { + fn(); + } +} + +void listen(std::function fn) { listeners.push_back(std::move(fn)); } + +bool launch_at_login() { + HKEY k = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kRunKey, 0, KEY_READ, &k) != ERROR_SUCCESS) { + return false; + } + std::wstring v = read_string(k, L"TinyHand", L""); + RegCloseKey(k); + return !v.empty(); +} + +void set_launch_at_login(bool on) { + if (!persist) { + return; + } + HKEY k = nullptr; + if (RegCreateKeyExW(HKEY_CURRENT_USER, kRunKey, 0, nullptr, 0, KEY_WRITE, nullptr, &k, nullptr) != ERROR_SUCCESS) { + th::log(L"launch at login change failed: cannot open Run key"); + return; + } + if (on) { + write_string(k, L"TinyHand", L"\"" + th::exe_path() + L"\""); + } else { + RegDeleteValueW(k, L"TinyHand"); + } + RegCloseKey(k); +} + +void set_cursor_hidden_flag(bool hidden) { + if (!persist) { + return; + } + HKEY k = nullptr; + if (RegCreateKeyExW(HKEY_CURRENT_USER, kKey, 0, nullptr, 0, KEY_WRITE, nullptr, &k, nullptr) == ERROR_SUCCESS) { + write_dword(k, L"cursorHidden", hidden); + RegCloseKey(k); + } +} + +bool cursor_hidden_flag() { + HKEY k = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kKey, 0, KEY_READ, &k) != ERROR_SUCCESS) { + return false; + } + bool v = read_dword(k, L"cursorHidden", 0) != 0; + RegCloseKey(k); + return v; +} + +} // namespace prefs diff --git a/windows/src/prefs.h b/windows/src/prefs.h new file mode 100644 index 0000000..aec129e --- /dev/null +++ b/windows/src/prefs.h @@ -0,0 +1,95 @@ +#pragma once + +#include "common.h" + +enum class InteractKey { Alt, Ctrl, Shift, Off }; + +struct Hotkey { + UINT mods = 0; + UINT vk = 0; + std::wstring display() const; + DWORD pack() const { return (DWORD(mods) << 16) | (vk & 0xFFFF); } + static Hotkey unpack(DWORD v) { return Hotkey{UINT(v >> 16), UINT(v & 0xFFFF)}; } + bool operator==(const Hotkey &o) const { return mods == o.mods && vk == o.vk; } +}; + +struct StyleInfo { + const wchar_t *id; + const wchar_t *title; + uint32_t ink; +}; + +struct InkChoiceInfo { + const wchar_t *id; + const wchar_t *title; + uint32_t color; + bool match; +}; + +struct TapSoundInfo { + const wchar_t *id; + const wchar_t *title; + int resource; +}; + +constexpr uint32_t kInkPurple = 0x8754F2; +constexpr uint32_t kInkRed = 0xFF291F; + +extern const std::vector kStyles; +extern const std::vector kInkChoices; +extern const std::vector kTapSounds; + +bool style_is_laser(const std::wstring &id); +const StyleInfo *style_info(const std::wstring &id); +std::wstring style_title(const std::wstring &id); +uint32_t style_ink(const std::wstring &id); +uint32_t ink_for(const std::wstring &style); +const wchar_t *interact_key_name(InteractKey key); +int interact_vk(InteractKey key); + +struct Prefs { + std::wstring pointerStyle = L"lavender"; + double pointerSize = 0.13; + bool showShadow = true; + double fadeSeconds = 3; + bool handSound = true; + bool laserSound = false; + std::wstring tapSound = L"thock"; + InteractKey interactKey = InteractKey::Alt; + bool interactKeepsHand = false; + std::wstring inkChoice = L"match"; + bool showInkButton = true; + bool snapShapes = true; + std::vector pinned; + Hotkey toggleKey{MOD_CONTROL | MOD_ALT, 'H'}; + Hotkey clearKey{MOD_CONTROL | MOD_ALT, 'X'}; + bool hintShown = false; + bool welcomeShown = false; +}; + +namespace prefs { + +constexpr double kMinSize = 0.08; +constexpr double kMaxSize = 0.20; +constexpr size_t kMaxPinned = 8; + +Prefs &get(); +void load(); +void save(); +void reset(); +// Saves and tells every listener (overlay, toolbar, tray menu) to refresh. +void changed(); +void listen(std::function fn); +// Self-tests run with persistence off so they never touch the user's settings. +void set_persist(bool on); + +std::vector default_pinned(); +bool launch_at_login(); +void set_launch_at_login(bool on); + +// Set while the system cursor is swapped for a blank one, so a crash can +// be repaired on the next start. +void set_cursor_hidden_flag(bool hidden); +bool cursor_hidden_flag(); + +} // namespace prefs diff --git a/windows/src/resource.h b/windows/src/resource.h new file mode 100644 index 0000000..8d733f9 --- /dev/null +++ b/windows/src/resource.h @@ -0,0 +1,14 @@ +#pragma once + +#define IDI_APP 1 + +#define IDR_SOUND_THOCK 101 +#define IDR_SOUND_CLICK 102 +#define IDR_SOUND_POP 103 +#define IDR_SOUND_WOOD 104 +#define IDR_SOUND_BOOP 105 +#define IDR_SOUND_SQUEAK 106 + +#define IDR_TRAY_ON 201 +#define IDR_TRAY_OFF 202 +#define IDR_APP_PNG 203 diff --git a/windows/src/selftest.cpp b/windows/src/selftest.cpp new file mode 100644 index 0000000..e8bb3c8 --- /dev/null +++ b/windows/src/selftest.cpp @@ -0,0 +1,713 @@ +#include "selftest.h" + +#include "app.h" +#include "arm.h" +#include "ink.h" +#include "prefs.h" +#include "toolbar.h" + +#include +#include + +namespace selftest { + +void print(const char *fmt, ...) { + char buf[2048]; + va_list args; + va_start(args, fmt); + int n = vsnprintf(buf, sizeof(buf) - 2, fmt, args); + va_end(args); + if (n < 0) { + return; + } + n = std::min(n, int(sizeof(buf) - 3)); + buf[n++] = '\n'; + buf[n] = 0; + static HANDLE out = [] { + HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); + if ((!h || h == INVALID_HANDLE_VALUE) && AttachConsole(ATTACH_PARENT_PROCESS)) { + h = GetStdHandle(STD_OUTPUT_HANDLE); + } + return h; + }(); + if (out && out != INVALID_HANDLE_VALUE) { + DWORD written = 0; + WriteFile(out, buf, DWORD(n), &written, nullptr); + } + buf[n - 1] = 0; + wchar_t wide[2048]; + MultiByteToWideChar(CP_UTF8, 0, buf, -1, wide, int(std::size(wide))); + th::log(L"selftest: %ls", wide); +} + +namespace { + +struct Rng { + uint32_t s; + float next() { + s = s * 1664525u + 1013904223u; + return float(s >> 8) / 16777216.0f; + } + float range(float a, float b) { return a + (b - a) * next(); } +}; + +using Pts = std::vector; + +// A hand-drawn looking trace through the given corners: uneven spacing, +// jitter and a slow wobble, with a small overshoot when it closes. +Pts trace(const Pts &corners, bool closed, Rng &r, float jitter = 2.0f) { + Pts out; + Pts path = corners; + if (closed) { + path.push_back(corners[0]); + Gdiplus::PointF a = corners[0], b = corners[1]; + path.push_back(Gdiplus::PointF(a.X + (b.X - a.X) * 0.12f, a.Y + (b.Y - a.Y) * 0.12f)); + } + float phase = r.range(0, 6.28f); + float walked = 0; + for (size_t i = 0; i + 1 < path.size(); i++) { + Gdiplus::PointF a = path[i], b = path[i + 1]; + float len = std::hypot(b.X - a.X, b.Y - a.Y); + float nx = -(b.Y - a.Y) / std::max(1.0f, len), ny = (b.X - a.X) / std::max(1.0f, len); + for (float d = 0; d < len; d += r.range(3, 9)) { + float t = d / len; + float wobble = 2.5f * std::sin(phase + (walked + d) * 0.03f); + out.emplace_back(a.X + (b.X - a.X) * t + nx * wobble + r.range(-jitter, jitter), + a.Y + (b.Y - a.Y) * t + ny * wobble + r.range(-jitter, jitter)); + } + walked += len; + } + out.push_back(path.back()); + return out; +} + +Pts regular(Gdiplus::PointF c, float radius, int n, float startDeg) { + Pts v; + for (int i = 0; i < n; i++) { + float a = float((startDeg + 360.0 * i / n) * th::kPi / 180.0); + v.emplace_back(c.X + radius * std::cos(a), c.Y + radius * std::sin(a)); + } + return v; +} + +Pts curve(Gdiplus::PointF c, float rx, float ry, Rng &r) { + Pts out; + float start = r.range(0, 6.28f); + int n = 70; + for (int i = 0; i <= n + 7; i++) { + float a = start + float(2 * th::kPi) * float(i) / float(n); + float wob = 1 + 0.025f * std::sin(a * 3 + start); + out.emplace_back(c.X + rx * wob * std::cos(a) + r.range(-1.5f, 1.5f), + c.Y + ry * wob * std::sin(a) + r.range(-1.5f, 1.5f)); + } + return out; +} + +struct Sample { + const char *name; + th_shape_kind expect; +}; + +const Sample kSamples[] = { + {"line", TH_SHAPE_LINE}, {"arrow", TH_SHAPE_ARROW}, {"circle", TH_SHAPE_CIRCLE}, + {"ellipse", TH_SHAPE_ELLIPSE}, {"triangle", TH_SHAPE_TRIANGLE}, {"square", TH_SHAPE_SQUARE}, + {"rectangle", TH_SHAPE_RECTANGLE}, {"diamond", TH_SHAPE_DIAMOND}, {"pentagon", TH_SHAPE_PENTAGON}, + {"hexagon", TH_SHAPE_HEXAGON}, {"star", TH_SHAPE_STAR}, {"heart", TH_SHAPE_HEART}, + {"scribble", TH_SHAPE_NONE}, +}; + +Pts make_sample(int index, Gdiplus::PointF c, float s, Rng &r) { + switch (kSamples[index].expect) { + case TH_SHAPE_LINE: + return trace({{c.X - s * 1.3f, c.Y + s * 0.05f}, {c.X + s * 1.3f, c.Y - s * 0.07f}}, false, r); + case TH_SHAPE_ARROW: { + Gdiplus::PointF tail(c.X - s * 1.2f, c.Y + s * 0.5f), tip(c.X + s * 1.1f, c.Y - s * 0.4f); + float ang = std::atan2(tip.Y - tail.Y, tip.X - tail.X); + float head = s * 0.45f; + Gdiplus::PointF b1(tip.X - head * std::cos(ang - 0.5f), tip.Y - head * std::sin(ang - 0.5f)); + Gdiplus::PointF b2(tip.X - head * std::cos(ang + 0.5f), tip.Y - head * std::sin(ang + 0.5f)); + return trace({tail, tip, b1, tip, b2}, false, r, 1.2f); + } + case TH_SHAPE_CIRCLE: + return curve(c, s, s, r); + case TH_SHAPE_ELLIPSE: + return curve(c, s * 1.35f, s * 0.7f, r); + case TH_SHAPE_TRIANGLE: + return trace(regular(c, s, 3, -90 + r.range(-4, 4)), true, r); + case TH_SHAPE_SQUARE: + return trace(regular(c, s * 1.05f, 4, 45 + r.range(-3, 3)), true, r); + case TH_SHAPE_RECTANGLE: + return trace({{c.X - s * 1.4f, c.Y - s * 0.7f}, + {c.X + s * 1.4f, c.Y - s * 0.72f}, + {c.X + s * 1.38f, c.Y + s * 0.7f}, + {c.X - s * 1.42f, c.Y + s * 0.68f}}, + true, r); + case TH_SHAPE_DIAMOND: + return trace({{c.X, c.Y - s}, {c.X + s * 0.75f, c.Y}, {c.X, c.Y + s}, {c.X - s * 0.75f, c.Y}}, true, r); + case TH_SHAPE_PENTAGON: + return trace(regular(c, s, 5, -90 + r.range(-4, 4)), true, r); + case TH_SHAPE_HEXAGON: + return trace(regular(c, s, 6, r.range(-4, 4)), true, r); + case TH_SHAPE_STAR: { + Pts v; + for (int i = 0; i < 10; i++) { + float a = float((-90 + 36.0 * i) * th::kPi / 180.0); + float rad = i % 2 ? s * 0.42f : s; + v.emplace_back(c.X + rad * std::cos(a), c.Y + rad * std::sin(a)); + } + return trace(v, true, r, 1.2f); + } + case TH_SHAPE_HEART: { + Pts out; + float k = s / 16.0f; + for (int i = 0; i <= 80; i++) { + float t = float(2 * th::kPi) * float(i) / 80.0f; + float x = 16 * std::pow(std::sin(t), 3.0f); + float y = 13 * std::cos(t) - 5 * std::cos(2 * t) - 2 * std::cos(3 * t) - std::cos(4 * t); + out.emplace_back(c.X + x * k + r.range(-1.2f, 1.2f), c.Y - y * k + r.range(-1.2f, 1.2f)); + } + return out; + } + default: { + Pts out; + Gdiplus::PointF p = c; + float a = 0; + for (int i = 0; i < 90; i++) { + a += r.range(-1.4f, 1.4f); + p.X = std::clamp(p.X + 6 * std::cos(a), c.X - s * 1.4f, c.X + s * 1.4f); + p.Y = std::clamp(p.Y + 6 * std::sin(a), c.Y - s, c.Y + s); + out.push_back(p); + } + return out; + } + } +} + +std::unique_ptr family() { + for (const wchar_t *name : {L"Segoe UI", L"Tahoma", L"Arial"}) { + auto f = std::make_unique(name); + if (f->IsAvailable()) { + return f; + } + } + return std::unique_ptr(Gdiplus::FontFamily::GenericSansSerif()->Clone()); +} + +} // namespace + +int render(const std::wstring &out) { + prefs::set_persist(false); + const int cellW = 284, cellH = 250, cols = 7; + const int panelW = 200, panelH = 300, panelCols = 10; + const int W = 2000; + const int shapesH = cellH * 2 + 20; + const int panelRows = int((kStyles.size() + panelCols - 1) / panelCols); + const int H = shapesH + panelRows * panelH; + std::unique_ptr canvas(th::new_pargb(W, H)); + if (!canvas) { + return 3; + } + Gdiplus::Graphics g(canvas.get()); + g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); + g.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit); + g.Clear(Gdiplus::Color(255, 250, 250, 248)); + auto fam = family(); + Gdiplus::Font font(fam.get(), 15, Gdiplus::FontStyleRegular, Gdiplus::UnitPixel); + Gdiplus::SolidBrush text(Gdiplus::Color(255, 60, 60, 66)); + Gdiplus::SolidBrush bad(Gdiplus::Color(255, 200, 40, 30)); + + int failures = 0; + prefs::get().snapShapes = true; + prefs::get().fadeSeconds = 0; + for (int i = 0; i < int(std::size(kSamples)); i++) { + int col = i % cols, row = i / cols; + Gdiplus::PointF c(float(col * cellW + cellW / 2), float(row * cellH + cellH / 2 - 10)); + Rng r{uint32_t(1234 + i * 77)}; + Pts raw = make_sample(i, c, 62, r); + Gdiplus::Pen thin(Gdiplus::Color(255, 190, 190, 196), 2); + g.DrawLines(&thin, raw.data(), INT(raw.size())); + Ink ink; + ink.begin(raw[0], 5, ink_for(L"lavender")); + for (size_t k = 1; k < raw.size(); k++) { + ink.extend(raw[k]); + } + th_shape_kind got = ink.end(th::now()); + ink.draw(g); + bool ok = got == kSamples[i].expect || + (kSamples[i].expect == TH_SHAPE_CIRCLE && got == TH_SHAPE_ELLIPSE) || + (kSamples[i].expect == TH_SHAPE_SQUARE && got == TH_SHAPE_RECTANGLE); + failures += !ok; + std::string label = std::string(kSamples[i].name) + " -> " + th_shape_name(got); + std::wstring wl(label.begin(), label.end()); + g.DrawString(wl.c_str(), -1, &font, Gdiplus::PointF(float(col * cellW + 16), float(row * cellH + cellH - 34)), + ok ? &text : &bad); + print("shape %-10s -> %-10s %s", kSamples[i].name, th_shape_name(got), ok ? "ok" : "MISMATCH"); + } + + prefs::get().snapShapes = false; + int loaded = 0; + for (size_t i = 0; i < kStyles.size(); i++) { + int col = int(i) % panelCols, row = int(i) / panelCols; + Gdiplus::GraphicsState saved = g.Save(); + g.TranslateTransform(float(col * panelW), float(shapesH + row * panelH)); + g.SetClip(Gdiplus::Rect(0, 0, panelW, panelH)); + g.Clear(Gdiplus::Color(255, 237, 240, 235)); + Arm arm; + arm.setSize(panelW, panelH); + arm.setStyle(kStyles[i].id); + loaded += arm.style() == kStyles[i].id; + arm.press(); + arm.held = true; + Gdiplus::PointF target(float(panelW - 80), 120.0f); + Ink ink; + Gdiplus::PointF center(target.X + 40 * 0.5f, target.Y + 8); + for (int k = 0; k <= 60; k++) { + float a = float(std::atan2(target.Y - center.Y, target.X - center.X) + 2 * th::kPi * k / 60.0); + Gdiplus::PointF p(center.X + 40 * 0.5f * std::cos(a), center.Y + 32 * 0.5f * std::sin(a)); + if (k == 0) { + ink.begin(p, arm.inkWidth(), ink_for(arm.style())); + } else { + ink.extend(p); + } + } + ink.extend(target); + ink.draw(g); + arm.aim(target.X, target.Y, 1.0); + arm.draw(g); + if (!arm.isLaser()) { + Gdiplus::SolidBrush marker(Gdiplus::Color(255, 242, 204, 26)); + g.FillEllipse(&marker, target.X - 3, target.Y - 3, 6.0f, 6.0f); + } + g.ResetClip(); + g.Restore(saved); + } + print("hands drawn: %d of %d styles loaded from %ls", loaded, int(kStyles.size()), hands::asset_dir().c_str()); + bool saved = th::save_png(canvas.get(), out); + print("rendered %ls %dx%d", out.c_str(), W, H); + if (!saved) { + return 4; + } + return failures + (loaded == int(kStyles.size()) ? 0 : 100); +} + +int toolbar(const std::wstring &out) { + prefs::set_persist(false); + Toolbar bar; + if (!bar.create(GetModuleHandleW(nullptr))) { + return 3; + } + bar.select(L"lavender"); + RECT mon{0, 0, 1280, 800}, work{0, 0, 1280, 760}; + bar.place(mon, work, 1.0f); + bool ok = bar.renderPng(out, false, false); + std::wstring hint = out.substr(0, out.size() - 4) + L"-hint.png"; + std::wstring tray = out.substr(0, out.size() - 4) + L"-ink.png"; + ok = bar.renderPng(hint, true, false) && ok; + ok = bar.renderPng(tray, true, true) && ok; + print("rendered %ls, %ls, %ls", out.c_str(), hint.c_str(), tray.c_str()); + return ok ? 0 : 4; +} + +int settings(HINSTANCE instance, const std::wstring &out) { + (void)instance; + prefs::set_persist(false); + SettingsWindow window; + const wchar_t *names[] = {L"general", L"pointers", L"appearance", L"drawing", L"sound", L"about"}; + std::wstring base = out.size() > 4 ? out.substr(0, out.size() - 4) : out; + bool ok = true; + for (int s = 0; s < SettingsWindow::SectionCount; s++) { + std::wstring path = base + L"-" + names[s] + L".png"; + ok = window.snapshot(path, s, false) && ok; + print("rendered %ls", path.c_str()); + } + std::wstring picker = base + L"-pointers-picker.png"; + ok = window.snapshot(picker, SettingsWindow::Pointers, false, true) && ok; + std::wstring dark = base + L"-drawing-dark.png"; + ok = window.snapshot(dark, SettingsWindow::Drawing, true) && ok; + print("rendered %ls and %ls", picker.c_str(), dark.c_str()); + return ok ? 0 : 4; +} + +namespace { + +void pump_for(double seconds) { + double until = th::now() + seconds; + MSG msg; + do { + while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + Sleep(4); + } while (th::now() < until); +} + +} // namespace + +// Drives the Settings window with window messages (the real mouse never +// moves) and checks that each control changes the setting it shows. +int settings_ui(HINSTANCE instance) { + prefs::set_persist(false); + prefs::get() = Prefs(); + prefs::get().pinned = prefs::default_pinned(); + SettingsWindow window; + window.applyHotkey = [](const Hotkey &, bool) { return true; }; + window.show(instance, SettingsWindow::General); + HWND hwnd = window.hwnd(); + int failures = 0; + auto check = [&failures](const char *name, bool ok) { + print("%s %s", ok ? "PASS" : "FAIL", name); + failures += !ok; + }; + auto settle = [hwnd] { + UpdateWindow(hwnd); + pump_for(0.05); + UpdateWindow(hwnd); + }; + auto at = [&](const std::wstring &label, bool control, POINT *p, RECT *rect = nullptr) { + settle(); + RECT r; + if (!window.locate(label, control, &r)) { + print(" could not find %ls", label.c_str()); + return false; + } + p->x = (r.left + r.right) / 2; + p->y = (r.top + r.bottom) / 2; + if (rect) { + *rect = r; + } + return true; + }; + auto press = [&](POINT p, POINT release) { + SendMessageW(hwnd, WM_MOUSEMOVE, 0, MAKELPARAM(p.x, p.y)); + SendMessageW(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(p.x, p.y)); + for (int i = 1; i <= 6; i++) { + LONG x = p.x + (release.x - p.x) * i / 6, y = p.y + (release.y - p.y) * i / 6; + SendMessageW(hwnd, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(x, y)); + } + SendMessageW(hwnd, WM_LBUTTONUP, 0, MAKELPARAM(release.x, release.y)); + settle(); + }; + auto click = [&](const std::wstring &label, bool control = false) { + POINT p; + if (!at(label, control, &p)) { + return false; + } + press(p, p); + return true; + }; + Prefs &p = prefs::get(); + + check("Drawing page opens from the sidebar", click(L"Drawing") && window.section() == SettingsWindow::Drawing); + bool snap = p.snapShapes; + check("Snap shapes switch flips the setting", click(L"Snap shapes", true) && p.snapShapes == !snap); + click(L"Snap shapes", true); + POINT sw; + RECT swatches; + if (at(L"Ink colour", true, &sw, &swatches)) { + double k = double(swatches.right - swatches.left) / 26.0; + POINT red{swatches.left + LONG(std::lround((34 + 13) * k)), sw.y}; + press(red, red); + } + check("Second swatch picks red ink", p.inkChoice == L"red"); + + check("Sound page opens", click(L"Sound") && window.section() == SettingsWindow::Sound); + check("Pop tile picks the Pop sound", click(L"Pop") && p.tapSound == L"pop" && p.handSound); + check("Off tile turns tap sound off", click(L"Off") && !p.handSound); + bool laser = p.laserSound; + click(L"Laser dot taps too", true); + check("Laser switch is disabled while sound is off", p.laserSound == laser); + + check("Pointers page opens", click(L"Pointers") && window.section() == SettingsWindow::Pointers); + POINT from, to; + if (at(L"lavender", false, &from) && at(L"plush", false, &to)) { + press(from, to); + } + auto &pin = p.pinned; + check("Dragging a hand reorders the toolbar", pin.size() == 6 && pin[0] == L"glove" && pin[3] == L"lavender"); + RECT slot; + POINT c; + if (at(L"glove", false, &c, &slot)) { + POINT badge{slot.right - 4, slot.top + 3}; + SendMessageW(hwnd, WM_MOUSEMOVE, 0, MAKELPARAM(c.x, c.y)); + press(badge, badge); + } + check("The x on a hand removes it", pin.size() == 5 && + std::find(pin.begin(), pin.end(), L"glove") == pin.end()); + check("+ opens the picker and a hand gets added", click(L"+") && click(L"pink") && pin.size() == 6 && + pin.back() == L"pink"); + if (at(L"pink", false, &from)) { + press(from, POINT{from.x, from.y + LONG(150 * th::dpi_scale_window(hwnd))}); + } + check("Dragging a hand out of the row removes it", pin.size() == 5 && + std::find(pin.begin(), pin.end(), L"pink") == pin.end()); + check("Reset puts the default hands back", click(L"Reset") && pin == prefs::default_pinned()); + + check("Appearance page opens", click(L"Appearance") && window.section() == SettingsWindow::Appearance); + RECT slider; + POINT mid; + if (at(L"Size", true, &mid, &slider)) { + POINT left{slider.left + 2, mid.y}; + POINT right{slider.right - 2, mid.y}; + press(mid, right); + bool max = std::fabs(p.pointerSize - prefs::kMaxSize) < 0.001; + press(mid, left); + bool min = std::fabs(p.pointerSize - prefs::kMinSize) < 0.001; + check("Size slider reaches both ends", max && min); + } else { + check("Size slider reaches both ends", false); + } + bool shadow = p.showShadow; + check("Shadow switch flips the setting", click(L"Shadow under the pointer", true) && p.showShadow == !shadow); + + SendMessageW(hwnd, WM_CLOSE, 0, 0); + print("%s: %d failure(s)", failures ? "SETTINGS UI FAILED" : "SETTINGS UI PASSED", failures); + return failures; +} + +int welcome(const std::wstring &out) { + prefs::set_persist(false); + WelcomeWindow card; + std::wstring dark = out.size() > 4 ? out.substr(0, out.size() - 4) + L"-dark.png" : out + L"-dark"; + bool ok = card.snapshot(out, false) && card.snapshot(dark, true); + print("rendered %ls and %ls", out.c_str(), dark.c_str()); + return ok ? 0 : 4; +} + +namespace { + +class Catcher { +public: + int clicks = 0; + HWND hwnd = nullptr; + + bool create(HINSTANCE instance, RECT r) { + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = Proc; + wc.hInstance = instance; + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.hbrBackground = reinterpret_cast(COLOR_WINDOW + 1); + wc.lpszClassName = L"TinyHandCatcher"; + RegisterClassExW(&wc); + hwnd = CreateWindowExW(WS_EX_TOPMOST, L"TinyHandCatcher", L"TinyHand e2e target", WS_POPUP | WS_VISIBLE, + r.left, r.top, r.right - r.left, r.bottom - r.top, nullptr, nullptr, instance, this); + return hwnd != nullptr; + } + +private: + static LRESULT CALLBACK Proc(HWND h, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_NCCREATE) { + SetWindowLongPtrW(h, GWLP_USERDATA, + reinterpret_cast(reinterpret_cast(lp)->lpCreateParams)); + } + auto *self = reinterpret_cast(GetWindowLongPtrW(h, GWLP_USERDATA)); + if (self && (msg == WM_LBUTTONDOWN || msg == WM_RBUTTONDOWN)) { + self->clicks++; + } + return DefWindowProcW(h, msg, wp, lp); + } +}; + +void pump(double seconds) { + double until = th::now() + seconds; + MSG msg; + do { + while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + Sleep(4); + } while (th::now() < until); +} + +void move_to(Gdiplus::PointF p) { + int vx = GetSystemMetrics(SM_XVIRTUALSCREEN), vy = GetSystemMetrics(SM_YVIRTUALSCREEN); + int vw = std::max(2, GetSystemMetrics(SM_CXVIRTUALSCREEN)), vh = std::max(2, GetSystemMetrics(SM_CYVIRTUALSCREEN)); + INPUT in{}; + in.type = INPUT_MOUSE; + in.mi.dx = LONG(std::lround((p.X - float(vx)) * 65535.0f / float(vw - 1))); + in.mi.dy = LONG(std::lround((p.Y - float(vy)) * 65535.0f / float(vh - 1))); + in.mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; + SendInput(1, &in, sizeof(in)); +} + +void button(bool down) { + INPUT in{}; + in.type = INPUT_MOUSE; + in.mi.dwFlags = down ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP; + SendInput(1, &in, sizeof(in)); +} + +void key(WORD vk, bool down) { + INPUT in{}; + in.type = INPUT_KEYBOARD; + in.ki.wVk = vk; + in.ki.dwFlags = down ? 0 : KEYEVENTF_KEYUP; + SendInput(1, &in, sizeof(in)); +} + +void chord(std::initializer_list keys) { + for (WORD k : keys) { + key(k, true); + pump(0.02); + } + for (auto it = std::rbegin(keys); it != std::rend(keys); ++it) { + key(*it, false); + pump(0.02); + } +} + +void click(Gdiplus::PointF p) { + move_to(p); + pump(0.08); + button(true); + pump(0.05); + button(false); + pump(0.25); +} + +void drag(const Pts &pts) { + move_to(pts[0]); + pump(0.06); + button(true); + pump(0.03); + for (const auto &p : pts) { + move_to(p); + pump(0.006); + } + button(false); + pump(0.3); +} + +Pts offset(Pts pts, float dx, float dy) { + for (auto &p : pts) { + p.X += dx; + p.Y += dy; + } + return pts; +} + +} // namespace + +int e2e(HINSTANCE instance) { + prefs::set_persist(false); + Prefs &p = prefs::get(); + p.hintShown = true; + p.welcomeShown = true; + p.handSound = false; + p.snapShapes = true; + p.fadeSeconds = 0; + p.interactKey = InteractKey::Alt; + p.pointerStyle = L"lavender"; + + int failures = 0; + auto check = [&failures](const char *name, bool ok, const char *detail = "") { + print("%s %-34s %s", ok ? "PASS" : "FAIL", name, detail); + failures += !ok; + }; + + POINT saved; + GetCursorPos(&saved); + App app; + if (!app.init(instance, true)) { + print("FAIL app init"); + return 2; + } + MONITORINFO mi{}; + mi.cbSize = sizeof(mi); + GetMonitorInfoW(MonitorFromPoint(POINT{0, 0}, MONITOR_DEFAULTTOPRIMARY), &mi); + RECT m = mi.rcMonitor; + Gdiplus::PointF c(float(m.left + m.right) / 2, float(m.top + m.bottom) / 2 - 40); + RECT target{LONG(c.X) - 300, LONG(c.Y) - 200, LONG(c.X) + 300, LONG(c.Y) + 200}; + Catcher catcher; + catcher.create(instance, target); + pump(0.3); + + click(c); + check("click reaches app while pointer off", catcher.clicks == 1); + + Overlay &o = app.overlay(); + chord({VK_CONTROL, VK_MENU, 'H'}); + pump(0.3); + check("Ctrl+Alt+H turns the pointer on", o.active()); + check("mouse hook installed", o.hookInstalled()); + + int before = catcher.clicks; + click(c); + char detail[128]; + snprintf(detail, sizeof(detail), "app clicks %d -> %d, taps %d", before, catcher.clicks, o.presses()); + check("click becomes a tap, app sees nothing", catcher.clicks == before && o.presses() == 1, detail); + + struct Case { + int sample; + float dx, dy; + th_shape_kind alt; + } cases[] = { + {2, -170, -60, TH_SHAPE_ELLIPSE}, {6, 150, -60, TH_SHAPE_SQUARE}, {4, -170, 110, TH_SHAPE_NONE}, + {10, 150, 110, TH_SHAPE_NONE}, {12, 0, 20, TH_SHAPE_NONE}, + }; + int strokes = 0; + for (const Case &k : cases) { + Rng r{uint32_t(99 + k.sample)}; + Pts pts = offset(make_sample(k.sample, Gdiplus::PointF(0, 0), 55, r), c.X + k.dx, c.Y + k.dy); + drag(pts); + strokes++; + th_shape_kind got = o.lastShape(); + bool ok = (got == kSamples[k.sample].expect || (k.alt != TH_SHAPE_NONE && got == k.alt)) && + o.strokeCount() == strokes; + snprintf(detail, sizeof(detail), "got %s, strokes %d", th_shape_name(got), o.strokeCount()); + std::string name = std::string("draw ") + kSamples[k.sample].name; + check(name.c_str(), ok, detail); + } + + Pts line; + for (int i = 0; i <= 40; i++) { + line.emplace_back(c.X - 220 + 11.0f * float(i), c.Y + 175 + (i % 3 == 0 ? 1.0f : 0.0f)); + } + drag(line); + strokes++; + check("draw line", o.lastShape() == TH_SHAPE_LINE && o.strokeCount() == strokes); + Gdiplus::PointF tip = line.back(); + Pts head; + for (int i = 0; i <= 8; i++) { + head.emplace_back(tip.X - 28 + 3.5f * float(i), tip.Y - 22 + 2.75f * float(i)); + } + for (int i = 1; i <= 8; i++) { + head.emplace_back(tip.X - 3.5f * float(i), tip.Y + 2.75f * float(i)); + } + drag(head); + snprintf(detail, sizeof(detail), "got %s, strokes %d", th_shape_name(o.lastShape()), o.strokeCount()); + check("V at the end turns the line into an arrow", o.lastShape() == TH_SHAPE_ARROW && o.strokeCount() == strokes, + detail); + + before = catcher.clicks; + key(VK_MENU, true); + pump(0.05); + click(c); + key(VK_MENU, false); + pump(0.1); + snprintf(detail, sizeof(detail), "app clicks %d -> %d", before, catcher.clicks); + check("Alt+click goes through for real", catcher.clicks == before + 1, detail); + + chord({VK_CONTROL, VK_MENU, 'X'}); + pump(0.2); + check("Ctrl+Alt+X clears the drawing", o.strokeCount() == 0); + + chord({VK_ESCAPE}); + pump(0.2); + check("Esc turns the pointer off", !o.active()); + + before = catcher.clicks; + click(c); + check("clicks work again after Esc", catcher.clicks == before + 1); + + DestroyWindow(catcher.hwnd); + app.shutdown(); + SetCursorPos(saved.x, saved.y); + print("%s: %d failure(s)", failures ? "E2E FAILED" : "E2E PASSED", failures); + return failures; +} + +} // namespace selftest diff --git a/windows/src/selftest.h b/windows/src/selftest.h new file mode 100644 index 0000000..36a590d --- /dev/null +++ b/windows/src/selftest.h @@ -0,0 +1,19 @@ +#pragma once + +#include "common.h" + +// Command-line checks that need no human: offscreen renders to PNG and an +// end-to-end run that drives real input through the mouse hook. They work +// the same on Windows and under Wine. +namespace selftest { + +int render(const std::wstring &out); +int toolbar(const std::wstring &out); +int settings(HINSTANCE instance, const std::wstring &out); +int welcome(const std::wstring &out); +int settings_ui(HINSTANCE instance); +int e2e(HINSTANCE instance); + +void print(const char *fmt, ...); + +} // namespace selftest diff --git a/windows/src/settings.cpp b/windows/src/settings.cpp new file mode 100644 index 0000000..e6d3570 --- /dev/null +++ b/windows/src/settings.cpp @@ -0,0 +1,1579 @@ +#include "settings.h" + +#include "hands.h" +#include "sound.h" +#include "version.h" + +#include +#include + +namespace { + +const wchar_t *kClass = L"TinyHandSettings"; + +constexpr float kWidth = 780; +constexpr float kHeight = 560; +constexpr float kSidebar = 210; +constexpr float kPad = 28; +constexpr float kX0 = kSidebar + kPad; + +enum Glyph { Gear, Hand, Sparkles, Pencil, Speaker, Info, Wave, Mute, Plus, Arrow, Chevrons, Cross, Minus }; + +struct SectionInfo { + const wchar_t *title; + uint32_t tint; + int glyph; +}; + +const SectionInfo kSections[] = { + {L"General", 0x8C8C8C, Gear}, {L"Pointers", 0x8C6BF2, Hand}, {L"Appearance", 0xF27399, Sparkles}, + {L"Drawing", 0xF59433, Pencil}, {L"Sound", 0x338CFA, Speaker}, {L"About", 0x737373, Info}, +}; + +const std::pair kFadeChoices[] = { + {L"After 3 seconds", 3}, {L"After 6 seconds", 6}, {L"After 10 seconds", 10}, {L"Keep until cleared", 0}}; + +const std::pair kInteractChoices[] = { + {L"Alt", InteractKey::Alt}, {L"Ctrl", InteractKey::Ctrl}, {L"Shift", InteractKey::Shift}, {L"Off", InteractKey::Off}}; + +const wchar_t *kKeepOne = L"Keep at least one pointer in the toolbar."; + +uint32_t mix(uint32_t a, uint32_t b, float t) { + auto ch = [&](int shift) { + float x = float((a >> shift) & 0xFF) * (1 - t) + float((b >> shift) & 0xFF) * t; + return uint32_t(std::lround(x)) << shift; + }; + return ch(16) | ch(8) | ch(0); +} + +void round_fill(Gdiplus::Graphics &g, const Gdiplus::RectF &r, float radius, Gdiplus::Color c) { + Gdiplus::GraphicsPath p; + th::add_rounded_rect(p, r, radius); + Gdiplus::SolidBrush b(c); + g.FillPath(&b, &p); +} + +void round_stroke(Gdiplus::Graphics &g, const Gdiplus::RectF &r, float radius, Gdiplus::Color c, float width) { + Gdiplus::GraphicsPath p; + Gdiplus::RectF inner(r.X + width / 2, r.Y + width / 2, r.Width - width, r.Height - width); + th::add_rounded_rect(p, inner, radius); + Gdiplus::Pen pen(c, width); + g.DrawPath(&pen, &p); +} + +void soft_shadow(Gdiplus::Graphics &g, const Gdiplus::RectF &r, float radius, float strength) { + for (int i = 3; i >= 1; i--) { + float grow = float(i) * 1.2f; + Gdiplus::RectF s(r.X - grow, r.Y - grow + 1.2f, r.Width + 2 * grow, r.Height + 2 * grow); + round_fill(g, s, radius + grow, th::black(strength / float(i + 1))); + } +} + +Gdiplus::Color with_alpha(Gdiplus::Color c, float k) { + return Gdiplus::Color(BYTE(std::lround(c.GetA() * std::clamp(k, 0.0f, 1.0f))), c.GetR(), c.GetG(), c.GetB()); +} + +void draw_tinted(Gdiplus::Graphics &g, Gdiplus::Bitmap *image, const Gdiplus::RectF &r, Gdiplus::Color c) { + if (!image) { + return; + } + float a = c.GetA() / 255.0f; + Gdiplus::ColorMatrix m = {{{0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0}, + {0, 0, 0, a, 0}, + {c.GetR() / 255.0f, c.GetG() / 255.0f, c.GetB() / 255.0f, 0, 1}}}; + Gdiplus::ImageAttributes attrs; + attrs.SetColorMatrix(&m); + g.DrawImage(image, r, 0, 0, Gdiplus::REAL(image->GetWidth()), Gdiplus::REAL(image->GetHeight()), Gdiplus::UnitPixel, &attrs); +} + +void draw_faded(Gdiplus::Graphics &g, Gdiplus::Bitmap *image, const Gdiplus::RectF &r, float alpha) { + if (!image) { + return; + } + if (alpha >= 0.999f) { + g.DrawImage(image, r); + return; + } + Gdiplus::ColorMatrix m = {{{1, 0, 0, 0, 0}, {0, 1, 0, 0, 0}, {0, 0, 1, 0, 0}, {0, 0, 0, alpha, 0}, {0, 0, 0, 0, 1}}}; + Gdiplus::ImageAttributes attrs; + attrs.SetColorMatrix(&m); + g.DrawImage(image, r, 0, 0, Gdiplus::REAL(image->GetWidth()), Gdiplus::REAL(image->GetHeight()), Gdiplus::UnitPixel, &attrs); +} + +void laser_icon(Gdiplus::Graphics &g, const Gdiplus::RectF &r, float alpha) { + float cx = r.X + r.Width / 2, cy = r.Y + r.Height / 2, k = r.Width / 64.0f; + float glow = 18 * k; + Gdiplus::GraphicsPath halo; + halo.AddEllipse(cx - glow, cy - glow, 2 * glow, 2 * glow); + Gdiplus::PathGradientBrush brush(&halo); + brush.SetCenterColor(th::rgba(kInkRed, 0.55f * alpha)); + Gdiplus::Color edge = th::rgba(kInkRed, 0); + INT n = 1; + brush.SetSurroundColors(&edge, &n); + g.FillPath(&brush, &halo); + Gdiplus::SolidBrush red(th::rgba(kInkRed, alpha)); + g.FillEllipse(&red, cx - 7 * k, cy - 7 * k, 14 * k, 14 * k); + Gdiplus::SolidBrush core(Gdiplus::Color(BYTE(242 * alpha), 255, 219, 209)); + g.FillEllipse(&core, cx - 3 * k, cy - 3 * k, 6 * k, 6 * k); +} + +void pointer_icon(Gdiplus::Graphics &g, const std::wstring &style, const Gdiplus::RectF &r, float alpha = 1) { + if (style_is_laser(style)) { + laser_icon(g, r, alpha); + } else { + draw_faded(g, hands::cached_thumbnail(style), r, alpha); + } +} + +std::vector keycaps(const Hotkey &h) { + std::vector caps; + if (h.mods & MOD_CONTROL) { + caps.push_back(L"Ctrl"); + } + if (h.mods & MOD_ALT) { + caps.push_back(L"Alt"); + } + if (h.mods & MOD_SHIFT) { + caps.push_back(L"Shift"); + } + if (h.mods & MOD_WIN) { + caps.push_back(L"Win"); + } + Hotkey keyOnly{0, h.vk}; + caps.push_back(keyOnly.display()); + return caps; +} + +} // namespace + +Gdiplus::Color SettingsWindow::Palette::ink(float alpha) const { return dark ? th::white(alpha) : th::black(alpha); } + +// --------------------------------------------------------------------------- +// Window plumbing + +LRESULT CALLBACK SettingsWindow::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_NCCREATE) { + auto *cs = reinterpret_cast(lp); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(cs->lpCreateParams)); + reinterpret_cast(cs->lpCreateParams)->hwnd_ = hwnd; + } + auto *self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + return self ? self->proc(msg, wp, lp) : DefWindowProcW(hwnd, msg, wp, lp); +} + +void SettingsWindow::create() { + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = WndProc; + wc.hInstance = instance_; + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.hIcon = LoadIconW(instance_, MAKEINTRESOURCEW(IDI_APP)); + wc.lpszClassName = kClass; + RegisterClassExW(&wc); + POINT center{GetSystemMetrics(SM_CXSCREEN) / 2, GetSystemMetrics(SM_CYSCREEN) / 2}; + scale_ = th::dpi_scale_at(center); + CreateWindowExW(0, kClass, L"TinyHand Settings", WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, + CW_USEDEFAULT, CW_USEDEFAULT, 100, 100, nullptr, nullptr, instance_, this); + scale_ = th::dpi_scale_window(hwnd_); + resize(); +} + +void SettingsWindow::resize() { + RECT r{0, 0, LONG(std::lround(kWidth * scale_)), LONG(std::lround(kHeight * scale_))}; + AdjustWindowRectEx(&r, WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, FALSE, 0); + SetWindowPos(hwnd_, nullptr, 0, 0, r.right - r.left, r.bottom - r.top, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); +} + +void SettingsWindow::show(HINSTANCE instance, int section) { + instance_ = instance; + if (!listening_) { + listening_ = true; + prefs::listen([this] { redraw(); }); + } + if (!hwnd_) { + create(); + } + if (section >= 0 && section < SectionCount) { + section_ = section; + } + shortcutError_.clear(); + pinMessage_.clear(); + picking_ = false; + bool dark = th::apps_use_dark(); + BOOL useDark = dark; + using SetAttr = HRESULT(WINAPI *)(HWND, DWORD, LPCVOID, DWORD); + if (HMODULE dwm = LoadLibraryW(L"dwmapi.dll")) { + if (auto fn = reinterpret_cast(reinterpret_cast(GetProcAddress(dwm, "DwmSetWindowAttribute")))) { + fn(hwnd_, 20, &useDark, sizeof(useDark)); + } + } + std::vector all; + for (const auto &s : kStyles) { + all.push_back(s.id); + } + hands::preload_thumbnails(all, hwnd_, WM_TH_THUMBS); + if (!IsWindowVisible(hwnd_)) { + RECT r; + GetWindowRect(hwnd_, &r); + MONITORINFO mi{}; + mi.cbSize = sizeof(mi); + GetMonitorInfoW(MonitorFromWindow(GetForegroundWindow(), MONITOR_DEFAULTTOPRIMARY), &mi); + int w = r.right - r.left, h = r.bottom - r.top; + SetWindowPos(hwnd_, nullptr, (mi.rcWork.left + mi.rcWork.right - w) / 2, + (mi.rcWork.top + mi.rcWork.bottom - h) / 2, 0, 0, SWP_NOSIZE | SWP_NOZORDER); + } + ShowWindow(hwnd_, SW_SHOWNORMAL); + SetForegroundWindow(hwnd_); + redraw(); +} + +void SettingsWindow::redraw() { + if (hwnd_ && IsWindowVisible(hwnd_)) { + InvalidateRect(hwnd_, nullptr, FALSE); + } +} + +LRESULT SettingsWindow::proc(UINT msg, WPARAM wp, LPARAM lp) { + auto px = [this](LPARAM l) { return Gdiplus::PointF(GET_X_LPARAM(l) / scale_, GET_Y_LPARAM(l) / scale_); }; + switch (msg) { + case WM_PAINT: { + PAINTSTRUCT ps; + HDC dc = BeginPaint(hwnd_, &ps); + RECT rc; + GetClientRect(hwnd_, &rc); + if (surface_.resize(rc.right, rc.bottom)) { + { + Gdiplus::Graphics g(surface_.bitmap.get()); + g.ScaleTransform(scale_, scale_); + paint(g, float(rc.right) / scale_, float(rc.bottom) / scale_); + } + GdiFlush(); + BitBlt(dc, 0, 0, rc.right, rc.bottom, surface_.dc, 0, 0, SRCCOPY); + } + EndPaint(hwnd_, &ps); + return 0; + } + case WM_ERASEBKGND: + return 1; + case WM_TH_THUMBS: + redraw(); + return 0; + case WM_LBUTTONDOWN: { + SetCapture(hwnd_); + SetFocus(hwnd_); + auto p = px(lp); + mouseDown(p.X, p.Y); + return 0; + } + case WM_MOUSEMOVE: { + auto p = px(lp); + mouseMove(p.X, p.Y); + TRACKMOUSEEVENT t{sizeof(t), TME_LEAVE, hwnd_, 0}; + TrackMouseEvent(&t); + return 0; + } + case WM_LBUTTONUP: { + ReleaseCapture(); + auto p = px(lp); + mouseUp(p.X, p.Y); + return 0; + } + case WM_MOUSELEAVE: + if (hover_ != -1 && pressed_ < 0) { + hover_ = -1; + redraw(); + } + return 0; + case WM_SETCURSOR: + if (LOWORD(lp) == HTCLIENT) { + bool link = hover_ >= 0 && hover_ < int(widgets_.size()) && + (widgets_[size_t(hover_)].kind == Kind::LinkRow || widgets_[size_t(hover_)].kind == Kind::TextButton); + SetCursor(LoadCursorW(nullptr, link ? IDC_HAND : IDC_ARROW)); + return TRUE; + } + break; + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (recording_ >= 0) { + recordKey(UINT(wp)); + return 0; + } + if (wp == VK_ESCAPE) { + if (picking_) { + picking_ = false; + redraw(); + } else { + ShowWindow(hwnd_, SW_HIDE); + } + return 0; + } + break; + case WM_CHAR: + case WM_SYSCHAR: + if (recording_ >= 0) { + return 0; + } + break; + case WM_ACTIVATE: + if (LOWORD(wp) == WA_INACTIVE) { + stopRecording(); + } + break; + case WM_DPICHANGED: { + scale_ = float(HIWORD(wp)) / 96.0f; + fontScale_ = 0; + const RECT *r = reinterpret_cast(lp); + SetWindowPos(hwnd_, nullptr, r->left, r->top, r->right - r->left, r->bottom - r->top, + SWP_NOZORDER | SWP_NOACTIVATE); + resize(); + redraw(); + return 0; + } + case WM_SETTINGCHANGE: + redraw(); + break; + case WM_CLOSE: + stopRecording(); + ShowWindow(hwnd_, SW_HIDE); + return 0; + default: + break; + } + return DefWindowProcW(hwnd_, msg, wp, lp); +} + +// --------------------------------------------------------------------------- +// Layout helpers + +float SettingsWindow::textHeight(Gdiplus::Graphics &g, const std::wstring &s, int font, float width) { + Gdiplus::RectF bound; + Gdiplus::StringFormat fmt; + g.MeasureString(s.c_str(), -1, fonts_[font].get(), Gdiplus::RectF(0, 0, width, 10000), &fmt, &bound); + return std::ceil(bound.Height); +} + +float SettingsWindow::textWidth(Gdiplus::Graphics &g, const std::wstring &s, int font) { + Gdiplus::RectF bound; + Gdiplus::StringFormat fmt(Gdiplus::StringFormatFlagsNoWrap); + g.MeasureString(s.c_str(), -1, fonts_[font].get(), Gdiplus::PointF(0, 0), &fmt, &bound); + return std::ceil(bound.Width); +} + +SettingsWindow::Widget &SettingsWindow::add(Kind kind, Gdiplus::RectF r) { + widgets_.emplace_back(); + Widget &w = widgets_.back(); + w.kind = kind; + w.r = r; + w.enabled = !dim_; + return w; +} + +SettingsWindow::Widget &SettingsWindow::text(Gdiplus::Graphics &g, const std::wstring &s, int font, float x, float y, + float width, bool secondary) { + float h = textHeight(g, s, font, width); + Widget &w = add(Kind::Text, Gdiplus::RectF(x, y, width, h + 1)); + w.text = s; + w.font = font; + w.secondary = secondary; + return w; +} + +float SettingsWindow::card(Gdiplus::Graphics &g, float y, const wchar_t *title, + std::vector> rows) { + const float cw = kWidth - kX0 - kPad; + if (title) { + text(g, title, Header, kX0 + 4, y, cw - 8, true); + y += 16 + 8; + } + size_t index = widgets_.size(); + add(Kind::Card, Gdiplus::RectF(kX0, y, cw, 0)); + float top = y; + for (size_t i = 0; i < rows.size(); i++) { + if (i > 0) { + add(Kind::Separator, Gdiplus::RectF(kX0 + 14, y, cw - 14, 1)); + } + y = rows[i](y); + } + widgets_[index].r.Height = y - top; + return y; +} + +float SettingsWindow::row(Gdiplus::Graphics &g, float y, const std::wstring &title, const std::wstring &detail, + Kind trailing, float trailW, float trailH, std::function setup) { + const float cw = kWidth - kX0 - kPad; + float textW = cw - 28 - trailW - 12; + float th1 = textHeight(g, title, Body, textW); + float dh = detail.empty() ? 0 : textHeight(g, detail, Detail, textW); + float block = th1 + (detail.empty() ? 0 : 2 + dh); + float h = std::max(44.0f, block + 20); + float by = y + (h - block) / 2; + text(g, title, Body, kX0 + 14, by, textW); + if (!detail.empty()) { + text(g, detail, Detail, kX0 + 14, by + th1 + 2, textW, true); + } + if (trailW > 0 && setup) { + setup(add(trailing, Gdiplus::RectF(kX0 + cw - 14 - trailW, y + (h - trailH) / 2, trailW, trailH))); + } + return y + h; +} + +// --------------------------------------------------------------------------- +// Pages + +void SettingsWindow::build(Gdiplus::Graphics &g, float width, float height) { + (void)width; + if (fontScale_ != scale_ || !family_) { + fontScale_ = scale_; + for (const wchar_t *name : {L"Segoe UI", L"Tahoma", L"Arial"}) { + auto f = std::make_unique(name); + if (f->IsAvailable()) { + family_ = std::move(f); + break; + } + } + if (!family_) { + family_.reset(Gdiplus::FontFamily::GenericSansSerif()->Clone()); + } + auto semi = std::make_unique(L"Segoe UI Semibold"); + bool haveSemi = semi->IsAvailable(); + if (haveSemi) { + semibold_ = std::move(semi); + } + auto make = [&](float size, bool strong, bool bold) { + if (bold) { + return std::make_unique(family_.get(), size, Gdiplus::FontStyleBold, Gdiplus::UnitPixel); + } + if (strong && haveSemi) { + return std::make_unique(semibold_.get(), size, Gdiplus::FontStyleRegular, + Gdiplus::UnitPixel); + } + return std::make_unique(family_.get(), size, + strong ? Gdiplus::FontStyleBold : Gdiplus::FontStyleRegular, + Gdiplus::UnitPixel); + }; + fonts_[Body] = make(13, false, false); + fonts_[BodyBold] = make(13, true, false); + fonts_[Detail] = make(11, false, false); + fonts_[Header] = make(12, true, false); + fonts_[Title] = make(22, false, true); + fonts_[Name] = make(18, false, true); + fonts_[Caption] = make(11, false, false); + fonts_[Medium] = make(12, false, false); + fonts_[Key] = make(11.5f, true, false); + } + + widgets_.clear(); + dim_ = false; + float y = 16; + for (int s = 0; s < SectionCount; s++) { + Widget &w = add(Kind::Sidebar, Gdiplus::RectF(10, y, kSidebar - 20, 36)); + w.id = s; + w.text = kSections[s].title; + w.selected = s == section_; + w.click = [this, s] { + section_ = s; + picking_ = false; + pinMessage_.clear(); + stopRecording(); + redraw(); + }; + y += 38; + } + add(Kind::AppIcon, Gdiplus::RectF(20, height - 16 - 22, 22, 22)); + text(g, L"TinyHand", Header, 50, height - 16 - 22 + 3, kSidebar - 60); + + text(g, kSections[section_].title, Title, kX0, 22, kWidth - kX0 - kPad); + y = 22 + 30 + 22; + switch (section_) { + case General: + pageGeneral(g, y); + break; + case Pointers: + pagePointers(g, y); + break; + case Appearance: + pageAppearance(g, y); + break; + case Drawing: + pageDrawing(g, y); + break; + case Sound: + pageSound(g, y); + break; + default: + pageAbout(g, y); + break; + } +} + +void SettingsWindow::pageGeneral(Gdiplus::Graphics &g, float y) { + Prefs &p = prefs::get(); + auto recorderRow = [&](const wchar_t *title, int which) { + return [&, title, which](float ry) { + return row(g, ry, title, L"", Kind::Recorder, 150, 30, [this, which](Widget &w) { + w.id = which; + w.selected = recording_ == which; + w.click = [this, which] { startRecording(which); }; + }); + }; + }; + y = card(g, y, L"Shortcuts", {recorderRow(L"Turn the pointer on or off", 0), recorderRow(L"Clear the drawing", 1)}); + if (!shortcutError_.empty()) { + y += 8; + Widget &e = text(g, shortcutError_, Medium, kX0 + 4, y, kWidth - kX0 - kPad - 8); + e.error = true; + y = e.r.Y + e.r.Height; + } + y += 22; + + auto popup = [&](float ry, const std::wstring &title, const std::wstring &detail, std::vector options, + int current, std::function pick) { + float w = 0; + for (const auto &o : options) { + w = std::max(w, textWidth(g, o, Medium)); + } + return row(g, ry, title, detail, Kind::Popup, w + 40, 24, [&](Widget &widget) { + widget.options = options; + widget.id = current; + widget.text = options[size_t(std::max(0, current))]; + widget.pick = pick; + }); + }; + std::vector> clicking; + clicking.push_back([&](float ry) { + std::vector opts; + int current = 0; + for (size_t i = 0; i < std::size(kInteractChoices); i++) { + opts.push_back(kInteractChoices[i].first); + if (p.interactKey == kInteractChoices[i].second) { + current = int(i); + } + } + return popup(ry, L"Hold to use the app", L"Clicks, drags and scrolls reach the app.", opts, current, [](int i) { + prefs::get().interactKey = kInteractChoices[i].second; + prefs::changed(); + }); + }); + if (p.interactKey != InteractKey::Off) { + clicking.push_back([&](float ry) { + return popup(ry, L"While holding", + p.interactKeepsHand ? L"The hand stays, in menus too." : L"The normal cursor comes back, in menus too.", + {L"Show the cursor", L"Keep the hand"}, p.interactKeepsHand ? 1 : 0, [](int i) { + prefs::get().interactKeepsHand = i == 1; + prefs::changed(); + }); + }); + } + y = card(g, y, L"Clicking for real", clicking); + y += 22; + card(g, y, L"Startup", {[&](float ry) { + return row(g, ry, L"Open TinyHand when you log in", L"", Kind::Switch, 38, 22, [](Widget &w) { + w.on = prefs::launch_at_login(); + w.click = [] { prefs::set_launch_at_login(!prefs::launch_at_login()); }; + }); + }}); +} + +void SettingsWindow::pagePointers(Gdiplus::Graphics &g, float y) { + const float cw = kWidth - kX0 - kPad; + const std::vector &pinned = prefs::get().pinned; + text(g, L"In your toolbar", Header, kX0 + 4, y, 200, true); + float rw = textWidth(g, L"Reset", Caption) + 2; + Widget &reset = add(Kind::TextButton, Gdiplus::RectF(kX0 + cw - 4 - rw, y + 1, rw, 15)); + reset.text = L"Reset"; + reset.click = [this] { + prefs::get().pinned = prefs::default_pinned(); + pinMessage_.clear(); + prefs::changed(); + }; + y += 16 + 8; + + slotRow_ = Gdiplus::RectF(kX0, y, cw, 78); + add(Kind::SlotRow, slotRow_); + slotStep_ = (cw - 20 + 6) / float(prefs::kMaxPinned); + const float slotW = slotStep_ - 6; + const std::vector &order = dragging_ ? draft_ : pinned; + const std::wstring dragged = dragging_ && dragIndex_ >= 0 && dragIndex_ < int(pinned.size()) ? pinned[size_t(dragIndex_)] + : L""; + bool full = order.size() >= prefs::kMaxPinned; + size_t slot = 0; + for (; slot < order.size(); slot++) { + if (dragging_ && order[slot] == dragged) { + continue; + } + Widget &s = add(Kind::Slot, Gdiplus::RectF(kX0 + 10 + float(slot) * slotStep_, y + 10, slotW, 58)); + s.id = int(slot); + s.text = order[slot]; + } + if (!full) { + Widget &a = add(Kind::AddSlot, Gdiplus::RectF(kX0 + 10 + float(slot) * slotStep_, y + 10, slotW, 58)); + a.text = L"+"; + a.selected = picking_; + a.click = [this] { + picking_ = !picking_; + redraw(); + }; + slot++; + } + for (; slot < prefs::kMaxPinned; slot++) { + add(Kind::EmptySlot, Gdiplus::RectF(kX0 + 10 + float(slot) * slotStep_, y + 10, slotW, 58)); + } + y += 78 + 8; + text(g, + pinned.size() >= prefs::kMaxPinned ? L"The toolbar is full. Remove a pointer to add another. Drag to reorder." + : L"Press + to add a pointer. Drag to reorder, or out to remove.", + Caption, kX0 + 4, y, cw - 8, true); + y += 18; + if (!pinMessage_.empty()) { + y += 4; + text(g, pinMessage_, Medium, kX0 + 4, y, cw - 8).error = true; + } + + if (dragging_) { + Widget &f = add(Kind::Slot, Gdiplus::RectF(0, 0, slotW, 58)); + f.id = -1; + f.text = dragged; + f.selected = true; + f.error = dragOut_; + if (dragOut_) { + f.r.X = dragX_ - slotW / 2; + f.r.Y = dragY_ - 29; + } else { + f.r.X = std::clamp(dragX_ - slotW / 2, slotRow_.X + 4, slotRow_.X + slotRow_.Width - slotW - 4); + f.r.Y = slotRow_.Y + 10; + } + } + + if (picking_) { + const int cols = 8; + const float cell = 56, cellH = 54; + int count = int(kStyles.size()); + int rows = (count + cols - 1) / cols; + float pw = cols * cell + 24, ph = float(rows) * cellH + 24; + Gdiplus::RectF panel(kX0 + cw - pw, slotRow_.Y + slotRow_.Height + 8, pw, ph); + add(Kind::Picker, panel); + for (int k = 0; k < count; k++) { + std::wstring id = kStyles[size_t(k)].id; + Widget &c = add(Kind::PickerCell, Gdiplus::RectF(panel.X + 12 + float(k % cols) * cell, + panel.Y + 12 + float(k / cols) * cellH, cell, cellH)); + c.text = id; + c.selected = std::find(pinned.begin(), pinned.end(), id) != pinned.end(); + c.click = [this, id] { + auto &pin = prefs::get().pinned; + if (std::find(pin.begin(), pin.end(), id) != pin.end() || pin.size() >= prefs::kMaxPinned) { + return; + } + pin.push_back(id); + picking_ = false; + pinMessage_.clear(); + prefs::changed(); + }; + } + } +} + +void SettingsWindow::pageAppearance(Gdiplus::Graphics &g, float y) { + card(g, y, L"Pointer", + {[&](float ry) { + return row(g, ry, L"Size", L"", Kind::Slider, 226, 22, [](Widget &w) { + double size = prefs::get().pointerSize; + w.value = float((size - prefs::kMinSize) / (prefs::kMaxSize - prefs::kMinSize)); + w.slide = [](float t) { + prefs::get().pointerSize = prefs::kMinSize + double(t) * (prefs::kMaxSize - prefs::kMinSize); + prefs::changed(); + }; + }); + }, + [&](float ry) { + return row(g, ry, L"Shadow under the pointer", L"", Kind::Switch, 38, 22, [](Widget &w) { + w.on = prefs::get().showShadow; + w.click = [] { + prefs::get().showShadow = !prefs::get().showShadow; + prefs::changed(); + }; + }); + }}); +} + +void SettingsWindow::pageDrawing(Gdiplus::Graphics &g, float y) { + Prefs &p = prefs::get(); + std::wstring inkDetail = L"Matches each pointer."; + for (const auto &c : kInkChoices) { + if (p.inkChoice == c.id && !c.match) { + std::wstring name = c.title; + for (auto &ch : name) { + ch = wchar_t(towlower(ch)); + } + inkDetail = L"All pointers draw in " + name + L"."; + } + } + auto toggle = [&](float ry, const wchar_t *title, const wchar_t *detail, bool *value) { + return row(g, ry, title, detail, Kind::Switch, 38, 22, [value](Widget &w) { + w.on = *value; + w.click = [value] { + *value = !*value; + prefs::changed(); + }; + }); + }; + card(g, y, L"Ink", + {[&](float ry) { + const float step = 34; + float total = float(kInkChoices.size()) * 26 + float(kInkChoices.size() - 1) * 8; + return row(g, ry, L"Ink colour", inkDetail, Kind::Swatch, total, 26, [this, step](Widget &first) { + Gdiplus::RectF r(first.r.X, first.r.Y, 26, 26); + bool enabled = first.enabled; + for (size_t i = 0; i < kInkChoices.size(); i++) { + Widget &w = i == 0 ? first : add(Kind::Swatch, Gdiplus::RectF(r.X + float(i) * step, r.Y, 26, 26)); + w.r.Width = 26; + w.enabled = enabled; + w.id = int(i); + w.selected = prefs::get().inkChoice == kInkChoices[i].id; + w.text = kInkChoices[i].title; + std::wstring id = kInkChoices[i].id; + w.click = [id] { + prefs::get().inkChoice = id; + prefs::changed(); + }; + } + }); + }, + [&](float ry) { + std::vector opts; + int current = 0; + for (size_t i = 0; i < std::size(kFadeChoices); i++) { + opts.push_back(kFadeChoices[i].first); + if (std::fabs(p.fadeSeconds - kFadeChoices[i].second) < 0.01) { + current = int(i); + } + } + float w = 0; + for (const auto &o : opts) { + w = std::max(w, textWidth(g, o, Medium)); + } + return row(g, ry, L"Lines fade", L"Clear them any time with " + p.clearKey.display() + L".", Kind::Popup, + w + 40, 24, [&](Widget &widget) { + widget.options = opts; + widget.id = current; + widget.text = opts[size_t(current)]; + widget.pick = [](int i) { + prefs::get().fadeSeconds = kFadeChoices[i].second; + prefs::changed(); + }; + }); + }, + [&](float ry) { + return toggle(ry, L"Snap shapes", + L"Rough lines, arrows, circles, boxes, triangles, stars and hearts turn into clean shapes.", + &p.snapShapes); + }, + [&](float ry) { + return toggle(ry, L"Ink button in the toolbar", L"Change the colour while you present.", &p.showInkButton); + }}); +} + +void SettingsWindow::pageSound(Gdiplus::Graphics &g, float y) { + const float cw = kWidth - kX0 - kPad; + Prefs &p = prefs::get(); + text(g, L"Tap sound", Header, kX0 + 4, y, 200, true); + y += 16 + 8; + const float gap = 8, tw = (cw - 3 * gap) / 4, th = 74; + for (size_t k = 0; k <= kTapSounds.size(); k++) { + Widget &t = add(Kind::SoundTile, Gdiplus::RectF(kX0 + float(k % 4) * (tw + gap), y + float(k / 4) * (th + gap), tw, th)); + t.id = int(k); + if (k == 0) { + t.text = L"Off"; + t.selected = !p.handSound; + t.click = [] { + prefs::get().handSound = false; + prefs::changed(); + }; + } else { + const TapSoundInfo &s = kTapSounds[k - 1]; + t.text = s.title; + t.selected = p.handSound && p.tapSound == s.id; + std::wstring id = s.id; + t.click = [id] { + prefs::get().tapSound = id; + prefs::get().handSound = true; + TapSound::preview(id); + prefs::changed(); + }; + } + } + y += 2 * th + gap + 8; + text(g, L"Click a sound to hear it. It plays each time the hand taps.", Caption, kX0 + 4, y, cw - 8, true); + y += 18 + 22; + dim_ = !p.handSound; + card(g, y, nullptr, {[&](float ry) { + return row(g, ry, L"Laser dot taps too", L"Off by default, for quiet pointing.", Kind::Switch, 38, 22, + [](Widget &w) { + w.on = prefs::get().laserSound; + w.click = [] { + if (!prefs::get().handSound) { + return; + } + prefs::get().laserSound = !prefs::get().laserSound; + prefs::changed(); + }; + }); + }}); + dim_ = false; +} + +void SettingsWindow::pageAbout(Gdiplus::Graphics &g, float y) { + const float cw = kWidth - kX0 - kPad; + add(Kind::AppIcon, Gdiplus::RectF(kX0, y, 72, 72)); + text(g, L"TinyHand", Name, kX0 + 88, y + 10, 300); + text(g, std::wstring(L"Version ") + TH_VERSION, Medium, kX0 + 88, y + 36, 300, true); + text(g, L"A tiny hand for your PC. Free and open source.", Medium, kX0 + 88, y + 53, cw - 88, true); + y += 72 + 22; + struct Link { + const wchar_t *title, *detail, *url; + }; + const Link links[] = { + {L"Website", L"tinyhand.app", L"https://tinyhand.app"}, + {L"Source code", L"github.com/nilbuild/tinyhand", L"https://github.com/nilbuild/tinyhand"}, + {L"Made by", L"@nilbuild on X", L"https://x.com/nilbuild"}, + {L"License", L"MIT", L"https://github.com/nilbuild/tinyhand/blob/main/LICENSE"}, + }; + std::vector> rows; + for (const Link &l : links) { + rows.push_back([this, l, cw](float ry) { + Widget &w = add(Kind::LinkRow, Gdiplus::RectF(kX0, ry, cw, 44)); + w.text = l.title; + w.detail = l.detail; + std::wstring url = l.url; + w.click = [url] { ShellExecuteW(nullptr, L"open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL); }; + return ry + 44; + }); + } + y = card(g, y, nullptr, rows); + y += 22; + std::wstring label = L"Reset all settings…"; + float rw = textWidth(g, label, Caption) + 2; + Widget &reset = add(Kind::TextButton, Gdiplus::RectF(kX0 + cw - 4 - rw, y, rw, 15)); + reset.text = label; + reset.click = [this] { + if (MessageBoxW(hwnd_, L"Reset all TinyHand settings to how they started?", L"TinyHand", + MB_YESNO | MB_ICONQUESTION) != IDYES) { + return; + } + prefs::reset(); + if (applyHotkey) { + applyHotkey(prefs::get().toggleKey, true); + applyHotkey(prefs::get().clearKey, false); + } + redraw(); + }; +} + +// --------------------------------------------------------------------------- +// Drawing + +void SettingsWindow::paint(Gdiplus::Graphics &g, float width, float height) { + bool dark = forceTheme_ >= 0 ? forceTheme_ == 1 : th::apps_use_dark(); + pal_ = dark ? Palette{0x2B2B2D, 0x232325, 0xF2F2F7, 0x98989F, 0x0A84FF, 0x48484A, 0x343436, true} + : Palette{0xECECEC, 0xE2E2E2, 0x1D1D1F, 0x6E6E73, 0x0A7AFF, 0xFFFFFF, 0xF7F7F7, false}; + g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); + g.SetTextRenderingHint(Gdiplus::TextRenderingHintClearTypeGridFit); + g.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic); + build(g, width, height); + g.Clear(th::rgba(pal_.window)); + Gdiplus::SolidBrush side(th::rgba(pal_.sidebar)); + g.FillRectangle(&side, 0.0f, 0.0f, kSidebar, height); + for (size_t i = 0; i < widgets_.size(); i++) { + draw(g, widgets_[i], int(i)); + } +} + +void SettingsWindow::drawGlyph(Gdiplus::Graphics &g, int glyph, const Gdiplus::RectF &r, Gdiplus::Color c) { + const float w = r.Width, h = r.Height, x = r.X, y = r.Y; + auto P = [&](float u, float v) { return Gdiplus::PointF(x + u * w, y + v * h); }; + Gdiplus::Pen pen(c, std::max(1.2f, w * 0.1f)); + pen.SetLineCap(Gdiplus::LineCapRound, Gdiplus::LineCapRound, Gdiplus::DashCapRound); + pen.SetLineJoin(Gdiplus::LineJoinRound); + Gdiplus::SolidBrush brush(c); + switch (glyph) { + case Gear: { + Gdiplus::Pen ring(c, w * 0.13f); + g.DrawEllipse(&ring, x + w * 0.31f, y + h * 0.31f, w * 0.38f, h * 0.38f); + Gdiplus::Pen tooth(c, w * 0.14f); + for (int i = 0; i < 8; i++) { + float a = float(i) * float(th::kPi) / 4; + g.DrawLine(&tooth, x + w * (0.5f + 0.24f * std::cos(a)), y + h * (0.5f + 0.24f * std::sin(a)), + x + w * (0.5f + 0.37f * std::cos(a)), y + h * (0.5f + 0.37f * std::sin(a))); + } + break; + } + case Hand: + draw_tinted(g, hands::cached_thumbnail(L"glove"), Gdiplus::RectF(x + w * 0.08f, y + h * 0.08f, w * 0.84f, h * 0.84f), c); + break; + case Sparkles: { + auto star = [&](float cx, float cy, float rad) { + Gdiplus::PointF pts[8]; + for (int i = 0; i < 8; i++) { + float a = float(i) * float(th::kPi) / 4 - float(th::kPi) / 2; + float rr = i % 2 ? rad * 0.3f : rad; + pts[i] = Gdiplus::PointF(x + w * cx + rr * w * std::cos(a), y + h * cy + rr * h * std::sin(a)); + } + g.FillPolygon(&brush, pts, 8); + }; + star(0.56f, 0.56f, 0.34f); + star(0.27f, 0.27f, 0.15f); + break; + } + case Pencil: { + Gdiplus::PointF nib[] = {P(0.3f, 0.82f), P(0.5f, 0.18f), P(0.7f, 0.82f)}; + Gdiplus::Pen thick(c, w * 0.12f); + thick.SetLineCap(Gdiplus::LineCapRound, Gdiplus::LineCapRound, Gdiplus::DashCapRound); + thick.SetLineJoin(Gdiplus::LineJoinRound); + g.DrawLines(&thick, nib, 3); + g.FillEllipse(&brush, x + w * 0.45f, y + h * 0.44f, w * 0.1f, h * 0.1f); + break; + } + case Speaker: + case Mute: { + Gdiplus::PointF body[] = {P(0.12f, 0.38f), P(0.3f, 0.38f), P(0.52f, 0.18f), P(0.52f, 0.82f), P(0.3f, 0.62f), + P(0.12f, 0.62f)}; + g.FillPolygon(&brush, body, 6); + if (glyph == Speaker) { + g.DrawArc(&pen, x + w * 0.42f, y + h * 0.32f, w * 0.36f, h * 0.36f, -55, 110); + g.DrawArc(&pen, x + w * 0.34f, y + h * 0.16f, w * 0.6f, h * 0.68f, -55, 110); + } else { + g.DrawLine(&pen, P(0.62f, 0.38f), P(0.88f, 0.64f)); + g.DrawLine(&pen, P(0.62f, 0.64f), P(0.88f, 0.38f)); + } + break; + } + case Info: { + Gdiplus::Pen ring(c, w * 0.09f); + g.DrawEllipse(&ring, x + w * 0.16f, y + h * 0.16f, w * 0.68f, h * 0.68f); + g.FillEllipse(&brush, x + w * 0.45f, y + h * 0.29f, w * 0.1f, h * 0.1f); + Gdiplus::Pen bar(c, w * 0.11f); + bar.SetLineCap(Gdiplus::LineCapRound, Gdiplus::LineCapRound, Gdiplus::DashCapRound); + g.DrawLine(&bar, P(0.5f, 0.47f), P(0.5f, 0.7f)); + break; + } + case Wave: { + const float heights[] = {0.3f, 0.55f, 0.85f, 0.45f, 0.85f, 0.55f, 0.3f}; + Gdiplus::Pen bar(c, w * 0.07f); + bar.SetLineCap(Gdiplus::LineCapRound, Gdiplus::LineCapRound, Gdiplus::DashCapRound); + for (int i = 0; i < 7; i++) { + float u = 0.11f + 0.13f * float(i); + g.DrawLine(&bar, P(u, 0.5f - heights[i] / 2), P(u, 0.5f + heights[i] / 2)); + } + break; + } + case Plus: + g.DrawLine(&pen, P(0.5f, 0.15f), P(0.5f, 0.85f)); + g.DrawLine(&pen, P(0.15f, 0.5f), P(0.85f, 0.5f)); + break; + case Arrow: + g.DrawLine(&pen, P(0.22f, 0.78f), P(0.78f, 0.22f)); + g.DrawLine(&pen, P(0.36f, 0.22f), P(0.78f, 0.22f)); + g.DrawLine(&pen, P(0.78f, 0.22f), P(0.78f, 0.64f)); + break; + case Chevrons: { + Gdiplus::PointF up[] = {P(0.2f, 0.4f), P(0.5f, 0.15f), P(0.8f, 0.4f)}; + Gdiplus::PointF down[] = {P(0.2f, 0.6f), P(0.5f, 0.85f), P(0.8f, 0.6f)}; + g.DrawLines(&pen, up, 3); + g.DrawLines(&pen, down, 3); + break; + } + case Cross: + g.DrawLine(&pen, P(0.25f, 0.25f), P(0.75f, 0.75f)); + g.DrawLine(&pen, P(0.25f, 0.75f), P(0.75f, 0.25f)); + break; + case Minus: + g.DrawLine(&pen, P(0.25f, 0.5f), P(0.75f, 0.5f)); + break; + default: + break; + } +} + +void SettingsWindow::drawShortcut(Gdiplus::Graphics &g, const Widget &w) { + const Hotkey &h = w.id == 0 ? prefs::get().toggleKey : prefs::get().clearKey; + bool hovered = &w == (hover_ >= 0 ? &widgets_[size_t(hover_)] : nullptr); + round_fill(g, w.r, 7, pal_.ink(w.selected ? 0.1f : hovered ? 0.08f : 0.05f)); + if (w.selected) { + round_stroke(g, w.r, 7, th::rgba(pal_.accent, 0.8f), 1.5f); + Gdiplus::StringFormat fmt; + fmt.SetAlignment(Gdiplus::StringAlignmentCenter); + fmt.SetLineAlignment(Gdiplus::StringAlignmentCenter); + Gdiplus::SolidBrush b(th::rgba(pal_.secondary)); + g.DrawString(L"Press keys", -1, fonts_[Medium].get(), w.r, &fmt, &b); + return; + } + std::vector caps = keycaps(h); + std::vector widths; + float total = 0; + for (const auto &c : caps) { + widths.push_back(std::max(22.0f, textWidth(g, c, Key) + 12)); + total += widths.back(); + } + total += 4 * float(caps.size() - 1); + float x = w.r.X + (w.r.Width - total) / 2; + float y = w.r.Y + (w.r.Height - 22) / 2; + Gdiplus::StringFormat fmt; + fmt.SetAlignment(Gdiplus::StringAlignmentCenter); + fmt.SetLineAlignment(Gdiplus::StringAlignmentCenter); + Gdiplus::SolidBrush textBrush(th::rgba(pal_.text, 0.85f)); + for (size_t i = 0; i < caps.size(); i++) { + Gdiplus::RectF cap(x, y, widths[i], 22); + round_fill(g, Gdiplus::RectF(cap.X, cap.Y + 1, cap.Width, cap.Height), 5, pal_.ink(0.12f)); + round_fill(g, cap, 5, th::rgba(pal_.dark ? 0x5A5A5E : 0xFBFBFB)); + round_stroke(g, cap, 5, pal_.ink(0.16f), 1); + g.DrawString(caps[i].c_str(), -1, fonts_[Key].get(), cap, &fmt, &textBrush); + x += widths[i] + 4; + } +} + +void SettingsWindow::draw(Gdiplus::Graphics &g, const Widget &w, int index) { + const bool hovered = index == hover_; + const float alpha = w.enabled ? 1.0f : 0.5f; + Gdiplus::StringFormat center; + center.SetAlignment(Gdiplus::StringAlignmentCenter); + center.SetLineAlignment(Gdiplus::StringAlignmentCenter); + Gdiplus::StringFormat leftMid; + leftMid.SetLineAlignment(Gdiplus::StringAlignmentCenter); + leftMid.SetFormatFlags(Gdiplus::StringFormatFlagsNoWrap); + Gdiplus::SolidBrush textBrush(th::rgba(pal_.text, alpha)); + Gdiplus::SolidBrush secondaryBrush(th::rgba(pal_.secondary, alpha)); + + switch (w.kind) { + case Kind::Sidebar: { + if (w.selected || hovered) { + round_fill(g, w.r, 8, pal_.ink(w.selected ? 0.1f : 0.05f)); + } + Gdiplus::RectF tile(w.r.X + 8, w.r.Y + 6, 24, 24); + uint32_t tint = kSections[w.id].tint; + Gdiplus::LinearGradientBrush grad(tile, th::rgba(mix(tint, 0xFFFFFF, 0.15f)), th::rgba(tint), + Gdiplus::LinearGradientModeVertical); + Gdiplus::GraphicsPath p; + th::add_rounded_rect(p, tile, 6); + g.FillPath(&grad, &p); + drawGlyph(g, kSections[w.id].glyph, Gdiplus::RectF(tile.X + 4, tile.Y + 4, 16, 16), th::white(1)); + g.DrawString(w.text.c_str(), -1, fonts_[w.selected ? BodyBold : Body].get(), + Gdiplus::RectF(tile.X + 34, w.r.Y, w.r.Width - 50, w.r.Height), &leftMid, &textBrush); + return; + } + case Kind::Text: { + Gdiplus::StringFormat fmt; + if (w.right) { + fmt.SetAlignment(Gdiplus::StringAlignmentFar); + } + Gdiplus::SolidBrush red(th::rgba(0xE5484D, alpha)); + g.DrawString(w.text.c_str(), -1, fonts_[w.font].get(), Gdiplus::RectF(w.r.X, w.r.Y, w.r.Width, w.r.Height + 4), + &fmt, w.error ? &red : w.secondary ? &secondaryBrush : &textBrush); + return; + } + case Kind::Card: + round_fill(g, w.r, 12, pal_.ink(0.045f)); + return; + case Kind::Separator: { + Gdiplus::SolidBrush b(pal_.ink(0.07f)); + g.FillRectangle(&b, w.r); + return; + } + case Kind::Switch: { + Gdiplus::RectF r = w.r; + round_fill(g, r, r.Height / 2, + with_alpha(w.on ? th::rgba(pal_.accent) : pal_.ink(pal_.dark ? 0.2f : 0.13f), alpha)); + float d = r.Height - 4; + float kx = w.on ? r.X + r.Width - 2 - d : r.X + 2; + Gdiplus::SolidBrush shade(th::black(0.12f * alpha)); + g.FillEllipse(&shade, kx - 0.5f, r.Y + 2.5f, d + 1, d + 1); + Gdiplus::SolidBrush knob(with_alpha(th::white(1), alpha)); + g.FillEllipse(&knob, kx, r.Y + 2, d, d); + return; + } + case Kind::Popup: { + soft_shadow(g, w.r, 6, 0.05f); + round_fill(g, w.r, 6, th::rgba(hovered ? mix(pal_.control, pal_.dark ? 0xFFFFFF : 0x000000, 0.04f) : pal_.control)); + round_stroke(g, w.r, 6, pal_.ink(0.1f), 1); + g.DrawString(w.text.c_str(), -1, fonts_[Medium].get(), + Gdiplus::RectF(w.r.X + 10, w.r.Y, w.r.Width - 30, w.r.Height), &leftMid, &textBrush); + drawGlyph(g, Chevrons, Gdiplus::RectF(w.r.X + w.r.Width - 19, w.r.Y + 6, 10, 12), th::rgba(pal_.text, 0.7f)); + return; + } + case Kind::Slider: { + Gdiplus::RectF small(w.r.X, w.r.Y + 6, 10, 10), big(w.r.X + w.r.Width - 16, w.r.Y + 3, 16, 16); + draw_tinted(g, hands::cached_thumbnail(L"glove"), small, th::rgba(pal_.secondary)); + draw_tinted(g, hands::cached_thumbnail(L"glove"), big, th::rgba(pal_.secondary)); + float tx = w.r.X + 20, tw = w.r.Width - 46, cy = w.r.Y + w.r.Height / 2; + round_fill(g, Gdiplus::RectF(tx, cy - 2, tw, 4), 2, pal_.ink(0.15f)); + float kx = tx + tw * std::clamp(w.value, 0.0f, 1.0f); + round_fill(g, Gdiplus::RectF(tx, cy - 2, kx - tx, 4), 2, th::rgba(pal_.accent)); + Gdiplus::SolidBrush shade(th::black(0.16f)); + g.FillEllipse(&shade, kx - 10.5f, cy - 9.5f, 21.0f, 21.0f); + Gdiplus::SolidBrush knob(th::rgba(pal_.dark ? 0xE5E5EA : 0xFFFFFF)); + g.FillEllipse(&knob, kx - 10, cy - 10, 20.0f, 20.0f); + return; + } + case Kind::Recorder: + drawShortcut(g, w); + return; + case Kind::Swatch: { + const InkChoiceInfo &c = kInkChoices[size_t(w.id)]; + Gdiplus::RectF dot(w.r.X + 3, w.r.Y + 3, 20, 20); + if (hovered && !w.selected) { + dot = Gdiplus::RectF(w.r.X + 2.2f, w.r.Y + 2.2f, 21.6f, 21.6f); + } + if (c.match) { + for (int i = 0; i < 24; i++) { + float hue = float(i) / 24.0f; + auto channel = [hue](float offset) { + float v = std::fabs(std::fmod(hue * 6 + offset, 6.0f) - 3) - 1; + return std::clamp(v, 0.0f, 1.0f); + }; + uint32_t rgb = (uint32_t(channel(0) * 255) << 16) | (uint32_t(channel(4) * 255) << 8) | + uint32_t(channel(2) * 255); + Gdiplus::SolidBrush slice(th::rgba(rgb, alpha)); + g.FillPie(&slice, dot, -90.0f + 15.0f * float(i), 15.5f); + } + } else { + Gdiplus::SolidBrush fill(th::rgba(c.color, alpha)); + g.FillEllipse(&fill, dot); + Gdiplus::Pen edge(pal_.ink(c.color == 0x1D1D21 || c.color == 0xFFFFFF ? 0.3f : 0.15f), 1); + g.DrawEllipse(&edge, dot.X + 0.5f, dot.Y + 0.5f, dot.Width - 1, dot.Height - 1); + } + if (w.selected) { + Gdiplus::Pen ring(th::rgba(pal_.accent), 2); + g.DrawEllipse(&ring, w.r.X + 1, w.r.Y + 1, w.r.Width - 2, w.r.Height - 2); + } + return; + } + case Kind::SoundTile: { + round_fill(g, w.r, 12, w.selected ? th::rgba(pal_.accent, 0.12f) : pal_.ink(hovered ? 0.07f : 0.035f)); + if (w.selected) { + round_stroke(g, w.r, 12, th::rgba(pal_.accent, 0.7f), 1.5f); + } + Gdiplus::Color glyph = w.selected ? th::rgba(pal_.accent) : th::rgba(pal_.secondary); + drawGlyph(g, w.id == 0 ? Mute : Wave, Gdiplus::RectF(w.r.X + w.r.Width / 2 - 11, w.r.Y + 14, 22, 22), glyph); + g.DrawString(w.text.c_str(), -1, fonts_[w.selected ? Header : Medium].get(), + Gdiplus::RectF(w.r.X, w.r.Y + 44, w.r.Width, 18), ¢er, + w.selected ? &textBrush : &secondaryBrush); + return; + } + case Kind::SlotRow: + round_fill(g, w.r, 14, pal_.ink(0.045f)); + return; + case Kind::Slot: { + bool lifted = w.selected; + Gdiplus::RectF r = w.r; + if (lifted) { + r = Gdiplus::RectF(r.X - r.Width * 0.03f, r.Y - r.Height * 0.03f, r.Width * 1.06f, r.Height * 1.06f); + soft_shadow(g, Gdiplus::RectF(r.X, r.Y + 3, r.Width, r.Height), 10, 0.12f); + } else { + soft_shadow(g, r, 10, 0.05f); + } + round_fill(g, r, 10, th::rgba(pal_.dark ? 0x3A3A3C : 0xF7F7F7, 0.97f)); + pointer_icon(g, w.text, Gdiplus::RectF(r.X + (r.Width - 40) / 2, r.Y + (r.Height - 40) / 2, 40, 40)); + if (lifted && w.error) { + Gdiplus::RectF badge(r.X + r.Width - 12, r.Y - 5, 17, 17); + Gdiplus::SolidBrush red(th::rgba(0xFF3B30)); + g.FillEllipse(&red, badge); + drawGlyph(g, Minus, badge, th::white(1)); + } else if (hovered && !dragging_ && w.id >= 0) { + Gdiplus::RectF badge(r.X + r.Width - 11, r.Y - 5, 16, 16); + Gdiplus::SolidBrush dark(th::black(0.55f)); + g.FillEllipse(&dark, badge); + drawGlyph(g, Cross, Gdiplus::RectF(badge.X + 3, badge.Y + 3, 10, 10), th::white(1)); + } + return; + } + case Kind::AddSlot: { + bool on = hovered || w.selected; + round_fill(g, w.r, 10, pal_.ink(on ? 0.09f : 0.05f)); + Gdiplus::GraphicsPath p; + th::add_rounded_rect(p, Gdiplus::RectF(w.r.X + 0.5f, w.r.Y + 0.5f, w.r.Width - 1, w.r.Height - 1), 10); + Gdiplus::Pen dash(pal_.ink(0.16f), 1); + Gdiplus::REAL pattern[] = {4, 3}; + dash.SetDashPattern(pattern, 2); + g.DrawPath(&dash, &p); + drawGlyph(g, Plus, Gdiplus::RectF(w.r.X + w.r.Width / 2 - 7, w.r.Y + w.r.Height / 2 - 7, 14, 14), + on ? th::rgba(pal_.text) : th::rgba(pal_.secondary)); + return; + } + case Kind::EmptySlot: + round_fill(g, w.r, 10, pal_.ink(0.035f)); + return; + case Kind::TextButton: { + Gdiplus::StringFormat fmt(Gdiplus::StringFormatFlagsNoWrap); + g.DrawString(w.text.c_str(), -1, fonts_[Caption].get(), Gdiplus::PointF(w.r.X, w.r.Y), + &fmt, hovered ? &textBrush : &secondaryBrush); + return; + } + case Kind::LinkRow: { + if (hovered) { + Gdiplus::SolidBrush b(pal_.ink(0.04f)); + g.FillRectangle(&b, w.r); + } + g.DrawString(w.text.c_str(), -1, fonts_[Body].get(), Gdiplus::RectF(w.r.X + 14, w.r.Y, w.r.Width, w.r.Height), + &leftMid, &textBrush); + float dw = textWidth(g, w.detail, Medium); + g.DrawString(w.detail.c_str(), -1, fonts_[Medium].get(), + Gdiplus::RectF(w.r.X + w.r.Width - 32 - dw, w.r.Y, dw + 4, w.r.Height), &leftMid, &secondaryBrush); + drawGlyph(g, Arrow, Gdiplus::RectF(w.r.X + w.r.Width - 26, w.r.Y + w.r.Height / 2 - 5, 10, 10), + th::rgba(pal_.secondary)); + return; + } + case Kind::Picker: + soft_shadow(g, Gdiplus::RectF(w.r.X, w.r.Y + 4, w.r.Width, w.r.Height), 12, 0.16f); + round_fill(g, w.r, 12, th::rgba(pal_.popover)); + round_stroke(g, w.r, 12, pal_.ink(0.1f), 1); + return; + case Kind::PickerCell: + if (hovered && !w.selected) { + round_fill(g, Gdiplus::RectF(w.r.X + 2, w.r.Y + 2, w.r.Width - 4, w.r.Height - 4), 10, pal_.ink(0.08f)); + } + pointer_icon(g, w.text, Gdiplus::RectF(w.r.X + (w.r.Width - 40) / 2, w.r.Y + (w.r.Height - 40) / 2, 40, 40), + w.selected ? 0.35f : 1.0f); + return; + case Kind::AppIcon: { + static std::unique_ptr icon(th::load_png_resource(IDR_APP_PNG)); + if (icon) { + g.DrawImage(icon.get(), w.r); + } + return; + } + case Kind::Glyph: + return; + } +} + +// --------------------------------------------------------------------------- +// Input + +int SettingsWindow::hit(float x, float y) const { + for (int i = int(widgets_.size()) - 1; i >= 0; i--) { + const Widget &w = widgets_[size_t(i)]; + switch (w.kind) { + case Kind::Text: + case Kind::Card: + case Kind::Separator: + case Kind::SlotRow: + case Kind::EmptySlot: + case Kind::AppIcon: + case Kind::Glyph: + continue; + case Kind::Picker: + if (w.r.Contains(x, y)) { + return -1; + } + continue; + default: + break; + } + if (w.kind == Kind::Slot && w.id < 0) { + continue; + } + Gdiplus::RectF r = w.r; + if (w.kind == Kind::Slot) { + r = Gdiplus::RectF(r.X, r.Y - 6, r.Width + 6, r.Height + 6); + } + if (r.Contains(x, y)) { + return i; + } + } + return -1; +} + +void SettingsWindow::mouseDown(float x, float y) { + leftDown_ = true; + if (picking_) { + bool inPicker = false; + for (const Widget &w : widgets_) { + if (w.kind == Kind::Picker && w.r.Contains(x, y)) { + inPicker = true; + } + } + if (!inPicker) { + picking_ = false; + pressed_ = -1; + redraw(); + return; + } + } + int h = hit(x, y); + if (recording_ >= 0 && (h < 0 || widgets_[size_t(h)].kind != Kind::Recorder)) { + stopRecording(); + } + pressed_ = h; + if (h < 0) { + return; + } + Widget &w = widgets_[size_t(h)]; + if (w.kind == Kind::Slider) { + sliding_ = h; + mouseMove(x, y); + } else if (w.kind == Kind::Slot) { + dragIndex_ = w.id; + dragStartX_ = dragX_ = x; + dragStartY_ = dragY_ = y; + dragging_ = false; + dragOut_ = false; + draft_ = prefs::get().pinned; + } +} + +void SettingsWindow::mouseMove(float x, float y) { + if (sliding_ >= 0 && sliding_ < int(widgets_.size())) { + const Widget &w = widgets_[size_t(sliding_)]; + float tx = w.r.X + 20, tw = w.r.Width - 46; + float t = std::clamp((x - tx) / tw, 0.0f, 1.0f); + if (w.slide) { + w.slide(t); + } + redraw(); + return; + } + if (dragIndex_ >= 0 && leftDown_) { + if (!dragging_ && std::hypot(x - dragStartX_, y - dragStartY_) > 3) { + dragging_ = true; + } + if (dragging_) { + dragX_ = x; + dragY_ = y; + const auto &pinned = prefs::get().pinned; + std::wstring style = pinned[size_t(dragIndex_)]; + std::vector base; + for (const auto &s : pinned) { + if (s != style) { + base.push_back(s); + } + } + Gdiplus::RectF zone(slotRow_.X - 16, slotRow_.Y - 28, slotRow_.Width + 32, slotRow_.Height + 56); + if (zone.Contains(x, y)) { + int slot = std::clamp(int((x - slotRow_.X - 10) / slotStep_), 0, int(base.size())); + base.insert(base.begin() + slot, style); + draft_ = base; + dragOut_ = false; + } else { + dragOut_ = pinned.size() > 1; + draft_ = dragOut_ ? base : pinned; + } + redraw(); + return; + } + } + int h = hit(x, y); + if (h != hover_) { + hover_ = h; + redraw(); + } +} + +void SettingsWindow::commitDrag() { + auto &pinned = prefs::get().pinned; + Gdiplus::RectF zone(slotRow_.X - 16, slotRow_.Y - 28, slotRow_.Width + 32, slotRow_.Height + 56); + bool outside = !zone.Contains(dragX_, dragY_); + if (outside && pinned.size() <= 1) { + pinMessage_ = kKeepOne; + } else { + pinned = draft_; + pinMessage_.clear(); + } + dragging_ = false; + dragOut_ = false; + dragIndex_ = -1; + prefs::changed(); + redraw(); +} + +void SettingsWindow::mouseUp(float x, float y) { + leftDown_ = false; + if (sliding_ >= 0) { + sliding_ = -1; + pressed_ = -1; + return; + } + if (dragging_) { + pressed_ = -1; + commitDrag(); + return; + } + int h = hit(x, y); + int was = pressed_; + pressed_ = -1; + if (dragIndex_ >= 0) { + int index = dragIndex_; + dragIndex_ = -1; + if (h == was && h >= 0) { + const Widget &w = widgets_[size_t(h)]; + bool onBadge = x >= w.r.X + w.r.Width - 13 && y <= w.r.Y + 13; + if (onBadge) { + auto &pinned = prefs::get().pinned; + if (pinned.size() <= 1) { + pinMessage_ = kKeepOne; + redraw(); + } else { + pinned.erase(pinned.begin() + index); + pinMessage_.clear(); + prefs::changed(); + } + } + } + return; + } + if (h < 0 || h != was) { + return; + } + Widget w = widgets_[size_t(h)]; + if (w.kind == Kind::Popup && w.pick) { + HMENU menu = CreatePopupMenu(); + for (size_t i = 0; i < w.options.size(); i++) { + AppendMenuW(menu, MF_STRING | (int(i) == w.id ? MF_CHECKED : 0), UINT(i + 1), w.options[i].c_str()); + } + POINT p{LONG(std::lround(w.r.X * scale_)), LONG(std::lround((w.r.Y + w.r.Height + 2) * scale_))}; + ClientToScreen(hwnd_, &p); + UINT cmd = UINT(TrackPopupMenu(menu, TPM_RETURNCMD | TPM_LEFTALIGN | TPM_TOPALIGN, p.x, p.y, 0, hwnd_, nullptr)); + DestroyMenu(menu); + if (cmd > 0) { + w.pick(int(cmd - 1)); + } + redraw(); + return; + } + if (w.click && w.enabled) { + w.click(); + redraw(); + } +} + +bool SettingsWindow::locate(const std::wstring &label, bool control, RECT *out) const { + for (size_t i = 0; i < widgets_.size(); i++) { + const Widget &w = widgets_[i]; + const Widget *target = nullptr; + if (!control && w.text == label && + (w.kind == Kind::Sidebar || w.kind == Kind::SoundTile || (w.kind == Kind::Slot && w.id >= 0) || + w.kind == Kind::PickerCell || w.kind == Kind::TextButton || w.kind == Kind::AddSlot)) { + target = &w; + } else if (control && w.kind == Kind::Text && w.font == Body && w.text == label) { + for (size_t k = i + 1; k < widgets_.size() && !target; k++) { + Kind kind = widgets_[k].kind; + if (kind == Kind::Switch || kind == Kind::Slider || kind == Kind::Popup || kind == Kind::Recorder || + kind == Kind::Swatch) { + target = &widgets_[k]; + } + } + } + if (target) { + out->left = LONG(std::lround(target->r.X * scale_)); + out->top = LONG(std::lround(target->r.Y * scale_)); + out->right = LONG(std::lround((target->r.X + target->r.Width) * scale_)); + out->bottom = LONG(std::lround((target->r.Y + target->r.Height) * scale_)); + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Shortcut recording + +void SettingsWindow::startRecording(int which) { + if (recording_ == which) { + stopRecording(); + return; + } + stopRecording(); + recording_ = which; + shortcutError_.clear(); + if (onRecording) { + onRecording(true); + } + SetFocus(hwnd_); + redraw(); +} + +void SettingsWindow::stopRecording() { + if (recording_ < 0) { + return; + } + recording_ = -1; + if (onRecording) { + onRecording(false); + } + redraw(); +} + +bool SettingsWindow::recordKey(UINT vk) { + switch (vk) { + case VK_CONTROL: + case VK_LCONTROL: + case VK_RCONTROL: + case VK_MENU: + case VK_LMENU: + case VK_RMENU: + case VK_SHIFT: + case VK_LSHIFT: + case VK_RSHIFT: + case VK_LWIN: + case VK_RWIN: + return true; + default: + break; + } + UINT mods = 0; + if (GetKeyState(VK_CONTROL) < 0) { + mods |= MOD_CONTROL; + } + if (GetKeyState(VK_MENU) < 0) { + mods |= MOD_ALT; + } + if (GetKeyState(VK_SHIFT) < 0) { + mods |= MOD_SHIFT; + } + if (GetKeyState(VK_LWIN) < 0 || GetKeyState(VK_RWIN) < 0) { + mods |= MOD_WIN; + } + if (vk == VK_ESCAPE && mods == 0) { + stopRecording(); + return true; + } + if (!(mods & (MOD_CONTROL | MOD_ALT | MOD_WIN))) { + shortcutError_ = L"Use Ctrl, Alt or Win together with a key."; + redraw(); + return true; + } + Hotkey h{mods, vk}; + int which = recording_; + stopRecording(); + bool ok = applyHotkey ? applyHotkey(h, which == 0) : false; + shortcutError_ = ok ? L"" : h.display() + L" is already used by another app. Pick a different shortcut."; + redraw(); + return true; +} + +// --------------------------------------------------------------------------- +// Offscreen snapshot for --settings-test + +bool SettingsWindow::snapshot(const std::wstring &path, int section, bool dark, bool picker) { + for (const auto &s : kStyles) { + if (!style_is_laser(s.id)) { + hands::thumbnail(s.id); + } + } + hands::thumbnail(L"glove"); + section_ = std::clamp(section, 0, int(SectionCount) - 1); + picking_ = picker; + scale_ = 1; + fontScale_ = 0; + std::unique_ptr canvas(th::new_pargb(int(kWidth), int(kHeight))); + if (!canvas) { + return false; + } + forceTheme_ = dark ? 1 : 0; + { + Gdiplus::Graphics g(canvas.get()); + paint(g, kWidth, kHeight); + } + forceTheme_ = -1; + return th::save_png(canvas.get(), path); +} diff --git a/windows/src/settings.h b/windows/src/settings.h new file mode 100644 index 0000000..4f5aac2 --- /dev/null +++ b/windows/src/settings.h @@ -0,0 +1,152 @@ +#pragma once + +#include "prefs.h" + +// Settings, laid out like the macOS Settings window: a sidebar of sections +// (General, Pointers, Appearance, Drawing, Sound, About), each page made of +// titled cards with rows, switches, menus and swatches. Everything is drawn +// with GDI+ so it looks the same on every Windows version and under Wine, +// follows the light or dark app theme, and applies changes at once. +class SettingsWindow { +public: + enum Section { General, Pointers, Appearance, Drawing, Sound, About, SectionCount }; + + // Returns false when the shortcut could not be registered. + std::function applyHotkey; + // True while a shortcut is being recorded, so global hotkeys step aside. + std::function onRecording; + + void show(HINSTANCE instance, int section = -1); + // Draws one page offscreen at 1x, for --settings-test. + bool snapshot(const std::wstring &path, int section, bool dark, bool picker = false); + HWND hwnd() const { return hwnd_; } + int section() const { return section_; } + // For --settings-ui-test: where the widget labelled `label` is (a sidebar + // row, sound tile, pointer id, "+"), or with control=true the switch, + // slider or menu on the row titled `label`. Client pixels. + bool locate(const std::wstring &label, bool control, RECT *out) const; + +private: + enum class Kind { + Sidebar, + Text, + Card, + Separator, + Switch, + Popup, + Slider, + Recorder, + Swatch, + SoundTile, + SlotRow, + Slot, + AddSlot, + EmptySlot, + TextButton, + LinkRow, + Picker, + PickerCell, + AppIcon, + Glyph, + }; + enum Font { Body, BodyBold, Detail, Header, Title, Name, Caption, Medium, Key, FontCount }; + + struct Widget { + Kind kind = Kind::Text; + Gdiplus::RectF r; + int id = 0; + int font = Body; + bool secondary = false; + bool error = false; + bool right = false; + bool on = false; + bool enabled = true; + bool selected = false; + float value = 0; + std::wstring text; + std::wstring detail; + std::vector options; + std::function click; + std::function pick; + std::function slide; + }; + + struct Palette { + uint32_t window, sidebar, text, secondary, accent, control, popover; + bool dark; + Gdiplus::Color ink(float alpha) const; + }; + + static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); + LRESULT proc(UINT msg, WPARAM wp, LPARAM lp); + void create(); + void resize(); + void build(Gdiplus::Graphics &g, float width, float height); + void paint(Gdiplus::Graphics &g, float width, float height); + void draw(Gdiplus::Graphics &g, const Widget &w, int index); + void drawGlyph(Gdiplus::Graphics &g, int glyph, const Gdiplus::RectF &r, Gdiplus::Color c); + void drawShortcut(Gdiplus::Graphics &g, const Widget &w); + void redraw(); + + float textHeight(Gdiplus::Graphics &g, const std::wstring &text, int font, float width); + float textWidth(Gdiplus::Graphics &g, const std::wstring &text, int font); + Widget &add(Kind kind, Gdiplus::RectF r); + Widget &text(Gdiplus::Graphics &g, const std::wstring &s, int font, float x, float y, float width, + bool secondary = false); + float card(Gdiplus::Graphics &g, float y, const wchar_t *title, std::vector> rows); + float row(Gdiplus::Graphics &g, float y, const std::wstring &title, const std::wstring &detail, Kind trailing, + float trailW, float trailH, std::function setup); + + void pageGeneral(Gdiplus::Graphics &g, float y); + void pagePointers(Gdiplus::Graphics &g, float y); + void pageAppearance(Gdiplus::Graphics &g, float y); + void pageDrawing(Gdiplus::Graphics &g, float y); + void pageSound(Gdiplus::Graphics &g, float y); + void pageAbout(Gdiplus::Graphics &g, float y); + + int hit(float x, float y) const; + void mouseDown(float x, float y); + void mouseMove(float x, float y); + void mouseUp(float x, float y); + void startRecording(int which); + void stopRecording(); + bool recordKey(UINT vk); + void commitDrag(); + void preloadThumbnails(); + + HINSTANCE instance_ = nullptr; + HWND hwnd_ = nullptr; + th::Surface surface_; + float scale_ = 1; + Palette pal_{}; + std::unique_ptr family_; + std::unique_ptr semibold_; + std::unique_ptr fonts_[FontCount]; + float fontScale_ = 0; + bool listening_ = false; + + std::vector widgets_; + int section_ = General; + int hover_ = -1; + int pressed_ = -1; + int sliding_ = -1; + int recording_ = -1; + std::wstring shortcutError_; + std::wstring pinMessage_; + bool picking_ = false; + bool dim_ = false; + int forceTheme_ = -1; + bool leftDown_ = false; + + // Toolbar row drag (reorder, or drag out to remove). + int dragIndex_ = -1; + bool dragging_ = false; + bool dragOut_ = false; + float dragStartX_ = 0; + float dragStartY_ = 0; + float dragX_ = 0; + float dragY_ = 0; + std::vector draft_; + Gdiplus::RectF slotRow_; + float slotStep_ = 0; +}; diff --git a/windows/src/sound.cpp b/windows/src/sound.cpp new file mode 100644 index 0000000..7782fc1 --- /dev/null +++ b/windows/src/sound.cpp @@ -0,0 +1,26 @@ +#include "sound.h" + +#include "prefs.h" + +#include + +static void play_id(const std::wstring &id) { + for (const auto &t : kTapSounds) { + if (id == t.id) { + DWORD size = 0; + const void *wav = th::resource_bytes(t.resource, &size); + if (wav) { + PlaySoundW(static_cast(wav), nullptr, SND_MEMORY | SND_ASYNC | SND_NODEFAULT); + } + return; + } + } +} + +void TapSound::play() const { + if (enabled) { + play_id(prefs::get().tapSound); + } +} + +void TapSound::preview(const std::wstring &id) { play_id(id); } diff --git a/windows/src/sound.h b/windows/src/sound.h new file mode 100644 index 0000000..ca1bd14 --- /dev/null +++ b/windows/src/sound.h @@ -0,0 +1,11 @@ +#pragma once + +#include "common.h" + +// Tap sounds are embedded WAVs played straight from the resource section. +class TapSound { +public: + bool enabled = true; + void play() const; + static void preview(const std::wstring &id); +}; diff --git a/windows/src/toolbar.cpp b/windows/src/toolbar.cpp new file mode 100644 index 0000000..7739c46 --- /dev/null +++ b/windows/src/toolbar.cpp @@ -0,0 +1,596 @@ +#include "toolbar.h" + +#include "hands.h" +#include "prefs.h" + +namespace { + +const wchar_t *kClass = L"TinyHandToolbar"; + +std::unique_ptr font_family() { + for (const wchar_t *name : {L"Segoe UI", L"Tahoma", L"Arial"}) { + auto f = std::make_unique(name); + if (f->IsAvailable()) { + return f; + } + } + return std::unique_ptr(Gdiplus::FontFamily::GenericSansSerif()->Clone()); +} + +// Icons are drawn in a 64-unit box (like the macOS 64 pt images) scaled into +// the button. +struct Box { + Gdiplus::RectF r; + float k() const { return r.Width / 64.0f; } + float x(float u) const { return r.X + u * k(); } + float y(float u) const { return r.Y + u * k(); } +}; + +void ink_dot(Gdiplus::Graphics &g, const Gdiplus::RectF &rect, uint32_t color) { + Box b{rect}; + float r = 13 * b.k(), cx = b.x(32), cy = b.y(32); + Gdiplus::SolidBrush shade(th::black(0.35f)); + g.FillEllipse(&shade, cx - r - 1.5f * b.k(), cy - r - 0.5f * b.k(), 2 * r + 3 * b.k(), 2 * r + 3 * b.k()); + Gdiplus::SolidBrush fill(th::rgba(color)); + g.FillEllipse(&fill, cx - r, cy - r, 2 * r, 2 * r); + Gdiplus::SolidBrush shine(th::white(0.35f)); + g.FillEllipse(&shine, cx - r * 0.55f, cy - r * 0.65f, r * 0.7f, r * 0.5f); +} + +void rainbow_dot(Gdiplus::Graphics &g, const Gdiplus::RectF &rect) { + Box b{rect}; + float r = 13 * b.k(), cx = b.x(32), cy = b.y(32); + Gdiplus::SolidBrush shade(th::black(0.35f)); + g.FillEllipse(&shade, cx - r - 1.5f * b.k(), cy - r - 0.5f * b.k(), 2 * r + 3 * b.k(), 2 * r + 3 * b.k()); + const uint32_t colors[] = {0xFF3B30, 0xFF9500, 0xFFCC00, 0x34C759, 0x007AFF, 0xAF52DE}; + for (int i = 0; i < 6; i++) { + Gdiplus::SolidBrush slice(th::rgba(colors[i])); + g.FillPie(&slice, cx - r, cy - r, 2 * r, 2 * r, -90.0f - 60.0f * float(i), -60.0f); + } +} + +void laser_icon(Gdiplus::Graphics &g, const Gdiplus::RectF &rect) { + Box b{rect}; + float cx = b.x(32), cy = b.y(32); + float glow = 18 * b.k(); + Gdiplus::GraphicsPath halo; + halo.AddEllipse(cx - glow, cy - glow, 2 * glow, 2 * glow); + Gdiplus::PathGradientBrush brush(&halo); + brush.SetCenterColor(th::rgba(kInkRed, 0.55f)); + Gdiplus::Color edge = th::rgba(kInkRed, 0); + INT n = 1; + brush.SetSurroundColors(&edge, &n); + g.FillPath(&brush, &halo); + Gdiplus::SolidBrush red(th::rgba(kInkRed)); + g.FillEllipse(&red, cx - 7 * b.k(), cy - 7 * b.k(), 14 * b.k(), 14 * b.k()); + Gdiplus::SolidBrush core(Gdiplus::Color(242, 255, 219, 209)); + g.FillEllipse(&core, cx - 3 * b.k(), cy - 3 * b.k(), 6 * b.k(), 6 * b.k()); +} + +void speaker_icon(Gdiplus::Graphics &g, const Gdiplus::RectF &rect, bool on, float scale) { + float s = scale; + float cx = rect.X + rect.Width / 2, cy = rect.Y + rect.Height / 2; + Gdiplus::Color color = th::white(on ? 0.85f : 0.45f); + Gdiplus::SolidBrush brush(color); + float ox = cx - 9 * s, oy = cy - 8 * s; + Gdiplus::PointF body[] = {{ox, oy + 5 * s}, {ox + 4 * s, oy + 5 * s}, {ox + 9 * s, oy + 0.5f * s}, + {ox + 9 * s, oy + 15.5f * s}, {ox + 4 * s, oy + 11 * s}, {ox, oy + 11 * s}}; + g.FillPolygon(&brush, body, 6); + Gdiplus::Pen pen(color, 1.8f * s); + pen.SetLineCap(Gdiplus::LineCapRound, Gdiplus::LineCapRound, Gdiplus::DashCapRound); + if (on) { + g.DrawArc(&pen, ox + 7 * s, oy + 4 * s, 8 * s, 8 * s, -50, 100); + g.DrawArc(&pen, ox + 6 * s, oy + 0.5f * s, 15 * s, 15 * s, -50, 100); + } else { + g.DrawLine(&pen, ox + 12.5f * s, oy + 5 * s, ox + 18.5f * s, oy + 11 * s); + g.DrawLine(&pen, ox + 12.5f * s, oy + 11 * s, ox + 18.5f * s, oy + 5 * s); + } +} + +void cross_icon(Gdiplus::Graphics &g, const Gdiplus::RectF &rect, float scale) { + float cx = rect.X + rect.Width / 2, cy = rect.Y + rect.Height / 2, d = 5.5f * scale; + Gdiplus::Pen pen(th::white(0.85f), 2.0f * scale); + pen.SetLineCap(Gdiplus::LineCapRound, Gdiplus::LineCapRound, Gdiplus::DashCapRound); + g.DrawLine(&pen, cx - d, cy - d, cx + d, cy + d); + g.DrawLine(&pen, cx - d, cy + d, cx + d, cy - d); +} + +void fill_round(Gdiplus::Graphics &g, const Gdiplus::RectF &r, float radius, Gdiplus::Color c) { + Gdiplus::GraphicsPath p; + th::add_rounded_rect(p, r, radius); + Gdiplus::SolidBrush b(c); + g.FillPath(&b, &p); +} + +} // namespace + +bool Toolbar::create(HINSTANCE instance) { + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = WndProc; + wc.hInstance = instance; + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.lpszClassName = kClass; + RegisterClassExW(&wc); + hwnd_ = CreateWindowExW(WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_TRANSPARENT, + kClass, L"TinyHand toolbar", WS_POPUP, 0, 0, 10, 10, nullptr, nullptr, instance, this); + current_ = prefs::get().pointerStyle; + refresh(); + return hwnd_ != nullptr; +} + +LRESULT CALLBACK Toolbar::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_NCCREATE) { + auto *cs = reinterpret_cast(lp); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(cs->lpCreateParams)); + reinterpret_cast(cs->lpCreateParams)->hwnd_ = hwnd; + } + auto *self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + return self ? self->proc(msg, wp, lp) : DefWindowProcW(hwnd, msg, wp, lp); +} + +LRESULT Toolbar::proc(UINT msg, WPARAM wp, LPARAM lp) { + switch (msg) { + case WM_MOUSEACTIVATE: + return MA_NOACTIVATE; + case WM_MOUSEMOVE: { + POINT p{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}; + int h = hit(p); + if (h != hover_) { + hover_ = h; + render(); + } + TRACKMOUSEEVENT t{sizeof(t), TME_LEAVE, hwnd_, 0}; + TrackMouseEvent(&t); + return 0; + } + case WM_MOUSELEAVE: + if (hover_ != -1) { + hover_ = -1; + render(); + } + return 0; + case WM_LBUTTONDOWN: + pressed_ = hit(POINT{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}); + return 0; + case WM_LBUTTONUP: { + int h = hit(POINT{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}); + int was = pressed_; + pressed_ = -1; + if (h >= 0 && h == was) { + activate(h); + } + return 0; + } + default: + break; + } + return DefWindowProcW(hwnd_, msg, wp, lp); +} + +void Toolbar::refresh() { + std::vector styles = prefs::get().pinned; + if (std::find(styles.begin(), styles.end(), current_) == styles.end()) { + styles.push_back(current_); + } + styles_ = styles; + if (!prefs::get().showInkButton) { + trayOpen_ = false; + } + layout(); + if (shown_) { + render(); + } +} + +void Toolbar::select(const std::wstring &style) { + current_ = style; + refresh(); +} + +void Toolbar::setSoundOn(bool on) { + if (soundOn_ == on) { + return; + } + soundOn_ = on; + if (shown_) { + render(); + } +} + +void Toolbar::layout() { + const float s = scale_; + const float pad = 10 * s, padV = 8 * s, btn = 40 * s, gap = 6 * s; + items_.clear(); + float x = pad; + for (const auto &id : styles_) { + items_.push_back({Kind::Style, id, Gdiplus::RectF(x, padV, btn, btn)}); + x += btn + gap; + } + items_.push_back({Kind::Divider, L"", Gdiplus::RectF(x, padV + (btn - 24 * s) / 2, 1 * s, 24 * s)}); + x += 1 * s + gap; + if (prefs::get().showInkButton) { + items_.push_back({Kind::Ink, L"", Gdiplus::RectF(x, padV, btn, btn)}); + x += btn + gap; + } + items_.push_back({Kind::Sound, L"", Gdiplus::RectF(x, padV, btn, btn)}); + x += btn + gap; + items_.push_back({Kind::Exit, L"", Gdiplus::RectF(x, padV, btn, btn)}); + x += btn + pad; + const float barW = x, barH = btn + 2 * padV; + + const float swatch = 30 * s, swatchGap = 2 * s; + const float trayW = 12 * s + float(kInkChoices.size()) * swatch + float(kInkChoices.size() - 1) * swatchGap; + const float trayH = swatch + 10 * s; + + float hintW = 0, hintH = 36 * s; + if (!hintParts_.empty()) { + auto family = font_family(); + Gdiplus::Font bold(family.get(), 12.5f * s, Gdiplus::FontStyleBold, Gdiplus::UnitPixel); + Gdiplus::Font regular(family.get(), 12.5f * s, Gdiplus::FontStyleRegular, Gdiplus::UnitPixel); + Gdiplus::Bitmap scratch(1, 1, PixelFormat32bppPARGB); + Gdiplus::Graphics g(&scratch); + hintW = 32 * s; + for (size_t i = 0; i < hintParts_.size(); i++) { + Gdiplus::RectF a, b; + g.MeasureString(hintParts_[i].first.c_str(), -1, &bold, Gdiplus::PointF(0, 0), &a); + std::wstring rest = L" " + hintParts_[i].second; + g.MeasureString(rest.c_str(), -1, ®ular, Gdiplus::PointF(0, 0), &b); + hintW += a.Width + b.Width + (i ? 14 * s : 0); + } + } + + const float M = 24 * s; + const float W = std::max(barW, hintW) + 2 * trayW + 2 * M; + const float H = M + hintH + 12 * s + trayH + 8 * s + barH + M; + bar_ = Gdiplus::RectF(std::round((W - barW) / 2), std::round(H - M - barH), barW, barH); + for (auto &item : items_) { + item.rect.X += bar_.X; + item.rect.Y += bar_.Y; + } + float inkX = bar_.X + barW / 2; + for (const auto &item : items_) { + if (item.kind == Kind::Ink) { + inkX = item.rect.X + item.rect.Width / 2; + } + } + tray_ = Gdiplus::RectF(std::round(inkX - trayW / 2), bar_.Y - 8 * s - trayH, trayW, trayH); + if (trayOpen_) { + float sx = tray_.X + 6 * s; + for (const auto &c : kInkChoices) { + items_.push_back({Kind::Swatch, c.id, Gdiplus::RectF(sx, tray_.Y + 5 * s, swatch, swatch)}); + sx += swatch + swatchGap; + } + } + float hintBottom = (trayOpen_ ? tray_.Y : bar_.Y) - 12 * s; + hint_ = Gdiplus::RectF(std::round(bar_.X + barW / 2 - hintW / 2), hintBottom - hintH, hintW, hintH); + + size_ = SIZE{LONG(std::ceil(W)), LONG(std::ceil(H))}; + LONG centerX = (monitor_.left + monitor_.right) / 2; + origin_ = POINT{centerX - LONG(std::lround(bar_.X + barW / 2)), + work_.bottom - LONG(std::lround(14 * s)) - LONG(std::lround(bar_.Y + barH))}; +} + +void Toolbar::drawPanel(Gdiplus::Graphics &g, const Gdiplus::RectF &r, float radius) const { + for (int i = 6; i >= 1; i--) { + float grow = float(i) * 1.6f * scale_; + Gdiplus::RectF sr(r.X - grow, r.Y - grow + 3 * scale_, r.Width + 2 * grow, r.Height + 2 * grow); + fill_round(g, sr, radius + grow, th::black(0.045f)); + } + fill_round(g, r, radius, th::rgba(0x141416, 0.9f)); + Gdiplus::GraphicsPath border; + Gdiplus::RectF br(r.X + 0.5f, r.Y + 0.5f, r.Width - 1, r.Height - 1); + th::add_rounded_rect(border, br, radius); + Gdiplus::Pen pen(th::white(0.12f), 1.0f); + g.DrawPath(&pen, &border); +} + +void Toolbar::drawHint(Gdiplus::Graphics &g) const { + if (hintParts_.empty() || hintAlpha_ <= 0) { + return; + } + const float s = scale_; + Gdiplus::RectF r = hint_; + fill_round(g, r, 12 * s, th::rgba(0x141416, 0.9f * hintAlpha_)); + auto family = font_family(); + Gdiplus::Font bold(family.get(), 12.5f * s, Gdiplus::FontStyleBold, Gdiplus::UnitPixel); + Gdiplus::Font regular(family.get(), 12.5f * s, Gdiplus::FontStyleRegular, Gdiplus::UnitPixel); + Gdiplus::SolidBrush strong(th::white(hintAlpha_)); + Gdiplus::SolidBrush soft(th::white(0.7f * hintAlpha_)); + float x = r.X + 16 * s; + for (size_t i = 0; i < hintParts_.size(); i++) { + Gdiplus::RectF a, b; + g.MeasureString(hintParts_[i].first.c_str(), -1, &bold, Gdiplus::PointF(0, 0), &a); + std::wstring rest = L" " + hintParts_[i].second; + g.MeasureString(rest.c_str(), -1, ®ular, Gdiplus::PointF(0, 0), &b); + float y = r.Y + (r.Height - a.Height) / 2; + g.DrawString(hintParts_[i].first.c_str(), -1, &bold, Gdiplus::PointF(x, y), &strong); + g.DrawString(rest.c_str(), -1, ®ular, Gdiplus::PointF(x + a.Width - 4 * s, y + (a.Height - b.Height) / 2), + &soft); + x += a.Width + b.Width + 14 * s; + } +} + +void Toolbar::drawItem(Gdiplus::Graphics &g, const Item &item, int index) const { + const float s = scale_; + const Gdiplus::RectF &r = item.rect; + bool hovered = index == hover_; + switch (item.kind) { + case Kind::Divider: + g.FillRectangle(std::make_unique(th::white(0.18f)).get(), r); + return; + case Kind::Style: { + if (item.id == current_) { + fill_round(g, r, 10 * s, th::white(0.18f)); + } else if (hovered) { + fill_round(g, r, 10 * s, th::white(0.08f)); + } + if (style_is_laser(item.id)) { + laser_icon(g, r); + } else if (Gdiplus::Bitmap *thumb = hands::thumbnail(item.id)) { + g.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic); + Gdiplus::RectF inner(r.X + 2 * s, r.Y + 2 * s, r.Width - 4 * s, r.Height - 4 * s); + g.DrawImage(thumb, inner); + } + return; + } + case Kind::Ink: + if (trayOpen_) { + fill_round(g, r, 10 * s, th::white(0.18f)); + } else if (hovered) { + fill_round(g, r, 10 * s, th::white(0.08f)); + } + ink_dot(g, r, ink_for(current_)); + return; + case Kind::Sound: + if (hovered) { + fill_round(g, r, 10 * s, th::white(0.08f)); + } + speaker_icon(g, r, soundOn_, s); + return; + case Kind::Exit: + if (hovered) { + fill_round(g, r, 10 * s, th::white(0.08f)); + } + cross_icon(g, r, s); + return; + case Kind::Swatch: { + if (item.id == prefs::get().inkChoice) { + fill_round(g, r, r.Width / 2, th::white(0.2f)); + } else if (hovered) { + fill_round(g, r, r.Width / 2, th::white(0.1f)); + } + for (const auto &c : kInkChoices) { + if (item.id == c.id) { + if (c.match) { + rainbow_dot(g, r); + } else { + ink_dot(g, r, c.color); + } + } + } + return; + } + } +} + +void Toolbar::render() { + if (!hwnd_ || !surface_.resize(size_.cx, size_.cy)) { + return; + } + surface_.clear(); + { + Gdiplus::Graphics g(surface_.bitmap.get()); + g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); + g.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit); + drawHint(g); + if (trayOpen_) { + drawPanel(g, tray_, 14 * scale_); + } + drawPanel(g, bar_, 16 * scale_); + for (size_t i = 0; i < items_.size(); i++) { + drawItem(g, items_[i], int(i)); + } + } + present(); +} + +void Toolbar::present() { + if (shown_) { + surface_.present(hwnd_, origin_, BYTE(std::lround(std::clamp(alpha_, 0.0f, 1.0f) * 255))); + } +} + +int Toolbar::hit(POINT p) const { + for (size_t i = 0; i < items_.size(); i++) { + const Item &it = items_[i]; + if (it.kind != Kind::Divider && p.x >= it.rect.X && p.x < it.rect.X + it.rect.Width && p.y >= it.rect.Y && + p.y < it.rect.Y + it.rect.Height) { + return int(i); + } + } + return -1; +} + +void Toolbar::activate(int index) { + Item item = items_[size_t(index)]; + switch (item.kind) { + case Kind::Style: + if (onPick) { + onPick(item.id); + } + break; + case Kind::Ink: + setTrayOpen(!trayOpen_); + break; + case Kind::Sound: + if (onToggleSound) { + onToggleSound(); + } + break; + case Kind::Exit: + if (onExit) { + onExit(); + } + break; + case Kind::Swatch: + prefs::get().inkChoice = item.id; + trayOpen_ = false; + prefs::changed(); + break; + case Kind::Divider: + break; + } +} + +void Toolbar::setTrayOpen(bool open) { + if (open == trayOpen_) { + return; + } + trayOpen_ = open; + hover_ = -1; + layout(); + if (shown_) { + render(); + } +} + +void Toolbar::place(const RECT &monitor, const RECT &work, float scale) { + monitor_ = monitor; + work_ = work; + scale_ = scale; + trayOpen_ = false; + layout(); + if (shown_) { + render(); + } +} + +void Toolbar::show() { + shown_ = true; + layout(); + render(); + ShowWindow(hwnd_, SW_SHOWNOACTIVATE); + SetWindowPos(hwnd_, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); +} + +void Toolbar::hide() { + trayOpen_ = false; + hintParts_.clear(); + hintAlpha_ = hintTarget_ = 0; + revealed_ = false; + alpha_ = alphaTarget_ = 0; + shown_ = false; + hover_ = -1; + SetWindowLongPtrW(hwnd_, GWL_EXSTYLE, GetWindowLongPtrW(hwnd_, GWL_EXSTYLE) | WS_EX_TRANSPARENT); + ShowWindow(hwnd_, SW_HIDE); + layout(); +} + +void Toolbar::setRevealed(bool value) { + if (value == revealed_) { + return; + } + revealed_ = value; + if (!value) { + setTrayOpen(false); + } + alphaTarget_ = value ? 1.0f : 0.0f; + LONG_PTR ex = GetWindowLongPtrW(hwnd_, GWL_EXSTYLE); + SetWindowLongPtrW(hwnd_, GWL_EXSTYLE, value ? (ex & ~LONG_PTR(WS_EX_TRANSPARENT)) : (ex | WS_EX_TRANSPARENT)); +} + +void Toolbar::showHint(const std::vector> &parts, double now) { + hintParts_ = parts; + hintTarget_ = 1; + lastAnimate_ = now; + layout(); + if (shown_) { + render(); + } +} + +void Toolbar::hideHint(double) { hintTarget_ = 0; } + +bool Toolbar::animating() const { return alpha_ != alphaTarget_ || hintAlpha_ != hintTarget_; } + +void Toolbar::animate(double now) { + double dt = std::clamp(now - lastAnimate_, 0.0, 0.1); + lastAnimate_ = now; + if (!shown_) { + return; + } + auto step = [dt](float &v, float target, double in, double out) { + if (v == target) { + return false; + } + float d = float(dt / (target > v ? in : out)); + v = target > v ? std::min(target, v + d) : std::max(target, v - d); + return true; + }; + bool hintChanged = step(hintAlpha_, hintTarget_, 0.2, 0.3); + bool alphaChanged = step(alpha_, alphaTarget_, 0.15, 0.35); + if (hintChanged && hintAlpha_ <= 0 && hintTarget_ <= 0) { + hintParts_.clear(); + layout(); + } + if (hintChanged) { + render(); + } else if (alphaChanged) { + present(); + } +} + +RECT Toolbar::barRect() const { + return RECT{origin_.x + LONG(bar_.X), origin_.y + LONG(bar_.Y), origin_.x + LONG(bar_.X + bar_.Width), + origin_.y + LONG(bar_.Y + bar_.Height)}; +} + +RECT Toolbar::frameRect() const { + RECT r = barRect(); + if (trayOpen_) { + r.left = std::min(r.left, origin_.x + LONG(tray_.X)); + r.right = std::max(r.right, origin_.x + LONG(tray_.X + tray_.Width)); + r.top = std::min(r.top, origin_.y + LONG(tray_.Y)); + } + return r; +} + +bool Toolbar::buttonCenter(const std::wstring &style, POINT *out) const { + for (const auto &item : items_) { + if (item.kind == Kind::Style && item.id == style) { + out->x = origin_.x + LONG(item.rect.X + item.rect.Width / 2); + out->y = origin_.y + LONG(item.rect.Y + item.rect.Height / 2); + return true; + } + } + return false; +} + +bool Toolbar::renderPng(const std::wstring &path, bool withHint, bool withTray) { + if (withHint) { + hintParts_ = {{L"Click", L"to tap"}, {L"Drag", L"to draw"}, {L"Hold Alt", L"to click for real"}, + {L"Esc", L"to exit"}}; + hintAlpha_ = hintTarget_ = 1; + } + trayOpen_ = withTray; + layout(); + if (!surface_.resize(size_.cx, size_.cy)) { + return false; + } + bool wasShown = shown_; + shown_ = false; + render(); + shown_ = wasShown; + std::unique_ptr out(th::new_pargb(size_.cx, size_.cy)); + if (!out) { + return false; + } + { + Gdiplus::Graphics g(out.get()); + g.Clear(Gdiplus::Color(255, 41, 41, 41)); + g.DrawImage(surface_.bitmap.get(), 0, 0, size_.cx, size_.cy); + } + return th::save_png(out.get(), path); +} diff --git a/windows/src/toolbar.h b/windows/src/toolbar.h new file mode 100644 index 0000000..c3bb6aa --- /dev/null +++ b/windows/src/toolbar.h @@ -0,0 +1,81 @@ +#pragma once + +#include "common.h" + +// The floating bar at the bottom of the screen (Toolbar.swift): pinned hands, +// ink colour tray, tap sound and exit, plus the first-run hint. +class Toolbar { +public: + bool create(HINSTANCE instance); + + std::function onPick; + std::function onToggleSound; + std::function onExit; + + void select(const std::wstring &style); + void setSoundOn(bool on); + void refresh(); + void place(const RECT &monitor, const RECT &work, float scale); + void show(); + void hide(); + void setRevealed(bool revealed); + bool revealed() const { return revealed_; } + void showHint(const std::vector> &parts, double now); + void hideHint(double now); + void animate(double now); + bool animating() const; + + RECT barRect() const; + RECT frameRect() const; + bool buttonCenter(const std::wstring &style, POINT *out) const; + HWND hwnd() const { return hwnd_; } + + bool renderPng(const std::wstring &path, bool withHint, bool withTray); + +private: + enum class Kind { Style, Ink, Sound, Exit, Swatch, Divider }; + struct Item { + Kind kind; + std::wstring id; + Gdiplus::RectF rect; + }; + + static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); + LRESULT proc(UINT msg, WPARAM wp, LPARAM lp); + void layout(); + void render(); + void present(); + void setTrayOpen(bool open); + int hit(POINT p) const; + void activate(int index); + void drawItem(Gdiplus::Graphics &g, const Item &item, int index) const; + void drawPanel(Gdiplus::Graphics &g, const Gdiplus::RectF &r, float radius) const; + void drawHint(Gdiplus::Graphics &g) const; + + HWND hwnd_ = nullptr; + th::Surface surface_; + float scale_ = 1; + RECT monitor_{0, 0, 1280, 800}; + RECT work_{0, 0, 1280, 760}; + POINT origin_{0, 0}; + SIZE size_{0, 0}; + + std::vector styles_; + std::wstring current_ = L"lavender"; + bool soundOn_ = true; + bool trayOpen_ = false; + bool shown_ = false; + bool revealed_ = false; + int hover_ = -1; + int pressed_ = -1; + + std::vector items_; + Gdiplus::RectF bar_, tray_, hint_; + + std::vector> hintParts_; + float hintAlpha_ = 0; + float hintTarget_ = 0; + float alpha_ = 0; + float alphaTarget_ = 0; + double lastAnimate_ = 0; +}; diff --git a/windows/src/util.cpp b/windows/src/util.cpp new file mode 100644 index 0000000..32302b0 --- /dev/null +++ b/windows/src/util.cpp @@ -0,0 +1,333 @@ +#include "common.h" + +#include +#include +#include +#include + +namespace th { + +double now() { + static LARGE_INTEGER freq = [] { + LARGE_INTEGER f; + QueryPerformanceFrequency(&f); + return f; + }(); + LARGE_INTEGER t; + QueryPerformanceCounter(&t); + return double(t.QuadPart) / double(freq.QuadPart); +} + +std::wstring exe_path() { + wchar_t buf[MAX_PATH * 2]; + DWORD n = GetModuleFileNameW(nullptr, buf, DWORD(std::size(buf))); + return std::wstring(buf, n); +} + +std::wstring exe_dir() { + std::wstring p = exe_path(); + size_t slash = p.find_last_of(L"\\/"); + return slash == std::wstring::npos ? L"." : p.substr(0, slash); +} + +std::wstring data_dir() { + wchar_t *base = nullptr; + std::wstring dir; + if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &base)) && base) { + dir = std::wstring(base) + L"\\TinyHand"; + } else { + dir = exe_dir(); + } + if (base) { + CoTaskMemFree(base); + } + CreateDirectoryW(dir.c_str(), nullptr); + return dir; +} + +bool file_exists(const std::wstring &path) { + DWORD a = GetFileAttributesW(path.c_str()); + return a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY); +} + +void log(const wchar_t *fmt, ...) { + static std::wstring path = data_dir() + L"\\TinyHand.log"; + wchar_t msg[1024]; + va_list args; + va_start(args, fmt); + _vsnwprintf(msg, std::size(msg) - 1, fmt, args); + va_end(args); + msg[std::size(msg) - 1] = 0; + + SYSTEMTIME st; + GetLocalTime(&st); + wchar_t line[1200]; + int n = _snwprintf(line, std::size(line) - 1, L"%04d-%02d-%02d %02d:%02d:%02d.%03d [%lu] %ls\r\n", st.wYear, + st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, + GetCurrentProcessId(), msg); + if (n < 0) { + return; + } + char utf8[2400]; + int bytes = WideCharToMultiByte(CP_UTF8, 0, line, n, utf8, sizeof(utf8), nullptr, nullptr); + HANDLE f = CreateFileW(path.c_str(), FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (f != INVALID_HANDLE_VALUE) { + DWORD written = 0; + WriteFile(f, utf8, DWORD(bytes), &written, nullptr); + CloseHandle(f); + } +} + +bool apps_use_dark() { + DWORD v = 1, size = sizeof(v); + if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + L"AppsUseLightTheme", RRF_RT_REG_DWORD, nullptr, &v, &size) == ERROR_SUCCESS) { + return v == 0; + } + return false; +} + +float dpi_scale_at(POINT pt) { + using GetDpiForMonitorFn = HRESULT(WINAPI *)(HMONITOR, int, UINT *, UINT *); + static GetDpiForMonitorFn fn = [] { + HMODULE shcore = LoadLibraryW(L"shcore.dll"); + return shcore ? reinterpret_cast( + reinterpret_cast(GetProcAddress(shcore, "GetDpiForMonitor"))) + : nullptr; + }(); + if (fn) { + UINT x = 96, y = 96; + if (SUCCEEDED(fn(MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST), 0, &x, &y)) && y > 0) { + return float(y) / 96.0f; + } + } + HDC dc = GetDC(nullptr); + int dpi = GetDeviceCaps(dc, LOGPIXELSY); + ReleaseDC(nullptr, dc); + return dpi > 0 ? float(dpi) / 96.0f : 1.0f; +} + +float dpi_scale_window(HWND hwnd) { + using GetDpiForWindowFn = UINT(WINAPI *)(HWND); + static GetDpiForWindowFn fn = [] { + HMODULE user = GetModuleHandleW(L"user32.dll"); + return user ? reinterpret_cast( + reinterpret_cast(GetProcAddress(user, "GetDpiForWindow"))) + : nullptr; + }(); + if (fn && hwnd) { + UINT dpi = fn(hwnd); + if (dpi > 0) { + return float(dpi) / 96.0f; + } + } + POINT pt{0, 0}; + return dpi_scale_at(pt); +} + +Gdiplus::Color rgba(uint32_t rgb, float alpha) { + BYTE a = BYTE(std::clamp(alpha, 0.0f, 1.0f) * 255.0f + 0.5f); + return Gdiplus::Color(a, BYTE((rgb >> 16) & 0xFF), BYTE((rgb >> 8) & 0xFF), BYTE(rgb & 0xFF)); +} + +Gdiplus::Color white(float alpha) { return rgba(0xFFFFFF, alpha); } +Gdiplus::Color black(float alpha) { return rgba(0x000000, alpha); } + +Gdiplus::Bitmap *new_pargb(int width, int height) { + auto *b = new Gdiplus::Bitmap(std::max(1, width), std::max(1, height), PixelFormat32bppPARGB); + if (b->GetLastStatus() != Gdiplus::Ok) { + delete b; + return nullptr; + } + return b; +} + +static Gdiplus::Bitmap *decode(const void *data, DWORD size) { + IStream *stream = SHCreateMemStream(static_cast(data), size); + if (!stream) { + return nullptr; + } + Gdiplus::Bitmap *decoded = Gdiplus::Bitmap::FromStream(stream); + Gdiplus::Bitmap *copy = nullptr; + if (decoded && decoded->GetLastStatus() == Gdiplus::Ok) { + int w = int(decoded->GetWidth()); + int h = int(decoded->GetHeight()); + copy = new_pargb(w, h); + if (copy) { + Gdiplus::Graphics g(copy); + g.SetCompositingMode(Gdiplus::CompositingModeSourceCopy); + g.DrawImage(decoded, Gdiplus::Rect(0, 0, w, h), 0, 0, w, h, Gdiplus::UnitPixel); + } + } + delete decoded; + stream->Release(); + return copy; +} + +Gdiplus::Bitmap *load_png_file(const std::wstring &path) { + HANDLE f = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr); + if (f == INVALID_HANDLE_VALUE) { + return nullptr; + } + LARGE_INTEGER size{}; + GetFileSizeEx(f, &size); + std::vector data(size_t(size.QuadPart)); + DWORD read = 0; + BOOL ok = ReadFile(f, data.data(), DWORD(data.size()), &read, nullptr); + CloseHandle(f); + if (!ok || read != data.size()) { + return nullptr; + } + return decode(data.data(), read); +} + +const void *resource_bytes(int id, DWORD *size) { + HRSRC r = FindResourceW(nullptr, MAKEINTRESOURCEW(id), MAKEINTRESOURCEW(10)); + if (!r) { + return nullptr; + } + HGLOBAL h = LoadResource(nullptr, r); + if (!h) { + return nullptr; + } + if (size) { + *size = SizeofResource(nullptr, r); + } + return LockResource(h); +} + +Gdiplus::Bitmap *load_png_resource(int id) { + DWORD size = 0; + const void *data = resource_bytes(id, &size); + return data ? decode(data, size) : nullptr; +} + +bool save_png(Gdiplus::Bitmap *bitmap, const std::wstring &path) { + UINT count = 0, bytes = 0; + Gdiplus::GetImageEncodersSize(&count, &bytes); + if (!bytes) { + return false; + } + std::vector buf(bytes); + auto *codecs = reinterpret_cast(buf.data()); + Gdiplus::GetImageEncoders(count, bytes, codecs); + for (UINT i = 0; i < count; i++) { + if (wcscmp(codecs[i].MimeType, L"image/png") == 0) { + return bitmap->Save(path.c_str(), &codecs[i].Clsid, nullptr) == Gdiplus::Ok; + } + } + return false; +} + +void add_rounded_rect(Gdiplus::GraphicsPath &path, const Gdiplus::RectF &r, float radius) { + float d = std::min(radius * 2, std::min(r.Width, r.Height)); + path.StartFigure(); + path.AddArc(r.X, r.Y, d, d, 180, 90); + path.AddArc(r.X + r.Width - d, r.Y, d, d, 270, 90); + path.AddArc(r.X + r.Width - d, r.Y + r.Height - d, d, d, 0, 90); + path.AddArc(r.X, r.Y + r.Height - d, d, d, 90, 90); + path.CloseFigure(); +} + +Surface::~Surface() { + bitmap.reset(); + if (dc) { + SelectObject(dc, old); + DeleteDC(dc); + } + if (dib) { + DeleteObject(dib); + } +} + +bool Surface::resize(int w, int h) { + if (w == width && h == height && dib) { + return true; + } + bitmap.reset(); + if (dc) { + SelectObject(dc, old); + DeleteDC(dc); + dc = nullptr; + } + if (dib) { + DeleteObject(dib); + dib = nullptr; + } + width = std::max(1, w); + height = std::max(1, h); + BITMAPINFO bi{}; + bi.bmiHeader.biSize = sizeof(bi.bmiHeader); + bi.bmiHeader.biWidth = width; + bi.bmiHeader.biHeight = -height; + bi.bmiHeader.biPlanes = 1; + bi.bmiHeader.biBitCount = 32; + bi.bmiHeader.biCompression = BI_RGB; + void *raw = nullptr; + dib = CreateDIBSection(nullptr, &bi, DIB_RGB_COLORS, &raw, nullptr, 0); + if (!dib) { + width = height = 0; + return false; + } + bits = static_cast(raw); + dc = CreateCompatibleDC(nullptr); + old = SelectObject(dc, dib); + bitmap = std::make_unique(width, height, width * 4, PixelFormat32bppPARGB, bits); + clear(); + return true; +} + +void Surface::clear() { + if (bits) { + memset(bits, 0, size_t(width) * size_t(height) * 4); + } +} + +void Surface::clear(const RECT &r) { + int x0 = std::max(0, int(r.left)); + int x1 = std::min(width, int(r.right)); + int y0 = std::max(0, int(r.top)); + int y1 = std::min(height, int(r.bottom)); + if (!bits || x1 <= x0 || y1 <= y0) { + return; + } + for (int y = y0; y < y1; y++) { + memset(bits + (size_t(y) * width + x0) * 4, 0, size_t(x1 - x0) * 4); + } +} + +bool Surface::present(HWND hwnd, POINT origin, BYTE alpha, const RECT *dirty) { + if (!dc) { + return false; + } + GdiFlush(); + SIZE size{width, height}; + POINT src{0, 0}; + BLENDFUNCTION blend{AC_SRC_OVER, 0, alpha, AC_SRC_ALPHA}; + if (dirty) { + RECT clipped = *dirty; + clipped.left = std::max(0L, clipped.left); + clipped.top = std::max(0L, clipped.top); + clipped.right = std::min(LONG(width), clipped.right); + clipped.bottom = std::min(LONG(height), clipped.bottom); + if (clipped.right <= clipped.left || clipped.bottom <= clipped.top) { + return true; + } + UPDATELAYEREDWINDOWINFO info{}; + info.cbSize = sizeof(info); + info.pptDst = &origin; + info.psize = &size; + info.hdcSrc = dc; + info.pptSrc = &src; + info.pblend = &blend; + info.dwFlags = ULW_ALPHA; + info.prcDirty = &clipped; + if (UpdateLayeredWindowIndirect(hwnd, &info)) { + return true; + } + } + return UpdateLayeredWindow(hwnd, nullptr, &origin, &size, dc, &src, 0, &blend, ULW_ALPHA) != FALSE; +} + +} // namespace th diff --git a/windows/src/welcome.cpp b/windows/src/welcome.cpp new file mode 100644 index 0000000..627315b --- /dev/null +++ b/windows/src/welcome.cpp @@ -0,0 +1,239 @@ +#include "welcome.h" + +#include "prefs.h" + +namespace { + +const wchar_t *kClass = L"TinyHandWelcome"; +constexpr float kCardW = 340; +constexpr float kCardH = 300; +constexpr float kMargin = 24; +enum Part { None, Start, Close }; + +Gdiplus::RectF card_rect() { return Gdiplus::RectF(kMargin, kMargin, kCardW, kCardH); } +Gdiplus::RectF button_rect() { return Gdiplus::RectF(kMargin + (kCardW - 180) / 2, kMargin + 57 + 76 + 16 + 24 + 6 + 17 + 22, 180, 32); } +Gdiplus::RectF close_rect() { return Gdiplus::RectF(kMargin + kCardW - 14 - 18, kMargin + 12, 20, 20); } + +std::unique_ptr family() { + for (const wchar_t *name : {L"Segoe UI", L"Tahoma", L"Arial"}) { + auto f = std::make_unique(name); + if (f->IsAvailable()) { + return f; + } + } + return std::unique_ptr(Gdiplus::FontFamily::GenericSansSerif()->Clone()); +} + +void round_fill(Gdiplus::Graphics &g, const Gdiplus::RectF &r, float radius, Gdiplus::Color c) { + Gdiplus::GraphicsPath p; + th::add_rounded_rect(p, r, radius); + Gdiplus::SolidBrush b(c); + g.FillPath(&b, &p); +} + +} // namespace + +LRESULT CALLBACK WelcomeWindow::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_NCCREATE) { + auto *cs = reinterpret_cast(lp); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(cs->lpCreateParams)); + reinterpret_cast(cs->lpCreateParams)->hwnd_ = hwnd; + } + auto *self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + return self ? self->proc(msg, wp, lp) : DefWindowProcW(hwnd, msg, wp, lp); +} + +int WelcomeWindow::hit(POINT client) const { + float x = float(client.x) / scale_, y = float(client.y) / scale_; + if (button_rect().Contains(x, y)) { + return Start; + } + if (close_rect().Contains(x, y)) { + return Close; + } + return None; +} + +LRESULT WelcomeWindow::proc(UINT msg, WPARAM wp, LPARAM lp) { + switch (msg) { + case WM_NCHITTEST: { + POINT p{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}; + ScreenToClient(hwnd_, &p); + if (hit(p) != None) { + return HTCLIENT; + } + return card_rect().Contains(float(p.x) / scale_, float(p.y) / scale_) ? HTCAPTION : HTNOWHERE; + } + case WM_MOUSEMOVE: { + int h = hit(POINT{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}); + if (h != hover_) { + hover_ = h; + render(); + } + TRACKMOUSEEVENT t{sizeof(t), TME_LEAVE, hwnd_, 0}; + TrackMouseEvent(&t); + return 0; + } + case WM_MOUSELEAVE: + hover_ = None; + render(); + return 0; + case WM_LBUTTONDOWN: + pressed_ = hit(POINT{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}); + return 0; + case WM_LBUTTONUP: { + int h = hit(POINT{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}); + if (h != None && h == pressed_) { + close(); + if (h == Start && onStart) { + onStart(); + } + } + pressed_ = None; + return 0; + } + case WM_KEYDOWN: + if (wp == VK_RETURN) { + close(); + if (onStart) { + onStart(); + } + return 0; + } + if (wp == VK_ESCAPE) { + close(); + return 0; + } + break; + case WM_SETTINGCHANGE: + render(); + break; + default: + break; + } + return DefWindowProcW(hwnd_, msg, wp, lp); +} + +void WelcomeWindow::paint(Gdiplus::Graphics &g, bool dark) { + g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); + g.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit); + g.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic); + Gdiplus::RectF card = card_rect(); + for (int i = 8; i >= 1; i--) { + float grow = float(i) * 2.2f; + round_fill(g, Gdiplus::RectF(card.X - grow, card.Y - grow + 6, card.Width + 2 * grow, card.Height + 2 * grow), + 20 + grow, th::black(0.028f)); + } + round_fill(g, card, 20, th::rgba(dark ? 0x2C2C2E : 0xF4F4F6)); + Gdiplus::GraphicsPath border; + th::add_rounded_rect(border, Gdiplus::RectF(card.X + 0.5f, card.Y + 0.5f, card.Width - 1, card.Height - 1), 20); + Gdiplus::Pen edge(dark ? th::white(0.1f) : th::black(0.08f), 1); + g.DrawPath(&edge, &border); + + static std::unique_ptr icon(th::load_png_resource(IDR_APP_PNG)); + float y = card.Y + 57; + if (icon) { + g.DrawImage(icon.get(), Gdiplus::RectF(card.X + (kCardW - 76) / 2, y, 76, 76)); + } + y += 76 + 16; + auto fam = family(); + auto semi = std::make_unique(L"Segoe UI Semibold"); + Gdiplus::Font title(semi->IsAvailable() ? semi.get() : fam.get(), 19, + semi->IsAvailable() ? Gdiplus::FontStyleRegular : Gdiplus::FontStyleBold, Gdiplus::UnitPixel); + Gdiplus::Font body(fam.get(), 13, Gdiplus::FontStyleRegular, Gdiplus::UnitPixel); + Gdiplus::StringFormat center; + center.SetAlignment(Gdiplus::StringAlignmentCenter); + center.SetLineAlignment(Gdiplus::StringAlignmentCenter); + Gdiplus::SolidBrush primary(th::rgba(dark ? 0xF2F2F7 : 0x1D1D1F)); + Gdiplus::SolidBrush secondary(th::rgba(dark ? 0x98989F : 0x6E6E73)); + g.DrawString(L"You're all set", -1, &title, Gdiplus::RectF(card.X, y, kCardW, 24), ¢er, &primary); + y += 24 + 6; + std::wstring hint = L"Press " + prefs::get().toggleKey.display() + L" any time to start."; + g.DrawString(hint.c_str(), -1, &body, Gdiplus::RectF(card.X, y, kCardW, 17), ¢er, &secondary); + + Gdiplus::RectF b = button_rect(); + uint32_t accent = dark ? 0x0A84FF : 0x0A7AFF; + round_fill(g, b, 8, th::rgba(accent, hover_ == Start ? 0.88f : 1.0f)); + Gdiplus::SolidBrush white(th::white(1)); + g.DrawString(L"Start", -1, &body, b, ¢er, &white); + + Gdiplus::RectF c = close_rect(); + if (hover_ == Close) { + Gdiplus::SolidBrush bg(dark ? th::white(0.1f) : th::black(0.06f)); + g.FillEllipse(&bg, c); + } + Gdiplus::Pen x(dark ? th::white(0.4f) : th::black(0.3f), 1.6f); + x.SetLineCap(Gdiplus::LineCapRound, Gdiplus::LineCapRound, Gdiplus::DashCapRound); + float cx = c.X + c.Width / 2, cy = c.Y + c.Height / 2, d = 4.5f; + g.DrawLine(&x, cx - d, cy - d, cx + d, cy + d); + g.DrawLine(&x, cx - d, cy + d, cx + d, cy - d); +} + +void WelcomeWindow::render() { + if (!hwnd_) { + return; + } + int w = int(std::lround((kCardW + 2 * kMargin) * scale_)); + int h = int(std::lround((kCardH + 2 * kMargin) * scale_)); + if (!surface_.resize(w, h)) { + return; + } + surface_.clear(); + { + Gdiplus::Graphics g(surface_.bitmap.get()); + g.ScaleTransform(scale_, scale_); + paint(g, th::apps_use_dark()); + } + RECT r; + GetWindowRect(hwnd_, &r); + surface_.present(hwnd_, POINT{r.left, r.top}, 255); +} + +void WelcomeWindow::show(HINSTANCE instance) { + if (!hwnd_) { + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = WndProc; + wc.hInstance = instance; + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.lpszClassName = kClass; + RegisterClassExW(&wc); + CreateWindowExW(WS_EX_LAYERED | WS_EX_TOOLWINDOW, kClass, L"Welcome to TinyHand", WS_POPUP, 0, 0, 10, 10, + nullptr, nullptr, instance, this); + } + MONITORINFO mi{}; + mi.cbSize = sizeof(mi); + POINT cursor; + GetCursorPos(&cursor); + GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTOPRIMARY), &mi); + scale_ = th::dpi_scale_at(cursor); + int w = int(std::lround((kCardW + 2 * kMargin) * scale_)); + int h = int(std::lround((kCardH + 2 * kMargin) * scale_)); + SetWindowPos(hwnd_, nullptr, (mi.rcWork.left + mi.rcWork.right - w) / 2, (mi.rcWork.top + mi.rcWork.bottom - h) / 2, + w, h, SWP_NOZORDER | SWP_NOACTIVATE); + hover_ = pressed_ = None; + render(); + ShowWindow(hwnd_, SW_SHOW); + SetForegroundWindow(hwnd_); +} + +void WelcomeWindow::close() { + if (hwnd_) { + ShowWindow(hwnd_, SW_HIDE); + } +} + +bool WelcomeWindow::snapshot(const std::wstring &path, bool dark) { + int w = int(kCardW + 2 * kMargin), h = int(kCardH + 2 * kMargin); + std::unique_ptr canvas(th::new_pargb(w, h)); + if (!canvas) { + return false; + } + { + Gdiplus::Graphics g(canvas.get()); + g.Clear(dark ? Gdiplus::Color(255, 60, 60, 64) : Gdiplus::Color(255, 200, 204, 210)); + paint(g, dark); + } + return th::save_png(canvas.get(), path); +} diff --git a/windows/src/welcome.h b/windows/src/welcome.h new file mode 100644 index 0000000..005eb5d --- /dev/null +++ b/windows/src/welcome.h @@ -0,0 +1,27 @@ +#pragma once + +#include "common.h" + +// The first-run card from WelcomeWindow.swift. Windows needs no permission, +// so it is always the "You're all set" card with a Start button. +class WelcomeWindow { +public: + std::function onStart; + + void show(HINSTANCE instance); + void close(); + bool snapshot(const std::wstring &path, bool dark); + +private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); + LRESULT proc(UINT msg, WPARAM wp, LPARAM lp); + void render(); + void paint(Gdiplus::Graphics &g, bool dark); + int hit(POINT client) const; + + HWND hwnd_ = nullptr; + th::Surface surface_; + float scale_ = 1; + int hover_ = 0; + int pressed_ = 0; +}; From c5106e9b02630b64a501be4fe5052ef0008a9dd7 Mon Sep 17 00:00:00 2001 From: Monish Meher Date: Mon, 9 Nov 2026 18:14:27 +0530 Subject: [PATCH 4/4] Build and check the Windows port in CI, document it The Windows job cross-compiles on Ubuntu with mingw-w64, then a windows-latest job runs the shape tests, the render test and the Settings UI test. The macOS job also runs the shape tests. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 43 +++++++++++++++++++++++++++++++++++++ README.md | 10 ++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a1cd008..1597a31 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,3 +13,46 @@ jobs: - uses: actions/checkout@v4 - name: Build (universal) run: swift build -c release --arch arm64 --arch x86_64 + - name: Shape recognizer tests + run: ./scripts/test-shapes.sh + + windows: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Install mingw-w64 + run: sudo apt-get update && sudo apt-get install -y mingw-w64 zip + - name: Build TinyHand.exe and the shape tests + run: make -C windows -j4 dist test + - uses: actions/upload-artifact@v4 + with: + name: TinyHand-windows + path: | + windows/build/TinyHand.exe + windows/build/shape_test.exe + windows/dist/*.zip + + windows-check: + needs: windows + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: TinyHand-windows + path: windows + - name: Shape recognizer tests + run: windows/build/shape_test.exe + - name: Render every shape and pointer + shell: pwsh + run: | + $p = Start-Process windows/build/TinyHand.exe -ArgumentList '--render-test', "$env:RUNNER_TEMP\render.png" -Wait -PassThru + if ($p.ExitCode -ne 0) { Get-Content "$env:LOCALAPPDATA\TinyHand\TinyHand.log"; exit $p.ExitCode } + - name: Click through Settings + shell: pwsh + run: | + $p = Start-Process windows/build/TinyHand.exe -ArgumentList '--settings-ui-test' -Wait -PassThru + Get-Content "$env:LOCALAPPDATA\TinyHand\TinyHand.log" | Select-String 'selftest' + exit $p.ExitCode diff --git a/README.md b/README.md index e1f8647..88aa3ec 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # TinyHand [![Latest release](https://img.shields.io/github/v/release/nilbuild/tinyhand?display_name=tag&sort=semver&cacheSeconds=3600)](https://github.com/nilbuild/tinyhand/releases/latest) -[![Platform: macOS](https://img.shields.io/badge/platform-macOS-lightgrey)](https://tinyhand.app) +[![Platform: macOS | Windows](https://img.shields.io/badge/platform-macOS%20%7C%20Windows-lightgrey)](https://tinyhand.app) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) A tiny hand on a stick that points at things on your Mac screen. @@ -27,12 +27,20 @@ Open `TinyHand.dmg`, drag TinyHand to Applications and allow **Accessibility** w | Right-click the menu bar icon | Menu and Settings | | Click | Tap (nothing underneath gets clicked) | | Hold and drag | Draw on the screen | +| Draw a rough shape | It snaps into a clean line, arrow, circle, box, triangle, diamond, star or heart (turn it off in Settings, Drawing) | +| Draw a small V at the end of a line | The line becomes an arrow | | **⌃⌥X** | Clear the drawing | | **Esc** | Clear everything and turn the pointer off | While the pointer is on, a small toolbar at the bottom of the screen lets you pick one of 36 hands or a laser dot, mute the tap sound, or exit. +## Windows + +TinyHand also runs on Windows 10 and 11 (x64): the same hands, drawing, shape snapping, tap sounds and +Settings, from the tray instead of the menu bar. Build it with `make -C windows` (mingw-w64) and see +[windows/README.md](windows/README.md) for details. + ## License MIT. See [LICENSE](LICENSE).