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/.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/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/README.md b/README.md index bc0a8c3..64c22b2 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,6 +27,8 @@ 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 | | Hold **⌘** while drawing | Add an arrowhead to the end of the line | | **⌃⌥X** | Clear the drawing | | **Esc** | Clear everything and turn the pointer off | @@ -58,6 +60,12 @@ records the app's `pid`, so a file left behind by a crash can be detected. If TinyHand isn't running, opening any link launches it and then runs the command. +## 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). 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/Sources/TinyHand/AppDelegate.swift b/Sources/TinyHand/AppDelegate.swift index e759900..b90958b 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? private lazy var automation = Automation(overlay: overlay) @@ -86,12 +87,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 { @@ -146,6 +150,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() } @@ -210,6 +215,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 766dbed..7cbd4d5 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() @@ -26,6 +27,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 } @@ -65,9 +68,19 @@ final class Ink { guard let s = stroke else { return } + let drawn = points + let heldArrow = arrow stroke = nil points = [] arrow = false + if Preferences.snapShapes { + if !heldArrow && snapArrowhead(drawn, stroke: s) { + return + } + snap(drawn, stroke: s, arrow: heldArrow) + } else { + lastLine = nil + } freeze(s) if keep { return @@ -78,9 +91,88 @@ final class Ink { func clear() { stroke = nil points = [] + lastLine = nil layer.sublayers?.forEach { $0.removeFromSuperlayer() } } + private func snap(_ drawn: [CGPoint], stroke s: CAShapeLayer, arrow: Bool) { + 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 + } + // A stroke drawn holding Command keeps its arrowhead: a straight one becomes a clean arrow, + // anything else stays the curved arrow that was drawn. + if arrow { + guard kind == TH_SHAPE_LINE else { + return + } + show(shape, in: s, head: max(14, s.lineWidth * 3.2)) + Log.write("snapped arrow stroke to a straight arrow") + 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, head: CGFloat? = nil) { + 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() + } + if let size = head, i == 0, let start = pts.first, let end = pts.last { + addHead(to: path, from: start, to: end, size: size) + } + } + 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 @@ -97,6 +189,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 @@ -107,7 +203,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() } } @@ -149,6 +248,10 @@ final class Ink { return } let back = points.last { hypot(end.x - $0.x, end.y - $0.y) >= size * 1.2 } ?? points.first ?? end + addHead(to: path, from: back, to: end, size: size) + } + + private func addHead(to path: CGMutablePath, from back: CGPoint, to end: CGPoint, size: CGFloat) { let length = hypot(end.x - back.x, end.y - back.y) guard length > 1 else { return diff --git a/Sources/TinyHand/Preferences.swift b/Sources/TinyHand/Preferences.swift index 6eff8f7..99f138b 100644 --- a/Sources/TinyHand/Preferences.swift +++ b/Sources/TinyHand/Preferences.swift @@ -154,6 +154,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] { @@ -187,7 +192,7 @@ enum Preferences { static func resetAll() { for key in ["shortcut.toggle", "shortcut.clear", "pointerSize", "showShadow", "fadeSeconds", "sound.hands", "sound.laser", "interactKey", "interactKeepsHand", "doubleShiftClickThrough", "pinnedStyles", "pointerStyle", - "inkChoice", "showInkButton", "showToolbar", "tapSound"] { + "inkChoice", "showInkButton", "showToolbar", "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 4b62fc9..6d33b49 100644 --- a/Sources/TinyHand/SettingsView.swift +++ b/Sources/TinyHand/SettingsView.swift @@ -1002,6 +1002,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) + } } } } 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" "$@" 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 0000000..53896ba Binary files /dev/null and b/windows/res/appicon.png differ 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 0000000..7daf6fa Binary files /dev/null and b/windows/res/tinyhand.ico differ diff --git a/windows/res/tinyhand.manifest b/windows/res/tinyhand.manifest new file mode 100644 index 0000000..d34d560 --- /dev/null +++ b/windows/res/tinyhand.manifest @@ -0,0 +1,29 @@ + + + + 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; +};