Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/mac.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ on:
- '!mac/docs/**'
- 'LICENSE'
- 'THIRD-PARTY-NOTICES.md'
- 'tests/fixtures/shared/**'
- '.github/workflows/mac.yml'
pull_request:
# The licence and notices ship inside the Mac zip too, so a PR that changes them runs these checks.
paths: [ 'mac/**', 'ui-shared/**', '.github/workflows/mac.yml', 'LICENSE', 'THIRD-PARTY-NOTICES.md', 'tools/check_copyright.ps1' ]
paths: [ 'mac/**', 'ui-shared/**', 'tests/fixtures/shared/**', '.github/workflows/mac.yml', 'LICENSE', 'THIRD-PARTY-NOTICES.md', 'tools/check_copyright.ps1' ]
workflow_dispatch:

permissions:
Expand Down
7 changes: 7 additions & 0 deletions docs/architecture/11-build-test-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ headlessly, so the unit tests are the only verification loop that runs everywher
a slider may be narrower than the core's clamp only when listed there with a reason. After an
intended change to a default or a clamp, regenerate the fixture with
`set WIND_UPDATE_FIXTURES=1 && build.bat test` and review its diff.
- `tests/fixtures/shared/` holds the cases Wind shares with Wind for Mac, whose view geometry, zoom
controller and view owner are Swift ports of `src/track` and `src/zoom`: `tests/test_shared_fixtures.cpp`
runs them here, `mac/Tests/WindCoreChecks/SharedFixtures.swift` on the Mac (`mac.yml` runs on
changes to the folder). Rows are tagged `all`, `win` or `mac`; deliberate differences (the wheel
glide, keyboard pan speed at low zoom, `setLevel` during the ease-out) are per-platform rows. The
`all` and `win` values come from the C++ code: the same `WIND_UPDATE_FIXTURES=1` run rewrites them,
then the Mac has to match.

**UI suite.** `ui/tests/*.spec.js` are Playwright specs, run with `npx playwright test` in `ui/`
(the config starts the Vite server). Each spec installs a fake `window.chrome.webview` that answers
Expand Down
218 changes: 218 additions & 0 deletions mac/Tests/WindCoreChecks/SharedFixtures.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// Copyright (c) 2026 Max Aubert. All rights reserved.
// Proprietary source-available software; see LICENSE. SPDX-License-Identifier: LicenseRef-Wind-Proprietary
import Foundation

@testable import WindCore

