@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/rail/rail.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Array, Cause, Effect, Option, Schema, Stream } from "effect";
1
+ import { Array, Cause, Effect, Number, Option, Schema, Stream } from "effect";
2
2
  import * as Command from "foldkit/command";
3
3
  import * as Dom from "foldkit/dom";
4
4
  import type { Html } from "foldkit/html";
@@ -7,18 +7,32 @@ import * as Mount from "foldkit/mount";
7
7
  import * as Render from "foldkit/render";
8
8
  import { modifyFields } from "foldkit/struct";
9
9
  import { defineView } from "foldkit/submodel";
10
+ import * as Subscription from "foldkit/subscription";
10
11
  import type * as Update from "foldkit/update";
11
12
 
12
13
  import * as Button from "../button/button.ts";
14
+ import { escapeKeys, markDragging, pointerMessages, splitterAttributes } from "../drag/drag.ts";
13
15
  import * as IconButton from "../iconButton/iconButton.ts";
16
+ import { prefixKeys } from "../subscription/prefixKeys.ts";
14
17
 
15
18
  // MODEL
16
19
 
17
- /** Schema for a rail: whether it is open as a drawer (at 900px and under). Wider, it is
18
- * always in the page and this stays false. */
20
+ /** A splitter a pointer drags: pressed at `pressX` on a rail `fromWidth` wide, and the width
21
+ * it previews. */
22
+ const Resizing = Schema.Struct({
23
+ pointerId: Schema.Number,
24
+ pressX: Schema.Number,
25
+ fromWidth: Schema.Number,
26
+ width: Schema.Number,
27
+ });
28
+
29
+ /** Schema for a rail: whether it is open as a drawer (at 900px and under; wider, it is always
30
+ * in the page and this stays false), and its splitter while a pointer drags it. Its width is
31
+ * the parent's. */
19
32
  export const Model = Schema.Struct({
20
33
  id: Schema.String,
21
34
  isOpen: Schema.Boolean,
35
+ maybeResizing: Schema.Option(Resizing),
22
36
  });
23
37
  export type Model = typeof Model.Type;
24
38
 
@@ -29,6 +43,12 @@ export type Model = typeof Model.Type;
29
43
  * the window growing past 900px while it is open, where the rail is back in the page. */
