@gjermundgaraba-designs/pixel-paper 0.21.0 → 0.22.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.
Files changed (52) hide show
  1. package/DESIGN.md +18 -12
  2. package/dist/src/bench/bench.d.ts +14 -0
  3. package/dist/src/bench/bench.d.ts.map +1 -0
  4. package/dist/src/bench/bench.js +9 -0
  5. package/dist/src/bench/index.d.ts +3 -0
  6. package/dist/src/bench/index.d.ts.map +1 -0
  7. package/dist/src/bench/index.js +1 -0
  8. package/dist/src/chip/chip.d.ts +10 -0
  9. package/dist/src/chip/chip.d.ts.map +1 -1
  10. package/dist/src/chip/chip.js +19 -0
  11. package/dist/src/chip/index.d.ts +2 -2
  12. package/dist/src/chip/index.d.ts.map +1 -1
  13. package/dist/src/chip/index.js +1 -1
  14. package/dist/src/drag/drag.d.ts +18 -0
  15. package/dist/src/drag/drag.d.ts.map +1 -1
  16. package/dist/src/drag/drag.js +35 -7
  17. package/dist/src/index.d.ts +1 -0
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +1 -0
  20. package/dist/src/rail/index.d.ts +1 -1
  21. package/dist/src/rail/index.d.ts.map +1 -1
  22. package/dist/src/rail/index.js +1 -1
  23. package/dist/src/rail/rail.d.ts +217 -17
  24. package/dist/src/rail/rail.d.ts.map +1 -1
  25. package/dist/src/rail/rail.js +133 -15
  26. package/dist/src/reel/index.d.ts +1 -1
  27. package/dist/src/reel/index.d.ts.map +1 -1
  28. package/dist/src/reel/index.js +1 -1
  29. package/dist/src/reel/layout.d.ts +21 -26
  30. package/dist/src/reel/layout.d.ts.map +1 -1
  31. package/dist/src/reel/layout.js +84 -106
  32. package/dist/src/reel/reel.d.ts +30 -172
  33. package/dist/src/reel/reel.d.ts.map +1 -1
  34. package/dist/src/reel/reel.js +144 -195
  35. package/dist/src/rows/rows.d.ts +9 -1
  36. package/dist/src/rows/rows.d.ts.map +1 -1
  37. package/dist/src/rows/rows.js +1 -1
  38. package/package.json +1 -1
  39. package/src/bench/bench.ts +24 -0
  40. package/src/bench/index.ts +2 -0
  41. package/src/chip/chip.ts +31 -0
  42. package/src/chip/index.ts +2 -2
  43. package/src/drag/drag.ts +60 -1
  44. package/src/index.ts +1 -0
  45. package/src/pixel-paper.css +196 -32
  46. package/src/rail/index.ts +2 -0
  47. package/src/rail/rail.ts +181 -16
  48. package/src/reel/index.ts +0 -1
  49. package/src/reel/layout.ts +78 -118
  50. package/src/reel/reel.ts +108 -216
  51. package/src/rows/rows.ts +14 -2
  52. package/src/tokens.css +2 -1
package/src/reel/reel.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Array, Effect, Equal, Match, Number, Option, Order, Schema, Stream, String } from "effect";
2
2
  import * as Command from "foldkit/command";
3
- import type { Attribute, Html, HtmlBuilder, KeyboardModifiers } from "foldkit/html";
3
+ import type { Attribute, Html, HtmlBuilder } from "foldkit/html";
4
4
  import { defineMessageUnion } from "foldkit/message";
5
5
  import * as Mount from "foldkit/mount";
6
6
  import * as Render from "foldkit/render";
@@ -17,10 +17,12 @@ import {
17
17
  escapeKeys,
18
18
  markDragging,
19
19
  measureLift,
20
- pressingPointer,
20
+ pointerMessages as dragMessages,
21
+ splitterAttributes,
21
22
  } from "../drag/drag.ts";
22
23
  import * as Glyph from "../glyph/glyph.ts";
23
24
  import * as IconButton from "../iconButton/iconButton.ts";
25
+ import { isPlain } from "../keys/keys.ts";
24
26
  import { prefixKeys } from "../subscription/prefixKeys.ts";
25
27
 
