@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,20 @@
1
+ export { Layout, OutMessage, Pane, apply } from "./layout.ts";
2
+ export {
3
+ Action,
4
+ AnnounceReel,
5
+ FocusGrip,
6
+ FocusPane,
7
+ Message,
8
+ Model,
9
+ RestoreFocus,
10
+ ScrollIntoView,
11
+ SizeLiftedPane,
12
+ focusedPaneId,
13
+ init,
14
+ perform,
15
+ reconcile,
16
+ subscriptions,
17
+ update,
18
+ view,
19
+ } from "./reel.ts";
20
+ export type { InitConfig, PaneRender, UpdateContext, ViewInputs } from "./reel.ts";
@@ -0,0 +1,602 @@
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.
3
+
4
+ import { Array, Equal, Match, Number, Option, Predicate, Record, Schema, pipe } from "effect";
5
+ import { defineMessageUnion } from "foldkit/message";
6
+ import { defineTaggedUnion } from "foldkit/schema";
7
+
8
+ import { LOCALE } from "../locale/locale.ts";
9
+
10
+ // LAYOUT SCHEMAS
11
+
12
+ /** Schema for one pane as the reel knows it: its id, unique in the reel and the application's
13
+ * to keep, its name in words (its grip, its splitter and what screen readers hear), and its
14
+ * share of its column's height, a weight against its neighbours'. */
15
+ export const Pane = Schema.Struct({
16
+ id: Schema.String,
17
+ label: Schema.String,
18
+ weight: Schema.Number,
19
+ });
20
+ export type Pane = typeof Pane.Type;
21
+
22
+ /** Schema for one column: its id, unique among the reel's columns and the application's to
23
+ * keep, its width, a fraction of the reel's visible width (1 fills it), and its panes, top
24
+ * to bottom. A column `apply` makes for a pane pulled out takes the pane's id (`notes`, or
25
+ * `notes/2` where a column has it), so pane and column ids share one namespace. */
26
+ export const Column = Schema.Struct({
27
+ id: Schema.String,
28
+ width: Schema.Number,
29
+ panes: Schema.NonEmptyArray(Pane),
30
+ });
31
+ export type Column = typeof Column.Type;
32
+
33
+ /** Schema for a reel's layout: its columns, left to right. The application's to keep. */
34
+ export const Layout = Schema.Array(Column);
35
+ export type Layout = typeof Layout.Type;
36
+
37
+ /** What is held: a pane or a column, by its id. */
38
+ export const Subject = defineTaggedUnion({
39
+ Pane: { paneId: Schema.String },
40
+ Column: { columnId: Schema.String },
41
+ });
42
+ export type Subject = typeof Subject.Type;
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. */
46
+ export const Target = defineTaggedUnion({
47
+ Into: { columnId: Schema.String, index: Schema.Number },
48
+ Gap: { maybeBeforeColumnId: Schema.Option(Schema.String) },
49
+ });
50
+ export type Target = typeof Target.Type;
51
+
52
+ // REQUEST
53
+
54
+ /** Union of what a reel asks the application to do to its layout, which the application
55
+ * applies (`apply` does it) or not, by id, so a request still means what it meant after the
56
+ * layout changed under it. `MovedPane`: a pane into a column, at `toIndex` among that
57
+ * column's other panes. `PulledOutPane`: a pane into a column of its own, before a column
58
+ * (none: after the last). `MovedColumn`: a column to before another (none: last).
59
+ * `ResizedColumn`: a column's width. `ResizedPanes`: a column's panes' weights, by pane id. */
60
+ export const OutMessage = defineMessageUnion({
61
+ MovedPane: { paneId: Schema.String, toColumnId: Schema.String, toIndex: Schema.Number },
62
+ PulledOutPane: { paneId: Schema.String, maybeBeforeColumnId: Schema.Option(Schema.String) },
63
+ MovedColumn: { columnId: Schema.String, maybeBeforeColumnId: Schema.Option(Schema.String) },
64
+ ResizedColumn: { columnId: Schema.String, width: Schema.Number },
65
+ ResizedPanes: {
66
+ columnId: Schema.String,
67
+ weights: Schema.Record(Schema.String, Schema.Number),
68
+ },
69
+ });
70
+ export type OutMessage = typeof OutMessage.Type;
71
+
72
+ /** A splitter: the one after a column, which sizes its width, or the one below a pane, which
73
+ * shares the height between it and the pane under it. */
74
+ export const Splitter = defineTaggedUnion({
75
+ Width: { columnId: Schema.String },
76
+ Split: { abovePaneId: Schema.String },
77
+ });
78
+ export type Splitter = typeof Splitter.Type;
79
+
80
+ /** A resize: a column's width, or its panes' weights. */
81
+ export const Resize = Schema.Union([OutMessage.ResizedColumn, OutMessage.ResizedPanes]);
82
+ export type Resize = typeof Resize.Type;
83
+
84
+ // LAYOUT
85
+
86
+ /** A pane where it sits: its column (and that column's index) and its row. */
87
+ export type Place = Readonly<{ column: Column; columnIndex: number; row: number; pane: Pane }>;
88
+
89
+ const placesOf = (layout: Layout): ReadonlyArray<Place> =>
90
+ Array.flatMap(layout, (column, columnIndex) =>
91
+ Array.map(column.panes, (pane, row) => ({ column, columnIndex, row, pane })),
92
+ );
93
+
94
+ export const placeOf = (layout: Layout, paneId: string): Option.Option<Place> =>
95
+ Array.findFirst(placesOf(layout), (place) => place.pane.id === paneId);
96
+
97
+ export const placeAt = (layout: Layout, columnIndex: number, row: number): Option.Option<Place> =>
98
+ Option.flatMap(Array.get(layout, columnIndex), (column) =>
99
+ Option.map(Array.get(column.panes, row), (pane) => ({ column, columnIndex, row, pane })),
100
+ );
101
+
102
+ export const firstPlace = (layout: Layout): Option.Option<Place> => placeAt(layout, 0, 0);
103
+
104
+ export const columnIndexOf = (layout: Layout, columnId: string): Option.Option<number> =>
105
+ Array.findFirstIndex(layout, (column) => column.id === columnId);
106
+
107
+ export const columnOf = (layout: Layout, columnId: string): Option.Option<Column> =>
108
+ Array.findFirst(layout, (column) => column.id === columnId);
109
+
110
+ /** The id of the column after the one at `columnIndex`: none after the last. */
111
+ const nextColumnId = (layout: Layout, columnIndex: number): Option.Option<string> =>
112
+ Option.map(Array.get(layout, columnIndex + 1), (column) => column.id);
113
+
114
+ const meanWeight = (panes: ReadonlyArray<Pane>): number =>
115
+ Array.match(panes, {
116
+ onEmpty: () => 1,
117
+ onNonEmpty: (some) => Number.sumAll(Array.map(some, (pane) => pane.weight)) / some.length,
118
+ });
119
+
120
+ const othersIn = (column: Column, paneId: string): ReadonlyArray<Pane> =>
121
+ Array.filter(column.panes, (pane) => pane.id !== paneId);
122
+
123
+ const insertAt = <A>(items: ReadonlyArray<A>, index: number, item: A): Array.NonEmptyArray<A> => {
124
+ const [before, after] = Array.splitAt(
125
+ items,
126
+ Number.clamp(index, { minimum: 0, maximum: items.length }),
127
+ );
128
+ return Array.appendAll(Array.append(before, item), after);
129
+ };
130
+
131
+ /** `layout` with the pane `paneId` taken out, and any column it leaves empty gone. */
132
+ const without = (layout: Layout, paneId: string): Layout =>
133
+ Array.flatMap(layout, (column) => {
134
+ const others = othersIn(column, paneId);
135
+ return Array.isReadonlyArrayNonEmpty(others) ? [{ ...column, panes: others }] : [];
136
+ });
137
+
138
+ /** Where a column goes when it is put before `maybeBeforeColumnId`: none when that column is
139
+ * not in `layout`. */
140
+ const gapIndex = (
141
+ layout: Layout,
142
+ maybeBeforeColumnId: Option.Option<string>,
143
+ ): Option.Option<number> =>
144
+ Option.match(maybeBeforeColumnId, {
145
+ onNone: () => Option.some(layout.length),
146
+ onSome: (columnId) => columnIndexOf(layout, columnId),
147
+ });
148
+
149
+ /** A column id for a column made for `paneId`: the pane's own id, or the first `id/2`,
150
+ * `id/3`… no column in `layout` has. */
151
+ const freshColumnId = (layout: Layout, paneId: string, suffix = 1): string => {
152
+ const candidate = suffix === 1 ? paneId : `${paneId}/${suffix}`;
153
+ return Array.some(layout, (column) => column.id === candidate)
154
+ ? freshColumnId(layout, paneId, suffix + 1)
155
+ : candidate;
156
+ };
157
+
158
+ const movePane = (layout: Layout, paneId: string, toColumnId: string, toIndex: number): Layout =>
159
+ Option.match(Option.all({ from: placeOf(layout, paneId), to: columnOf(layout, toColumnId) }), {
160
+ onNone: () => layout,
161
+ onSome: ({ from, to }) => {
162
+ const others = othersIn(to, paneId);
163
+ const weight = to.id === from.column.id ? from.pane.weight : meanWeight(others);
164
+ const panes = insertAt(others, toIndex, { ...from.pane, weight });
165
+ // the target column keeps its place even when the pane was alone in it
166
+ return Array.flatMap(layout, (column) =>
167
+ column.id === toColumnId ? [{ ...column, panes }] : without([column], paneId),
168
+ );
169
+ },
170
+ });
171
+
172
+ const pullOutPane = (
173
+ layout: Layout,
174
+ paneId: string,
175
+ maybeBeforeColumnId: Option.Option<string>,
176
+ ): Layout =>
177
+ Option.match(placeOf(layout, paneId), {
178
+ onNone: () => layout,
179
+ onSome: (from) => {
180
+ const isAlone = from.column.panes.length === 1;
181
+ const isWhereItIs =
182
+ Equal.equals(maybeBeforeColumnId, Option.some(from.column.id)) ||
183
+ Equal.equals(maybeBeforeColumnId, nextColumnId(layout, from.columnIndex));
184
+ const rest = without(layout, paneId);
185
+
186
+ if (isAlone && isWhereItIs) {
187
+ return layout;
188
+ }
189
+
190
+ return Option.match(gapIndex(rest, maybeBeforeColumnId), {
191
+ onNone: () => layout,
192
+ onSome: (gap) =>
193
+ insertAt(rest, gap, {
194
+ id: freshColumnId(rest, paneId),
195
+ width: from.column.width,
196
+ panes: [{ ...from.pane, weight: 1 }],
197
+ }),
198
+ });
199
+ },
200
+ });
201
+
202
+ const moveColumn = (
203
+ layout: Layout,
204
+ columnId: string,
205
+ maybeBeforeColumnId: Option.Option<string>,
206
+ ): Layout => {
207
+ const rest = Array.filter(layout, (column) => column.id !== columnId);
208
+ return Option.match(
209
+ Option.all({
210
+ column: columnOf(layout, columnId),
211
+ gap: gapIndex(rest, maybeBeforeColumnId),
212
+ }),
213
+ {
214
+ onNone: () => layout,
215
+ onSome: ({ column, gap }) => insertAt(rest, gap, column),
216
+ },
217
+ );
218
+ };
219
+
220
+ const modifyColumn = (layout: Layout, columnId: string, f: (column: Column) => Column): Layout =>
221
+ Array.map(layout, (column) => (column.id === columnId ? f(column) : column));
222
+
223
+ /**
224
+ * The layout once `request` is applied: what the reel asked, done the way every client does
225
+ * it. A pane that leaves its column takes the mean weight of the column it joins; a column
226
+ * left without panes goes; a pane pulled out keeps its old column's width, in a column named
227
+ * for it. A request naming a pane or column the layout no longer has changes nothing.
228
+ */
229
+ export const apply = (layout: Layout, request: OutMessage): Layout =>
230
+ OutMessage.match(request, {
231
+ MovedPane: ({ paneId, toColumnId, toIndex }) => movePane(layout, paneId, toColumnId, toIndex),
232
+ PulledOutPane: ({ paneId, maybeBeforeColumnId }) =>
233
+ pullOutPane(layout, paneId, maybeBeforeColumnId),
234
+ MovedColumn: ({ columnId, maybeBeforeColumnId }) =>
235
+ moveColumn(layout, columnId, maybeBeforeColumnId),
236
+ ResizedColumn: ({ columnId, width }) =>
237
+ modifyColumn(layout, columnId, (column) => ({ ...column, width })),
238
+ ResizedPanes: ({ columnId, weights }) =>
239
+ modifyColumn(layout, columnId, (column) => ({
240
+ ...column,
241
+ panes: Array.map(column.panes, (pane) => ({
242
+ ...pane,
243
+ weight: Option.getOrElse(Record.get(weights, pane.id), () => pane.weight),
244
+ })),
245
+ })),
246
+ });
247
+
248
+ // WIDTHS
249
+
250
+ /** How close a width is to a preset to count as it. */
251
+ const PRESET_TOLERANCE = 0.01;
252
+
253
+ /** The widths a column cycles through, as fractions of the reel's visible width, with the
254
+ * words they are said in and the mark a column's head prints. They live here, not in the
255
+ * tokens, because they are words as much as sizes. */
256
+ const PRESETS: Array.NonEmptyReadonlyArray<
257
+ Readonly<{ width: number; words: string; mark: string }>
258
+ > = [
259
+ { width: 1 / 3, words: "a third of the width", mark: "1/3" },
260
+ { width: 1 / 2, words: "half the width", mark: "1/2" },
261
+ { width: 2 / 3, words: "two thirds of the width", mark: "2/3" },
262
+ { width: 1, words: "the full width", mark: "full" },
263
+ ];
264
+
265
+ const presetOf = (width: number) =>
266
+ Array.findFirst(PRESETS, (preset) => Math.abs(preset.width - width) < PRESET_TOLERANCE);
267
+
268
+ const percent = (width: number): string => Math.round(width * 100).toLocaleString(LOCALE);
269
+
270
+ export const widthWords = (width: number): string =>
271
+ Option.match(presetOf(width), {
272
+ onNone: () => `${percent(width)}% of the width`,
273
+ onSome: ({ words }) => words,
274
+ });
275
+
276
+ export const widthMark = (width: number): string =>
277
+ Option.match(presetOf(width), {
278
+ onNone: () => `${percent(width)}%`,
279
+ onSome: ({ mark }) => mark,
280
+ });
281
+
282
+ /** The next preset wider than `width`, or the narrowest past the widest. */
283
+ export const nextPreset = (width: number): number =>
284
+ Option.getOrElse(
285
+ Array.findFirst(PRESETS, (preset) => preset.width > width + PRESET_TOLERANCE),
286
+ () => Array.headNonEmpty(PRESETS),
287
+ ).width;
288
+
289
+ // WORDS
290
+
291
+ /** The words a place is said in. `{pane}` is a pane's label and `{column}` a column's
292
+ * number, counted from one. */
293
+ const WORDS = {
294
+ columnSubject: "column with {pane}",
295
+ place: "column {column} of {columns}",
296
+ stackedPlace: "{row} of {rows} in column {column} of {columns}",
297
+ };
298
+
299
+ /** What a place is called when a drop would leave the pane or column where it is. */
300
+ export const WHERE_IT_WAS = "where it was";
301
+
302
+ export const fill = (template: string, fields: Readonly<Record<string, string | number>>): string =>
303
+ Array.reduce(Object.entries(fields), template, (text, [key, value]) =>
304
+ text.replaceAll(`{${key}}`, Predicate.isNumber(value) ? value.toLocaleString(LOCALE) : value),
305
+ );
306
+
307
+ export const subjectWords = (layout: Layout, subject: Subject): Option.Option<string> =>
308
+ Subject.match(subject, {
309
+ Pane: ({ paneId }) => Option.map(placeOf(layout, paneId), ({ pane }) => pane.label),
310
+ Column: ({ columnId }) =>
311
+ Option.map(columnOf(layout, columnId), (column) =>
312
+ fill(WORDS.columnSubject, { pane: Array.headNonEmpty(column.panes).label }),
313
+ ),
314
+ });
315
+
316
+ export const placeWords = (layout: Layout, place: Place): string => {
317
+ const rows = place.column.panes.length;
318
+ const fields = {
319
+ column: place.columnIndex + 1,
320
+ columns: layout.length,
321
+ row: place.row + 1,
322
+ rows,
323
+ };
324
+ return rows > 1 ? fill(WORDS.stackedPlace, fields) : fill(WORDS.place, fields);
325
+ };
326
+
327
+ /** Where `subject` sits in `layout`, in words. */
328
+ export const whereWords = (layout: Layout, subject: Subject): Option.Option<string> =>
329
+ Subject.match(subject, {
330
+ Pane: ({ paneId }) => Option.map(placeOf(layout, paneId), (place) => placeWords(layout, place)),
331
+ Column: ({ columnId }) =>
332
+ Option.map(columnIndexOf(layout, columnId), (column) =>
333
+ fill(WORDS.place, { column: column + 1, columns: layout.length }),
334
+ ),
335
+ });
336
+
337
+ // TARGETS
338
+
339
+ /** Where `subject` is now, as a target: a pane at its place, a column in the gap before
340
+ * itself. */
341
+ export const originOf = (layout: Layout, subject: Subject): Option.Option<Target> =>
342
+ Subject.match(subject, {
343
+ Pane: ({ paneId }) =>
344
+ Option.map(placeOf(layout, paneId), ({ column, row }) =>
345
+ Target.Into({ columnId: column.id, index: row }),
346
+ ),
347
+ Column: ({ columnId }) =>
348
+ Option.map(columnIndexOf(layout, columnId), () =>
349
+ Target.Gap({ maybeBeforeColumnId: Option.some(columnId) }),
350
+ ),
351
+ });
352
+
353
+ export const isPresent = (layout: Layout, subject: Subject): boolean =>
354
+ Option.isSome(originOf(layout, subject));
355
+
356
+ /** What dropping `subject` at `target` asks of the layout: none where it would change
357
+ * nothing (where it is, or a target whose column is gone). */
358
+ export const requestOf = (
359
+ layout: Layout,
360
+ subject: Subject,
361
+ target: Target,
362
+ ): Option.Option<OutMessage> =>
363
+ Option.filter(
364
+ Subject.match(subject, {
365
+ Pane: ({ paneId }) =>
366
+ Option.some(
367
+ Target.match(target, {
368
+ Into: ({ columnId, index }) =>
369
+ OutMessage.MovedPane({ paneId, toColumnId: columnId, toIndex: index }),
370
+ Gap: ({ maybeBeforeColumnId }) =>
371
+ OutMessage.PulledOutPane({ paneId, maybeBeforeColumnId }),
372
+ }),
373
+ ),
374
+ Column: ({ columnId }) =>
375
+ Target.match(target, {
376
+ Into: () => Option.none<OutMessage>(),
377
+ Gap: ({ maybeBeforeColumnId }) =>
378
+ Option.some(OutMessage.MovedColumn({ columnId, maybeBeforeColumnId })),
379
+ }),
380
+ }),
381
+ (request) => !Equal.equals(apply(layout, request), layout),
382
+ );
383
+
384
+ /** Where `subject` would be once dropped at `target`, in words. */
385
+ export const landingWords = (layout: Layout, subject: Subject, target: Target): string =>
386
+ Option.match(requestOf(layout, subject, target), {
387
+ onNone: () => WHERE_IT_WAS,
388
+ onSome: (request) =>
389
+ Option.getOrElse(whereWords(apply(layout, request), subject), () => WHERE_IT_WAS),
390
+ });
391
+
392
+ /** The column a target sits in or before (the last, for the gap after it), which a keyboard
393
+ * step scrolls to. */
394
+ export const columnOfTarget = (layout: Layout, target: Target): Option.Option<string> =>
395
+ Target.match(target, {
396
+ Into: ({ columnId }) => Option.some(columnId),
397
+ Gap: ({ maybeBeforeColumnId }) =>
398
+ Option.orElse(maybeBeforeColumnId, () => Option.map(Array.last(layout), ({ id }) => id)),
399
+ });
400
+
401
+ export const Step = Schema.Literals(["Up", "Down", "Left", "Right"]);
402
+ export type Step = typeof Step.Type;
403
+
404
+ // A keyboard step reads the layout as a row of stops: the gap before each column (even), then
405
+ // the column itself (odd), and the gap after the last. A column steps from gap to gap.
406
+ const stopOf = (layout: Layout, target: Target): Option.Option<number> =>
407
+ Target.match(target, {
408
+ Into: ({ columnId }) => Option.map(columnIndexOf(layout, columnId), (column) => column * 2 + 1),
409
+ Gap: ({ maybeBeforeColumnId }) =>
410
+ Option.map(gapIndex(layout, maybeBeforeColumnId), (gap) => gap * 2),
411
+ });
412
+
413
+ /** Whether the column `target` names (into it, or the gap before it) is still in `layout`. */
414
+ export const isTargetPresent = (layout: Layout, target: Target): boolean =>
415
+ Option.isSome(stopOf(layout, target));
416
+
417
+ const targetAtStop = (layout: Layout, subject: Subject, stop: number, row: number): Target =>
418
+ stop % 2 === 0
419
+ ? Target.Gap({
420
+ maybeBeforeColumnId: Option.map(Array.get(layout, stop / 2), ({ id }) => id),
421
+ })
422
+ : Option.match(Array.get(layout, (stop - 1) / 2), {
423
+ onNone: () => Target.Gap({ maybeBeforeColumnId: Option.none() }),
424
+ onSome: (column) =>
425
+ Target.Into({ columnId: column.id, index: Math.min(row, roomIn(column, subject)) }),
426
+ });
427
+
428
+ /** The last index `subject` may take among a column's panes. */
429
+ const roomIn = (column: Column, subject: Subject): number =>
430
+ Subject.match(subject, {
431
+ Pane: ({ paneId }) => othersIn(column, paneId).length,
432
+ Column: () => 0,
433
+ });
434
+
435
+ const stepOnce = (layout: Layout, subject: Subject, target: Target, step: Step): Target => {
436
+ const row = Target.match(target, { Into: ({ index }) => index, Gap: () => 0 });
437
+ const stride = subject._tag === "Column" ? 2 : 1;
438
+
439
+ return Option.match(stopOf(layout, target), {
440
+ onNone: () => target,
441
+ onSome: (stop) =>
442
+ Match.value(step).pipe(
443
+ Match.withReturnType<Target>(),
444
+ Match.when("Left", () => targetAtStop(layout, subject, Math.max(stop - stride, 0), row)),
445
+ Match.when("Right", () =>
446
+ targetAtStop(layout, subject, Math.min(stop + stride, layout.length * 2), row),
447
+ ),
448
+ Match.whenOr("Up", "Down", () =>
449
+ Target.match(target, {
450
+ Gap: () => target,
451
+ Into: ({ columnId }) =>
452
+ Option.match(columnOf(layout, columnId), {
453
+ onNone: () => target,
454
+ onSome: (column) =>
455
+ Target.Into({
456
+ columnId,
457
+ index: Number.clamp(step === "Up" ? row - 1 : row + 1, {
458
+ minimum: 0,
459
+ maximum: roomIn(column, subject),
460
+ }),
461
+ }),
462
+ }),
463
+ }),
464
+ ),
465
+ Match.exhaustive,
466
+ ),
467
+ });
468
+ };
469
+
470
+ /** A keyboard step from `target`: left and right cross from a column to the gap beside it and
471
+ * on to the next column, up and down move among a column's panes. A stop where the drop would
472
+ * change nothing is passed over, but for where `subject` started; with nothing further that
473
+ * way, it stays (where `subject` started, if the target's column has gone). */
474
+ export const steppedTarget = (
475
+ layout: Layout,
476
+ subject: Subject,
477
+ target: Target,
478
+ step: Step,
479
+ ): Target => {
480
+ const origin = originOf(layout, subject);
481
+ // a target whose column has gone steps from where `subject` started instead
482
+ const start = isTargetPresent(layout, target) ? target : Option.getOrElse(origin, () => target);
483
+ const isStop = (candidate: Target): boolean =>
484
+ Option.isSome(requestOf(layout, subject, candidate)) ||
485
+ Option.exists(origin, (place) => Equal.equals(place, candidate));
486
+ const walk = (from: Target, left: number): Target => {
487
+ const next = stepOnce(layout, subject, from, step);
488
+ if (Equal.equals(next, from) || left === 0) {
489
+ return start;
490
+ }
491
+ return isStop(next) ? next : walk(next, left - 1);
492
+ };
493
+ return walk(start, layout.length * 2 + Array.length(placesOf(layout)));
494
+ };
495
+
496
+ /** The share of a column's width, from either edge, where a pointer over it means the gap
497
+ * beside it (a column of its own) rather than a place among its panes, up to `EDGE_MAX`
498
+ * pixels. */
499
+ const EDGE_SHARE = 0.2;
500
+ const EDGE_MAX = 48;
501
+
502
+ /** A column as a pointer reads it: its id, its left and right edges, and the vertical middles
503
+ * of its slots, the held pane's left out. */
504
+ export type ColumnBox = Readonly<{
505
+ id: string;
506
+ left: number;
507
+ right: number;
508
+ slotMiddles: ReadonlyArray<number>;
509
+ }>;
510
+
511
+ const gapBefore = (columns: ReadonlyArray<ColumnBox>, gap: number): Target =>
512
+ Target.Gap({ maybeBeforeColumnId: Option.map(Array.get(columns, gap), ({ id }) => id) });
513
+
514
+ /** Where a pointer at (`clientX`, `clientY`) over a reel whose columns sit at `columns`, left
515
+ * to right, would drop `subject`. A column lands in the gap nearest the pointer, counted by
516
+ * the columns whose middle it has passed. A pane over a column's edge band (or over a gap, or
517
+ * past the last column) lands in that gap, as a column of its own; over the rest of a column
518
+ * it lands among that column's other panes, after each whose middle is above the pointer. */
519
+ export const targetAtPoint = (
520
+ subject: Subject,
521
+ columns: ReadonlyArray<ColumnBox>,
522
+ clientX: number,
523
+ clientY: number,
524
+ ): Target =>
525
+ Subject.match(subject, {
526
+ Column: () =>
527
+ gapBefore(
528
+ columns,
529
+ Array.filter(columns, ({ left, right }) => (left + right) / 2 < clientX).length,
530
+ ),
531
+ Pane: () =>
532
+ pipe(
533
+ Array.findFirstIndex(columns, ({ right }) => clientX <= right),
534
+ Option.flatMap((index) => Option.map(Array.get(columns, index), (box) => ({ index, box }))),
535
+ Option.match({
536
+ onNone: () => gapBefore(columns, columns.length),
537
+ onSome: ({ index, box: { id, left, right, slotMiddles } }) => {
538
+ const band = Math.min((right - left) * EDGE_SHARE, EDGE_MAX);
539
+ if (clientX < left + band) {
540
+ return gapBefore(columns, index);
541
+ } else if (clientX > right - band) {
542
+ return gapBefore(columns, index + 1);
543
+ } else {
544
+ return Target.Into({
545
+ columnId: id,
546
+ index: Array.filter(slotMiddles, (middle) => middle < clientY).length,
547
+ });
548
+ }
549
+ },
550
+ }),
551
+ ),
552
+ });
553
+
554
+ // RESIZE
555
+
556
+ /** The narrowest a column's width goes by a splitter, and the least share of its column a pane
557
+ * keeps: the stylesheet keeps both on screen at their minimums, so these only keep a stored
558
+ * size from reaching nothing. */
559
+ const WIDTH_FLOOR = 0.1;
560
+ const SHARE_FLOOR = 0.05;
561
+ const PRECISION = 10_000;
562
+
563
+ const rounded = (value: number): number => Math.round(value * PRECISION) / PRECISION;
564
+
565
+ /** What moving `splitter` by `share` asks of the layout, from the sizes the layout holds (not
566
+ * the sizes printed, which the stylesheet may hold at a minimum): a width splitter adds
567
+ * `share` to its column's width; a pane splitter moves `share` of its column's height from
568
+ * the pane below it to the pane above. None when what it sizes is gone. */
569
+ export const resizeOf = (
570
+ layout: Layout,
571
+ splitter: Splitter,
572
+ share: number,
573
+ ): Option.Option<Resize> =>
574
+ Splitter.match(splitter, {
575
+ Width: ({ columnId }) =>
576
+ Option.map(columnOf(layout, columnId), (column) =>
577
+ OutMessage.ResizedColumn({
578
+ columnId,
579
+ width: rounded(Number.clamp(column.width + share, { minimum: WIDTH_FLOOR, maximum: 1 })),
580
+ }),
581
+ ),
582
+ Split: ({ abovePaneId }) =>
583
+ Option.flatMap(placeOf(layout, abovePaneId), ({ column, row, pane: above }) =>
584
+ Option.map(Array.get(column.panes, row + 1), (below) => {
585
+ const total = Number.sumAll(Array.map(column.panes, ({ weight }) => weight));
586
+ const pair = above.weight + below.weight;
587
+ const floor = Math.min(total * SHARE_FLOOR, pair / 2);
588
+ const nextAbove = Number.clamp(above.weight + share * total, {
589
+ minimum: floor,
590
+ maximum: pair - floor,
591
+ });
592
+ return OutMessage.ResizedPanes({
593
+ columnId: column.id,
594
+ weights: { [above.id]: rounded(nextAbove), [below.id]: rounded(pair - nextAbove) },
595
+ });
596
+ }),
597
+ ),
598
+ });
599
+
600
+ /** Whether what `splitter` sizes is still in `layout`. */
601
+ export const isSplitterPresent = (layout: Layout, splitter: Splitter): boolean =>
602
+ Option.isSome(resizeOf(layout, splitter, 0));