/// The cases Wind for Mac shares with Windows Wind (the repo's tests/fixtures/shared/, also read by
/// tests/test_shared_fixtures.cpp): view geometry, the zoom controller and the view owner, which are
/// ports of the Windows code. Rows tagged all or mac run here; win rows are Windows-only. The file
/// headers give the formats. Windows is the origin, so a change there regenerates the all rows and
/// this side has to follow.
struct SharedFixtureTests {
/// mac/Tests/WindCoreChecks/ -> the repo root.
static let dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
.deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("tests/fixtures/shared")
static let tolerance = 1e-6 // documented in the fixture headers

struct BadRow: Error, CustomStringConvertible {
let description: String
init(_ d: String) { description = d }
}

func viewMathCases() throws { try runFile("view_math_cases.txt", atLeast: 50) }
func zoomControllerCases() throws { try runFile("zoom_controller_cases.txt", atLeast: 50) }
func viewOwnerCases() throws { try runFile("view_owner_cases.txt", atLeast: 14) }

func runFile(_ name: String, atLeast: Int) throws {
let text = try String(contentsOf: Self.dir.appendingPathComponent(name), encoding: .utf8)
var n = 0
for raw in text.split(whereSeparator: \.isNewline) {
let line = raw.trimmingCharacters(in: .whitespaces)
if line.isEmpty || line.hasPrefix("#") { continue }
let toks = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
guard toks.count >= 2, ["all", "win", "mac"].contains(toks[1]) else {
print(" \(name): bad row \"\(line)\""); expect(false); continue
}
if toks[1] == "win" { continue }
let body = Array(toks.dropFirst(2))
let want = body.filter { $0.hasPrefix("=") }.map { String($0.dropFirst()) }
let got: [String]
do {
switch toks[0] {
case "zoom": got = try zoomSequence(body)
case "owner": got = try ownerSequence(body)
default: got = try plain(toks[0], Array(body.prefix(while: { !$0.hasPrefix("=") })))
}
} catch {
print(" \(name): \"\(line)\": \(error)"); expect(false); continue
}
let ok = got.count == want.count && zip(want, got).allSatisfy { Self.same($0.0, $0.1) }
if !ok { print(" \(name): \"\(line)\"\n got =\(got.joined(separator: " ="))") }
expect(ok)
n += 1
}
expect(n >= atLeast)
}

static func same(_ want: String, _ got: String) -> Bool {
if let w = Double(want), let g = Double(got) { return abs(w - g) <= tolerance }
return want == got
}

static func num(_ v: Double) -> String { String(format: "%.10g", v) }

func number(_ s: String) throws -> Double {
guard let v = Double(s) else { throw BadRow("not a number: \(s)") }
return v
}

func whole(_ s: String) throws -> Int {
let v = try number(s)
guard v == v.rounded() else { throw BadRow("not a whole number: \(s)") }
return Int(v)
}

func keyValue(_ t: String) -> (String, String) {
guard let eq = t.firstIndex(of: "=") else { return (t, "") }
return (String(t[..<eq]), String(t[t.index(after: eq)...]))
}

/// One call of a plain function: every input comes before the expected values.
func plain(_ fn: String, _ args: [String]) throws -> [String] {
let a = try args.map { try number($0) }
func need(_ n: Int) throws { if a.count != n { throw BadRow("\(fn): wrong input count") } }
let num = Self.num
switch fn {
case "clamp":
try need(5)
let c = clampCenter(Pt(a[0], a[1]), level: a[2], screen: Sz(a[3], a[4]))
return [num(c.x), num(c.y)]
case "edge":
try need(12)
let c = edgePanCenter(cur: Pt(a[0], a[1]), ptr: Pt(a[2], a[3]), level: a[4], screen: Sz(a[5], a[6]),
marginPct: a[7], body: CursorBody(l: a[8], t: a[9], r: a[10], b: a[11]))
return [num(c.x), num(c.y)]
case "track":
try need(11)
guard let c = trackTargetCenter(Rc(l: a[0], t: a[1], r: a[2], b: a[3]), cur: Pt(a[4], a[5]), level: a[6],
screen: Sz(a[7], a[8]), align: Int(a[9]), marginPct: a[10]) else { return ["none"] }
return [num(c.x), num(c.y)]
case "still":
try need(11)
let c = stillViewTarget(Pt(a[0], a[1]), cur: Pt(a[2], a[3]), ptr: Pt(a[4], a[5]), level: a[6],
screen: Sz(a[7], a[8]), marginPct: a[9], leadPct: a[10])
return [num(c.x), num(c.y)]
case "keypan":
try need(3)
return [num(keyPanSpeed(level: a[0], screenWidth: a[1], panSpeed: a[2]))]
case "dir":
try need(2)
switch resolveDirection(inHeld: a[0] != 0, outHeld: a[1] != 0) {
case .zoomIn: return ["in"]
case .zoomOut: return ["out"]
case .none: return ["none"]
}
case "notch":
try need(1)
return [num(wheelNotchStep(speed: a[0]))]
case "quick":
try need(4)
let r = applyQuickZoom(cur: a[0], stored: a[1], def: a[2], maxLevel: a[3])
return [num(r.newLevel), num(r.newStored)]
default:
throw BadRow("unknown function \(fn)")
}
}

/// A zoom step sequence: one output per '=' token, in order.
func zoomSequence(_ toks: [String]) throws -> [String] {
var minL = 1.0, maxL = 30.0
for t in toks {
let (k, v) = keyValue(t)
if k == "min" { minL = try number(v) }
if k == "max" { maxL = try number(v) }
}
var z = ZoomController(minLevel: minL, maxLevel: maxL)
var out: [String] = []
for t in toks {
if t.hasPrefix("=") { out.append(Self.num(z.level)); continue }
let (k, v) = keyValue(t)
switch k {
case "min", "max": break
case "profile":
let p = try v.split(separator: ",").map { try number(String($0)) }
guard p.count == 5 else { throw BadRow("profile needs 5 values") }
z.setProfile(inSpeed: p[0], outSpeed: p[1], smooth: p[2] != 0, accel: p[3], rampSeconds: p[4])
case "ease": try z.setEaseOut(seconds: number(v))
case "in": z.direction = .zoomIn
case "out": z.direction = .zoomOut
case "none": z.direction = .none
case "t":
let p = v.split(separator: "*").map(String.init)
guard p.count == 2 else { throw BadRow("t needs HZ*N") }
let dt = try 1.0 / number(p[0])
let n = try whole(p[1])
for _ in 0..<n { z.tick(dt) }
case "wheel": try z.wheelNotches(Double(whole(v)))
case "step":
let p = v.split(separator: ",").map(String.init)
guard p.count == 2 else { throw BadRow("step needs N,S") }
try z.stepTarget(Double(whole(p[0])), step: number(p[1]))
case "set": try z.setLevel(number(v))
case "reset": z.reset()
default: throw BadRow("unknown zoom token \(t)")
}
}
return out
}

/// A view owner step sequence: each '=' token runs one stepViewOwner.
func ownerSequence(_ toks: [String]) throws -> [String] {
var s = ViewOwnerState()
var i = ViewOwnerInputs()
i.enabled = true; i.trackCaret = true; i.trackFocus = true; i.dtMs = 8
var out: [String] = []
for t in toks {
if t.hasPrefix("=") {
stepViewOwner(&s, i)
let owner: String
switch s.owner {
case .mouse: owner = "mouse"
case .caret: owner = "caret"
case .focus: owner = "focus"
case .keys: owner = "keys"
}
out.append("\(owner):\(s.warpPointer ? 1 : 0):\(s.target.seq)")
i.mouseDx = 0; i.mouseDy = 0; i.buttonDown = false // these last one step
continue
}
if t == "reset" { resetViewOwnerForSession(&s); continue }
let (k, v) = keyValue(t)
switch k {
case "en": i.enabled = try whole(v) != 0
case "tc": i.trackCaret = try whole(v) != 0
case "tf": i.trackFocus = try whole(v) != 0
case "dx": i.mouseDx = try number(v)
case "dy": i.mouseDy = try number(v)
case "btn": i.buttonDown = try whole(v) != 0
case "sb": i.msSinceButton = try number(v)
case "sk": i.msSinceKey = try number(v)
case "kab": i.keyAfterButton = try whole(v) != 0
case "dt": i.dtMs = try number(v)
case "seq": i.snap.seq = try UInt(whole(v))
case "pan": i.panning = try whole(v) != 0
case "act": i.trackActive = try whole(v) != 0
case "kind":
switch v {
case "none": i.snap.kind = .none
case "caret": i.snap.kind = .caret
case "focus": i.snap.kind = .focus
default: throw BadRow("unknown kind \(v)")
}
default: throw BadRow("unknown owner token \(t)")
}
}
return out
}
}
4 changes: 4 additions & 0 deletions mac/Tests/WindCoreChecks/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ let sh = ShotTests()
run("hotkeysFollowTheUsersSettings", sh.hotkeysFollowTheUsersSettings)
run("rectRoundsToNearestPoints", sh.rectRoundsToNearestPoints)
run("defaultsFixtureIsCurrent", DefaultsFixture.isCurrent)
let sf = SharedFixtureTests()
run("sharedViewMathCases", sf.viewMathCases)
run("sharedZoomControllerCases", sf.zoomControllerCases)
run("sharedViewOwnerCases", sf.viewOwnerCases)