26
28
  import {
@@ -37,11 +39,9 @@ import {
37
39
  Target,
38
40
  WHERE_IT_WAS,
39
41
  apply,
40
- columnOf,
41
42
  columnOfTarget,
42
43
  fill,
43
44
  firstPlace,
44
- isPresent,
45
45
  isSplitterPresent,
46
46
  isTargetPresent,
47
47
  landingWords,
@@ -56,17 +56,16 @@ import {
56
56
  subjectWords,
57
57
  targetAtPoint,
58
58
  whereWords,
59
- widthMark,
60
59
  widthWords,
61
60
  } from "./layout.ts";
62
61
 
63
62
  // MODEL
64
63
 
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. */
64
+ /** The words a reel says. `{pane}` is a pane's label, `{subject}` what moves (the held pane,
65
+ * or a column a key moves, by its first pane), `{where}` a place and `{column}` a column's
66
+ * number. */
67
67
  const COPY = {
68
68
  paneGrip: "Move {pane}",
69
- columnGrip: "Move column {column}",
70
69
  gripHelp:
71
70
  "Press space to pick it up. Arrow keys move it, space drops it, escape or tab puts it back.",
72
71
  widthLabel: "Width of column {column}",
@@ -102,9 +101,9 @@ const By = defineTaggedUnion({
102
101
  });
103
102
  type By = typeof By.Type;
104
103
 
105
- /** What is held, how, and where it would land: none while a pointer is outside the reel. */
104
+ /** The pane held, how, and where it would land: none while a pointer is outside the reel. */
106
105
  const Held = Schema.Struct({
107
- subject: Subject,
106
+ paneId: Schema.String,
108
107
  by: By,
109
108
  maybeTarget: Schema.Option(Target),
110
109
  });
@@ -142,12 +141,12 @@ type PointerAt = typeof PointerAt.Type;
142
141
  /** Union of all Messages a reel can produce. */
143
142
  export const Message = defineMessageUnion({
144
143
  PressedGrip: {
145
- subject: Subject,
144
+ paneId: Schema.String,
146
145
  pointerId: Schema.Number,
147
146
  clientX: Schema.Number,
148
147
  clientY: Schema.Number,
149
148
  },
150
- PressedGripKey: { subject: Subject },
149
+ PressedGripKey: { paneId: Schema.String },
151
150
  MovedPointer: PointerAt.fields,
152
151
  ReleasedPointer: PointerAt.fields,
153
152
  CancelledPointer: {},
@@ -169,7 +168,6 @@ export const Message = defineMessageUnion({
169
168
  CompletedAnnounceReel: {},
170
169
  CompletedFocusPane: {},
171
170
  CompletedRestoreFocus: {},
172
- CompletedFocusGrip: {},
173
171
  CompletedScrollIntoView: {},
174
172
  CompletedSizeLiftedPane: {},
175
173
  });
@@ -223,12 +221,6 @@ const paneHook = (model: Model, paneId: string): string => `${model.id}/pane/${p
223
221
 
224
222
  const columnHook = (model: Model, columnId: string): string => `${model.id}/column/${columnId}`;
225
223
 
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
224
  const scrollerOf = (reelHook: string): Option.Option<HTMLElement> =>
233
225
  Option.fromNullishOr(
234
226
  document.querySelector<HTMLElement>(`[data-reel="${CSS.escape(reelHook)}"] > .reel-view`),
@@ -295,23 +287,6 @@ export const RestoreFocus = Command.define("RestoreFocus", {
295
287
  }),
296
288
  });
297
289
 
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
290
  /** Scrolls the pane or column `hook` names into view, by the least distance either way, its
316
291
  * gutter kept by the stylesheet's `scroll-margin`, once the render that placed it is in the
317
292
  * page. It jumps: motion is stepped or instant, and a follow is instant. */
@@ -358,7 +333,9 @@ const focusOn = (model: Model, place: Place): Model =>
358
333
  const announce = (text: string) => AnnounceReel({ text });
359
334
 
360
335
  const heldWords = (layout: Layout, held: Held): string =>
361
- String.capitalize(Option.getOrElse(subjectWords(layout, held.subject), () => ""));
336
+ String.capitalize(
337
+ Option.getOrElse(subjectWords(layout, Subject.Pane({ paneId: held.paneId })), () => ""),
338
+ );
362
339
 
363
340
  const scrollToTarget = (model: Model, layout: Layout, target: Target) =>
364
341
  Array.fromOption(
@@ -367,9 +344,8 @@ const scrollToTarget = (model: Model, layout: Layout, target: Target) =>
367
344
  ),
368
345
  );
369
346
 
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. */
347
+ /** Lets go of the held pane: dropped at its target when it is carried and the drop changes
348
+ * something (reported as the request), put back otherwise, said in `putBack`'s words. */
373
349
  const letGo = (model: Model, layout: Layout, held: Held, putBack: string): UpdateReturn => {
374
350
  const released = withHeld(model, Option.none());
375
351
  if (!isCarried(held)) {
@@ -378,13 +354,8 @@ const letGo = (model: Model, layout: Layout, held: Held, putBack: string): Updat
378
354
 
379
355
  const subject = heldWords(layout, held);
380
356
  const maybeRequest = Option.flatMap(held.maybeTarget, (target) =>
381
- requestOf(layout, held.subject, target),
357
+ requestOf(layout, held.paneId, target),
382
358
  );
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
359
 
389
360
  return Option.match(maybeRequest, {
390
361
  onNone: () => ({
@@ -394,12 +365,11 @@ const letGo = (model: Model, layout: Layout, held: Held, putBack: string): Updat
394
365
  onSome: (request) => ({
395
366
  model: released,
396
367
  commands: [
397
- ...refocus,
398
368
  announce(
399
369
  fill(COPY.dropped, {
400
370
  subject,
401
371
  where: Option.getOrElse(
402
- whereWords(apply(layout, request), held.subject),
372
+ whereWords(apply(layout, request), Subject.Pane({ paneId: held.paneId })),
403
373
  () => WHERE_IT_WAS,
404
374
  ),
405
375
  }),
@@ -413,16 +383,16 @@ const letGo = (model: Model, layout: Layout, held: Held, putBack: string): Updat
413
383
  const cancel = (model: Model, layout: Layout, held: Held): UpdateReturn =>
414
384
  letGo(model, layout, { ...held, maybeTarget: Option.none() }, COPY.cancelled);
415
385
 
416
- const lift = (model: Model, layout: Layout, subject: Subject, by: By): UpdateReturn =>
386
+ const lift = (model: Model, layout: Layout, paneId: string, by: By): UpdateReturn =>
417
387
  Option.match(
418
388
  Option.filter(
419
- originOf(layout, subject),
389
+ originOf(layout, paneId),
420
390
  () => !isHolding(model) && Option.isNone(model.maybeResizing),
421
391
  ),
422
392
  {
423
393
  onNone: () => ({ model }),
424
394
  onSome: (origin) => {
425
- const held: Held = { subject, by, maybeTarget: Option.some(origin) };
395
+ const held: Held = { paneId, by, maybeTarget: Option.some(origin) };
426
396
  return {
427
397
  model: withHeld(model, Option.some(held)),
428
398
  commands:
@@ -464,7 +434,7 @@ const stepHeld = (model: Model, layout: Layout, held: Held, step: Step): UpdateR
464
434
  {
465
435
  onNone: () => ({ model }),
466
436
  onSome: (target) => {
467
- const next = steppedTarget(layout, held.subject, target, step);
437
+ const next = steppedTarget(layout, held.paneId, target, step);
468
438
  if (Equal.equals(next, target)) {
469
439
  return { model };
470
440
  }
@@ -474,7 +444,7 @@ const stepHeld = (model: Model, layout: Layout, held: Held, step: Step): UpdateR
474
444
  announce(
475
445
  fill(COPY.over, {
476
446
  subject: heldWords(layout, held),
477
- where: landingWords(layout, held.subject, next),
447
+ where: landingWords(layout, held.paneId, next),
478
448
  }),
479
449
  ),
480
450
  ...scrollToTarget(model, layout, next),
@@ -518,11 +488,11 @@ export const update = (model: Model, message: Message, context: UpdateContext):
518
488
  const { layout } = context;
519
489
 
520
490
  return Message.match<UpdateReturn>(message, {
521
- PressedGrip: ({ subject, pointerId, clientX, clientY }) =>
491
+ PressedGrip: ({ paneId, pointerId, clientX, clientY }) =>
522
492
  lift(
523
493
  model,
524
494
  layout,
525
- subject,
495
+ paneId,
526
496
  By.Pointer({
527
497
  pointerId,
528
498
  pressX: clientX,
@@ -532,7 +502,7 @@ export const update = (model: Model, message: Message, context: UpdateContext):
532
502
  isCarrying: false,
533
503
  }),
534
504
  ),
535
- PressedGripKey: ({ subject }) => lift(model, layout, subject, By.Keyboard()),
505
+ PressedGripKey: ({ paneId }) => lift(model, layout, paneId, By.Keyboard()),
536
506
  MovedPointer: (pointer) =>
537
507
  whileHeldBy(model, "Pointer", (held) => movePointer(model, layout, held, pointer)),
538
508
  ReleasedPointer: (pointer) =>
@@ -594,7 +564,6 @@ export const update = (model: Model, message: Message, context: UpdateContext):
594
564
  CompletedAnnounceReel: () => ({ model }),
595
565
  CompletedFocusPane: () => ({ model }),
596
566
  CompletedRestoreFocus: () => ({ model }),
597
- CompletedFocusGrip: () => ({ model }),
598
567
  CompletedScrollIntoView: () => ({ model }),
599
568
  CompletedSizeLiftedPane: () => ({ model }),
600
569
  });
@@ -606,8 +575,8 @@ export const update = (model: Model, message: Message, context: UpdateContext):
606
575
  * Brings the reel in line with a layout that changed: call it whenever the application changes
607
576
  * the layout, by a request it applied, another client's or one of its own (a pane closed). The
608
577
  * 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;
578
+ * where it was, and the keyboard follows where it went with the pane. A drag whose pane has
579
+ * gone is cancelled, and one whose target's column has gone aims where it started;
611
580
  * a splitter whose column or pane has gone lets go.
612
581
  */
613
582
  export const reconcile = (model: Model, context: UpdateContext): UpdateReturn => {
@@ -637,7 +606,7 @@ export const reconcile = (model: Model, context: UpdateContext): UpdateReturn =>
637
606
  return Option.match(settled.maybeHeld, {
638
607
  onNone: () => ({ model: settled, commands: restore }),
639
608
  onSome: (held) => {
640
- if (!isPresent(layout, held.subject)) {
609
+ if (Option.isNone(placeOf(layout, held.paneId))) {
641
610
  return {
642
611
  model: withHeld(settled, Option.none()),
643
612
  commands: [...restore, announce(COPY.gone)],
@@ -645,7 +614,7 @@ export const reconcile = (model: Model, context: UpdateContext): UpdateReturn =>
645
614
  }
646
615
  const maybeTarget = Option.orElse(
647
616
  Option.filter(held.maybeTarget, (target) => isTargetPresent(layout, target)),
648
- () => (held.by._tag === "Keyboard" ? originOf(layout, held.subject) : Option.none()),
617
+ () => (held.by._tag === "Keyboard" ? originOf(layout, held.paneId) : Option.none()),
649
618
  );
650
619
  return {
651
620
  model: withHeld(settled, Option.some({ ...held, maybeTarget })),
@@ -855,12 +824,8 @@ const LONGEST_FRAME_MILLIS = 50;
855
824
 
856
825
  /** A column as the pointer reads it from the page: its edges, and the middles of its slots
857
826
  * but the held pane's. */
858
- const columnBoxOf = (element: Element, subject: Subject): ColumnBox => {
827
+ const columnBoxOf = (element: Element, heldId: string): ColumnBox => {
859
828
  const rect = element.getBoundingClientRect();
860
- const heldId = Subject.match(subject, {
861
- Pane: ({ paneId }) => Option.some(paneId),
862
- Column: () => Option.none<string>(),
863
- });
864
829
  return {
865
830
  id: element.getAttribute("data-reel-column") ?? "",
866
831
  left: rect.left,
@@ -868,7 +833,7 @@ const columnBoxOf = (element: Element, subject: Subject): ColumnBox => {
868
833
  slotMiddles: Array.map(
869
834
  Array.filter(
870
835
  Array.fromIterable(element.querySelectorAll("[data-reel-slot]")),
871
- (slot) => !Option.contains(heldId, slot.getAttribute("data-reel-slot") ?? ""),
836
+ (slot) => slot.getAttribute("data-reel-slot") !== heldId,
872
837
  ),
873
838
  (slot) => {
874
839
  const slotRect = slot.getBoundingClientRect();
@@ -878,11 +843,11 @@ const columnBoxOf = (element: Element, subject: Subject): ColumnBox => {
878
843
  };
879
844
  };
880
845
 
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. */
846
+ /** Where a pointer at (`clientX`, `clientY`) would drop the pane `paneId` in the reel
847
+ * `reelHook` names, read from where its columns and their slots sit: none outside the reel. */
883
848
  const pointerTarget = (
884
849
  reelHook: string,
885
- subject: Subject,
850
+ paneId: string,
886
851
  clientX: number,
887
852
  clientY: number,
888
853
  ): Option.Option<Target> =>
@@ -898,9 +863,8 @@ const pointerTarget = (
898
863
  }),
899
864
  (view) =>
900
865
  targetAtPoint(
901
- subject,
902
866
  Array.map(Array.fromIterable(view.querySelectorAll("[data-reel-column]")), (element) =>
903
- columnBoxOf(element, subject),
867
+ columnBoxOf(element, paneId),
904
868
  ),
905
869
  clientX,
906
870
  clientY,
@@ -909,7 +873,7 @@ const pointerTarget = (
909
873
 
910
874
  const PointerHold = Schema.Struct({
911
875
  reelHook: Schema.String,
912
- subject: Subject,
876
+ paneId: Schema.String,
913
877
  pointerId: Schema.Number,
914
878
  });
915
879
  type PointerHold = typeof PointerHold.Type;
@@ -917,18 +881,16 @@ type PointerHold = typeof PointerHold.Type;
917
881
  const pointerAt = (hold: PointerHold, event: PointerEvent): PointerAt => ({
918
882
  clientX: event.clientX,
919
883
  clientY: event.clientY,
920
- maybeTarget: pointerTarget(hold.reelHook, hold.subject, event.clientX, event.clientY),
884
+ maybeTarget: pointerTarget(hold.reelHook, hold.paneId, event.clientX, event.clientY),
921
885
  });
922
886
 
923
887
  /** The pressing pointer's moves, release and cancel; another pointer's are not the reel's. */
924
888
  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
- );
889
+ dragMessages<Message>(hold.pointerId, {
890
+ moved: (event) => Message.MovedPointer(pointerAt(hold, event)),
891
+ released: (event) => Message.ReleasedPointer(pointerAt(hold, event)),
892
+ cancelled: () => Message.CancelledPointer(),
893
+ });
932
894
 
933
895
  /** Which way a pointer at `at` scrolls a box from `low` to `high`: towards the edge it is near. */
934
896
  const towards = (at: number, low: number, high: number): number => {
@@ -941,7 +903,7 @@ const towards = (at: number, low: number, high: number): number => {
941
903
  }
942
904
  };
943
905
 
944
- /** While a pointer carries a pane or a column near the reel's edges, the reel scrolls that way,
906
+ /** While a pointer carries a pane near the reel's edges, the reel scrolls that way,
945
907
  * by the time each frame took, so a drop can reach a column or a place out of view. */
946
908
  const edgeScroll = (hold: PointerHold): Stream.Stream<never> =>
947
909
  Stream.callback<never>(() =>
@@ -1036,23 +998,21 @@ const splitterMessages = (hold: SplitterHold): Stream.Stream<Message> =>
1036
998
  hold.splitter._tag === "Width" ? event.clientX - hold.pressX : event.clientY - hold.pressY;
1037
999
  return scale > 0 ? moved / scale : 0;
1038
1000
  };
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
- );
1001
+ return dragMessages<Message>(hold.pointerId, {
1002
+ moved: (event) => Message.MovedSplitter({ share: shareOf(event) }),
1003
+ released: (event) => Message.ReleasedSplitter({ share: shareOf(event) }),
1004
+ cancelled: () => Message.CancelledSplitter(),
1005
+ });
1046
1006
  });
1047
1007
 
1048
1008
  const pointerHoldOf = (model: Model, isCarryingOnly: boolean): Option.Option<PointerHold> =>
1049
- Option.flatMap(model.maybeHeld, ({ subject, by }) =>
1009
+ Option.flatMap(model.maybeHeld, ({ paneId, by }) =>
1050
1010
  By.match(by, {
1051
1011
  Keyboard: () => Option.none(),
1052
1012
  Pointer: ({ pointerId, isCarrying }) =>
1053
1013
  isCarryingOnly && !isCarrying
1054
1014
  ? Option.none()
1055
- : Option.some({ reelHook: model.id, subject, pointerId }),
1015
+ : Option.some({ reelHook: model.id, paneId, pointerId }),
1056
1016
  }),
1057
1017
  );
1058
1018
 
@@ -1107,7 +1067,7 @@ const reelSubscriptions = Subscription.make<Model, Message>()((entry) => ({
1107
1067
  ),
1108
1068
  }));
1109
1069
 
1110
- /** A held pane or column follows the pointer (the reel scrolling near its edges), a dragged
1070
+ /** A held pane follows the pointer (the reel scrolling near its edges), a dragged
1111
1071
  * splitter resizes, and Escape puts either back, before a dialog or the page sees it, from
1112
1072
  * listeners that run only while something is held. Lift them into the application's
1113
1073
  * Subscriptions, once per reel, behind a key prefix of the parent's: `subscriptions("tiles")`
@@ -1159,7 +1119,7 @@ type ReelInfo = Readonly<{
1159
1119
  toPaneView: (pane: Pane, render: PaneRender) => Html;
1160
1120
  maybeFocusedId: Option.Option<string>;
1161
1121
  maybeMark: Option.Option<Target>;
1162
- heldPaneIds: ReadonlySet<string>;
1122
+ maybeCarriedId: Option.Option<string>;
1163
1123
  }>;
1164
1124
 
1165
1125
  const helpId = (model: Model): string => `${model.id}-grip-help`;
@@ -1174,9 +1134,6 @@ const HELD_STEPS: Readonly<Record<string, Step>> = {
1174
1134
  ArrowRight: "Right",
1175
1135
  };
1176
1136
 
1177
- const isPlain = (modifiers: KeyboardModifiers): boolean =>
1178
- !modifiers.shiftKey && !modifiers.ctrlKey && !modifiers.altKey && !modifiers.metaKey;
1179
-
1180
1137
  const isDropKey = (key: string): boolean => key === " " || key === "Enter";
1181
1138
 
1182
1139
  /** The anchor that pins a pane to its slot. Pane ids are the application's, so the name is
@@ -1190,31 +1147,16 @@ const byId = Order.mapInput(Order.String, (pane: Pane) => pane.id);
1190
1147
  const markOf = (model: Model, layout: Layout): Option.Option<Target> =>
1191
1148
  Option.flatMap(Option.filter(model.maybeHeld, isCarried), (held) =>
1192
1149
  Option.filter(held.maybeTarget, (target) =>
1193
- Option.isSome(requestOf(layout, held.subject, target)),
1150
+ Option.isSome(requestOf(layout, held.paneId, target)),
1194
1151
  ),
1195
1152
  );
1196
1153
 
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
- );
1154
+ const carriedIdOf = (model: Model): Option.Option<string> =>
1155
+ Option.map(Option.filter(model.maybeHeld, isCarried), ({ paneId }) => paneId);
1212
1156
 
1213
- const gripView = (model: Model, subject: Subject, label: string, h: HtmlBuilder<Message>): Html => {
1157
+ const gripView = (model: Model, pane: Pane, h: HtmlBuilder<Message>): Html => {
1214
1158
  const isIdleNow = !isHolding(model) && Option.isNone(model.maybeResizing);
1215
- const maybeHeldHere = Option.filter(model.maybeHeld, (held) =>
1216
- Equal.equals(held.subject, subject),
1217
- );
1159
+ const maybeHeldHere = Option.filter(model.maybeHeld, (held) => held.paneId === pane.id);
1218
1160
  const isHeld = Option.exists(maybeHeldHere, isCarried);
1219
1161
  const isKeyboardHeld = Option.exists(maybeHeldHere, (held) => held.by._tag === "Keyboard");
1220
1162
 
@@ -1231,7 +1173,7 @@ const gripView = (model: Model, subject: Subject, label: string, h: HtmlBuilder<
1231
1173
  );
1232
1174
  } else {
1233
1175
  return Option.liftPredicate(
1234
- Message.PressedGripKey({ subject }),
1176
+ Message.PressedGripKey({ paneId: pane.id }),
1235
1177
  () => isIdleNow && isDropKey(key),
1236
1178
  );
1237
1179
  }
@@ -1240,7 +1182,7 @@ const gripView = (model: Model, subject: Subject, label: string, h: HtmlBuilder<
1240
1182
  const onGripPress: Attribute<Message> = h.OnPointerDown(
1241
1183
  (_pointerType, button, _screenX, _screenY, _time, clientX, clientY, pointerId) =>
1242
1184
  Option.liftPredicate(
1243
- Message.PressedGrip({ subject, pointerId, clientX, clientY }),
1185
+ Message.PressedGrip({ paneId: pane.id, pointerId, clientX, clientY }),
1244
1186
  () => isIdleNow && button === LEFT_BUTTON,
1245
1187
  ),
1246
1188
  );
@@ -1248,10 +1190,10 @@ const gripView = (model: Model, subject: Subject, label: string, h: HtmlBuilder<
1248
1190
  return IconButton.view(
1249
1191
  {
1250
1192
  glyph: "grip",
1251
- label,
1193
+ label: fill(COPY.paneGrip, { pane: pane.label }),
1252
1194
  isQuiet: true,
1253
1195
  attributes: [
1254
- h.DataAttribute("reel-grip", subjectHook(model, subject)),
1196
+ h.DataAttribute("reel-grip", paneHook(model, pane.id)),
1255
1197
  h.AriaRoleDescription("draggable"),
1256
1198
  h.AriaDescribedBy(helpId(model)),
1257
1199
  Button.pressedAttribute(isHeld, h),
@@ -1264,34 +1206,23 @@ const gripView = (model: Model, subject: Subject, label: string, h: HtmlBuilder<
1264
1206
  );
1265
1207
  };
1266
1208
 
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 = (
1209
+ /** A splitter's handlers: a pointer drags it, and its arrows step it by `SPLITTER_STEP`. */
1210
+ const reelSplitterAttributes = (
1270
1211
  model: Model,
1271
1212
  splitter: Splitter,
1272
1213
  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
- };
1214
+ ): ReadonlyArray<Attribute<Message>> =>
1215
+ splitterAttributes(
1216
+ {
1217
+ orientation: splitter._tag === "Width" ? "vertical" : "horizontal",
1218
+ isIdle: !isHolding(model) && Option.isNone(model.maybeResizing),
1219
+ onPress: (pointerId, clientX, clientY) =>
1220
+ Message.PressedSplitter({ splitter, pointerId, clientX, clientY }),
1221
+ onStep: (direction) =>
1222
+ Message.PressedSplitterKey({ splitter, share: direction * SPLITTER_STEP }),
1223
+ },
1224
+ h,
1225
+ );
1295
1226
 
1296
1227
  const dropClass = (info: ReelInfo, column: Column, paneId: string): string =>
1297
1228
  Option.match(
@@ -1299,7 +1230,10 @@ const dropClass = (info: ReelInfo, column: Column, paneId: string): string =>
1299
1230
  if (mark._tag !== "Into" || mark.columnId !== column.id) {
1300
1231
  return Option.none();
1301
1232
  }
1302
- const others = Array.filter(column.panes, (pane) => !info.heldPaneIds.has(pane.id));
1233
+ const others = Array.filter(
1234
+ column.panes,
1235
+ (pane) => !Option.contains(info.maybeCarriedId, pane.id),
1236
+ );
1303
1237
  return Option.orElse(
1304
1238
  Option.map(
1305
1239
  Option.filter(Array.get(others, mark.index), (pane) => pane.id === paneId),
@@ -1341,14 +1275,13 @@ const splitView = (model: Model, above: Pane, share: number, h: HtmlBuilder<Mess
1341
1275
  h.keyed("div")(`split:${above.id}`, [
1342
1276
  h.Class("reel-split"),
1343
1277
  h.Role("separator"),
1344
- h.AriaOrientation("horizontal"),
1345
1278
  h.AriaLabel(fill(COPY.heightLabel, { pane: above.label })),
1346
1279
  h.AriaValuemin(0),
1347
1280
  h.AriaValuemax(100),
1348
1281
  h.AriaValuenow(Math.round(share * 100)),
1349
1282
  h.Tabindex(0),
1350
1283
  h.DataAttribute("reel-split", above.id),
1351
- ...splitterAttributes(model, Splitter.Split({ abovePaneId: above.id }), h),
1284
+ ...reelSplitterAttributes(model, Splitter.Split({ abovePaneId: above.id }), h),
1352
1285
  ]);
1353
1286
 
1354
1287
  const columnView = (
@@ -1379,26 +1312,11 @@ const columnView = (
1379
1312
  h.DataAttribute("reel-hook", columnHook(model, column.id)),
1380
1313
  h.Style({ "--reel-width": `${column.width}` }),
1381
1314
  ],
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
- ],
1315
+ stack,
1397
1316
  ),
1398
1317
  h.keyed("div")(`resize:${column.id}`, [
1399
1318
  h.Class(`reel-gap reel-resize${isGapMarked(info, maybeNextId) ? " drop-here" : ""}`),
1400
1319
  h.Role("separator"),
1401
- h.AriaOrientation("vertical"),
1402
1320
  h.AriaLabel(fill(COPY.widthLabel, { column: index + 1 })),
1403
1321
  h.AriaValuemin(0),
1404
1322
  h.AriaValuemax(100),
@@ -1406,7 +1324,7 @@ const columnView = (
1406
1324
  h.AriaValuetext(widthWords(column.width)),
1407
1325
  h.Tabindex(0),
1408
1326
  h.DataAttribute("reel-resize", column.id),
1409
- ...splitterAttributes(model, Splitter.Width({ columnId: column.id }), h),
1327
+ ...reelSplitterAttributes(model, Splitter.Width({ columnId: column.id }), h),
1410
1328
  ]),
1411
1329
  ];
1412
1330
  };
@@ -1426,7 +1344,7 @@ const paneLayerView = (
1426
1344
  [h.Class("reel-panes")],
1427
1345
  Array.map(panes, (pane) => {
1428
1346
  const isFocused = Option.contains(info.maybeFocusedId, pane.id);
1429
- const isHeld = info.heldPaneIds.has(pane.id);
1347
+ const isHeld = Option.contains(info.maybeCarriedId, pane.id);
1430
1348
  return h.keyed("div")(
1431
1349
  pane.id,
1432
1350
  [
@@ -1438,12 +1356,7 @@ const paneLayerView = (
1438
1356
  ],
1439
1357
  [
1440
1358
  info.toPaneView(pane, {
1441
- grip: gripView(
1442
- model,
1443
- Subject.Pane({ paneId: pane.id }),
1444
- fill(COPY.paneGrip, { pane: pane.label }),
1445
- h,
1446
- ),
1359
+ grip: gripView(model, pane, h),
1447
1360
  id: `${model.id}-${pane.id}`,
1448
1361
  }),
1449
1362
  ],
@@ -1452,8 +1365,8 @@ const paneLayerView = (
1452
1365
  );
1453
1366
  };
1454
1367
 
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. */
1368
+ /** The copy a pointer carries: the held pane's title bar, drawn by the reel from its label,
1369
+ * so whatever a pane holds (a terminal, a canvas) is never built twice. */
1457
1370
  const liftedCopyView = (info: ReelInfo, h: HtmlBuilder<Message>): ReadonlyArray<Html> => {
1458
1371
  const { model, layout } = info;
1459
1372
 
@@ -1471,12 +1384,7 @@ const liftedCopyView = (info: ReelInfo, h: HtmlBuilder<Message>): ReadonlyArray<
1471
1384
  h.Inert(true),
1472
1385
  h.OnMount(
1473
1386
  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
- }),
1387
+ sourceSelector: `[data-reel-pane="${CSS.escape(paneHook(model, held.paneId))}"]`,
1480
1388
  pressX,
1481
1389
  pressY,
1482
1390
  }),
@@ -1484,34 +1392,19 @@ const liftedCopyView = (info: ReelInfo, h: HtmlBuilder<Message>): ReadonlyArray<
1484
1392
  ],
1485
1393
  [
1486
1394
  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),
1395
+ [h.Class("panel window")],
1396
+ [
1397
+ h.div(
1398
+ [h.Class("panel-tb")],
1399
+ [
1400
+ h.span(
1401
+ [h.Class("panel-lead")],
1402
+ [h.span([h.Class("iconbtn quiet")], [Glyph.view({ name: "grip" }, h)])],
1403
+ ),
1404
+ h.h2([], [heldWords(layout, held)]),
1405
+ ],
1492
1406
  ),
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
- ),
1407
+ ],
1515
1408
  ),
1516
1409
  ],
1517
1410
  ),
@@ -1532,15 +1425,14 @@ const previewed = (model: Model, layout: Layout): Layout =>
1532
1425
  /**
1533
1426
  * A row of columns wider than the view when it must be, each a stack of panes, scrolled
1534
1427
  * 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`.
1428
+ * `div.reel-view`, whose `div.reel-track` lays out the columns (`div.reel-column`, a slot per
1429
+ * pane with a splitter between) with a splitter after each, and the panes themselves in
1430
+ * `div.reel-panes`, each pinned to its slot by anchor positioning. The reel takes its height
1431
+ * from whatever holds it. The layout is the parent's; each pane renders through `toPaneView`.
1540
1432
  *
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
1433
+ * A pane's grip lifts it under a pointer or the keyboard: Space or Enter picks it up, the
1434
+ * arrows move it (left and right through the gaps and columns, up and down among a column's
1435
+ * panes), Space drops it, and Escape or Tab puts it back. A drop reports what it asks
1544
1436
  * of the layout as the OutMessage. Lift `subscriptions(keyPrefix)` into the application's, or
1545
1437
  * nothing follows the pointer, and call `reconcile` whenever the layout changes.
1546
1438
  */
@@ -1558,7 +1450,7 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
1558
1450
  toPaneView,
1559
1451
  maybeFocusedId: focusedPaneId(model, layout),
1560
1452
  maybeMark: markOf(model, layout),
1561
- heldPaneIds: heldPanesOf(model, layout),
1453
+ maybeCarriedId: carriedIdOf(model),
1562
1454
  };
1563
1455
 
1564
1456
  return h.div(