@joystyk/cards-ui 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # @joystyk/cards-ui
2
+
3
+ ## 0.2.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [bb095c7]
8
+ - Updated dependencies [bb095c7]
9
+ - @joystyk/contracts@0.2.0
10
+ - @joystyk/kit@0.2.0
11
+
12
+ ## 0.1.0
13
+
14
+ ### Minor Changes
15
+
16
+ - Prepare the first compiled library release: ESM and API declarations for every public entry point, runtime assets, and checks against installed npm tarballs. Games and applications remain private.
17
+
18
+ ### Patch Changes
19
+
20
+ - Updated dependencies
21
+ - @joystyk/contracts@0.1.0
22
+ - @joystyk/kit@0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joystyk contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,36 @@
1
+ Third-party material in the Joystyk library release
2
+
3
+ This notice preserves third-party attribution. Original Joystyk code in the 17
4
+ platform libraries under packages/ uses the MIT license in packages/LICENSE.
5
+ That choice does not license the private applications, games or other repository
6
+ material, and does not replace the third-party terms below.
7
+
8
+ @joystyk/contracts
9
+ ------------------
10
+ Standard Schema v1 type definitions from @standard-schema/spec 1.1.0:
11
+ https://github.com/standard-schema/standard-schema
12
+ https://standardschema.dev
13
+ Copyright (c) 2024 Colin McDonnell. MIT License.
14
+ The complete license and source/change notes accompany the distributed
15
+ dist/standard-schema.d.ts definitions. Changes: individually exported declarations
16
+ and formatting; the declarations themselves are unchanged.
17
+
18
+ @joystyk/identity and @joystyk/content
19
+ -----------------------------------
20
+ Word blocklists derived from List of Dirty, Naughty, Obscene, and Otherwise Bad
21
+ Words, copyright Shutterstock, Inc. and contributors:
22
+ https://github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words
23
+ Licensed under Creative Commons Attribution 4.0 International:
24
+ https://creativecommons.org/licenses/by/4.0/
25
+ Identity uses the English list at commit
26
+ 5faf2ba42d7b1c0977169ec3611df25a3c08eb13. Changes include reducing or dropping
27
+ phrases, dropping words with everyday meanings, substituting stems for inflections
28
+ and adding entries. Identity's README and content's lists/blocklist.txt retain
29
+ their source-specific provenance and changes.
30
+
31
+ @joystyk/content
32
+ ---------------
33
+ SCOWL-derived common, vocabulary and ranked word lists use SCOWL 2020.12.07.
34
+ Their complete source licenses and attribution accompany the package in
35
+ lists/SCOWL-COPYRIGHT. Word-list transformations and build provenance are described
36
+ in the package README. Preserve that copyright file when redistributing the lists.
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
1
+ # @joystyk/cards-ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared card tables, hands and solitaire controls for Gyral games. A game supplies a public table layout and intentions; these components handle pointer, keyboard and screen-reader interaction. Keep game authority in the engine.
4
+
5
+ ## npm package
6
+
7
+ The first `0.1.0` release is being prepared; it has not been published. Once available:
8
+
9
+ ```sh
10
+ npm install @joystyk/cards-ui
11
+ ```
12
+
13
+ Node consumers require Node 24.15 or later. Browser entry points require a modern
14
+ browser and should be loaded in the browser. Tested with Gyral 0.3.1.
15
+
16
+ | Import | Format |
17
+ | --- | --- |
18
+ | `@joystyk/cards-ui` | ES module and TypeScript declarations |
19
+ | `@joystyk/cards-ui/controller` | ES module and TypeScript declarations |
20
+ | `@joystyk/cards-ui/layout` | ES module and TypeScript declarations |
21
+ | `@joystyk/cards-ui/solitaire` | ES module and TypeScript declarations |
22
+
23
+ Only the entries listed above are public API. Installed packages contain compiled ESM,
24
+ type declarations and runtime assets. Repository-relative links describe the Joystyk
25
+ workspace and are not needed to use the npm package.
26
+
27
+ ```ts
28
+ import { CardTable, CardHand } from '@joystyk/cards-ui';
29
+ import { EMPTY_LAYOUT, DEFAULT_OPTIONS } from '@joystyk/cards-ui/layout';
30
+
31
+ // CardTable and CardHand register <jy-card-table> and <jy-card-hand>.
32
+ // Bind the layout/options properties from your Gyral parent.
33
+ ```
@@ -0,0 +1,110 @@
1
+ import { type FaceCard, type PileSpec, type TableLayout, type TableOptions } from './layout.js';
2
+ /** Pointer travel, in CSS pixels, that turns a press into a drag (spec: Pointer). */
3
+ export declare const DRAG_THRESHOLD = 6;
4
+ export type Via = 'drag' | 'click' | 'keyboard';
5
+ /** Cards the player has picked up: the top `count` of pile `from`. */
6
+ export type Held = {
7
+ readonly from: string;
8
+ readonly count: number;
9
+ readonly via: Via;
10
+ };
11
+ /** A pointer pressed on a pile; `depth` counts cards from the top (0: the pile, not a card). */
12
+ export type Press = {
13
+ readonly pile: string | null;
14
+ readonly depth: number;
15
+ readonly x: number;
16
+ readonly y: number;
17
+ readonly dragging: boolean;
18
+ readonly dx: number;
19
+ readonly dy: number;
20
+ readonly over: string | null;
21
+ };
22
+ export type Ctl = {
23
+ /** The focused region. */
24
+ readonly region: number;
25
+ /** The remembered pile of each region: the one Tab reaches (roving tabindex). */
26
+ readonly piles: readonly number[];
27
+ /** Cards counted from the top of the focused pile: what Enter picks up. */
28
+ readonly depth: number;
29
+ readonly held: Held | null;
30
+ readonly press: Press | null;
31
+ /** The layout version last seen. */
32
+ readonly version: number;
33
+ /** A drag just dropped: the next layout lands without card flights. */
34
+ readonly settle: boolean;
35
+ /** The layout version that renders without card flights. */
36
+ readonly quiet: number | null;
37
+ };
38
+ /** What the table tells the game. */
39
+ export type TableOut = {
40
+ readonly _tag: 'Move';
41
+ readonly from: string;
42
+ readonly to: string;
43
+ readonly count: number;
44
+ readonly via: Via;
45
+ } | {
46
+ readonly _tag: 'Smart';
47
+ readonly from: string;
48
+ readonly count: number;
49
+ readonly via: Via;
50
+ } | {
51
+ readonly _tag: 'Activate';
52
+ readonly pile: string;
53
+ } | {
54
+ readonly _tag: 'Key';
55
+ readonly key: string;
56
+ readonly pile: string;
57
+ readonly count: number;
58
+ } | {
59
+ readonly _tag: 'Announce';
60
+ readonly message: string;
61
+ };
62
+ export type CtlMsg = {
63
+ readonly _tag: 'Key';
64
+ readonly key: string;
65
+ readonly ctrl: boolean;
66
+ readonly alt: boolean;
67
+ readonly meta: boolean;
68
+ readonly shift: boolean;
69
+ } | {
70
+ readonly _tag: 'Focus';
71
+ readonly pile: string;
72
+ } | {
73
+ readonly _tag: 'Down';
74
+ readonly pile: string | null;
75
+ readonly depth: number;
76
+ readonly x: number;
77
+ readonly y: number;
78
+ } | {
79
+ readonly _tag: 'Move';
80
+ readonly x: number;
81
+ readonly y: number;
82
+ readonly over: string | null;
83
+ } | {
84
+ readonly _tag: 'Up';
85
+ readonly over: string | null;
86
+ } | {
87
+ readonly _tag: 'Cancel';
88
+ };
89
+ /** One step: the new controller state, outputs, and a pile to focus after rendering. */
90
+ export type Step = {
91
+ readonly ctl: Ctl;
92
+ readonly outs: readonly TableOut[];
93
+ readonly focus: string | null;
94
+ };
95
+ export declare const initialCtl: (layout: TableLayout) => Ctl;
96
+ /**
97
+ * Brings the controller in line with a new layout: indexes stay in range, and a new version
98
+ * (the cards changed) drops any pick-up or press.
99
+ */
100
+ export declare const sync: (ctl: Ctl, layout: TableLayout) => Ctl;
101
+ /** The focused pile, if the table has any. */
102
+ export declare const focusedPile: (ctl: Ctl, layout: TableLayout) => PileSpec | null;
103
+ /** A card's name for the middle of a sentence: "queen of hearts", "7 of clubs". */
104
+ export declare const spoken: (card: FaceCard) => string;
105
+ /** "9 of spades", or "9 of spades and 2 more cards": the cards a pick-up of `count` takes. */
106
+ export declare const describe: (pile: PileSpec, count: number) => string;
107
+ /** The controller's reducer. */
108
+ export declare const step: (before: Ctl, layout: TableLayout, options: TableOptions, m: CtlMsg) => Step;
109
+ /** True when the table should render this layout without card flights. */
110
+ export declare const isQuiet: (ctl: Ctl, layout: TableLayout, options: TableOptions) => boolean;
@@ -0,0 +1,449 @@
1
+ import { findPile, isFaceCard, topCards } from "./layout.js";
2
+ import { cardName } from "@joystyk/kit/cards";
3
+ //#region src/controller.ts
4
+ /** Pointer travel, in CSS pixels, that turns a press into a drag (spec: Pointer). */
5
+ var DRAG_THRESHOLD = 6;
6
+ var initialCtl = (layout) => {
7
+ const first = layout.regions.findIndex((r) => r.piles.length > 0);
8
+ return {
9
+ region: Math.max(0, first),
10
+ piles: layout.regions.map(() => 0),
11
+ depth: 1,
12
+ held: null,
13
+ press: null,
14
+ version: layout.version,
15
+ settle: false,
16
+ quiet: null
17
+ };
18
+ };
19
+ var clamp = (n, min, max) => Math.min(max, Math.max(min, n));
20
+ /**
21
+ * Brings the controller in line with a new layout: indexes stay in range, and a new version
22
+ * (the cards changed) drops any pick-up or press.
23
+ */
24
+ var sync = (ctl, layout) => {
25
+ const piles = layout.regions.map((r, i) => clamp(ctl.piles[i] ?? 0, 0, Math.max(0, r.piles.length - 1)));
26
+ let region = clamp(ctl.region, 0, Math.max(0, layout.regions.length - 1));
27
+ if ((layout.regions[region]?.piles.length ?? 0) === 0) region = Math.max(0, layout.regions.findIndex((r) => r.piles.length > 0));
28
+ const moved = layout.version !== ctl.version;
29
+ const same = piles.length === ctl.piles.length && piles.every((p, i) => p === ctl.piles[i]) && region === ctl.region;
30
+ if (!moved && same) return ctl;
31
+ if (!moved) return {
32
+ ...ctl,
33
+ piles,
34
+ region
35
+ };
36
+ return {
37
+ ...ctl,
38
+ piles,
39
+ region,
40
+ depth: 1,
41
+ held: null,
42
+ press: null,
43
+ version: layout.version,
44
+ settle: false,
45
+ quiet: ctl.settle ? layout.version : null
46
+ };
47
+ };
48
+ /** The focused pile, if the table has any. */
49
+ var focusedPile = (ctl, layout) => layout.regions[ctl.region]?.piles[ctl.piles[ctl.region] ?? 0] ?? null;
50
+ /** A card's name for the middle of a sentence: "queen of hearts", "7 of clubs". */
51
+ var spoken = (card) => cardName(card).toLowerCase();
52
+ var capital = (s) => s.charAt(0).toUpperCase() + s.slice(1);
53
+ /** "9 of spades", or "9 of spades and 2 more cards": the cards a pick-up of `count` takes. */
54
+ var describe = (pile, count) => {
55
+ const [first] = topCards(pile, count);
56
+ if (first === void 0) return "nothing";
57
+ const more = count - 1;
58
+ return more === 0 ? spoken(first) : `${spoken(first)} and ${more} more ${more === 1 ? "card" : "cards"}`;
59
+ };
60
+ var none = (ctl) => ({
61
+ ctl,
62
+ outs: [],
63
+ focus: null
64
+ });
65
+ var say = (message) => ({
66
+ _tag: "Announce",
67
+ message
68
+ });
69
+ /** Picks up `count` cards from `pile` and says so. */
70
+ var pickUp = (ctl, pile, count, via) => {
71
+ const note = pile.note === void 0 ? "" : ` ${pile.note}`;
72
+ const help = via === "keyboard" ? " Choose a pile, then press Enter." : "";
73
+ return {
74
+ ctl: {
75
+ ...ctl,
76
+ held: {
77
+ from: pile.id,
78
+ count,
79
+ via
80
+ }
81
+ },
82
+ outs: [say(`Picked up ${describe(pile, count)}.${note}${help}`)],
83
+ focus: null
84
+ };
85
+ };
86
+ /** Drops the held cards on `to`: a move, or a smart move on their own pile. */
87
+ var drop = (ctl, held, to, via) => {
88
+ return {
89
+ ctl: {
90
+ ...ctl,
91
+ held: null
92
+ },
93
+ outs: [to === held.from ? {
94
+ _tag: "Smart",
95
+ from: held.from,
96
+ count: held.count,
97
+ via
98
+ } : {
99
+ _tag: "Move",
100
+ from: held.from,
101
+ to,
102
+ count: held.count,
103
+ via
104
+ }],
105
+ focus: null
106
+ };
107
+ };
108
+ var putBack = (ctl, layout) => {
109
+ const held = ctl.held;
110
+ if (held === null) return none(ctl);
111
+ const pile = findPile(layout, held.from)?.pile;
112
+ return {
113
+ ctl: {
114
+ ...ctl,
115
+ held: null
116
+ },
117
+ outs: [say(pile === void 0 ? "Put back." : `Put back ${describe(pile, held.count)}.`)],
118
+ focus: null
119
+ };
120
+ };
121
+ /** Moves the focus to pile `index` of the focused region. */
122
+ var focusAt = (ctl, layout, region, index) => {
123
+ const pile = layout.regions[region]?.piles[index];
124
+ if (pile === void 0) return none(ctl);
125
+ const piles = ctl.piles.slice();
126
+ piles[region] = index;
127
+ const changed = region !== ctl.region || index !== ctl.piles[region];
128
+ return {
129
+ ctl: {
130
+ ...ctl,
131
+ region,
132
+ piles,
133
+ depth: changed ? 1 : ctl.depth
134
+ },
135
+ outs: [],
136
+ focus: pile.id
137
+ };
138
+ };
139
+ /** Enter or Space on the focused pile. */
140
+ var activate = (ctl, pile) => {
141
+ if (ctl.held !== null) return drop(ctl, ctl.held, pile.id, "keyboard");
142
+ if (pile.action === true) return {
143
+ ctl,
144
+ outs: [{
145
+ _tag: "Activate",
146
+ pile: pile.id
147
+ }],
148
+ focus: null
149
+ };
150
+ if (pile.pickable >= 1) return pickUp(ctl, pile, clamp(ctl.depth, 1, pile.pickable), "keyboard");
151
+ return {
152
+ ctl,
153
+ outs: [say(pile.cards.length === 0 ? "Nothing to pick up: the pile is empty." : "Nothing here can be picked up.")],
154
+ focus: null
155
+ };
156
+ };
157
+ /** Up and Down: how many cards Enter picks up. */
158
+ var deepen = (ctl, pile, by) => {
159
+ if (pile.inspect === true && ctl.held === null) {
160
+ const depth = clamp(ctl.depth + by, 1, Math.max(1, pile.cards.length));
161
+ const card = pile.cards[pile.cards.length - depth];
162
+ return {
163
+ ctl: {
164
+ ...ctl,
165
+ depth
166
+ },
167
+ outs: isFaceCard(card) ? [say(capital(spoken(card)))] : [],
168
+ focus: null
169
+ };
170
+ }
171
+ if (ctl.held !== null || pile.pickable < 1) return none(ctl);
172
+ const depth = clamp(ctl.depth, 1, pile.pickable);
173
+ const next = depth + by;
174
+ if (next < 1) return {
175
+ ctl: {
176
+ ...ctl,
177
+ depth
178
+ },
179
+ outs: [],
180
+ focus: null
181
+ };
182
+ if (next > pile.pickable) return {
183
+ ctl: {
184
+ ...ctl,
185
+ depth
186
+ },
187
+ outs: pile.limit === void 0 ? [] : [say(pile.limit)],
188
+ focus: null
189
+ };
190
+ return {
191
+ ctl: {
192
+ ...ctl,
193
+ depth: next
194
+ },
195
+ outs: [say(capital(describe(pile, next)))],
196
+ focus: null
197
+ };
198
+ };
199
+ var isDigit = (key) => key.length === 1 && key >= "0" && key <= "9";
200
+ /** Navigate a geometric layout without consulting the DOM or skipping covered cards. */
201
+ var geometricFocus = (ctl, layout, key) => {
202
+ const region = layout.regions[ctl.region];
203
+ const here = focusedPile(ctl, layout)?.position;
204
+ if (region?.placement !== "free" || here === void 0) return null;
205
+ const candidates = region.piles.map((pile, index) => ({
206
+ index,
207
+ position: pile.position
208
+ })).filter((p) => p.position !== void 0);
209
+ const row = key === "ArrowUp" ? Math.max(-1, ...candidates.filter((p) => p.position.row < here.row).map((p) => p.position.row)) : key === "ArrowDown" ? Math.min(Infinity, ...candidates.filter((p) => p.position.row > here.row).map((p) => p.position.row)) : here.row;
210
+ let choices = candidates.filter((p) => p.position.row === row);
211
+ if (key === "ArrowLeft") choices = choices.filter((p) => p.position.column < here.column);
212
+ if (key === "ArrowRight") choices = choices.filter((p) => p.position.column > here.column);
213
+ choices.sort((a, b) => key === "Home" ? a.position.column - b.position.column : key === "End" ? b.position.column - a.position.column : Math.abs(a.position.column - here.column) - Math.abs(b.position.column - here.column) || a.position.column - b.position.column);
214
+ return choices[0] === void 0 ? none(ctl) : focusAt(ctl, layout, ctl.region, choices[0].index);
215
+ };
216
+ var onKey = (ctl, layout, options, m) => {
217
+ const pile = focusedPile(ctl, layout);
218
+ if (pile === null || m.alt || m.meta || m.ctrl) return none(ctl);
219
+ const count = layout.regions[ctl.region]?.piles.length ?? 0;
220
+ const index = ctl.piles[ctl.region] ?? 0;
221
+ if ([
222
+ "ArrowLeft",
223
+ "ArrowRight",
224
+ "ArrowUp",
225
+ "ArrowDown",
226
+ "Home",
227
+ "End"
228
+ ].includes(m.key)) {
229
+ const spatial = geometricFocus(ctl, layout, m.key);
230
+ if (spatial !== null) return spatial;
231
+ }
232
+ switch (m.key) {
233
+ case "ArrowLeft": return focusAt(ctl, layout, ctl.region, Math.max(0, index - 1));
234
+ case "ArrowRight": return focusAt(ctl, layout, ctl.region, Math.min(count - 1, index + 1));
235
+ case "Home": return focusAt(ctl, layout, ctl.region, 0);
236
+ case "End": return focusAt(ctl, layout, ctl.region, count - 1);
237
+ case "ArrowUp": return deepen(ctl, pile, 1);
238
+ case "ArrowDown": return deepen(ctl, pile, -1);
239
+ case "Enter":
240
+ case " ": return activate(ctl, pile);
241
+ case "Escape": return putBack(ctl, layout);
242
+ }
243
+ if (!options.singleKeys || m.shift) return none(ctl);
244
+ const key = m.key.toLowerCase();
245
+ if (isDigit(key)) {
246
+ const target = layout.regions.flatMap((r, ri) => r.piles.map((p, pi) => ({
247
+ p,
248
+ ri,
249
+ pi
250
+ }))).find(({ p }) => p.key === key);
251
+ if (target === void 0) return none(ctl);
252
+ const moved = focusAt(ctl, layout, target.ri, target.pi);
253
+ if (ctl.held === null) return layout.activateNumbers === true ? {
254
+ ...activate(moved.ctl, target.p),
255
+ focus: moved.focus
256
+ } : moved;
257
+ return {
258
+ ...drop(moved.ctl, ctl.held, target.p.id, "keyboard"),
259
+ focus: moved.focus
260
+ };
261
+ }
262
+ if (layout.keys.includes(key)) {
263
+ if (key === "n" || key === "t" || key === "w") {
264
+ const piles = layout.regions.flatMap((r, ri) => r.piles.map((p, pi) => ({
265
+ p,
266
+ ri,
267
+ pi
268
+ })));
269
+ const available = piles.filter(({ p }) => p.pickable > 0).sort((a, b) => (b.p.position?.row ?? -1) - (a.p.position?.row ?? -1) || (a.p.position?.column ?? a.pi) - (b.p.position?.column ?? b.pi));
270
+ const current = available.findIndex(({ p }) => p.id === pile.id);
271
+ const next = key === "n" ? available[(current + 1) % Math.max(1, available.length)] : piles.find(({ p }) => p.id === (key === "t" ? "turned" : "waste"));
272
+ if (next !== void 0) return focusAt(ctl, layout, next.ri, next.pi);
273
+ return none(ctl);
274
+ }
275
+ const held = ctl.held;
276
+ const out = held === null ? {
277
+ _tag: "Key",
278
+ key,
279
+ pile: pile.id,
280
+ count: clamp(ctl.depth, 1, Math.max(1, pile.pickable))
281
+ } : {
282
+ _tag: "Key",
283
+ key,
284
+ pile: held.from,
285
+ count: held.count
286
+ };
287
+ return {
288
+ ctl: {
289
+ ...ctl,
290
+ held: null
291
+ },
292
+ outs: [out],
293
+ focus: null
294
+ };
295
+ }
296
+ return none(ctl);
297
+ };
298
+ /** A click (a press released without dragging) on `pileId` at `depth`. */
299
+ var click = (ctl, layout, pileId, depth) => {
300
+ const found = pileId === null ? null : findPile(layout, pileId);
301
+ if (found === null) return putBack(ctl, layout);
302
+ const { pile } = found;
303
+ const focused = {
304
+ ...ctl,
305
+ region: found.region,
306
+ piles: ctl.piles.map((p, i) => i === found.region ? found.index : p)
307
+ };
308
+ const held = ctl.held;
309
+ if (held !== null) {
310
+ if (pile.id !== held.from) return drop(focused, held, pile.id, "click");
311
+ if (depth === held.count) return drop(focused, held, pile.id, "click");
312
+ if (depth >= 1 && depth <= pile.pickable) return pickUp({
313
+ ...focused,
314
+ depth
315
+ }, pile, depth, "click");
316
+ return putBack(focused, layout);
317
+ }
318
+ if (pile.action === true) return {
319
+ ctl: focused,
320
+ outs: [{
321
+ _tag: "Activate",
322
+ pile: pile.id
323
+ }],
324
+ focus: null
325
+ };
326
+ if (depth >= 1 && depth <= pile.pickable) return pickUp({
327
+ ...focused,
328
+ depth
329
+ }, pile, depth, "click");
330
+ return none(focused);
331
+ };
332
+ /** The controller's reducer. */
333
+ var step = (before, layout, options, m) => {
334
+ const ctl = sync(before, layout);
335
+ switch (m._tag) {
336
+ case "Key": return onKey(ctl, layout, options, m);
337
+ case "Focus": {
338
+ const found = findPile(layout, m.pile);
339
+ if (found === null) return none(ctl);
340
+ if (found.region === ctl.region && found.index === ctl.piles[found.region]) return none(ctl);
341
+ const piles = ctl.piles.slice();
342
+ piles[found.region] = found.index;
343
+ return none({
344
+ ...ctl,
345
+ region: found.region,
346
+ piles,
347
+ depth: 1
348
+ });
349
+ }
350
+ case "Down": {
351
+ const stale = ctl.press?.dragging === true;
352
+ return none({
353
+ ...ctl,
354
+ held: stale ? null : ctl.held,
355
+ press: {
356
+ pile: m.pile,
357
+ depth: m.depth,
358
+ x: m.x,
359
+ y: m.y,
360
+ dragging: false,
361
+ dx: 0,
362
+ dy: 0,
363
+ over: null
364
+ }
365
+ });
366
+ }
367
+ case "Move": {
368
+ const press = ctl.press;
369
+ if (press === null || press.pile === null) return none(ctl);
370
+ const dx = m.x - press.x;
371
+ const dy = m.y - press.y;
372
+ if (press.dragging) return none({
373
+ ...ctl,
374
+ press: {
375
+ ...press,
376
+ dx,
377
+ dy,
378
+ over: m.over
379
+ }
380
+ });
381
+ if (Math.sqrt(dx * dx + dy * dy) < 6) return none(ctl);
382
+ const pile = findPile(layout, press.pile)?.pile;
383
+ const top = pile?.cards[pile.cards.length - press.depth];
384
+ if (pile === void 0 || press.depth < 1 || press.depth > pile.pickable || !isFaceCard(top)) return none({
385
+ ...ctl,
386
+ press: null
387
+ });
388
+ return none({
389
+ ...ctl,
390
+ held: {
391
+ from: pile.id,
392
+ count: press.depth,
393
+ via: "drag"
394
+ },
395
+ press: {
396
+ ...press,
397
+ dragging: true,
398
+ dx,
399
+ dy,
400
+ over: m.over
401
+ }
402
+ });
403
+ }
404
+ case "Up": {
405
+ const press = ctl.press;
406
+ if (press === null) return none(ctl);
407
+ const released = {
408
+ ...ctl,
409
+ press: null
410
+ };
411
+ if (!press.dragging) return click(released, layout, press.pile, press.depth);
412
+ const held = ctl.held;
413
+ const to = m.over;
414
+ if (held === null || to === null || to === held.from) return none({
415
+ ...released,
416
+ held: null
417
+ });
418
+ return {
419
+ ctl: {
420
+ ...released,
421
+ held: null,
422
+ settle: true
423
+ },
424
+ outs: [{
425
+ _tag: "Move",
426
+ from: held.from,
427
+ to,
428
+ count: held.count,
429
+ via: "drag"
430
+ }],
431
+ focus: null
432
+ };
433
+ }
434
+ case "Cancel": {
435
+ const dragging = ctl.press?.dragging === true;
436
+ return none({
437
+ ...ctl,
438
+ press: null,
439
+ held: dragging ? null : ctl.held
440
+ });
441
+ }
442
+ }
443
+ };
444
+ /** True when the table should render this layout without card flights. */
445
+ var isQuiet = (ctl, layout, options) => options.calm || ctl.quiet === layout.version;
446
+ //#endregion
447
+ export { DRAG_THRESHOLD, describe, focusedPile, initialCtl, isQuiet, spoken, step, sync };
448
+
449
+ //# sourceMappingURL=controller.js.map