print(failures == 0 ? "all checks passed" : "\(failures) check(s) failed")
exit(failures == 0 ? 0 : 1)
5 changes: 5 additions & 0 deletions mac/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,11 @@ Spec and plan: `~/Documents/Claude/research/wind-mac/2026-11-01-settings-port-sp
are checked against `Tests/fixtures/mac_keybind_cases.txt` (wind-checks and `npm run test:rules`).
The schema defaults and ranges are checked against `Tests/fixtures/config_defaults.json`
(regenerate with `swift run wind-checks --write-defaults` after changing `Config`).
- **Shared with Windows:** `ViewMath.swift`, `ZoomController.swift` and `ViewOwner.swift` are checked
against the repo's `tests/fixtures/shared/` (wind-checks `SharedFixtures.swift`; the Windows tests read
the same files). Rows tagged `all` come from the Windows code and must match here; deliberate
differences (wheel glide spring, key pan speed, `setLevel` stopping the ease-out, the still view) are
`win`/`mac` rows.

## Hitches as apps open (owner 2026-11-04)
"As if it's getting scheduled out", when a prompt appears or an app opens: a brief freeze on Render. The OS was
Expand Down
105 changes: 105 additions & 0 deletions tests/fixtures/shared/view_math_cases.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# View geometry cases shared by Wind (Windows) and Wind for Mac. Read by tests/test_shared_fixtures.cpp
# (src/transform/transform.h, src/track/edge_pan.h, src/track/view_glide.h, src/input/keyboard_pan.h)
# and by mac/Tests/WindCoreChecks/SharedFixtures.swift (mac/Sources/WindCore/ViewMath.swift).
#
# One case per line: <function> <platform> <inputs...> =<expected> ...
# platform: all (both run it), win (Windows only), mac (Mac only). A function or row that differs on
# purpose is tagged per platform instead of forced to agree.
# Expected values are the tokens that start with '='. Numbers match within 1e-6 (absolute); words
# (none) match exactly. Coordinates are desktop px (Windows) or points (Mac), top-left origin; the
# view is its centre and level and shows screen/level around it. Margins are whole percent.
# all and win rows come from the C++ code (Windows is the origin): after an intended change there,
# set WIND_UPDATE_FIXTURES=1 && build.bat test
# rewrites them, then the Mac must match. mac rows are written by hand from the Swift code.
#
# clamp cx cy level W H =cx =cy (ComputeOffsetF + half a view / clampCenter)
# edge curX curY ptrX ptrY level W H margin bL bT bR bB =cx =cy (EdgePanCenter, no MPO wall / edgePanCenter)
# track l t r b curX curY level W H align margin =cx =cy, or =none for a rejected rect
# still tX tY curX curY ptrX ptrY level W H margin lead =tx =ty (Mac only: stillViewTarget has no Windows twin)
# keypan level W panSpeed =desktop px per second (differs on purpose: see below)

# clamp: the centre stays half a view from every edge
clamp all 960 540 2 1920 1080 =960 =540
clamp all 10 1070 4 1920 1080 =240 =945
clamp all 500 500 1 1920 1080 =960 =540
clamp all 100 100 0.5 1920 1080 =960 =540
clamp all 0 0 2 1920 1080 =480 =270
clamp all 1920 1080 2 1920 1080 =1440 =810
clamp all 1919 5 8 1920 1080 =1800 =67.5
clamp all 3000 -500 3 1920 1080 =1600 =180
clamp all 2500 1200 30 2560 1440 =2500 =1200
clamp all 1 1 64 3840 2160 =30 =16.875
clamp all 3839 2159 64 3840 2160 =3810 =2143.125
clamp all 1234.5 678.25 7.3 3840 2160 =1234.5 =678.25
clamp all 1000 500 1.5 1366 768 =910.6666667 =500

# edge: still inside the band, then out past each side, corners, cursor bodies, high zoom
edge all 960 540 1000 600 2 1920 1080 0 0 0 0 0 =960 =540
edge all 960 540 1500 540 2 1920 1080 0 0 0 0 0 =1020 =540
edge all 960 540 1440 810 2 1920 1080 0 0 0 0 0 =960 =540
edge all 960 540 1500 540 2 1920 1080 10 0 0 0 0 =1116 =540
edge all 960 540 400 100 2 1920 1080 10 0 0 0 0 =784 =316
edge all 960 540 1000 600 2 1920 1080 10 1 1 12 19 =960 =540
edge all 960 540 1340 800 2 1920 1080 10 1 1 12 19 =968 =603
edge all 480 270 0 0 2 1920 1080 10 0 0 0 0 =480 =270
edge all 1440 810 1919 1079 2 1920 1080 15 1 1 12 19 =1440 =810
edge all 480 270 1919 1079 2 1920 1080 15 1 1 12 19 =1440 =810
edge all 960 540 1100 560 16 1920 1080 20 1 1 12 19 =1076 =558.75
edge all 960 540 700 300 16 1920 1080 20 1 1 12 19 =735 =319.25
edge all 960 540 990 550 30 1920 1080 40 1 1 12 19 =995.5 =559
edge all 960 540 0 0 30 1920 1080 40 1 1 12 19 =32 =18
edge all 960 540 0 0 1 1920 1080 10 0 0 0 0 =960 =540
edge all 960 540 100 100 0.5 1920 1080 10 0 0 0 0 =960 =540
edge all 1920 1080 3000 2000 5 3840 2160 25 1 1 12 19 =2820 =1911
edge all 1920 1080 1000 1500 5 3840 2160 25 1 1 12 19 =1191 =1411
edge all 600 400 1200 700 3.7 2560 1440 12 2 2 16 26 =953.0810811 =578.1081081

# track: rejected rects
track all 0 0 0 0 960 540 2 1920 1080 0 15 =none
track all 3000 10 3001 20 960 540 2 1920 1080 0 15 =none
track all 10 3000 20 3010 960 540 2 1920 1080 0 15 =none
track all 10 10 5 20 960 540 2 1920 1080 0 15 =none
track all 10 10 20 5 960 540 2 1920 1080 0 15 =none
track all -20 -20 -1 -1 960 540 2 1920 1080 0 15 =none
track all 100 100 100 100 960 540 2 1920 1080 0 15 =none
# track: centred (align 0), clamped at the edges
track all 700 300 702 318 960 540 2 1920 1080 0 15 =701 =309
track all 100 100 100 120 960 540 2 1920 1080 0 15 =480 =270
track all 1900 1060 1902 1078 960 540 4 1920 1080 0 15 =1680 =945
track all 1915 500 1925 520 960 540 2 1920 1080 0 15 =1440 =510
track all 300 300 302 318 960 540 0.5 1920 1080 0 15 =960 =540
track all 300 300 302 318 960 540 20 1920 1080 0 15 =301 =309
# track: just far enough (align 1)
track all 900 500 902 518 960 540 2 1920 1080 1 15 =960 =540
track all 1400 500 1402 518 960 540 2 1920 1080 1 15 =1066 =540
track all 500 100 502 118 960 540 2 1920 1080 1 15 =836 =289
track all 600 300 1500 320 960 540 2 1920 1080 1 15 =936 =489
track all 1400 500 1402 518 960 540 2 1920 1080 1 0 =960 =540
track all 1000 560 1002 578 960 540 20 1920 1080 1 15 =968.4 =559.1
track all 1910 1070 1912 1088 960 540 3 1920 1080 1 10 =1600 =900

# keypan: Windows' ZoomRateScale power curve vs the Mac's linear ease (0.5 at 1x up to 1 at 3x)
keypan win 1 1920 1 =746.7025787
keypan win 2 1920 1 =565.7281521
keypan win 4 1920 1 =425.3532714
keypan win 7.5 1920 1 =299.0675978
keypan win 10 1920 1 =235.0731999
keypan win 30 1920 1 =79.99075169
keypan win 4 3840 2.5 =2126.766357
keypan mac 1 1920 1 =1200
keypan mac 2 1920 1 =900
keypan mac 4 1920 1 =600
keypan mac 7.5 1920 1 =320
keypan mac 10 1920 1 =240
keypan mac 30 1920 1 =80
keypan mac 4 3840 2.5 =3000

# still: the Mac's screenshot/colour-sampler mode (macOS's own zoom and cursor)
still mac 960 540 960 540 1100 600 3 1920 1080 15 0 =960 =540
still mac 960 540 960 540 1200 600 3 1920 1080 15 0 =1200 =540
still mac 1200 540 1100 540 1210 560 3 1920 1080 15 0 =1200 =540
still mac 1600 900 1600 900 1915 1075 3 1920 1080 15 0 =1600 =900
still mac 960 540 960 540 1200 600 3 1920 1080 15 20 =1328 =540
still mac 960 540 960 540 900 400 3 1920 1080 15 20 =960 =328
still mac 960 540 960 540 10 10 2 1920 1080 10 0 =480 =270
still mac 960 540 960 540 1000 600 1 1920 1080 10 0 =960 =540
Loading
Loading