@gjermundgaraba-designs/pixel-paper 0.18.0 → 0.19.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.
@@ -0,0 +1,1597 @@
1
+ import { Array, Effect, Equal, Match, Number, Option, Order, Schema, Stream, String } from "effect";
2
+ import * as Command from "foldkit/command";
3
+ import type { Attribute, Html, HtmlBuilder, KeyboardModifiers } from "foldkit/html";
4
+ import { defineMessageUnion } from "foldkit/message";
5
+ import * as Mount from "foldkit/mount";
6
+ import * as Render from "foldkit/render";
7
+ import { defineTaggedUnion } from "foldkit/schema";
8
+ import { modifyFields } from "foldkit/struct";
9
+ import { defineView } from "foldkit/submodel";
10
+ import * as Subscription from "foldkit/subscription";
11
+ import type * as Update from "foldkit/update";
12
+
13
+ import * as Announcer from "../announcer/announcer.ts";
14
+ import * as Button from "../button/button.ts";
15
+ import {
16
+ LEFT_BUTTON,
17
+ escapeKeys,
18
+ markDragging,
19
+ measureLift,
20
+ pressingPointer,
21
+ } from "../drag/drag.ts";
22
+ import * as Glyph from "../glyph/glyph.ts";
23
+ import * as IconButton from "../iconButton/iconButton.ts";
24
+ import { prefixKeys } from "../subscription/prefixKeys.ts";
25
+
26
+ import {
27
+ type Column,
28
+ type ColumnBox,
29
+ type Layout,
30
+ OutMessage,
31
+ type Pane,
32
+ type Place,
33
+ Resize,
34
+ Splitter,
35
+ Step,
36
+ Subject,
37
+ Target,
38
+ WHERE_IT_WAS,
39
+ apply,
40
+ columnOf,
41
+ columnOfTarget,
42
+ fill,
43
+ firstPlace,
44
+ isPresent,
45
+ isSplitterPresent,
46
+ isTargetPresent,
47
+ landingWords,
48
+ nextPreset,
49
+ originOf,
50
+ placeAt,
51
+ placeOf,
52
+ placeWords,
53
+ requestOf,
54
+ resizeOf,
55
+ steppedTarget,
56
+ subjectWords,
57
+ targetAtPoint,
58
+ whereWords,
59
+ widthMark,
60
+ widthWords,
61
+ } from "./layout.ts";
62
+
63
+ // MODEL
64
+
65
+ /** The words a reel says. `{pane}` is a pane's label, `{subject}` what is held (a pane, or a
66
+ * column by its first pane), `{where}` a place and `{column}` a column's number. */
67
+ const COPY = {
68
+ paneGrip: "Move {pane}",
69
+ columnGrip: "Move column {column}",
70
+ gripHelp:
71
+ "Press space to pick it up. Arrow keys move it, space drops it, escape or tab puts it back.",
72
+ widthLabel: "Width of column {column}",
73
+ heightLabel: "Height of {pane}",
74
+ focused: "{pane}, {where}.",
75
+ moved: "{subject} moved to {where}.",
76
+ widened: "Column {column} takes {width}.",
77
+ pickedUp: "Picked up {subject}.",
78
+ pickedUpByKeyboard:
79
+ "Picked up {subject}. Arrow keys move it, space drops it, escape or tab puts it back.",
80
+ over: "{subject}, {where}.",
81
+ dropped: "{subject} dropped at {where}.",
82
+ putBack: "{subject} put back where it was.",
83
+ cancelled: "Move cancelled. {subject} put back where it was.",
84
+ gone: "Move cancelled.",
85
+ };
86
+
87
+ /** The focused pane, with where it last sat, so focus finds a neighbour when it goes. */
88
+ const Focus = Schema.Struct({ paneId: Schema.String, column: Schema.Number, row: Schema.Number });
89
+
90
+ /** How something is held: by a pointer, pressed at (`pressX`, `pressY`) and now at (`x`,
91
+ * `y`), carrying it once it has moved past the threshold; or by the keyboard, on its grip. */
92
+ const By = defineTaggedUnion({
93
+ Pointer: {
94
+ pointerId: Schema.Number,
95
+ pressX: Schema.Number,
96
+ pressY: Schema.Number,
97
+ x: Schema.Number,
98
+ y: Schema.Number,
99
+ isCarrying: Schema.Boolean,
100
+ },
101
+ Keyboard: {},
102
+ });
103
+ type By = typeof By.Type;
104
+
105
+ /** What is held, how, and where it would land: none while a pointer is outside the reel. */
106
+ const Held = Schema.Struct({
107
+ subject: Subject,
108
+ by: By,
109
+ maybeTarget: Schema.Option(Target),
110
+ });
111
+ type Held = typeof Held.Type;
112
+
113
+ /** A splitter a pointer drags, pressed at (`pressX`, `pressY`), with the resize it previews. */
114
+ const Resizing = Schema.Struct({
115
+ splitter: Splitter,
116
+ pointerId: Schema.Number,
117
+ pressX: Schema.Number,
118
+ pressY: Schema.Number,
119
+ maybeResize: Schema.Option(Resize),
120
+ });
121
+ type Resizing = typeof Resizing.Type;
122
+
123
+ /** Schema for a reel: its id, the focused pane, what is held and a splitter being dragged. All
124
+ * of it is this client's; the layout is the application's. */
125
+ export const Model = Schema.Struct({
126
+ id: Schema.String,
127
+ maybeFocus: Schema.Option(Focus),
128
+ maybeHeld: Schema.Option(Held),
129
+ maybeResizing: Schema.Option(Resizing),
130
+ });
131
+ export type Model = typeof Model.Type;
132
+
133
+ // MESSAGE
134
+
135
+ const PointerAt = Schema.Struct({
136
+ clientX: Schema.Number,
137
+ clientY: Schema.Number,
138
+ maybeTarget: Schema.Option(Target),
139
+ });
140
+ type PointerAt = typeof PointerAt.Type;
141
+
142
+ /** Union of all Messages a reel can produce. */
143
+ export const Message = defineMessageUnion({
144
+ PressedGrip: {
145
+ subject: Subject,
146
+ pointerId: Schema.Number,
147
+ clientX: Schema.Number,
148
+ clientY: Schema.Number,
149
+ },
150
+ PressedGripKey: { subject: Subject },
151
+ MovedPointer: PointerAt.fields,
152
+ ReleasedPointer: PointerAt.fields,
153
+ CancelledPointer: {},
154
+ SteppedHeld: { step: Step },
155
+ DroppedHeld: {},
156
+ LeftGrip: {},
157
+ PressedEscape: {},
158
+ FocusedPane: { paneId: Schema.String },
159
+ PressedSplitter: {
160
+ splitter: Splitter,
161
+ pointerId: Schema.Number,
162
+ clientX: Schema.Number,
163
+ clientY: Schema.Number,
164
+ },
165
+ MovedSplitter: { share: Schema.Number },
166
+ ReleasedSplitter: { share: Schema.Number },
167
+ CancelledSplitter: {},
168
+ PressedSplitterKey: { splitter: Splitter, share: Schema.Number },
169
+ CompletedAnnounceReel: {},
170
+ CompletedFocusPane: {},
171
+ CompletedRestoreFocus: {},
172
+ CompletedFocusGrip: {},
173
+ CompletedScrollIntoView: {},
174
+ CompletedSizeLiftedPane: {},
175
+ });
176
+ export type Message = typeof Message.Type;
177
+
178
+ // INIT
179
+
180
+ /** Configuration for creating a reel with `init`. The `id` must be unique in the document;
181
+ * the reel's DOM hooks are scoped by it. */
182
+ export type InitConfig = Readonly<{ id: string }>;
183
+
184
+ /** Creates a reel focused on its first pane, holding nothing, at rest. */
185
+ export const init = (config: InitConfig): Model => ({
186
+ id: config.id,
187
+ maybeFocus: Option.none(),
188
+ maybeHeld: Option.none(),
189
+ maybeResizing: Option.none(),
190
+ });
191
+
192
+ // ACCESSORS
193
+
194
+ const focusedPlace = (model: Model, layout: Layout): Option.Option<Place> =>
195
+ Option.orElse(
196
+ Option.flatMap(model.maybeFocus, (focus) =>
197
+ Option.orElse(placeOf(layout, focus.paneId), () => {
198
+ const column = Number.clamp(focus.column, { minimum: 0, maximum: layout.length - 1 });
199
+ const rows = Option.match(Array.get(layout, column), {
200
+ onNone: () => 0,
201
+ onSome: (found) => found.panes.length,
202
+ });
203
+ return placeAt(layout, column, Number.clamp(focus.row, { minimum: 0, maximum: rows - 1 }));
204
+ }),
205
+ ),
206
+ () => firstPlace(layout),
207
+ );
208
+
209
+ /** The pane focus is on: the one last focused, or, when it has gone from `layout`, the pane
210
+ * now nearest its place; the first pane before any is focused. None for an empty layout. */
211
+ export const focusedPaneId = (model: Model, layout: Layout): Option.Option<string> =>
212
+ Option.map(focusedPlace(model, layout), ({ pane }) => pane.id);
213
+
214
+ const isHolding = (model: Model): boolean => Option.isSome(model.maybeHeld);
215
+
216
+ /** Whether `held` is carried: by the keyboard, or by a pointer past the threshold. */
217
+ const isCarried = (held: Held): boolean =>
218
+ By.match(held.by, { Pointer: ({ isCarrying }) => isCarrying, Keyboard: () => true });
219
+
220
+ // DOM HOOKS
221
+
222
+ const paneHook = (model: Model, paneId: string): string => `${model.id}/pane/${paneId}`;
223
+
224
+ const columnHook = (model: Model, columnId: string): string => `${model.id}/column/${columnId}`;
225
+
226
+ const subjectHook = (model: Model, subject: Subject): string =>
227
+ Subject.match(subject, {
228
+ Pane: ({ paneId }) => paneHook(model, paneId),
229
+ Column: ({ columnId }) => columnHook(model, columnId),
230
+ });
231
+
232
+ const scrollerOf = (reelHook: string): Option.Option<HTMLElement> =>
233
+ Option.fromNullishOr(
234
+ document.querySelector<HTMLElement>(`[data-reel="${CSS.escape(reelHook)}"] > .reel-view`),
235
+ );
236
+
237
+ const paneElement = (hook: string): Option.Option<HTMLElement> =>
238
+ Option.fromNullishOr(
239
+ document.querySelector<HTMLElement>(`[data-reel-pane="${CSS.escape(hook)}"]`),
240
+ );
241
+
242
+ /** Puts the keyboard in `pane`: on its control marked `data-reel-focus` (a terminal's input),
243
+ * or on the pane itself, without scrolling the page. */
244
+ const focusInto = (pane: HTMLElement): void => {
245
+ Option.getOrElse(
246
+ Option.fromNullishOr(pane.querySelector<HTMLElement>("[data-reel-focus]")),
247
+ () => pane,
248
+ ).focus({ preventScroll: true });
249
+ };
250
+
251
+ // COMMAND
252
+
253
+ /** Says a focus move, a move or a step of a drag through the page's live region (as
254
+ * `Announcer.Announce` does). */
255
+ export const AnnounceReel = Command.define("AnnounceReel", {
256
+ args: { text: Schema.String },
257
+ messages: [Message.CompletedAnnounceReel],
258
+ execute: ({ text }) => Announcer.announce(text).pipe(Effect.as(Message.CompletedAnnounceReel())),
259
+ });
260
+
261
+ /** Focuses the pane `paneHook` names, unless focus is already inside it: the control in it
262
+ * marked `data-reel-focus` (a terminal's input), or the pane itself. It does not scroll;
263
+ * ScrollIntoView does. */
264
+ export const FocusPane = Command.define("FocusPane", {
265
+ args: { paneHook: Schema.String },
266
+ messages: [Message.CompletedFocusPane],
267
+ execute: ({ paneHook: hook }) =>
268
+ Effect.gen(function* () {
269
+ yield* Render.afterCommit;
270
+ yield* Effect.sync(() => {
271
+ Option.map(
272
+ Option.filter(paneElement(hook), (pane) => !pane.contains(document.activeElement)),
273
+ focusInto,
274
+ );
275
+ });
276
+ return Message.CompletedFocusPane();
277
+ }),
278
+ });
279
+
280
+ /** Focuses the pane `paneHook` names only where focus was lost (it left with the pane that
281
+ * held it), so focus the reader put somewhere else stays there. */
282
+ export const RestoreFocus = Command.define("RestoreFocus", {
283
+ args: { paneHook: Schema.String },
284
+ messages: [Message.CompletedRestoreFocus],
285
+ execute: ({ paneHook: hook }) =>
286
+ Effect.gen(function* () {
287
+ yield* Render.afterCommit;
288
+ yield* Effect.sync(() => {
289
+ const active = document.activeElement;
290
+ if (active === null || active === document.body) {
291
+ Option.map(paneElement(hook), focusInto);
292
+ }
293
+ });
294
+ return Message.CompletedRestoreFocus();
295
+ }),
296
+ });
297
+
298
+ /** Puts focus back on a column's grip once the render that moved its column is in the page:
299
+ * a column's element moves with it, which takes focus off its grip. */
300
+ export const FocusGrip = Command.define("FocusGrip", {
301
+ args: { gripHook: Schema.String },
302
+ messages: [Message.CompletedFocusGrip],
303
+ execute: ({ gripHook }) =>
304
+ Effect.gen(function* () {
305
+ yield* Render.afterCommit;
306
+ yield* Effect.sync(() => {
307
+ document
308
+ .querySelector<HTMLElement>(`[data-reel-grip="${CSS.escape(gripHook)}"]`)
309
+ ?.focus({ preventScroll: true });
310
+ });
311
+ return Message.CompletedFocusGrip();
312
+ }),
313
+ });
314
+
315
+ /** Scrolls the pane or column `hook` names into view, by the least distance either way, its
316
+ * gutter kept by the stylesheet's `scroll-margin`, once the render that placed it is in the
317
+ * page. It jumps: motion is stepped or instant, and a follow is instant. */
318
+ export const ScrollIntoView = Command.define("ScrollIntoView", {
319
+ args: { hook: Schema.String },
320
+ messages: [Message.CompletedScrollIntoView],
321
+ execute: ({ hook }) =>
322
+ Effect.gen(function* () {
323
+ yield* Render.afterCommit;
324
+ yield* Effect.sync(() => {
325
+ document
326
+ .querySelector(
327
+ `[data-reel-pane="${CSS.escape(hook)}"], [data-reel-hook="${CSS.escape(hook)}"]`,
328
+ )
329
+ ?.scrollIntoView({ block: "nearest", inline: "nearest" });
330
+ });
331
+ return Message.CompletedScrollIntoView();
332
+ }),
333
+ });
334
+
335
+ // UPDATE
336
+
337
+ /** What the reel reads from its parent as each Message arrives: the layout as the
338
+ * application holds it now. */
339
+ export type UpdateContext = Readonly<{ layout: Layout }>;
340
+
341
+ type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage>;
342
+
343
+ /** How far a pointer moves from its press before it carries what it pressed. */
344
+ const CARRY_THRESHOLD = 5;
345
+
346
+ const withHeld = (model: Model, maybeHeld: Option.Option<Held>): Model =>
347
+ modifyFields(model, { maybeHeld: () => maybeHeld });
348
+
349
+ const withResizing = (model: Model, maybeResizing: Option.Option<Resizing>): Model =>
350
+ modifyFields(model, { maybeResizing: () => maybeResizing });
351
+
352
+ const focusOn = (model: Model, place: Place): Model =>
353
+ modifyFields(model, {
354
+ maybeFocus: () =>
355
+ Option.some({ paneId: place.pane.id, column: place.columnIndex, row: place.row }),
356
+ });
357
+
358
+ const announce = (text: string) => AnnounceReel({ text });
359
+
360
+ const heldWords = (layout: Layout, held: Held): string =>
361
+ String.capitalize(Option.getOrElse(subjectWords(layout, held.subject), () => ""));
362
+
363
+ const scrollToTarget = (model: Model, layout: Layout, target: Target) =>
364
+ Array.fromOption(
365
+ Option.map(columnOfTarget(layout, target), (columnId) =>
366
+ ScrollIntoView({ hook: columnHook(model, columnId) }),
367
+ ),
368
+ );
369
+
370
+ /** Lets go of what is held: dropped at its target when it is carried and the drop changes
371
+ * something (reported as the request), put back otherwise, said in `putBack`'s words. A
372
+ * column dropped by the keyboard gets its grip's focus back. */
373
+ const letGo = (model: Model, layout: Layout, held: Held, putBack: string): UpdateReturn => {
374
+ const released = withHeld(model, Option.none());
375
+ if (!isCarried(held)) {
376
+ return { model: released };
377
+ }
378
+
379
+ const subject = heldWords(layout, held);
380
+ const maybeRequest = Option.flatMap(held.maybeTarget, (target) =>
381
+ requestOf(layout, held.subject, target),
382
+ );
383
+ // a column that moves takes focus off its grip; one put back never left its place
384
+ const refocus =
385
+ held.subject._tag === "Column" && held.by._tag === "Keyboard"
386
+ ? [FocusGrip({ gripHook: subjectHook(model, held.subject) })]
387
+ : [];
388
+
389
+ return Option.match(maybeRequest, {
390
+ onNone: () => ({
391
+ model: released,
392
+ commands: [announce(fill(putBack, { subject }))],
393
+ }),
394
+ onSome: (request) => ({
395
+ model: released,
396
+ commands: [
397
+ ...refocus,
398
+ announce(
399
+ fill(COPY.dropped, {
400
+ subject,
401
+ where: Option.getOrElse(
402
+ whereWords(apply(layout, request), held.subject),
403
+ () => WHERE_IT_WAS,
404
+ ),
405
+ }),
406
+ ),
407
+ ],
408
+ outMessage: request,
409
+ }),
410
+ });
411
+ };
412
+
413
+ const cancel = (model: Model, layout: Layout, held: Held): UpdateReturn =>
414
+ letGo(model, layout, { ...held, maybeTarget: Option.none() }, COPY.cancelled);
415
+
416
+ const lift = (model: Model, layout: Layout, subject: Subject, by: By): UpdateReturn =>
417
+ Option.match(
418
+ Option.filter(
419
+ originOf(layout, subject),
420
+ () => !isHolding(model) && Option.isNone(model.maybeResizing),
421
+ ),
422
+ {
423
+ onNone: () => ({ model }),
424
+ onSome: (origin) => {
425
+ const held: Held = { subject, by, maybeTarget: Option.some(origin) };
426
+ return {
427
+ model: withHeld(model, Option.some(held)),
428
+ commands:
429
+ by._tag === "Keyboard"
430
+ ? [announce(fill(COPY.pickedUpByKeyboard, { subject: heldWords(layout, held) }))]
431
+ : [],
432
+ };
433
+ },
434
+ },
435
+ );
436
+
437
+ const movePointer = (model: Model, layout: Layout, held: Held, pointer: PointerAt): UpdateReturn =>
438
+ By.match(held.by, {
439
+ Keyboard: () => ({ model }),
440
+ Pointer: (by) => {
441
+ const isCarrying =
442
+ by.isCarrying ||
443
+ Math.hypot(pointer.clientX - by.pressX, pointer.clientY - by.pressY) > CARRY_THRESHOLD;
444
+ const next: Held = {
445
+ ...held,
446
+ by: By.Pointer({ ...by, x: pointer.clientX, y: pointer.clientY, isCarrying }),
447
+ maybeTarget: isCarrying ? pointer.maybeTarget : held.maybeTarget,
448
+ };
449
+ return {
450
+ model: withHeld(model, Option.some(next)),
451
+ commands:
452
+ isCarrying && !by.isCarrying
453
+ ? [announce(fill(COPY.pickedUp, { subject: heldWords(layout, held) }))]
454
+ : [],
455
+ };
456
+ },
457
+ });
458
+
459
+ /** A keyboard step of what is held, resolved from the layout, with the reel scrolled to the
460
+ * column it lands by. */
461
+ const stepHeld = (model: Model, layout: Layout, held: Held, step: Step): UpdateReturn =>
462
+ Option.match(
463
+ Option.filter(held.maybeTarget, () => held.by._tag === "Keyboard"),
464
+ {
465
+ onNone: () => ({ model }),
466
+ onSome: (target) => {
467
+ const next = steppedTarget(layout, held.subject, target, step);
468
+ if (Equal.equals(next, target)) {
469
+ return { model };
470
+ }
471
+ return {
472
+ model: withHeld(model, Option.some({ ...held, maybeTarget: Option.some(next) })),
473
+ commands: [
474
+ announce(
475
+ fill(COPY.over, {
476
+ subject: heldWords(layout, held),
477
+ where: landingWords(layout, held.subject, next),
478
+ }),
479
+ ),
480
+ ...scrollToTarget(model, layout, next),
481
+ ],
482
+ };
483
+ },
484
+ },
485
+ );
486
+
487
+ const whileHeld = (model: Model, f: (held: Held) => UpdateReturn): UpdateReturn =>
488
+ Option.match(model.maybeHeld, { onNone: () => ({ model }), onSome: f });
489
+
490
+ const whileHeldBy = (model: Model, by: By["_tag"], f: (held: Held) => UpdateReturn): UpdateReturn =>
491
+ whileHeld(model, (held) => (held.by._tag === by ? f(held) : { model }));
492
+
493
+ /** The resize a splitter dragged `share` of its scale asks for: previewed while it moves, and
494
+ * asked for when it is let go. */
495
+ const resizeSplitter = (
496
+ model: Model,
497
+ layout: Layout,
498
+ share: number,
499
+ isLetGo: boolean,
500
+ ): UpdateReturn =>
501
+ Option.match(model.maybeResizing, {
502
+ onNone: () => ({ model }),
503
+ onSome: (resizing) => {
504
+ const maybeResize = resizeOf(layout, resizing.splitter, share);
505
+ if (!isLetGo) {
506
+ return { model: withResizing(model, Option.some({ ...resizing, maybeResize })) };
507
+ }
508
+ return Option.match(maybeResize, {
509
+ onNone: () => ({ model: withResizing(model, Option.none()) }),
510
+ onSome: (resize) => ({ model: withResizing(model, Option.none()), outMessage: resize }),
511
+ });
512
+ },
513
+ });
514
+
515
+ /** Processes a reel Message against the layout as the application holds it now, and returns
516
+ * the next Model, Commands and OutMessage. */
517
+ export const update = (model: Model, message: Message, context: UpdateContext): UpdateReturn => {
518
+ const { layout } = context;
519
+
520
+ return Message.match<UpdateReturn>(message, {
521
+ PressedGrip: ({ subject, pointerId, clientX, clientY }) =>
522
+ lift(
523
+ model,
524
+ layout,
525
+ subject,
526
+ By.Pointer({
527
+ pointerId,
528
+ pressX: clientX,
529
+ pressY: clientY,
530
+ x: clientX,
531
+ y: clientY,
532
+ isCarrying: false,
533
+ }),
534
+ ),
535
+ PressedGripKey: ({ subject }) => lift(model, layout, subject, By.Keyboard()),
536
+ MovedPointer: (pointer) =>
537
+ whileHeldBy(model, "Pointer", (held) => movePointer(model, layout, held, pointer)),
538
+ ReleasedPointer: (pointer) =>
539
+ whileHeldBy(model, "Pointer", (held) =>
540
+ letGo(
541
+ model,
542
+ layout,
543
+ isCarried(held) ? { ...held, maybeTarget: pointer.maybeTarget } : held,
544
+ COPY.putBack,
545
+ ),
546
+ ),
547
+ CancelledPointer: () =>
548
+ whileHeldBy(model, "Pointer", (held) =>
549
+ letGo(model, layout, { ...held, maybeTarget: Option.none() }, COPY.putBack),
550
+ ),
551
+ SteppedHeld: ({ step }) =>
552
+ whileHeldBy(model, "Keyboard", (held) => stepHeld(model, layout, held, step)),
553
+ DroppedHeld: () =>
554
+ whileHeldBy(model, "Keyboard", (held) => letGo(model, layout, held, COPY.putBack)),
555
+ LeftGrip: () => whileHeldBy(model, "Keyboard", (held) => cancel(model, layout, held)),
556
+ PressedEscape: () =>
557
+ Option.isSome(model.maybeResizing)
558
+ ? { model: withResizing(model, Option.none()) }
559
+ : whileHeld(model, (held) => cancel(model, layout, held)),
560
+ FocusedPane: ({ paneId }) =>
561
+ Option.match(
562
+ Option.filter(
563
+ placeOf(layout, paneId),
564
+ () => !Option.exists(model.maybeFocus, (focus) => focus.paneId === paneId),
565
+ ),
566
+ {
567
+ onNone: () => ({ model }),
568
+ onSome: (place) => ({ model: focusOn(model, place) }),
569
+ },
570
+ ),
571
+ PressedSplitter: ({ splitter, pointerId, clientX, clientY }) =>
572
+ isHolding(model) || Option.isSome(model.maybeResizing) || !isSplitterPresent(layout, splitter)
573
+ ? { model }
574
+ : {
575
+ model: withResizing(
576
+ model,
577
+ Option.some({
578
+ splitter,
579
+ pointerId,
580
+ pressX: clientX,
581
+ pressY: clientY,
582
+ maybeResize: Option.none(),
583
+ }),
584
+ ),
585
+ },
586
+ MovedSplitter: ({ share }) => resizeSplitter(model, layout, share, false),
587
+ ReleasedSplitter: ({ share }) => resizeSplitter(model, layout, share, true),
588
+ CancelledSplitter: () => ({ model: withResizing(model, Option.none()) }),
589
+ PressedSplitterKey: ({ splitter, share }) =>
590
+ Option.match(resizeOf(layout, splitter, share), {
591
+ onNone: () => ({ model }),
592
+ onSome: (resize) => ({ model, outMessage: resize }),
593
+ }),
594
+ CompletedAnnounceReel: () => ({ model }),
595
+ CompletedFocusPane: () => ({ model }),
596
+ CompletedRestoreFocus: () => ({ model }),
597
+ CompletedFocusGrip: () => ({ model }),
598
+ CompletedScrollIntoView: () => ({ model }),
599
+ CompletedSizeLiftedPane: () => ({ model }),
600
+ });
601
+ };
602
+
603
+ // CAPABILITIES
604
+
605
+ /**
606
+ * Brings the reel in line with a layout that changed: call it whenever the application changes
607
+ * the layout, by a request it applied, another client's or one of its own (a pane closed). The
608
+ * focused pane's place is read again; when it has gone, focus moves to the pane now nearest
609
+ * where it was, and the keyboard follows where it went with the pane. A drag whose pane or
610
+ * column has gone is cancelled, and one whose target's column has gone aims where it started;
611
+ * a splitter whose column or pane has gone lets go.
612
+ */
613
+ export const reconcile = (model: Model, context: UpdateContext): UpdateReturn => {
614
+ const { layout } = context;
615
+ const isFocusGone = Option.exists(model.maybeFocus, (focus) =>
616
+ Option.isNone(placeOf(layout, focus.paneId)),
617
+ );
618
+ const maybePlace = Option.filter(focusedPlace(model, layout), () =>
619
+ Option.isSome(model.maybeFocus),
620
+ );
621
+ const focused = Option.match(maybePlace, {
622
+ onNone: () => model,
623
+ onSome: (place) => focusOn(model, place),
624
+ });
625
+ const restore = Array.fromOption(
626
+ Option.map(
627
+ Option.filter(maybePlace, () => isFocusGone),
628
+ (place) => RestoreFocus({ paneHook: paneHook(model, place.pane.id) }),
629
+ ),
630
+ );
631
+ const settled = Option.exists(model.maybeResizing, ({ splitter }) =>
632
+ isSplitterPresent(layout, splitter),
633
+ )
634
+ ? focused
635
+ : withResizing(focused, Option.none());
636
+
637
+ return Option.match(settled.maybeHeld, {
638
+ onNone: () => ({ model: settled, commands: restore }),
639
+ onSome: (held) => {
640
+ if (!isPresent(layout, held.subject)) {
641
+ return {
642
+ model: withHeld(settled, Option.none()),
643
+ commands: [...restore, announce(COPY.gone)],
644
+ };
645
+ }
646
+ const maybeTarget = Option.orElse(
647
+ Option.filter(held.maybeTarget, (target) => isTargetPresent(layout, target)),
648
+ () => (held.by._tag === "Keyboard" ? originOf(layout, held.subject) : Option.none()),
649
+ );
650
+ return {
651
+ model: withHeld(settled, Option.some({ ...held, maybeTarget })),
652
+ commands: restore,
653
+ };
654
+ },
655
+ });
656
+ };
657
+
658
+ /** What the application's keys ask of the reel. `Focus*`: focus the pane beside the focused
659
+ * one. `MovePane*`: move the focused pane up or down its column, or into the column beside it
660
+ * (past the first or last column, into a column of its own). `MoveColumn*`: move its column.
661
+ * `CycleWidth`: its column's width to the next preset. `PullOut`: the pane into a column of
662
+ * its own, right of its column. */
663
+ export const Action = Schema.Literals([
664
+ "FocusLeft",
665
+ "FocusRight",
666
+ "FocusUp",
667
+ "FocusDown",
668
+ "MovePaneLeft",
669
+ "MovePaneRight",
670
+ "MovePaneUp",
671
+ "MovePaneDown",
672
+ "MoveColumnLeft",
673
+ "MoveColumnRight",
674
+ "CycleWidth",
675
+ "PullOut",
676
+ ]);
677
+ export type Action = typeof Action.Type;
678
+
679
+ const focusStep = (model: Model, layout: Layout, place: Place): UpdateReturn => ({
680
+ model: focusOn(model, place),
681
+ commands: [
682
+ FocusPane({ paneHook: paneHook(model, place.pane.id) }),
683
+ ScrollIntoView({ hook: paneHook(model, place.pane.id) }),
684
+ announce(fill(COPY.focused, { pane: place.pane.label, where: placeWords(layout, place) })),
685
+ ],
686
+ });
687
+
688
+ /** Asks for `request` with focus kept on the pane it moves, at its new place, the reel
689
+ * scrolled to it and the move said; nothing where it would change nothing. */
690
+ const requestStep = (
691
+ model: Model,
692
+ layout: Layout,
693
+ place: Place,
694
+ subject: Subject,
695
+ maybeRequest: Option.Option<OutMessage>,
696
+ ): UpdateReturn =>
697
+ Option.match(
698
+ Option.filter(maybeRequest, (request) => !Equal.equals(apply(layout, request), layout)),
699
+ {
700
+ onNone: () => ({ model }),
701
+ onSome: (request) => {
702
+ const next = apply(layout, request);
703
+ const landing = Option.getOrElse(placeOf(next, place.pane.id), () => place);
704
+ const words = Option.getOrElse(subjectWords(layout, subject), () => place.pane.label);
705
+ const where = Option.getOrElse(whereWords(next, subject), () => WHERE_IT_WAS);
706
+
707
+ return {
708
+ model: focusOn(model, landing),
709
+ commands: [
710
+ ScrollIntoView({ hook: paneHook(model, place.pane.id) }),
711
+ announce(fill(COPY.moved, { subject: String.capitalize(words), where })),
712
+ ],
713
+ outMessage: request,
714
+ };
715
+ },
716
+ },
717
+ );
718
+
719
+ const columnIdAt = (layout: Layout, index: number): Option.Option<string> =>
720
+ Option.map(Array.get(layout, index), ({ id }) => id);
721
+
722
+ /**
723
+ * Does what the application's key for `action` asks, from the focused pane, against the
724
+ * layout as the application holds it: focus moves focus the pane and scroll the reel to it;
725
+ * moves report their request as the OutMessage, which the application applies, with focus
726
+ * kept on the pane. Each is said through the live region. Nothing happens while something is
727
+ * held, or where there is nowhere to go.
728
+ */
729
+ export const perform = (model: Model, action: Action, context: UpdateContext): UpdateReturn => {
730
+ const { layout } = context;
731
+
732
+ return Option.match(
733
+ Option.filter(focusedPlace(model, layout), () => !isHolding(model)),
734
+ {
735
+ onNone: () => ({ model }),
736
+ onSome: (place) => {
737
+ const { column, columnIndex, row, pane } = place;
738
+ const rows = column.panes.length;
739
+ const paneId = pane.id;
740
+ const ask = (
741
+ maybeRequest: Option.Option<OutMessage>,
742
+ subject: Subject = Subject.Pane({ paneId }),
743
+ ) => requestStep(model, layout, place, subject, maybeRequest);
744
+ const focusAt = (toColumn: number, toRow: number): UpdateReturn =>
745
+ Option.match(
746
+ Option.flatMap(Array.get(layout, toColumn), (found) =>
747
+ placeAt(
748
+ layout,
749
+ toColumn,
750
+ Number.clamp(toRow, { minimum: 0, maximum: found.panes.length - 1 }),
751
+ ),
752
+ ),
753
+ {
754
+ onNone: () => ({ model }),
755
+ onSome: (next) =>
756
+ next.pane.id === paneId ? { model } : focusStep(model, layout, next),
757
+ },
758
+ );
759
+ const sideways = (toColumn: number, edge: Option.Option<string>): UpdateReturn =>
760
+ ask(
761
+ Option.match(Array.get(layout, toColumn), {
762
+ onSome: (found) =>
763
+ Option.some(
764
+ OutMessage.MovedPane({
765
+ paneId,
766
+ toColumnId: found.id,
767
+ toIndex: Math.min(row, found.panes.length),
768
+ }),
769
+ ),
770
+ onNone: () =>
771
+ Option.liftPredicate(
772
+ OutMessage.PulledOutPane({ paneId, maybeBeforeColumnId: edge }),
773
+ () => rows > 1,
774
+ ),
775
+ }),
776
+ );
777
+ const moveColumn = (maybeBefore: Option.Option<Option.Option<string>>): UpdateReturn =>
778
+ ask(
779
+ Option.map(maybeBefore, (maybeBeforeColumnId) =>
780
+ OutMessage.MovedColumn({ columnId: column.id, maybeBeforeColumnId }),
781
+ ),
782
+ Subject.Column({ columnId: column.id }),
783
+ );
784
+
785
+ return Match.value(action).pipe(
786
+ Match.withReturnType<UpdateReturn>(),
787
+ Match.when("FocusLeft", () => focusAt(columnIndex - 1, row)),
788
+ Match.when("FocusRight", () => focusAt(columnIndex + 1, row)),
789
+ Match.when("FocusUp", () => (row > 0 ? focusAt(columnIndex, row - 1) : { model })),
790
+ Match.when("FocusDown", () => focusAt(columnIndex, row + 1)),
791
+ Match.when("MovePaneUp", () =>
792
+ ask(
793
+ Option.some(
794
+ OutMessage.MovedPane({ paneId, toColumnId: column.id, toIndex: row - 1 }),
795
+ ),
796
+ ),
797
+ ),
798
+ Match.when("MovePaneDown", () =>
799
+ ask(
800
+ Option.some(
801
+ OutMessage.MovedPane({ paneId, toColumnId: column.id, toIndex: row + 1 }),
802
+ ),
803
+ ),
804
+ ),
805
+ Match.when("MovePaneLeft", () => sideways(columnIndex - 1, columnIdAt(layout, 0))),
806
+ Match.when("MovePaneRight", () => sideways(columnIndex + 1, Option.none())),
807
+ Match.when("MoveColumnLeft", () =>
808
+ moveColumn(
809
+ Option.map(columnIdAt(layout, columnIndex - 1), (before) => Option.some(before)),
810
+ ),
811
+ ),
812
+ Match.when("MoveColumnRight", () =>
813
+ moveColumn(
814
+ Option.map(columnIdAt(layout, columnIndex + 1), () =>
815
+ columnIdAt(layout, columnIndex + 2),
816
+ ),
817
+ ),
818
+ ),
819
+ Match.when("CycleWidth", () => {
820
+ const width = nextPreset(column.width);
821
+ return {
822
+ model,
823
+ commands: [
824
+ ScrollIntoView({ hook: paneHook(model, paneId) }),
825
+ announce(fill(COPY.widened, { column: columnIndex + 1, width: widthWords(width) })),
826
+ ],
827
+ outMessage: OutMessage.ResizedColumn({ columnId: column.id, width }),
828
+ };
829
+ }),
830
+ Match.when("PullOut", () =>
831
+ ask(
832
+ Option.liftPredicate(
833
+ OutMessage.PulledOutPane({
834
+ paneId,
835
+ maybeBeforeColumnId: columnIdAt(layout, columnIndex + 1),
836
+ }),
837
+ () => rows > 1,
838
+ ),
839
+ ),
840
+ ),
841
+ Match.exhaustive,
842
+ );
843
+ },
844
+ },
845
+ );
846
+ };
847
+
848
+ // SUBSCRIPTION
849
+
850
+ /** How near the reel's edges a carried pane scrolls it, how fast, and the longest frame it
851
+ * counts, so a tab coming back from the background does not jump. */
852
+ const EDGE_SCROLL = 40;
853
+ const EDGE_SCROLL_PIXELS_PER_MILLI = 0.8;
854
+ const LONGEST_FRAME_MILLIS = 50;
855
+
856
+ /** A column as the pointer reads it from the page: its edges, and the middles of its slots
857
+ * but the held pane's. */
858
+ const columnBoxOf = (element: Element, subject: Subject): ColumnBox => {
859
+ const rect = element.getBoundingClientRect();
860
+ const heldId = Subject.match(subject, {
861
+ Pane: ({ paneId }) => Option.some(paneId),
862
+ Column: () => Option.none<string>(),
863
+ });
864
+ return {
865
+ id: element.getAttribute("data-reel-column") ?? "",
866
+ left: rect.left,
867
+ right: rect.right,
868
+ slotMiddles: Array.map(
869
+ Array.filter(
870
+ Array.fromIterable(element.querySelectorAll("[data-reel-slot]")),
871
+ (slot) => !Option.contains(heldId, slot.getAttribute("data-reel-slot") ?? ""),
872
+ ),
873
+ (slot) => {
874
+ const slotRect = slot.getBoundingClientRect();
875
+ return slotRect.top + slotRect.height / 2;
876
+ },
877
+ ),
878
+ };
879
+ };
880
+
881
+ /** Where a pointer at (`clientX`, `clientY`) would drop `subject` in the reel `reelHook`
882
+ * names, read from where its columns and their slots sit: none outside the reel. */
883
+ const pointerTarget = (
884
+ reelHook: string,
885
+ subject: Subject,
886
+ clientX: number,
887
+ clientY: number,
888
+ ): Option.Option<Target> =>
889
+ Option.map(
890
+ Option.filter(scrollerOf(reelHook), (view) => {
891
+ const rect = view.getBoundingClientRect();
892
+ return (
893
+ clientX >= rect.left &&
894
+ clientX <= rect.right &&
895
+ clientY >= rect.top &&
896
+ clientY <= rect.bottom
897
+ );
898
+ }),
899
+ (view) =>
900
+ targetAtPoint(
901
+ subject,
902
+ Array.map(Array.fromIterable(view.querySelectorAll("[data-reel-column]")), (element) =>
903
+ columnBoxOf(element, subject),
904
+ ),
905
+ clientX,
906
+ clientY,
907
+ ),
908
+ );
909
+
910
+ const PointerHold = Schema.Struct({
911
+ reelHook: Schema.String,
912
+ subject: Subject,
913
+ pointerId: Schema.Number,
914
+ });
915
+ type PointerHold = typeof PointerHold.Type;
916
+
917
+ const pointerAt = (hold: PointerHold, event: PointerEvent): PointerAt => ({
918
+ clientX: event.clientX,
919
+ clientY: event.clientY,
920
+ maybeTarget: pointerTarget(hold.reelHook, hold.subject, event.clientX, event.clientY),
921
+ });
922
+
923
+ /** The pressing pointer's moves, release and cancel; another pointer's are not the reel's. */
924
+ const pointerMessages = (hold: PointerHold): Stream.Stream<Message> =>
925
+ Stream.map(pressingPointer(hold.pointerId), (event) =>
926
+ Match.value(event.type).pipe(
927
+ Match.when("pointermove", () => Message.MovedPointer(pointerAt(hold, event))),
928
+ Match.when("pointerup", () => Message.ReleasedPointer(pointerAt(hold, event))),
929
+ Match.orElse(() => Message.CancelledPointer()),
930
+ ),
931
+ );
932
+
933
+ /** Which way a pointer at `at` scrolls a box from `low` to `high`: towards the edge it is near. */
934
+ const towards = (at: number, low: number, high: number): number => {
935
+ if (at < low + EDGE_SCROLL) {
936
+ return -1;
937
+ } else if (at > high - EDGE_SCROLL) {
938
+ return 1;
939
+ } else {
940
+ return 0;
941
+ }
942
+ };
943
+
944
+ /** While a pointer carries a pane or a column near the reel's edges, the reel scrolls that way,
945
+ * by the time each frame took, so a drop can reach a column or a place out of view. */
946
+ const edgeScroll = (hold: PointerHold): Stream.Stream<never> =>
947
+ Stream.callback<never>(() =>
948
+ Effect.acquireRelease(
949
+ Effect.sync(() => {
950
+ const state = {
951
+ maybeAt: Option.none<Readonly<{ x: number; y: number }>>(),
952
+ frame: 0,
953
+ last: performance.now(),
954
+ };
955
+ const onMove = (event: PointerEvent): void => {
956
+ if (event.pointerId === hold.pointerId) {
957
+ state.maybeAt = Option.some({ x: event.clientX, y: event.clientY });
958
+ }
959
+ };
960
+ const step = (now: number): void => {
961
+ const distance =
962
+ Math.min(now - state.last, LONGEST_FRAME_MILLIS) * EDGE_SCROLL_PIXELS_PER_MILLI;
963
+ state.last = now;
964
+ Option.map(
965
+ Option.all({ view: scrollerOf(hold.reelHook), at: state.maybeAt }),
966
+ ({ view, at }) => {
967
+ const rect = view.getBoundingClientRect();
968
+ view.scrollLeft += towards(at.x, rect.left, rect.right) * distance;
969
+ view.scrollTop += towards(at.y, rect.top, rect.bottom) * distance;
970
+ },
971
+ );
972
+ state.frame = requestAnimationFrame(step);
973
+ };
974
+ document.addEventListener("pointermove", onMove);
975
+ state.frame = requestAnimationFrame(step);
976
+ return { state, onMove };
977
+ }),
978
+ ({ state, onMove }) =>
979
+ Effect.sync(() => {
980
+ cancelAnimationFrame(state.frame);
981
+ document.removeEventListener("pointermove", onMove);
982
+ }),
983
+ ).pipe(Effect.flatMap(() => Effect.never)),
984
+ );
985
+
986
+ const SplitterHold = Schema.Struct({
987
+ reelHook: Schema.String,
988
+ splitter: Splitter,
989
+ pointerId: Schema.Number,
990
+ pressX: Schema.Number,
991
+ pressY: Schema.Number,
992
+ });
993
+ type SplitterHold = typeof SplitterHold.Type;
994
+
995
+ const heightOf = (element: Element): number => element.getBoundingClientRect().height;
996
+
997
+ /** The pixels a splitter's share is counted in, read when its drag begins: the reel's width
998
+ * less a gutter for a width (a column is `width × (view − gutter) − gutter` wide), its
999
+ * column's panes' height for a pane splitter. */
1000
+ const scaleOf = (hold: SplitterHold): number =>
1001
+ Option.getOrElse(
1002
+ Option.map(scrollerOf(hold.reelHook), (view) =>
1003
+ Splitter.match(hold.splitter, {
1004
+ Width: () =>
1005
+ view.clientWidth -
1006
+ Option.getOrElse(
1007
+ Option.map(
1008
+ Option.fromNullishOr(view.querySelector(".reel-gap")),
1009
+ (gap) => gap.getBoundingClientRect().width,
1010
+ ),
1011
+ () => 0,
1012
+ ),
1013
+ Split: ({ abovePaneId }) =>
1014
+ Number.sumAll(
1015
+ Array.map(
1016
+ Array.fromIterable(
1017
+ view
1018
+ .querySelector(`[data-reel-slot="${CSS.escape(abovePaneId)}"]`)
1019
+ ?.closest(".reel-column")
1020
+ ?.querySelectorAll("[data-reel-slot]") ?? [],
1021
+ ),
1022
+ heightOf,
1023
+ ),
1024
+ ),
1025
+ }),
1026
+ ),
1027
+ () => 0,
1028
+ );
1029
+
1030
+ /** The dragging pointer's moves, release and cancel, as shares of the splitter's scale. */
1031
+ const splitterMessages = (hold: SplitterHold): Stream.Stream<Message> =>
1032
+ Stream.suspend(() => {
1033
+ const scale = scaleOf(hold);
1034
+ const shareOf = (event: PointerEvent): number => {
1035
+ const moved =
1036
+ hold.splitter._tag === "Width" ? event.clientX - hold.pressX : event.clientY - hold.pressY;
1037
+ return scale > 0 ? moved / scale : 0;
1038
+ };
1039
+ return Stream.map(pressingPointer(hold.pointerId), (event) =>
1040
+ Match.value(event.type).pipe(
1041
+ Match.when("pointermove", () => Message.MovedSplitter({ share: shareOf(event) })),
1042
+ Match.when("pointerup", () => Message.ReleasedSplitter({ share: shareOf(event) })),
1043
+ Match.orElse(() => Message.CancelledSplitter()),
1044
+ ),
1045
+ );
1046
+ });
1047
+
1048
+ const pointerHoldOf = (model: Model, isCarryingOnly: boolean): Option.Option<PointerHold> =>
1049
+ Option.flatMap(model.maybeHeld, ({ subject, by }) =>
1050
+ By.match(by, {
1051
+ Keyboard: () => Option.none(),
1052
+ Pointer: ({ pointerId, isCarrying }) =>
1053
+ isCarryingOnly && !isCarrying
1054
+ ? Option.none()
1055
+ : Option.some({ reelHook: model.id, subject, pointerId }),
1056
+ }),
1057
+ );
1058
+
1059
+ const reelSubscriptions = Subscription.make<Model, Message>()((entry) => ({
1060
+ pointer: entry(
1061
+ { maybeHold: Schema.Option(PointerHold) },
1062
+ {
1063
+ modelToDependencies: (model) => ({ maybeHold: pointerHoldOf(model, false) }),
1064
+ dependenciesToStream: ({ maybeHold }) =>
1065
+ Option.match(maybeHold, {
1066
+ onNone: () => Stream.empty,
1067
+ onSome: (hold) => Stream.merge(pointerMessages(hold), markDragging),
1068
+ }),
1069
+ },
1070
+ ),
1071
+ edge: entry(
1072
+ { maybeHold: Schema.Option(PointerHold) },
1073
+ {
1074
+ modelToDependencies: (model) => ({ maybeHold: pointerHoldOf(model, true) }),
1075
+ dependenciesToStream: ({ maybeHold }) =>
1076
+ Option.match(maybeHold, { onNone: () => Stream.empty, onSome: edgeScroll }),
1077
+ },
1078
+ ),
1079
+ resize: entry(
1080
+ { maybeHold: Schema.Option(SplitterHold) },
1081
+ {
1082
+ modelToDependencies: (model) => ({
1083
+ maybeHold: Option.map(model.maybeResizing, ({ splitter, pointerId, pressX, pressY }) => ({
1084
+ reelHook: model.id,
1085
+ splitter,
1086
+ pointerId,
1087
+ pressX,
1088
+ pressY,
1089
+ })),
1090
+ }),
1091
+ dependenciesToStream: ({ maybeHold }) =>
1092
+ Option.match(maybeHold, {
1093
+ onNone: () => Stream.empty,
1094
+ onSome: (hold) => Stream.merge(splitterMessages(hold), markDragging),
1095
+ }),
1096
+ },
1097
+ ),
1098
+ escape: entry(
1099
+ { isHolding: Schema.Boolean },
1100
+ {
1101
+ modelToDependencies: (model) => ({
1102
+ isHolding: isHolding(model) || Option.isSome(model.maybeResizing),
1103
+ }),
1104
+ dependenciesToStream: ({ isHolding: isHoldingNow }) =>
1105
+ isHoldingNow ? escapeKeys(Message.PressedEscape()) : Stream.empty,
1106
+ },
1107
+ ),
1108
+ }));
1109
+
1110
+ /** A held pane or column follows the pointer (the reel scrolling near its edges), a dragged
1111
+ * splitter resizes, and Escape puts either back, before a dialog or the page sees it, from
1112
+ * listeners that run only while something is held. Lift them into the application's
1113
+ * Subscriptions, once per reel, behind a key prefix of the parent's: `subscriptions("tiles")`
1114
+ * is keyed `tilesPointer`, `tilesEdge`, `tilesResize` and `tilesEscape`. */
1115
+ export const subscriptions = <Prefix extends string>(keyPrefix: Prefix) =>
1116
+ prefixKeys(keyPrefix, reelSubscriptions);
1117
+
1118
+ // MOUNT
1119
+
1120
+ /** Sizes the copy that follows the pointer to what it lifts, and keeps the pointer where it
1121
+ * pressed. Measures the source once, when the copy appears. */
1122
+ export const SizeLiftedPane = Mount.define("SizeLiftedPane", {
1123
+ args: { sourceSelector: Schema.String, pressX: Schema.Number, pressY: Schema.Number },
1124
+ messages: [Message.CompletedSizeLiftedPane],
1125
+ execute: ({ element, sourceSelector, pressX, pressY }) =>
1126
+ Effect.sync(() => {
1127
+ measureLift(element, sourceSelector, pressX, pressY);
1128
+ return Message.CompletedSizeLiftedPane();
1129
+ }),
1130
+ });
1131
+
1132
+ // VIEW
1133
+
1134
+ /** What the reel hands `toPaneView` for each pane: put `grip` in the Window's `lead` and give
1135
+ * it `id`. */
1136
+ export type PaneRender = Readonly<{
1137
+ /** The pane's grip, for its title bar's `lead`: a quiet glyph button named "Move <label>". */
1138
+ grip: Html;
1139
+ /** The window's id, unique in the document. */
1140
+ id: string;
1141
+ }>;
1142
+
1143
+ /** Per-render inputs passed to `view` through `h.submodel`'s `viewInputs`. `toPaneView` is a
1144
+ * function because it may carry the parent's handlers. */
1145
+ export type ViewInputs = Readonly<{
1146
+ /** The columns and their panes, as the application holds them. */
1147
+ layout: Layout;
1148
+ /** Names the reel for screen readers. */
1149
+ label: string;
1150
+ /** Renders one pane, a `Window.view` with `render.grip` as its `lead` and `render.id` as its
1151
+ * `id`. Mark the control focus should land on in it (a terminal's input) with
1152
+ * `data-reel-focus`. */
1153
+ toPaneView: (pane: Pane, render: PaneRender) => Html;
1154
+ }>;
1155
+
1156
+ type ReelInfo = Readonly<{
1157
+ model: Model;
1158
+ layout: Layout;
1159
+ toPaneView: (pane: Pane, render: PaneRender) => Html;
1160
+ maybeFocusedId: Option.Option<string>;
1161
+ maybeMark: Option.Option<Target>;
1162
+ heldPaneIds: ReadonlySet<string>;
1163
+ }>;
1164
+
1165
+ const helpId = (model: Model): string => `${model.id}-grip-help`;
1166
+
1167
+ /** The share of the reel or the column a splitter's arrow key moves. */
1168
+ const SPLITTER_STEP = 0.05;
1169
+
1170
+ const HELD_STEPS: Readonly<Record<string, Step>> = {
1171
+ ArrowUp: "Up",
1172
+ ArrowDown: "Down",
1173
+ ArrowLeft: "Left",
1174
+ ArrowRight: "Right",
1175
+ };
1176
+
1177
+ const isPlain = (modifiers: KeyboardModifiers): boolean =>
1178
+ !modifiers.shiftKey && !modifiers.ctrlKey && !modifiers.altKey && !modifiers.metaKey;
1179
+
1180
+ const isDropKey = (key: string): boolean => key === " " || key === "Enter";
1181
+
1182
+ /** The anchor that pins a pane to its slot. Pane ids are the application's, so the name is
1183
+ * the pane's place in the id order, which the slot and the pane read from one list. */
1184
+ const anchorName = (index: number): string => `--reel-${index}`;
1185
+
1186
+ /** The panes in their layer's order: by id, which no move changes. */
1187
+ const byId = Order.mapInput(Order.String, (pane: Pane) => pane.id);
1188
+
1189
+ /** Where a carried drop would land, marked: none where it would change nothing. */
1190
+ const markOf = (model: Model, layout: Layout): Option.Option<Target> =>
1191
+ Option.flatMap(Option.filter(model.maybeHeld, isCarried), (held) =>
1192
+ Option.filter(held.maybeTarget, (target) =>
1193
+ Option.isSome(requestOf(layout, held.subject, target)),
1194
+ ),
1195
+ );
1196
+
1197
+ const heldPanesOf = (model: Model, layout: Layout): ReadonlySet<string> =>
1198
+ new Set(
1199
+ Option.match(Option.filter(model.maybeHeld, isCarried), {
1200
+ onNone: () => [],
1201
+ onSome: ({ subject }) =>
1202
+ Subject.match(subject, {
1203
+ Pane: ({ paneId }) => [paneId],
1204
+ Column: ({ columnId }) =>
1205
+ Option.match(columnOf(layout, columnId), {
1206
+ onNone: () => [],
1207
+ onSome: (column) => Array.map(column.panes, (pane) => pane.id),
1208
+ }),
1209
+ }),
1210
+ }),
1211
+ );
1212
+
1213
+ const gripView = (model: Model, subject: Subject, label: string, h: HtmlBuilder<Message>): Html => {
1214
+ const isIdleNow = !isHolding(model) && Option.isNone(model.maybeResizing);
1215
+ const maybeHeldHere = Option.filter(model.maybeHeld, (held) =>
1216
+ Equal.equals(held.subject, subject),
1217
+ );
1218
+ const isHeld = Option.exists(maybeHeldHere, isCarried);
1219
+ const isKeyboardHeld = Option.exists(maybeHeldHere, (held) => held.by._tag === "Keyboard");
1220
+
1221
+ // the keyboard holds an item on its grip: the keys are the grip's own, and focus leaving the
1222
+ // grip puts the item back, so a field reached by Tab types as it should
1223
+ const onGripKey: Attribute<Message> = h.OnKeyDownPreventDefault((key, modifiers) => {
1224
+ if (!isPlain(modifiers)) {
1225
+ return Option.none();
1226
+ } else if (isKeyboardHeld) {
1227
+ return isDropKey(key)
1228
+ ? Option.some(Message.DroppedHeld())
1229
+ : Option.map(Option.fromNullishOr(HELD_STEPS[key]), (step) =>
1230
+ Message.SteppedHeld({ step }),
1231
+ );
1232
+ } else {
1233
+ return Option.liftPredicate(
1234
+ Message.PressedGripKey({ subject }),
1235
+ () => isIdleNow && isDropKey(key),
1236
+ );
1237
+ }
1238
+ });
1239
+
1240
+ const onGripPress: Attribute<Message> = h.OnPointerDown(
1241
+ (_pointerType, button, _screenX, _screenY, _time, clientX, clientY, pointerId) =>
1242
+ Option.liftPredicate(
1243
+ Message.PressedGrip({ subject, pointerId, clientX, clientY }),
1244
+ () => isIdleNow && button === LEFT_BUTTON,
1245
+ ),
1246
+ );
1247
+
1248
+ return IconButton.view(
1249
+ {
1250
+ glyph: "grip",
1251
+ label,
1252
+ isQuiet: true,
1253
+ attributes: [
1254
+ h.DataAttribute("reel-grip", subjectHook(model, subject)),
1255
+ h.AriaRoleDescription("draggable"),
1256
+ h.AriaDescribedBy(helpId(model)),
1257
+ Button.pressedAttribute(isHeld, h),
1258
+ onGripKey,
1259
+ onGripPress,
1260
+ ...(isKeyboardHeld ? [h.OnBlur(Message.LeftGrip())] : []),
1261
+ ],
1262
+ },
1263
+ h,
1264
+ );
1265
+ };
1266
+
1267
+ /** A splitter's handlers: a pointer drags it, and its arrows (the side ones for a width, up
1268
+ * and down for a height), unmodified, step it; a modified arrow is the application's. */
1269
+ const splitterAttributes = (
1270
+ model: Model,
1271
+ splitter: Splitter,
1272
+ h: HtmlBuilder<Message>,
1273
+ ): ReadonlyArray<Attribute<Message>> => {
1274
+ const [less, more] =
1275
+ splitter._tag === "Width" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
1276
+ return [
1277
+ h.OnPointerDown(
1278
+ (_pointerType, button, _screenX, _screenY, _time, clientX, clientY, pointerId) =>
1279
+ Option.liftPredicate(
1280
+ Message.PressedSplitter({ splitter, pointerId, clientX, clientY }),
1281
+ () => button === LEFT_BUTTON && !isHolding(model) && Option.isNone(model.maybeResizing),
1282
+ ),
1283
+ ),
1284
+ h.OnKeyDownPreventDefault((key, modifiers) =>
1285
+ Option.liftPredicate(
1286
+ Message.PressedSplitterKey({
1287
+ splitter,
1288
+ share: key === more ? SPLITTER_STEP : -SPLITTER_STEP,
1289
+ }),
1290
+ () => isPlain(modifiers) && (key === less || key === more),
1291
+ ),
1292
+ ),
1293
+ ];
1294
+ };
1295
+
1296
+ const dropClass = (info: ReelInfo, column: Column, paneId: string): string =>
1297
+ Option.match(
1298
+ Option.flatMap(info.maybeMark, (mark) => {
1299
+ if (mark._tag !== "Into" || mark.columnId !== column.id) {
1300
+ return Option.none();
1301
+ }
1302
+ const others = Array.filter(column.panes, (pane) => !info.heldPaneIds.has(pane.id));
1303
+ return Option.orElse(
1304
+ Option.map(
1305
+ Option.filter(Array.get(others, mark.index), (pane) => pane.id === paneId),
1306
+ () => " drop-before",
1307
+ ),
1308
+ () =>
1309
+ Option.map(
1310
+ Option.filter(
1311
+ Array.last(others),
1312
+ (pane) => mark.index >= others.length && pane.id === paneId,
1313
+ ),
1314
+ () => " drop-after",
1315
+ ),
1316
+ );
1317
+ }),
1318
+ { onNone: () => "", onSome: (mark) => mark },
1319
+ );
1320
+
1321
+ const isGapMarked = (info: ReelInfo, maybeBeforeColumnId: Option.Option<string>): boolean =>
1322
+ Option.exists(
1323
+ info.maybeMark,
1324
+ (mark) => mark._tag === "Gap" && Equal.equals(mark.maybeBeforeColumnId, maybeBeforeColumnId),
1325
+ );
1326
+
1327
+ const slotView = (
1328
+ info: ReelInfo,
1329
+ anchors: ReadonlyMap<string, string>,
1330
+ column: Column,
1331
+ pane: Pane,
1332
+ h: HtmlBuilder<Message>,
1333
+ ): Html =>
1334
+ h.keyed("div")(pane.id, [
1335
+ h.Class(`reel-slot${dropClass(info, column, pane.id)}`),
1336
+ h.DataAttribute("reel-slot", pane.id),
1337
+ h.Style({ "--reel-weight": `${pane.weight}`, "--reel-anchor": anchors.get(pane.id) ?? "none" }),
1338
+ ]);
1339
+
1340
+ const splitView = (model: Model, above: Pane, share: number, h: HtmlBuilder<Message>): Html =>
1341
+ h.keyed("div")(`split:${above.id}`, [
1342
+ h.Class("reel-split"),
1343
+ h.Role("separator"),
1344
+ h.AriaOrientation("horizontal"),
1345
+ h.AriaLabel(fill(COPY.heightLabel, { pane: above.label })),
1346
+ h.AriaValuemin(0),
1347
+ h.AriaValuemax(100),
1348
+ h.AriaValuenow(Math.round(share * 100)),
1349
+ h.Tabindex(0),
1350
+ h.DataAttribute("reel-split", above.id),
1351
+ ...splitterAttributes(model, Splitter.Split({ abovePaneId: above.id }), h),
1352
+ ]);
1353
+
1354
+ const columnView = (
1355
+ info: ReelInfo,
1356
+ anchors: ReadonlyMap<string, string>,
1357
+ column: Column,
1358
+ index: number,
1359
+ h: HtmlBuilder<Message>,
1360
+ ): ReadonlyArray<Html> => {
1361
+ const { model, layout } = info;
1362
+ const total = Number.sumAll(Array.map(column.panes, (pane) => pane.weight));
1363
+ const stack = Array.flatMap(column.panes, (pane, row) => [
1364
+ ...Array.fromOption(
1365
+ Option.map(Array.get(column.panes, row - 1), (above) =>
1366
+ splitView(model, above, total > 0 ? above.weight / total : 0, h),
1367
+ ),
1368
+ ),
1369
+ slotView(info, anchors, column, pane, h),
1370
+ ]);
1371
+ const maybeNextId = Option.map(Array.get(layout, index + 1), ({ id }) => id);
1372
+
1373
+ return [
1374
+ h.keyed("div")(
1375
+ `column:${column.id}`,
1376
+ [
1377
+ h.Class("reel-column"),
1378
+ h.DataAttribute("reel-column", column.id),
1379
+ h.DataAttribute("reel-hook", columnHook(model, column.id)),
1380
+ h.Style({ "--reel-width": `${column.width}` }),
1381
+ ],
1382
+ [
1383
+ h.div(
1384
+ [h.Class("reel-column-head")],
1385
+ [
1386
+ gripView(
1387
+ model,
1388
+ Subject.Column({ columnId: column.id }),
1389
+ fill(COPY.columnGrip, { column: index + 1 }),
1390
+ h,
1391
+ ),
1392
+ h.span([h.Class("reel-width"), h.AriaHidden(true)], [widthMark(column.width)]),
1393
+ ],
1394
+ ),
1395
+ ...stack,
1396
+ ],
1397
+ ),
1398
+ h.keyed("div")(`resize:${column.id}`, [
1399
+ h.Class(`reel-gap reel-resize${isGapMarked(info, maybeNextId) ? " drop-here" : ""}`),
1400
+ h.Role("separator"),
1401
+ h.AriaOrientation("vertical"),
1402
+ h.AriaLabel(fill(COPY.widthLabel, { column: index + 1 })),
1403
+ h.AriaValuemin(0),
1404
+ h.AriaValuemax(100),
1405
+ h.AriaValuenow(Math.round(column.width * 100)),
1406
+ h.AriaValuetext(widthWords(column.width)),
1407
+ h.Tabindex(0),
1408
+ h.DataAttribute("reel-resize", column.id),
1409
+ ...splitterAttributes(model, Splitter.Width({ columnId: column.id }), h),
1410
+ ]),
1411
+ ];
1412
+ };
1413
+
1414
+ const paneLayerView = (
1415
+ info: ReelInfo,
1416
+ panes: ReadonlyArray<Pane>,
1417
+ anchors: ReadonlyMap<string, string>,
1418
+ h: HtmlBuilder<Message>,
1419
+ ): Html => {
1420
+ const { model } = info;
1421
+
1422
+ // NOTE: the panes keep one order in the page, by id, whatever the layout: a move changes
1423
+ // only where each is anchored, so no pane's element is taken out and put back, which would
1424
+ // drop focus, a terminal's scroll and whatever a Mount holds inside it.
1425
+ return h.div(
1426
+ [h.Class("reel-panes")],
1427
+ Array.map(panes, (pane) => {
1428
+ const isFocused = Option.contains(info.maybeFocusedId, pane.id);
1429
+ const isHeld = info.heldPaneIds.has(pane.id);
1430
+ return h.keyed("div")(
1431
+ pane.id,
1432
+ [
1433
+ h.Class(`reel-pane${isFocused ? " focused" : ""}${isHeld ? " drag-dim" : ""}`),
1434
+ h.DataAttribute("reel-pane", paneHook(model, pane.id)),
1435
+ h.Style({ "--reel-anchor": anchors.get(pane.id) ?? "none" }),
1436
+ h.Tabindex(-1),
1437
+ h.OnFocusEnter(Message.FocusedPane({ paneId: pane.id })),
1438
+ ],
1439
+ [
1440
+ info.toPaneView(pane, {
1441
+ grip: gripView(
1442
+ model,
1443
+ Subject.Pane({ paneId: pane.id }),
1444
+ fill(COPY.paneGrip, { pane: pane.label }),
1445
+ h,
1446
+ ),
1447
+ id: `${model.id}-${pane.id}`,
1448
+ }),
1449
+ ],
1450
+ );
1451
+ }),
1452
+ );
1453
+ };
1454
+
1455
+ /** The copy a pointer carries: the held panes' title bars, drawn by the reel from their
1456
+ * labels, so whatever a pane holds (a terminal, a canvas) is never built twice. */
1457
+ const liftedCopyView = (info: ReelInfo, h: HtmlBuilder<Message>): ReadonlyArray<Html> => {
1458
+ const { model, layout } = info;
1459
+
1460
+ return Array.fromOption(
1461
+ Option.flatMap(model.maybeHeld, (held) =>
1462
+ By.match(held.by, {
1463
+ Keyboard: () => Option.none(),
1464
+ Pointer: ({ isCarrying, pressX, pressY, x, y }) =>
1465
+ Option.liftPredicate(
1466
+ h.div(
1467
+ [
1468
+ h.Class("reel-lift"),
1469
+ h.Style({ transform: `translate3d(${x}px, ${y}px, 0)` }),
1470
+ h.AriaHidden(true),
1471
+ h.Inert(true),
1472
+ h.OnMount(
1473
+ SizeLiftedPane({
1474
+ sourceSelector: Subject.match(held.subject, {
1475
+ Pane: () =>
1476
+ `[data-reel-pane="${CSS.escape(subjectHook(model, held.subject))}"]`,
1477
+ Column: () =>
1478
+ `[data-reel-hook="${CSS.escape(subjectHook(model, held.subject))}"]`,
1479
+ }),
1480
+ pressX,
1481
+ pressY,
1482
+ }),
1483
+ ),
1484
+ ],
1485
+ [
1486
+ h.div(
1487
+ [h.Class("reel-lifted")],
1488
+ Array.map(
1489
+ Array.filter(
1490
+ Array.flatMap(layout, (column) => column.panes),
1491
+ (pane) => info.heldPaneIds.has(pane.id),
1492
+ ),
1493
+ (pane) =>
1494
+ h.div(
1495
+ [h.Class("panel window")],
1496
+ [
1497
+ h.div(
1498
+ [h.Class("panel-tb")],
1499
+ [
1500
+ h.span(
1501
+ [h.Class("panel-lead")],
1502
+ [
1503
+ h.span(
1504
+ [h.Class("iconbtn quiet")],
1505
+ [Glyph.view({ name: "grip" }, h)],
1506
+ ),
1507
+ ],
1508
+ ),
1509
+ h.h2([], [pane.label]),
1510
+ ],
1511
+ ),
1512
+ ],
1513
+ ),
1514
+ ),
1515
+ ),
1516
+ ],
1517
+ ),
1518
+ () => isCarrying,
1519
+ ),
1520
+ }),
1521
+ ),
1522
+ );
1523
+ };
1524
+
1525
+ /** The layout as it prints, with a dragged splitter's resize shown before it is asked for. */
1526
+ const previewed = (model: Model, layout: Layout): Layout =>
1527
+ Option.match(
1528
+ Option.flatMap(model.maybeResizing, ({ maybeResize }) => maybeResize),
1529
+ { onNone: () => layout, onSome: (resize) => apply(layout, resize) },
1530
+ );
1531
+
1532
+ /**
1533
+ * A row of columns wider than the view when it must be, each a stack of panes, scrolled
1534
+ * sideways, and down where a column is taller than the view: `div.reel` holding the scroller
1535
+ * `div.reel-view`, whose `div.reel-track` lays out the columns (`div.reel-column`, each a head
1536
+ * with its grip and width, then a slot per pane with a splitter between) with a splitter after
1537
+ * each, and the panes themselves in `div.reel-panes`, each pinned to its slot by anchor
1538
+ * positioning. The reel takes its height from whatever holds it. The layout is the parent's;
1539
+ * each pane renders through `toPaneView`.
1540
+ *
1541
+ * A pane's grip, or a column's, lifts it under a pointer or the keyboard: Space or Enter picks
1542
+ * it up, the arrows move it (left and right through the gaps and columns, up and down among a
1543
+ * column's panes), Space drops it, and Escape or Tab puts it back. A drop reports what it asks
1544
+ * of the layout as the OutMessage. Lift `subscriptions(keyPrefix)` into the application's, or
1545
+ * nothing follows the pointer, and call `reconcile` whenever the layout changes.
1546
+ */
1547
+ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h): Html => {
1548
+ const { label, toPaneView } = viewInputs;
1549
+ const layout = previewed(model, viewInputs.layout);
1550
+ const panes = Array.sort(
1551
+ Array.flatMap(layout, (column) => column.panes),
1552
+ byId,
1553
+ );
1554
+ const anchors = new Map(Array.map(panes, (pane, index) => [pane.id, anchorName(index)] as const));
1555
+ const info: ReelInfo = {
1556
+ model,
1557
+ layout,
1558
+ toPaneView,
1559
+ maybeFocusedId: focusedPaneId(model, layout),
1560
+ maybeMark: markOf(model, layout),
1561
+ heldPaneIds: heldPanesOf(model, layout),
1562
+ };
1563
+
1564
+ return h.div(
1565
+ [h.Class("reel"), h.Role("group"), h.AriaLabel(label), h.DataAttribute("reel", model.id)],
1566
+ [
1567
+ h.div(
1568
+ [h.Class("reel-view")],
1569
+ [
1570
+ h.div(
1571
+ [h.Class("reel-track")],
1572
+ [
1573
+ h.keyed("div")("gap:start", [
1574
+ h.Class(
1575
+ `reel-gap${
1576
+ isGapMarked(
1577
+ info,
1578
+ Option.map(Array.head(layout), ({ id }) => id),
1579
+ )
1580
+ ? " drop-here"
1581
+ : ""
1582
+ }`,
1583
+ ),
1584
+ ]),
1585
+ ...Array.flatMap(layout, (column, index) =>
1586
+ columnView(info, anchors, column, index, h),
1587
+ ),
1588
+ paneLayerView(info, panes, anchors, h),
1589
+ ],
1590
+ ),
1591
+ ],
1592
+ ),
1593
+ h.p([h.Id(helpId(model)), h.Class("sr-only")], [COPY.gripHelp]),
1594
+ ...liftedCopyView(info, h),
1595
+ ],
1596
+ );
1597
+ });