From f290e2614a70271923ba780613196fe58764c364 Mon Sep 17 00:00:00 2001 From: swipelab Date: Fri, 5 Jun 2026 00:14:44 +0300 Subject: [PATCH] =?UTF-8?q?P5.1:=20drag=20=E2=86=92=20adjacent-swap=20inte?= =?UTF-8?q?nt=20mapping=20(pure=20sx)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add swipe.sx: a pure swipe_intent(layout, start, end) -> ?Swap that turns a touch drag into an optional adjacent-swap intent (A, B). A is the cell under the drag start (via BoardLayout.point_to_cell); B is its orthogonal neighbour along the drag's dominant axis (larger of |dx|,|dy|, ties horizontal). Returns null for sub-threshold drags (a tap, threshold = cell_size * 0.5), starts off the board, or neighbours off the board. No rendering, no model mutation. Lock the logic with tests/swipe_intent.sx (+ expected golden), feeding synthetic down/up positions for right/left/up/down, sub-threshold tap, diagonal→dominant (both axes), edge-outward off-board, and start-off-board. bash tools/run_tests.sh passes (13/13); sx build --target ios-sim main.sx compiles. --- swipe.sx | 52 +++++++++++++++++++ tests/expected/swipe_intent.exit | 1 + tests/expected/swipe_intent.stdout | 12 +++++ tests/swipe_intent.sx | 82 ++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 swipe.sx create mode 100644 tests/expected/swipe_intent.exit create mode 100644 tests/expected/swipe_intent.stdout create mode 100644 tests/swipe_intent.sx diff --git a/swipe.sx b/swipe.sx new file mode 100644 index 0000000..c561b6c --- /dev/null +++ b/swipe.sx @@ -0,0 +1,52 @@ +// Pure drag → adjacent-swap intent mapping (Phase 5 input). Turns a touch drag — +// a down position and an up/move position, both in the same view-local coordinate +// space BoardLayout uses — into an optional swap intent (A, B): A is the cell +// under the drag start, B its orthogonal neighbour along the drag's dominant axis. +// Owns no rendering and mutates no model, so it is unit-testable headless; P5.2 +// feeds the resulting Swap straight into `commit_swap`. +#import "modules/std.sx"; +#import "modules/math"; +#import "modules/ui/types.sx"; +#import "board.sx"; +#import "board_layout.sx"; + +// A drag whose dominant-axis travel is below this fraction of a cell is treated +// as a tap, not a swipe, and yields no intent. Scaling to cell_size keeps the +// feel constant across screen sizes, since the layout sizes cells to the device. +SWIPE_THRESHOLD_FRACTION :f32: 0.5; + +// Map a drag to the adjacent-swap intent it expresses, or null when the gesture +// is not a board swipe. Returns null when: the start point is off the board; the +// dominant-axis travel is below the swipe threshold (a tap, not a swipe); or the +// resolved neighbour B would fall off the board. A and B are always orthogonally +// adjacent — the intent never spans more than one cell. The dominant axis is the +// larger of |dx|, |dy| (an exact tie resolves horizontal), and its sign picks the +// direction: +x → right, -x → left, +y → down, -y → up (screen y grows downward, +// matching `cell_frame`). Reuses `point_to_cell` so the start resolves to exactly +// the cell drawn under the finger. +swipe_intent :: (layout: *BoardLayout, start: Point, end: Point) -> ?Swap { + if a := layout.point_to_cell(start) { + dx := end.x - start.x; + dy := end.y - start.y; + adx := abs(dx); + ady := abs(dy); + + threshold := layout.cell_size * SWIPE_THRESHOLD_FRACTION; + if adx < threshold and ady < threshold { return null; } // a tap, not a swipe + + bcol := a.col; + brow := a.row; + if adx >= ady { + if dx > 0.0 { bcol += 1; } else { bcol -= 1; } + } else { + if dy > 0.0 { brow += 1; } else { brow -= 1; } + } + + if bcol < 0 or bcol >= BOARD_COLS or brow < 0 or brow >= BOARD_ROWS { + return null; // neighbour off the board + } + + return Swap.{ a = a, b = Cell.{ col = bcol, row = brow } }; + } + null // start off the board +} diff --git a/tests/expected/swipe_intent.exit b/tests/expected/swipe_intent.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/swipe_intent.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/swipe_intent.stdout b/tests/expected/swipe_intent.stdout new file mode 100644 index 0000000..ca9ddaf --- /dev/null +++ b/tests/expected/swipe_intent.stdout @@ -0,0 +1,12 @@ +grid origin (100,0) cell 75 threshold 37 +right: (3,5)->(4,5) +left: (3,5)->(2,5) +up: (3,5)->(3,4) +down: (3,5)->(3,6) +short: none +diag-horizontal: (3,5)->(4,5) +diag-vertical: (3,5)->(3,4) +offboard-right: none +offboard-up: none +start-offboard: none +ok: swipe-intent mapping resolves all cases diff --git a/tests/swipe_intent.sx b/tests/swipe_intent.sx new file mode 100644 index 0000000..018e7d2 --- /dev/null +++ b/tests/swipe_intent.sx @@ -0,0 +1,82 @@ +// Swipe-intent golden (P5.1): lock the pure drag → adjacent-swap mapping +// `swipe_intent`. Feeds SYNTHETIC down/up screen positions (built from the SAME +// BoardLayout the renderer uses) and asserts the resolved swap intent (A, B), so +// a drift in axis/direction/threshold logic changes both the dump and the exit +// code. No rendering, no model mutation — links headless like tests/hit_test.sx. +// +// Layout: 800×600, no safe inset → 600px square grid, cell 75, origin (100, 0); +// swipe threshold = cell_size * 0.5 = 37.5px on the dominant axis. A 60px drag +// clears it; a 10px drag does not. Failure is signalled via a non-zero exit code +// (the runner checks exit code AND stdout). +#import "modules/std.sx"; +#import "board.sx"; +#import "board_layout.sx"; +#import "swipe.sx"; + +cell_center :: (lay: *BoardLayout, col: s64, row: s64) -> Point { + cf := lay.cell_frame(col, row); + Point.{ x = cf.mid_x(), y = cf.mid_y() } +} + +// Print the resolved intent (locked in the golden) and report whether it matches +// the expected adjacent pair (A, B). Drives the exit code alongside the dump. +expect_swap :: (label: string, got: ?Swap, ac: s64, ar: s64, bc: s64, br: s64) -> bool { + if s := got { + print("{}: ({},{})->({},{})\n", label, s.a.col, s.a.row, s.b.col, s.b.row); + return s.a.col == ac and s.a.row == ar and s.b.col == bc and s.b.row == br; + } + print("{}: none\n", label); + false +} + +expect_none :: (label: string, got: ?Swap) -> bool { + if s := got { + print("{}: ({},{})->({},{})\n", label, s.a.col, s.a.row, s.b.col, s.b.row); + return false; + } + print("{}: none\n", label); + true +} + +main :: () -> s32 { + lay : BoardLayout = ---; + lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero()); + print("grid origin ({},{}) cell {} threshold {}\n", + cast(s64) lay.origin.x, cast(s64) lay.origin.y, cast(s64) lay.cell_size, + cast(s64) (lay.cell_size * SWIPE_THRESHOLD_FRACTION)); + + fails : s64 = 0; + + // A known interior cell; every cardinal swipe from it stays on the board. + start := cell_center(@lay, 3, 5); + D : f32 = 60.0; // dominant-axis travel, well above the 37.5 threshold + + if !expect_swap("right", swipe_intent(@lay, start, Point.{ x = start.x + D, y = start.y }), 3, 5, 4, 5) { fails += 1; } + if !expect_swap("left", swipe_intent(@lay, start, Point.{ x = start.x - D, y = start.y }), 3, 5, 2, 5) { fails += 1; } + if !expect_swap("up", swipe_intent(@lay, start, Point.{ x = start.x, y = start.y - D }), 3, 5, 3, 4) { fails += 1; } + if !expect_swap("down", swipe_intent(@lay, start, Point.{ x = start.x, y = start.y + D }), 3, 5, 3, 6) { fails += 1; } + + // Below threshold on both axes → a tap, not a swipe. + if !expect_none("short", swipe_intent(@lay, start, Point.{ x = start.x + 10.0, y = start.y + 5.0 })) { fails += 1; } + + // Diagonal drags resolve to the dominant axis (larger of |dx|, |dy|). + if !expect_swap("diag-horizontal", swipe_intent(@lay, start, Point.{ x = start.x + D, y = start.y + 25.0 }), 3, 5, 4, 5) { fails += 1; } + if !expect_swap("diag-vertical", swipe_intent(@lay, start, Point.{ x = start.x + 20.0, y = start.y - D }), 3, 5, 3, 4) { fails += 1; } + + // Edge cell swiped outward → neighbour off the board → no intent. + edge_right := cell_center(@lay, 7, 0); + if !expect_none("offboard-right", swipe_intent(@lay, edge_right, Point.{ x = edge_right.x + D, y = edge_right.y })) { fails += 1; } + edge_top := cell_center(@lay, 2, 0); + if !expect_none("offboard-up", swipe_intent(@lay, edge_top, Point.{ x = edge_top.x, y = edge_top.y - D })) { fails += 1; } + + // Drag starting off the board (left of the grid) → no intent. + off_start := Point.{ x = lay.origin.x - 5.0, y = lay.origin.y + 10.0 }; + if !expect_none("start-offboard", swipe_intent(@lay, off_start, Point.{ x = off_start.x + D, y = off_start.y })) { fails += 1; } + + if fails == 0 { + print("ok: swipe-intent mapping resolves all cases\n"); + return 0; + } + print("FAIL: {} swipe-intent checks failed\n", fails); + return 1; +}