30
44
  export const Message = defineMessageUnion({
31
45
  ClickedOpen: {},
46
+ PressedSplitter: { pointerId: Schema.Number, clientX: Schema.Number, width: Schema.Number },
47
+ MovedSplitter: { clientX: Schema.Number },
48
+ ReleasedSplitter: { clientX: Schema.Number },
49
+ CancelledSplitter: {},
50
+ PressedEscape: {},
51
+ PressedSplitterKey: { width: Schema.Number },
32
52
  ClickedClose: {},
33
53
  ClosedDrawer: {},
34
54
  BlurredDrawer: {},
@@ -38,14 +58,39 @@ export const Message = defineMessageUnion({
38
58
  });
39
59
  export type Message = typeof Message.Type;
40
60
 
61
+ /** What a rail asks of its parent. `ResizedRail`: its splitter was let go, or stepped by a
62
+ * key, at `width` pixels, within the rail's bounds; the parent keeps it (per client) and
63
+ * passes it back as the view's `maybeWidth`. */
64
+ export const OutMessage = defineMessageUnion({
65
+ ResizedRail: { width: Schema.Number },
66
+ });
67
+ export type OutMessage = typeof OutMessage.Type;
68
+
41
69
  // INIT
42
70
 
43
71
  /** Configuration for creating a rail with `init`. The `id` is the rail's, unique in the
44
72
  * document. */
45
73
  export type InitConfig = Readonly<{ id: string }>;
46
74
 
47
- /** Creates a rail, closed. */
48
- export const init = (config: InitConfig): Model => ({ id: config.id, isOpen: false });
75
+ /** Creates a rail, closed, its splitter at rest. */
76
+ export const init = (config: InitConfig): Model => ({
77
+ id: config.id,
78
+ isOpen: false,
79
+ maybeResizing: Option.none(),
80
+ });
81
+
82
+ // WIDTH
83
+
84
+ /** The rail's widths, in pixels: before the reader sizes it (as the stylesheet's `--rail` has
85
+ * it, which the splitter says as its value until a width is given), the least its panels
86
+ * still fit in (a tree's row keeps a glyph, a dot and a few words of its title), the most
87
+ * (which leaves the deck at least 421px of a 901px window, the narrowest one the rail stands
88
+ * in, more than a phone has), and what an arrow on its splitter moves it. The bounds and the
89
+ * step are the component's, not tokens: the splitter says them as its values. */
90
+ const WIDTH = { start: 272, least: 220, most: 480, step: 16 };
91
+
92
+ const bounded = (width: number): number =>
93
+ Math.round(Number.clamp(width, { minimum: WIDTH.least, maximum: WIDTH.most }));
49
94
 
50
95
  // ACCESSORS
51
96
 
@@ -135,10 +180,48 @@ const open = (model: Model): UpdateReturn => ({
135
180
  /** Closes the rail's drawer, focus back on its button. */
136
181
  export const close = (model: Model): UpdateReturn => closeDrawer(model, true);
137
182
 
138
- /** Processes a rail Message and returns the next Model and Commands. */
183
+ const withResizing = (model: Model, maybeResizing: Option.Option<typeof Resizing.Type>): Model =>
184
+ modifyFields(model, { maybeResizing: () => maybeResizing });
185
+
186
+ /** The width a dragged splitter previews with the pointer at `clientX`. */
187
+ const dragTo = (model: Model, clientX: number): Model =>
188
+ withResizing(
189
+ model,
190
+ Option.map(model.maybeResizing, (resizing) => ({
191
+ ...resizing,
192
+ width: bounded(resizing.fromWidth + clientX - resizing.pressX),
193
+ })),
194
+ );
195
+
196
+ /** Processes a rail Message and returns the next Model, Commands and OutMessage: a splitter let
197
+ * go where it changes the width, or stepped by a key, asks for the width. */
139
198
  export const update = (model: Model, message: Message) =>
140
- Message.match<UpdateReturn>(message, {
199
+ Message.match<Update.ReturnWithOutMessage<Model, Message, OutMessage>>(message, {
141
200
  ClickedOpen: () => open(model),
201
+ PressedSplitter: ({ pointerId, clientX, width }) =>
202
+ Option.isSome(model.maybeResizing)
203
+ ? { model }
204
+ : {
205
+ model: withResizing(
206
+ model,
207
+ Option.some({ pointerId, pressX: clientX, fromWidth: width, width }),
208
+ ),
209
+ },
210
+ MovedSplitter: ({ clientX }) => ({ model: dragTo(model, clientX) }),
211
+ ReleasedSplitter: ({ clientX }) =>
212
+ Option.match(dragTo(model, clientX).maybeResizing, {
213
+ onNone: () => ({ model }),
214
+ onSome: ({ fromWidth, width }) => ({
215
+ model: withResizing(model, Option.none()),
216
+ ...(width === fromWidth ? {} : { outMessage: OutMessage.ResizedRail({ width }) }),
217
+ }),
218
+ }),
219
+ CancelledSplitter: () => ({ model: withResizing(model, Option.none()) }),
220
+ PressedEscape: () => ({ model: withResizing(model, Option.none()) }),
221
+ PressedSplitterKey: ({ width }) => ({
222
+ model,
223
+ outMessage: OutMessage.ResizedRail({ width: bounded(width) }),
224
+ }),
142
225
  ClickedClose: () => close(model),
143
226
  ClosedDrawer: () => close(model),
144
227
  BlurredDrawer: () => closeDrawer(model, false),
@@ -216,37 +299,95 @@ export const FollowRailDrawer = Mount.defineStream("FollowRailDrawer", {
216
299
  },
217
300
  });
218
301
 
302
+ // SUBSCRIPTION
303
+
304
+ const railSubscriptions = Subscription.make<Model, Message>()((entry) => ({
305
+ resize: entry(
306
+ { maybePointerId: Schema.Option(Schema.Number) },
307
+ {
308
+ modelToDependencies: (model) => ({
309
+ maybePointerId: Option.map(model.maybeResizing, ({ pointerId }) => pointerId),
310
+ }),
311
+ dependenciesToStream: ({ maybePointerId }) =>
312
+ Option.match(maybePointerId, {
313
+ onNone: () => Stream.empty,
314
+ onSome: (pointerId) =>
315
+ Stream.merge(
316
+ pointerMessages<Message>(pointerId, {
317
+ moved: ({ clientX }) => Message.MovedSplitter({ clientX }),
318
+ released: ({ clientX }) => Message.ReleasedSplitter({ clientX }),
319
+ cancelled: () => Message.CancelledSplitter(),
320
+ }),
321
+ markDragging,
322
+ ),
323
+ }),
324
+ },
325
+ ),
326
+ escape: entry(
327
+ { isResizing: Schema.Boolean },
328
+ {
329
+ modelToDependencies: (model) => ({ isResizing: Option.isSome(model.maybeResizing) }),
330
+ dependenciesToStream: ({ isResizing }) =>
331
+ isResizing ? escapeKeys(Message.PressedEscape()) : Stream.empty,
332
+ },
333
+ ),
334
+ }));
335
+
336
+ /** A dragged splitter follows the pointer, and Escape puts it back before a dialog or the
337
+ * page sees it, from listeners that run only while it is dragged. Lift them into the
338
+ * application's Subscriptions behind a key prefix of the parent's: `subscriptions("rail")` is
339
+ * keyed `railResize` and `railEscape`. */
340
+ export const subscriptions = <Prefix extends string>(keyPrefix: Prefix) =>
341
+ prefixKeys(keyPrefix, railSubscriptions);
342
+
219
343
  // VIEW
220
344
 
221
345
  /** Per-render inputs passed to `view` through `h.submodel`'s `viewInputs`. */
222
346
  export type ViewInputs = Readonly<{
223
- /** Names the rail for screen readers, and its button and drawer head (`"collections"`). */
347
+ /** Names the rail for screen readers, and its button, drawer head and splitter
348
+ * (`"collections"`). */
224
349
  label: string;
350
+ /** The width the reader gave the rail, in pixels, as the parent keeps it from `ResizedRail`
351
+ * (for this client: another reader's window is not this one's); none for the stylesheet's.
352
+ * A width out of the rail's bounds prints at the nearer bound. */
353
+ maybeWidth: Option.Option<number>;
225
354
  /** The rail's panels: a tree, places, filters. A function because a Submodel's viewInputs
226
355
  * may hold functions only at the top level, and Foldkit runs this one in the parent's
227
356
  * boundary, so the handlers in what it returns dispatch the parent's Messages. */
228
357
  toAsideView: () => ReadonlyArray<Html>;
229
- /** The deck beside the rail, usually `main.deck#main`, given the button that opens the
230
- * drawer to print first in it. A function because it receives that button, and runs in the
231
- * parent's boundary like `toAsideView`. */
358
+ /** The page beside the rail, `main.deck#main` or `main.bench#main`, given the button that
359
+ * opens the drawer to print first in it. A function because it receives that button, and
360
+ * runs in the parent's boundary like `toAsideView`. */
232
361
  toDeckView: (openButton: Html) => Html;
233
362
  }>;
234
363
 
235
364
  /**
236
365
  * A column of panels beside the deck, fixed under the top bar and scrolling itself:
237
- * `div.railed >` the deck, then `aside.rail[popover="auto"][aria-label]`. One element serves
238
- * both layouts, so nothing in it renders twice or loses its state. At 900px and under it leaves the
239
- * page, and `span.rail-open > button.btn.small` at the top of the deck opens the same element as a
240
- * drawer over a veil, its head (the name and a close glyph) showing. Opening focuses its first
366
+ * `div.railed >` the deck, then `aside.rail[popover="auto"][aria-label]`, then its splitter,
367
+ * `div.rail-resize[role="separator"]`, on its right edge. One element serves both layouts, so
368
+ * nothing in it renders twice or loses its state. At 900px and under it leaves the page, and
369
+ * `span.rail-open > button.btn.small` at the top of the deck opens the same element as a drawer
370
+ * over a veil, its head (the name and a close glyph) showing. Opening focuses its first
241
371
  * control; Escape, a press on the veil, the close glyph, focus leaving or the window growing
242
372
  * past 900px closes it, and the first three put focus back on the button.
243
373
  *
374
+ * Its splitter sizes it, by a pointer (previewed as it moves) or the side arrows, within its
375
+ * bounds, and reports the width as `ResizedRail` for the parent to keep and pass back as
376
+ * `maybeWidth`, which the view sets as `--rail` on `div.railed`, so the page's padding and the
377
+ * rail's width follow it. The drawer keeps its own width. Lift `subscriptions(keyPrefix)` into
378
+ * the application's, or a dragged splitter follows nothing.
379
+ *
244
380
  * Its state is its own, not `@foldkit/ui`'s Popover: that panel is placed by Floating UI and
245
381
  * rendered only while open, and this one is always in the page, placed by the stylesheet, and
246
382
  * opened as a native popover so the browser keeps it in the top layer.
247
383
  */
248
384
  export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h): Html => {
249
385
  const { label, toAsideView, toDeckView } = viewInputs;
386
+ const maybeWidth = Option.orElse(
387
+ Option.map(model.maybeResizing, ({ width }) => width),
388
+ () => Option.map(viewInputs.maybeWidth, bounded),
389
+ );
390
+ const width = Option.getOrElse(maybeWidth, () => WIDTH.start);
250
391
 
251
392
  const openButton = h.span(
252
393
  [h.Class("rail-open")],
@@ -271,7 +412,10 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
271
412
  // NOTE: the deck comes first so the page's main content, and its h1, precede the rail's
272
413
  // panels in reading order; the stylesheet places the rail, fixed, at the left.
273
414
  return h.div(
274
- [h.Class("railed")],
415
+ [
416
+ h.Class("railed"),
417
+ ...Array.fromOption(Option.map(maybeWidth, (shown) => h.Style({ "--rail": `${shown}px` }))),
418
+ ],
275
419
  [
276
420
  toDeckView(openButton),
277
421
  h.aside(
@@ -296,6 +440,27 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
296
440
  ...toAsideView(),
297
441
  ],
298
442
  ),
443
+ h.div([
444
+ h.Class("rail-resize"),
445
+ h.Role("separator"),
446
+ h.AriaLabel(`Width of ${label}`),
447
+ h.AriaControls(model.id),
448
+ h.AriaValuemin(WIDTH.least),
449
+ h.AriaValuemax(WIDTH.most),
450
+ h.AriaValuenow(width),
451
+ h.AriaValuetext(`${width} pixels`),
452
+ h.Tabindex(0),
453
+ ...splitterAttributes(
454
+ {
455
+ orientation: "vertical",
456
+ isIdle: Option.isNone(model.maybeResizing),
457
+ onPress: (pointerId, clientX) => Message.PressedSplitter({ pointerId, clientX, width }),
458
+ onStep: (direction) =>
459
+ Message.PressedSplitterKey({ width: bounded(width + direction * WIDTH.step) }),
460
+ },
461
+ h,
462
+ ),
463
+ ]),
299
464
  ],
300
465
  );
301
466
  });
package/src/reel/index.ts CHANGED
@@ -2,7 +2,6 @@ export { Layout, OutMessage, Pane, apply } from "./layout.ts";
2
2
  export {
3
3
  Action,
4
4
  AnnounceReel,
5
- FocusGrip,
6
5
  FocusPane,
7
6
  Message,
8
7
  Model,
@@ -1,5 +1,5 @@
1
1
  // The reel's layout, apart from its Model: the panes, columns and requests the application
2
- // holds and applies, where a held pane or column may land, and how places are said.
2
+ // holds and applies, where a held pane may land, and how places are said.
3
3
 
4
4
  import { Array, Equal, Match, Number, Option, Predicate, Record, Schema, pipe } from "effect";
5
5
  import { defineMessageUnion } from "foldkit/message";
@@ -34,15 +34,15 @@ export type Column = typeof Column.Type;
34
34
  export const Layout = Schema.Array(Column);
35
35
  export type Layout = typeof Layout.Type;
36
36
 
37
- /** What is held: a pane or a column, by its id. */
37
+ /** What a move is said of: a pane, or a column (a key moves one), by its id. */
38
38
  export const Subject = defineTaggedUnion({
39
39
  Pane: { paneId: Schema.String },
40
40
  Column: { columnId: Schema.String },
41
41
  });
42
42
  export type Subject = typeof Subject.Type;
43
43
 
44
- /** Where a held pane or column would land: into a column, at a place among its other panes,
45
- * or in the gap before a column (none: after the last), as a column of its own. */
44
+ /** Where a held pane would land: into a column, at a place among its other panes, or in the
45
+ * gap before a column (none: after the last), as a column of its own. */
46
46
  export const Target = defineTaggedUnion({
47
47
  Into: { columnId: Schema.String, index: Schema.Number },
48
48
  Gap: { maybeBeforeColumnId: Schema.Option(Schema.String) },
@@ -101,10 +101,10 @@ export const placeAt = (layout: Layout, columnIndex: number, row: number): Optio
101
101
 
102
102
  export const firstPlace = (layout: Layout): Option.Option<Place> => placeAt(layout, 0, 0);
103
103
 
104
- export const columnIndexOf = (layout: Layout, columnId: string): Option.Option<number> =>
104
+ const columnIndexOf = (layout: Layout, columnId: string): Option.Option<number> =>
105
105
  Array.findFirstIndex(layout, (column) => column.id === columnId);
106
106
 
107
- export const columnOf = (layout: Layout, columnId: string): Option.Option<Column> =>
107
+ const columnOf = (layout: Layout, columnId: string): Option.Option<Column> =>
108
108
  Array.findFirst(layout, (column) => column.id === columnId);
109
109
 
110
110
  /** The id of the column after the one at `columnIndex`: none after the last. */
@@ -253,15 +253,13 @@ export const apply = (layout: Layout, request: OutMessage): Layout =>
253
253
  const PRESET_TOLERANCE = 0.01;
254
254
 
255
255
  /** The widths a column cycles through, as fractions of the reel's visible width, with the
256
- * words they are said in and the mark a column's head prints. They live here, not in the
257
- * tokens, because they are words as much as sizes. */
258
- const PRESETS: Array.NonEmptyReadonlyArray<
259
- Readonly<{ width: number; words: string; mark: string }>
260
- > = [
261
- { width: 1 / 3, words: "a third of the width", mark: "1/3" },
262
- { width: 1 / 2, words: "half the width", mark: "1/2" },
263
- { width: 2 / 3, words: "two thirds of the width", mark: "2/3" },
264
- { width: 1, words: "the full width", mark: "full" },
256
+ * words they are said in. They live here, not in the tokens, because they are words as much
257
+ * as sizes. */
258
+ const PRESETS: Array.NonEmptyReadonlyArray<Readonly<{ width: number; words: string }>> = [
259
+ { width: 1 / 3, words: "a third of the width" },
260
+ { width: 1 / 2, words: "half the width" },
261
+ { width: 2 / 3, words: "two thirds of the width" },
262
+ { width: 1, words: "the full width" },
265
263
  ];
266
264
 
267
265
  const presetOf = (width: number) =>
@@ -275,12 +273,6 @@ export const widthWords = (width: number): string =>
275
273
  onSome: ({ words }) => words,
276
274
  });
277
275
 
278
- export const widthMark = (width: number): string =>
279
- Option.match(presetOf(width), {
280
- onNone: () => `${percent(width)}%`,
281
- onSome: ({ mark }) => mark,
282
- });
283
-
284
276
  /** The next preset wider than `width`, or the narrowest past the widest. */
285
277
  export const nextPreset = (width: number): number =>
286
278
  Option.getOrElse(
@@ -298,7 +290,7 @@ const WORDS = {
298
290
  stackedPlace: "{row} of {rows} in column {column} of {columns}",
299
291
  };
300
292
 
301
- /** What a place is called when a drop would leave the pane or column where it is. */
293
+ /** What a place is called when a drop or a move would leave the pane or column where it is. */
302
294
  export const WHERE_IT_WAS = "where it was";
303
295
 
304
296
  export const fill = (template: string, fields: Readonly<Record<string, string | number>>): string =>
@@ -338,57 +330,39 @@ export const whereWords = (layout: Layout, subject: Subject): Option.Option<stri
338
330
 
339
331
  // TARGETS
340
332
 
341
- /** Where `subject` is now, as a target: a pane at its place, a column in the gap before
342
- * itself. */
343
- export const originOf = (layout: Layout, subject: Subject): Option.Option<Target> =>
344
- Subject.match(subject, {
345
- Pane: ({ paneId }) =>
346
- Option.map(placeOf(layout, paneId), ({ column, row }) =>
347
- Target.Into({ columnId: column.id, index: row }),
348
- ),
349
- Column: ({ columnId }) =>
350
- Option.map(columnIndexOf(layout, columnId), () =>
351
- Target.Gap({ maybeBeforeColumnId: Option.some(columnId) }),
352
- ),
353
- });
354
-
355
- export const isPresent = (layout: Layout, subject: Subject): boolean =>
356
- Option.isSome(originOf(layout, subject));
333
+ /** Where the pane `paneId` is now, as a target. */
334
+ export const originOf = (layout: Layout, paneId: string): Option.Option<Target> =>
335
+ Option.map(placeOf(layout, paneId), ({ column, row }) =>
336
+ Target.Into({ columnId: column.id, index: row }),
337
+ );
357
338
 
358
- /** What dropping `subject` at `target` asks of the layout: none where it would change
359
- * nothing (where it is, or a target whose column is gone). */
339
+ /** What dropping the pane `paneId` at `target` asks of the layout: none where it would
340
+ * change nothing (where it is, or a target whose column is gone). */
360
341
  export const requestOf = (
361
342
  layout: Layout,
362
- subject: Subject,
343
+ paneId: string,
363
344
  target: Target,
364
345
  ): Option.Option<OutMessage> =>
365
346
  Option.filter(
366
- Subject.match(subject, {
367
- Pane: ({ paneId }) =>
368
- Option.some(
369
- Target.match(target, {
370
- Into: ({ columnId, index }) =>
371
- OutMessage.MovedPane({ paneId, toColumnId: columnId, toIndex: index }),
372
- Gap: ({ maybeBeforeColumnId }) =>
373
- OutMessage.PulledOutPane({ paneId, maybeBeforeColumnId }),
374
- }),
375
- ),
376
- Column: ({ columnId }) =>
377
- Target.match(target, {
378
- Into: () => Option.none<OutMessage>(),
379
- Gap: ({ maybeBeforeColumnId }) =>
380
- Option.some(OutMessage.MovedColumn({ columnId, maybeBeforeColumnId })),
381
- }),
382
- }),
347
+ Option.some(
348
+ Target.match(target, {
349
+ Into: ({ columnId, index }) =>
350
+ OutMessage.MovedPane({ paneId, toColumnId: columnId, toIndex: index }),
351
+ Gap: ({ maybeBeforeColumnId }) => OutMessage.PulledOutPane({ paneId, maybeBeforeColumnId }),
352
+ }),
353
+ ),
383
354
  (request) => !Equal.equals(apply(layout, request), layout),
384
355
  );
385
356
 
386
- /** Where `subject` would be once dropped at `target`, in words. */
387
- export const landingWords = (layout: Layout, subject: Subject, target: Target): string =>
388
- Option.match(requestOf(layout, subject, target), {
357
+ /** Where the pane `paneId` would be once dropped at `target`, in words. */
358
+ export const landingWords = (layout: Layout, paneId: string, target: Target): string =>
359
+ Option.match(requestOf(layout, paneId, target), {
389
360
  onNone: () => WHERE_IT_WAS,
390
361
  onSome: (request) =>
391
- Option.getOrElse(whereWords(apply(layout, request), subject), () => WHERE_IT_WAS),
362
+ Option.getOrElse(
363
+ whereWords(apply(layout, request), Subject.Pane({ paneId })),
364
+ () => WHERE_IT_WAS,
365
+ ),
392
366
  });
393
367
 
394
368
  /** The column a target sits in or before (the last, for the gap after it), which a keyboard
@@ -404,7 +378,7 @@ export const Step = Schema.Literals(["Up", "Down", "Left", "Right"]);
404
378
  export type Step = typeof Step.Type;
405
379
 
406
380
  // A keyboard step reads the layout as a row of stops: the gap before each column (even), then
407
- // the column itself (odd), and the gap after the last. A column steps from gap to gap.
381
+ // the column itself (odd), and the gap after the last.
408
382
  const stopOf = (layout: Layout, target: Target): Option.Option<number> =>
409
383
  Target.match(target, {
410
384
  Into: ({ columnId }) => Option.map(columnIndexOf(layout, columnId), (column) => column * 2 + 1),
@@ -416,7 +390,7 @@ const stopOf = (layout: Layout, target: Target): Option.Option<number> =>
416
390
  export const isTargetPresent = (layout: Layout, target: Target): boolean =>
417
391
  Option.isSome(stopOf(layout, target));
418
392
 
419
- const targetAtStop = (layout: Layout, subject: Subject, stop: number, row: number): Target =>
393
+ const targetAtStop = (layout: Layout, paneId: string, stop: number, row: number): Target =>
420
394
  stop % 2 === 0
421
395
  ? Target.Gap({
422
396
  maybeBeforeColumnId: Option.map(Array.get(layout, stop / 2), ({ id }) => id),
@@ -424,28 +398,23 @@ const targetAtStop = (layout: Layout, subject: Subject, stop: number, row: numbe
424
398
  : Option.match(Array.get(layout, (stop - 1) / 2), {
425
399
  onNone: () => Target.Gap({ maybeBeforeColumnId: Option.none() }),
426
400
  onSome: (column) =>
427
- Target.Into({ columnId: column.id, index: Math.min(row, roomIn(column, subject)) }),
401
+ Target.Into({ columnId: column.id, index: Math.min(row, roomIn(column, paneId)) }),
428
402
  });
429
403
 
430
- /** The last index `subject` may take among a column's panes. */
431
- const roomIn = (column: Column, subject: Subject): number =>
432
- Subject.match(subject, {
433
- Pane: ({ paneId }) => othersIn(column, paneId).length,
434
- Column: () => 0,
435
- });
404
+ /** The last index the pane `paneId` may take among a column's panes. */
405
+ const roomIn = (column: Column, paneId: string): number => othersIn(column, paneId).length;
436
406
 
437
- const stepOnce = (layout: Layout, subject: Subject, target: Target, step: Step): Target => {
407
+ const stepOnce = (layout: Layout, paneId: string, target: Target, step: Step): Target => {
438
408
  const row = Target.match(target, { Into: ({ index }) => index, Gap: () => 0 });
439
- const stride = subject._tag === "Column" ? 2 : 1;
440
409
 
441
410
  return Option.match(stopOf(layout, target), {
442
411
  onNone: () => target,
443
412
  onSome: (stop) =>
444
413
  Match.value(step).pipe(
445
414
  Match.withReturnType<Target>(),
446
- Match.when("Left", () => targetAtStop(layout, subject, Math.max(stop - stride, 0), row)),
415
+ Match.when("Left", () => targetAtStop(layout, paneId, Math.max(stop - 1, 0), row)),
447
416
  Match.when("Right", () =>
448
- targetAtStop(layout, subject, Math.min(stop + stride, layout.length * 2), row),
417
+ targetAtStop(layout, paneId, Math.min(stop + 1, layout.length * 2), row),
449
418
  ),
450
419
  Match.whenOr("Up", "Down", () =>
451
420
  Target.match(target, {
@@ -458,7 +427,7 @@ const stepOnce = (layout: Layout, subject: Subject, target: Target, step: Step):
458
427
  columnId,
459
428
  index: Number.clamp(step === "Up" ? row - 1 : row + 1, {
460
429
  minimum: 0,
461
- maximum: roomIn(column, subject),
430
+ maximum: roomIn(column, paneId),
462
431
  }),
463
432
  }),
464
433
  }),
@@ -469,24 +438,25 @@ const stepOnce = (layout: Layout, subject: Subject, target: Target, step: Step):
469
438
  });
470
439
  };
471
440
 
472
- /** A keyboard step from `target`: left and right cross from a column to the gap beside it and
473
- * on to the next column, up and down move among a column's panes. A stop where the drop would
474
- * change nothing is passed over, but for where `subject` started; with nothing further that
475
- * way, it stays (where `subject` started, if the target's column has gone). */
441
+ /** A keyboard step of the pane `paneId` from `target`: left and right cross from a column to
442
+ * the gap beside it and on to the next column, up and down move among a column's panes. A
443
+ * stop where the drop would change nothing is passed over, but for where the pane started;
444
+ * with nothing further that way, it stays (where the pane started, if the target's column has
445
+ * gone). */
476
446
  export const steppedTarget = (
477
447
  layout: Layout,
478
- subject: Subject,
448
+ paneId: string,
479
449
  target: Target,
480
450
  step: Step,
481
451
  ): Target => {
482
- const origin = originOf(layout, subject);
483
- // a target whose column has gone steps from where `subject` started instead
452
+ const origin = originOf(layout, paneId);
453
+ // a target whose column has gone steps from where the pane started instead
484
454
  const start = isTargetPresent(layout, target) ? target : Option.getOrElse(origin, () => target);
485
455
  const isStop = (candidate: Target): boolean =>
486
- Option.isSome(requestOf(layout, subject, candidate)) ||
456
+ Option.isSome(requestOf(layout, paneId, candidate)) ||
487
457
  Option.exists(origin, (place) => Equal.equals(place, candidate));
488
458
  const walk = (from: Target, left: number): Target => {
489
- const next = stepOnce(layout, subject, from, step);
459
+ const next = stepOnce(layout, paneId, from, step);
490
460
  if (Equal.equals(next, from) || left === 0) {
491
461
  return start;
492
462
  }
@@ -514,44 +484,34 @@ const gapBefore = (columns: ReadonlyArray<ColumnBox>, gap: number): Target =>
514
484
  Target.Gap({ maybeBeforeColumnId: Option.map(Array.get(columns, gap), ({ id }) => id) });
515
485
 
516
486
  /** Where a pointer at (`clientX`, `clientY`) over a reel whose columns sit at `columns`, left
517
- * to right, would drop `subject`. A column lands in the gap nearest the pointer, counted by
518
- * the columns whose middle it has passed. A pane over a column's edge band (or over a gap, or
519
- * past the last column) lands in that gap, as a column of its own; over the rest of a column
520
- * it lands among that column's other panes, after each whose middle is above the pointer. */
487
+ * to right, would drop a pane. Over a column's edge band (or over a gap, or past the last
488
+ * column) it lands in that gap, as a column of its own; over the rest of a column it lands
489
+ * among that column's other panes, after each whose middle is above the pointer. */
521
490
  export const targetAtPoint = (
522
- subject: Subject,
523
491
  columns: ReadonlyArray<ColumnBox>,
524
492
  clientX: number,
525
493
  clientY: number,
526
494
  ): Target =>
527
- Subject.match(subject, {
528
- Column: () =>
529
- gapBefore(
530
- columns,
531
- Array.filter(columns, ({ left, right }) => (left + right) / 2 < clientX).length,
532
- ),
533
- Pane: () =>
534
- pipe(
535
- Array.findFirstIndex(columns, ({ right }) => clientX <= right),
536
- Option.flatMap((index) => Option.map(Array.get(columns, index), (box) => ({ index, box }))),
537
- Option.match({
538
- onNone: () => gapBefore(columns, columns.length),
539
- onSome: ({ index, box: { id, left, right, slotMiddles } }) => {
540
- const band = Math.min((right - left) * EDGE_SHARE, EDGE_MAX);
541
- if (clientX < left + band) {
542
- return gapBefore(columns, index);
543
- } else if (clientX > right - band) {
544
- return gapBefore(columns, index + 1);
545
- } else {
546
- return Target.Into({
547
- columnId: id,
548
- index: Array.filter(slotMiddles, (middle) => middle < clientY).length,
549
- });
550
- }
551
- },
552
- }),
553
- ),
554
- });
495
+ pipe(
496
+ Array.findFirstIndex(columns, ({ right }) => clientX <= right),
497
+ Option.flatMap((index) => Option.map(Array.get(columns, index), (box) => ({ index, box }))),
498
+ Option.match({
499
+ onNone: () => gapBefore(columns, columns.length),
500
+ onSome: ({ index, box: { id, left, right, slotMiddles } }) => {
501
+ const band = Math.min((right - left) * EDGE_SHARE, EDGE_MAX);
502
+ if (clientX < left + band) {
503
+ return gapBefore(columns, index);
504
+ } else if (clientX > right - band) {
505
+ return gapBefore(columns, index + 1);
506
+ } else {
507
+ return Target.Into({
508
+ columnId: id,
509
+ index: Array.filter(slotMiddles, (middle) => middle < clientY).length,
510
+ });
511
+ }
512
+ },
513
+ }),
514
+ );
555
515
 
556
516
  // RESIZE
557
517