@gjermundgaraba-designs/pixel-paper 0.20.0 → 0.21.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 (37) hide show
  1. package/DESIGN.md +3 -3
  2. package/dist/src/commandPalette/commandPalette.d.ts +4 -3
  3. package/dist/src/commandPalette/commandPalette.d.ts.map +1 -1
  4. package/dist/src/commandPalette/commandPalette.js +34 -35
  5. package/dist/src/commandPalette/match.d.ts +5 -2
  6. package/dist/src/commandPalette/match.d.ts.map +1 -1
  7. package/dist/src/commandPalette/match.js +14 -3
  8. package/dist/src/field/field.d.ts +3 -0
  9. package/dist/src/field/field.d.ts.map +1 -1
  10. package/dist/src/field/field.js +2 -2
  11. package/dist/src/lineDiff/index.d.ts +2 -0
  12. package/dist/src/lineDiff/index.d.ts.map +1 -1
  13. package/dist/src/lineDiff/index.js +1 -0
  14. package/dist/src/lineDiff/lineDiff.d.ts +8 -4
  15. package/dist/src/lineDiff/lineDiff.d.ts.map +1 -1
  16. package/dist/src/lineDiff/lineDiff.js +19 -15
  17. package/dist/src/lineDiff/patch.d.ts +35 -0
  18. package/dist/src/lineDiff/patch.d.ts.map +1 -0
  19. package/dist/src/lineDiff/patch.js +146 -0
  20. package/dist/src/menu/menu.d.ts +6 -6
  21. package/dist/src/reel/layout.d.ts +2 -1
  22. package/dist/src/reel/layout.d.ts.map +1 -1
  23. package/dist/src/reel/layout.js +22 -4
  24. package/dist/src/sortable/sortable.d.ts +30 -30
  25. package/dist/src/toast/toast.d.ts +12 -12
  26. package/dist/src/window/window.d.ts +3 -1
  27. package/dist/src/window/window.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/commandPalette/commandPalette.ts +11 -15
  30. package/src/commandPalette/match.ts +22 -5
  31. package/src/field/field.ts +5 -1
  32. package/src/lineDiff/index.ts +2 -0
  33. package/src/lineDiff/lineDiff.ts +29 -24
  34. package/src/lineDiff/patch.ts +218 -0
  35. package/src/pixel-paper.css +6 -0
  36. package/src/reel/layout.ts +27 -4
  37. package/src/window/window.ts +3 -1
@@ -329,7 +329,7 @@ type ShownList = Readonly<{
329
329
  shownShift: number;
330
330
  }>;
331
331
 
332
- const isListedIn = (rows: ReadonlyArray<ShownRow>, rowId: string): boolean =>
332
+ const isListed = (rows: ReadonlyArray<Readonly<{ id: string }>>, rowId: string): boolean =>
333
333
  Array.some(rows, (row) => row.id === rowId);
334
334
 
335
335
  // NOTE: where the cursor is among the rows a key carries (see `Model`). With no arrows waiting
@@ -339,16 +339,16 @@ const isListedIn = (rows: ReadonlyArray<ShownRow>, rowId: string): boolean =>
339
339
  // key waits for the next render.
340
340
  const cursorAsShown = (model: Model, shown: ShownList): Option.Option<Option.Option<string>> => {
341
341
  const { rows, maybeShownCursor, shownQuery, shownShift } = shown;
342
- const isListed = shownQuery === query(model);
342
+ const isTheList = shownQuery === query(model);
343
343
 
344
- if (isListed && model.cursorShift === 0) {
344
+ if (isTheList && model.cursorShift === 0) {
345
345
  return Option.some(
346
346
  Option.orElse(
347
- Option.filter(maybeCurrentCursor(model), (rowId) => isListedIn(rows, rowId)),
347
+ Option.filter(maybeCurrentCursor(model), (rowId) => isListed(rows, rowId)),
348
348
  () => maybeShownCursor,
349
349
  ),
350
350
  );
351
- } else if (isListed && shownShift === model.cursorShift) {
351
+ } else if (isTheList && shownShift === model.cursorShift) {
352
352
  return Option.some(maybeShownCursor);
353
353
  } else {
354
354
  return Option.none();
@@ -583,9 +583,6 @@ const pageOf = (commands: ReadonlyArray<Command>, state: OpenPage): Option.Optio
583
583
  Option.map((page) => ({ commandId: state.commandId, page, state })),
584
584
  );
585
585
 
586
- const isListed = (rows: ReadonlyArray<Choice | Command>, rowId: string): boolean =>
587
- Array.some(rows, (row) => row.id === rowId);
588
-
589
586
  const firstListed = (
590
587
  rows: ReadonlyArray<Choice>,
591
588
  candidates: ReadonlyArray<Option.Option<string>>,
@@ -633,13 +630,11 @@ const unshiftedOf = (model: Model, commands: ReadonlyArray<Command>): Shown => {
633
630
  };
634
631
  },
635
632
  onSome: ({ page, state }) => {
636
- const { rows, found } = pageChoices(page, state.query);
637
- const idsOf = (choices: ReadonlyArray<Choice>) =>
638
- HashSet.fromIterable(Array.map(choices, ({ id }) => id));
633
+ const { rows, found, contextIds } = pageChoices(page, state.query);
639
634
  return {
640
635
  maybePage,
641
636
  rows,
642
- contextIds: HashSet.difference(idsOf(rows), idsOf(found)),
637
+ contextIds,
643
638
  maybeCursorId: pipe(
644
639
  Option.filter(state.maybeCursor, (rowId) => isListed(rows, rowId)),
645
640
  Option.orElse(() =>
@@ -657,9 +652,10 @@ const unshiftedOf = (model: Model, commands: ReadonlyArray<Command>): Shown => {
657
652
 
658
653
  /** The row the cursor is on as the view shows it with `commands`: the row the reader moved it
659
654
  * to while that row is listed, else the page's `start` or `current` while the query finds it,
660
- * else the first row the query finds; none when nothing is listed. Compare it before and after the caller's commands change to know
661
- * when the cursor fell to another row (its row left) and should be scrolled into view with
662
- * `ScrollToCursor`; a cursor whose row is still listed keeps it, and the list its scroll. */
655
+ * else the first row the query finds; none when nothing is listed. Compare it before and after
656
+ * the caller's commands change to know when the cursor fell to another row (its row left) and
657
+ * should be scrolled into view with `ScrollToCursor`; a cursor whose row is still listed keeps
658
+ * it, and the list its scroll. */
663
659
  export const maybeCursorId = (
664
660
  model: Model,
665
661
  commands: ReadonlyArray<Command>,
@@ -78,9 +78,11 @@ export const findCommands = (
78
78
  return Array.sort(ordered, Order.mapInput(Order.Number, groupIndex));
79
79
  };
80
80
 
81
+ const isBlank = (query: string): boolean => String.isEmpty(String.trim(query));
82
+
81
83
  /** The choices a page's query finds. */
82
84
  export const findChoices = (page: Page, query: string): ReadonlyArray<Choice> =>
83
- String.isEmpty(String.trim(query))
85
+ isBlank(query)
84
86
  ? page.choices
85
87
  : Array.filter(page.choices, (choice) => matches(query, choice.label));
86
88
 
@@ -88,14 +90,22 @@ type Walk = Readonly<{ chain: ReadonlyArray<Choice>; keptIds: HashSet.HashSet<st
88
90
 
89
91
  const WALK_START: Walk = { chain: [], keptIds: HashSet.empty() };
90
92
 
91
- /** What a page shows for its query: the choices it finds, and the rows, which are those
92
- * choices each with the choices it sits under in the page's tree, in the page's order. */
93
+ /** What a page shows for its query: the choices it finds; the rows, which are those choices
94
+ * each with the choices it sits under in the page's tree, in the page's order; and the rows
95
+ * kept only for the choices under them. */
93
96
  export type PageChoices = Readonly<{
94
97
  rows: ReadonlyArray<Choice>;
95
98
  found: ReadonlyArray<Choice>;
99
+ contextIds: HashSet.HashSet<string>;
96
100
  }>;
97
101
 
98
- export const pageChoices = (page: Page, query: string): PageChoices => {
102
+ const unfiltered = (page: Page): PageChoices => ({
103
+ rows: page.choices,
104
+ found: page.choices,
105
+ contextIds: HashSet.empty(),
106
+ });
107
+
108
+ const treeChoices = (page: Page, query: string): PageChoices => {
99
109
  const found = findChoices(page, query);
100
110
  const foundIds = HashSet.fromIterable(Array.map(found, ({ id }) => id));
101
111
  const { keptIds } = Array.reduce(page.choices, WALK_START, ({ chain, keptIds }, choice): Walk => {
@@ -107,5 +117,12 @@ export const pageChoices = (page: Page, query: string): PageChoices => {
107
117
  : keptIds,
108
118
  };
109
119
  });
110
- return { rows: Array.filter(page.choices, ({ id }) => HashSet.has(keptIds, id)), found };
120
+ return {
121
+ rows: Array.filter(page.choices, ({ id }) => HashSet.has(keptIds, id)),
122
+ found,
123
+ contextIds: HashSet.difference(keptIds, foundIds),
124
+ };
111
125
  };
126
+
127
+ export const pageChoices = (page: Page, query: string): PageChoices =>
128
+ isBlank(query) ? unfiltered(page) : treeChoices(page, query);
@@ -48,6 +48,9 @@ export type ViewConfig<Message> = Readonly<{
48
48
  isBoxed?: boolean;
49
49
  /** Code and identifiers: the system monospace. */
50
50
  isMono?: boolean;
51
+ /** The label is for screen readers only (`.sr-only`): a field whose place says what it is,
52
+ * such as a browser's address bar. It still names the input. */
53
+ isLabelHidden?: boolean;
51
54
  /** The input's type (`"date"` for the browser's date control, always boxed); text when not
52
55
  * given. */
53
56
  type?: string;
@@ -71,6 +74,7 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
71
74
  isRequired = false,
72
75
  isBoxed = false,
73
76
  isMono = false,
77
+ isLabelHidden = false,
74
78
  type,
75
79
  inputAttributes = [],
76
80
  } = config;
@@ -87,7 +91,7 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
87
91
  h.div(
88
92
  [h.Class(`field${isBoxed || type === "date" ? " boxed" : ""}${isMono ? " mono" : ""}`)],
89
93
  [
90
- h.label(groups.label, [label]),
94
+ h.label([...groups.label, ...(isLabelHidden ? [h.Class("sr-only")] : [])], [label]),
91
95
  // NOTE: @foldkit/ui gap: Input has no required option, so the wrapper says it.
92
96
  h.input([
93
97
  ...inputAttributes,
@@ -1,2 +1,4 @@
1
1
  export { patchView, view } from "./lineDiff.ts";
2
2
  export type { PatchViewConfig, ViewConfig } from "./lineDiff.ts";
3
+ export { describePatch } from "./patch.ts";
4
+ export type { PatchDescription } from "./patch.ts";
@@ -1,15 +1,9 @@
1
- import { Array, Option, String } from "effect";
1
+ import { Array, Option } from "effect";
2
2
  import type { Html, HtmlBuilder } from "foldkit/html";
3
3
 
4
4
  import { LOCALE } from "../locale/locale.ts";
5
- import {
6
- DiffRow,
7
- type LineKind,
8
- diffRowsOf,
9
- diffRowsOfPatch,
10
- foldUnchanged,
11
- hasChanges,
12
- } from "./diff.ts";
5
+ import { DiffRow, type LineKind, diffRowsOf, foldUnchanged, hasChanges } from "./diff.ts";
6
+ import { SAME_NOTE, readPatch } from "./patch.ts";
13
7
 
14
8
  // VIEW
15
9
 
@@ -29,7 +23,8 @@ export type ViewConfig = Readonly<{
29
23
 
30
24
  /** Configuration for rendering a unified diff with `patchView`. */
31
25
  export type PatchViewConfig = Readonly<{
32
- /** One file's unified diff, as `diff -u` or `git diff -- <path>` writes it. */
26
+ /** One file's unified diff, as `diff -u` or `git diff -- <path>` writes it, its git header
27
+ * included. */
33
28
  patch: string;
34
29
  /** Names the two sides for screen readers: `"Version 3 against version 4"`. */
35
30
  label: string;
@@ -86,8 +81,6 @@ const rowView = <Message>(row: DiffRow, h: HtmlBuilder<Message>): Html =>
86
81
  const noteView = <Message>(note: string, h: HtmlBuilder<Message>): Html =>
87
82
  h.p([h.Class("note-line")], [note]);
88
83
 
89
- const SAME_NOTE = "No difference: both sides hold the same text.";
90
-
91
84
  const tableView = <Message>(
92
85
  rows: ReadonlyArray<DiffRow>,
93
86
  label: string,
@@ -123,18 +116,30 @@ export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html
123
116
  /** A unified diff the caller already has (from a server, or `git diff -- <path>`) as `view`'s
124
117
  * table: its hunks' lines, numbered from their headers, and the unchanged lines between hunks
125
118
  * folded into a counted row. Lines past the last hunk are not in the patch, so nothing counts
126
- * them. An empty patch says there is no difference, as `view` does; anything but one file
127
- * with a hunk (text that is not a diff, a binary change, several files) says it cannot be
128
- * shown as lines. */
119
+ * them. What git's header says beyond the lines prints as a note line above the table, or in
120
+ * its place: a rename, a mode change, an empty or binary file (`describePatch` has the
121
+ * sentences). A file whose type changed (a file that became a symlink) is two sections, each
122
+ * its own table under one note, named for its side (`"<label>, as it was"`). An empty patch
123
+ * says there is no difference, as `view` does; text that is not a diff, or holds several
124
+ * files, says it cannot be shown as lines. */
129
125
  export const patchView = <Message>(config: PatchViewConfig, h: HtmlBuilder<Message>): Html => {
130
126
  const { patch, label } = config;
131
-
132
- if (String.isEmpty(String.trim(patch))) {
133
- return noteView(SAME_NOTE, h);
134
- } else {
135
- return Option.match(diffRowsOfPatch(patch), {
136
- onNone: () => noteView("This diff cannot be shown as lines.", h),
137
- onSome: (rows) => tableView(rows, label, h),
138
- });
139
- }
127
+ const { notes, tables } = readPatch(patch);
128
+ const parts = [
129
+ ...(Array.isReadonlyArrayNonEmpty(notes) ? [noteView(Array.join(notes, " "), h)] : []),
130
+ ...Array.map(tables, ({ rows, maybeSide }) =>
131
+ tableView(
132
+ rows,
133
+ Option.match(maybeSide, { onNone: () => label, onSome: (side) => `${label}, ${side}` }),
134
+ h,
135
+ ),
136
+ ),
137
+ ];
138
+
139
+ // a lone table or note is drawn bare, so a flush window's body still meets its table
140
+ return Array.match(parts, {
141
+ onEmpty: () => noteView(SAME_NOTE, h),
142
+ onNonEmpty: ([part, ...others]) =>
143
+ Array.isArrayEmpty(others) ? part : h.div([h.Class("stack gap-s")], parts),
144
+ });
140
145
  };
@@ -0,0 +1,218 @@
1
+ import { Array, Option, String, pipe } from "effect";
2
+
3
+ import { type DiffRow, diffRowsOfPatch } from "./diff.ts";
4
+
5
+ // QUOTED PATHS
6
+
7
+ const ESCAPED_BYTES: Readonly<Record<string, number>> = {
8
+ a: 7,
9
+ b: 8,
10
+ t: 9,
11
+ n: 10,
12
+ v: 11,
13
+ f: 12,
14
+ r: 13,
15
+ };
16
+
17
+ // an octal escape is one byte of the name's UTF-8, so the bytes are gathered before decoding
18
+ const QUOTED_PART = /\\([0-7]{3})|\\(.)|[^\\]+/gu;
19
+
20
+ const bytesOf = (text: string): ReadonlyArray<number> => [...new TextEncoder().encode(text)];
21
+
22
+ const bytesOfPart = ([part, octal, escaped]: RegExpExecArray): ReadonlyArray<number> => {
23
+ if (octal !== undefined) {
24
+ return [Number.parseInt(octal, 8)];
25
+ } else if (escaped !== undefined) {
26
+ return Option.match(Option.fromUndefinedOr(ESCAPED_BYTES[escaped]), {
27
+ onNone: () => bytesOf(escaped),
28
+ onSome: (byte) => [byte],
29
+ });
30
+ } else {
31
+ return bytesOf(part);
32
+ }
33
+ };
34
+
35
+ /** The name a path in git's header stands for: git C-quotes a name with unusual bytes
36
+ * (`"caf\303\251.txt"`), and leaves any other as it is. */
37
+ export const unquoted = (path: string): string =>
38
+ path.length >= 2 && path.startsWith('"') && path.endsWith('"')
39
+ ? new TextDecoder().decode(
40
+ Uint8Array.from(
41
+ Array.flatMap(Array.fromIterable(path.slice(1, -1).matchAll(QUOTED_PART)), bytesOfPart),
42
+ ),
43
+ )
44
+ : path;
45
+
46
+ // HEADER
47
+
48
+ const GIT_SECTION = "diff --git ";
49
+
50
+ // the header is what comes before the first hunk, so a changed line that reads like a header
51
+ // line ("+rename from x") is never taken for one
52
+ const headOf = (section: string): ReadonlyArray<string> =>
53
+ Array.takeWhile(
54
+ section.split("\n"),
55
+ (line) => !line.startsWith("@@") && !line.startsWith("--- "),
56
+ );
57
+
58
+ const valueAfter = (head: ReadonlyArray<string>, prefix: string): Option.Option<string> =>
59
+ pipe(
60
+ Array.findFirst(head, String.startsWith(prefix)),
61
+ Option.map((line) => line.slice(prefix.length)),
62
+ );
63
+
64
+ const hasLine = (head: ReadonlyArray<string>, prefix: string): boolean =>
65
+ Option.isSome(valueAfter(head, prefix));
66
+
67
+ const hasHunk = (section: string): boolean =>
68
+ Array.some(section.split("\n"), String.startsWith("@@ "));
69
+
70
+ // git writes one section per file, after whatever precedes the first (a commit's message, a
71
+ // mail's head); a patch without a git header is one section
72
+ const sectionsOf = (patch: string): ReadonlyArray<string> => {
73
+ const [first = "", ...others] = patch.split(/(?=^diff --git )/mu);
74
+
75
+ return Array.isArrayNonEmpty(others) && !first.startsWith(GIT_SECTION)
76
+ ? others
77
+ : [first, ...others];
78
+ };
79
+
80
+ // READING
81
+
82
+ /** What a diff of two same texts says. */
83
+ export const SAME_NOTE = "No difference: both sides hold the same text.";
84
+ const UNREADABLE_NOTE = "This diff cannot be shown as lines.";
85
+ const BINARY_NOTE = "A binary file: its change has no lines.";
86
+
87
+ /** A table of the patch's lines; `maybeSide` says which side of a type change it is. */
88
+ type Table = Readonly<{ rows: ReadonlyArray<DiffRow>; maybeSide: Option.Option<string> }>;
89
+
90
+ /** What `patchView` draws: its notes as a line, then its tables. */
91
+ export type PatchReading = Readonly<{
92
+ maybeRenamedFrom: Option.Option<string>;
93
+ notes: ReadonlyArray<string>;
94
+ tables: ReadonlyArray<Table>;
95
+ }>;
96
+
97
+ const unreadable: PatchReading = {
98
+ maybeRenamedFrom: Option.none(),
99
+ notes: [UNREADABLE_NOTE],
100
+ tables: [],
101
+ };
102
+
103
+ const lineNote = (head: ReadonlyArray<string>, hasRows: boolean): Option.Option<string> => {
104
+ if (hasLine(head, "Binary files ") || hasLine(head, "GIT binary patch")) {
105
+ return Option.some(BINARY_NOTE);
106
+ } else if (hasRows) {
107
+ return Option.none();
108
+ } else if (hasLine(head, "new file mode ")) {
109
+ return Option.some("New empty file.");
110
+ } else if (hasLine(head, "deleted file mode ")) {
111
+ return Option.some("Deleted empty file.");
112
+ } else {
113
+ return Option.none();
114
+ }
115
+ };
116
+
117
+ const readSection = (section: string): PatchReading => {
118
+ const head = headOf(section);
119
+ const maybeRows = diffRowsOfPatch(section);
120
+ const maybeRenamedFrom = Option.map(valueAfter(head, "rename from "), unquoted);
121
+ const notes = [
122
+ ...Option.toArray(Option.map(maybeRenamedFrom, (from) => `Renamed from ${from}.`)),
123
+ ...Option.toArray(
124
+ Option.map(
125
+ Option.all({ before: valueAfter(head, "old mode "), after: valueAfter(head, "new mode ") }),
126
+ ({ before, after }) => `Mode ${before} → ${after}.`,
127
+ ),
128
+ ),
129
+ ...Option.toArray(lineNote(head, Option.isSome(maybeRows))),
130
+ ];
131
+ const isUnread = Option.isNone(maybeRows) && (hasHunk(section) || Array.isArrayEmpty(notes));
132
+
133
+ return isUnread
134
+ ? unreadable
135
+ : {
136
+ maybeRenamedFrom,
137
+ notes,
138
+ tables: Option.toArray(
139
+ Option.map(maybeRows, (rows) => ({ rows, maybeSide: Option.none() })),
140
+ ),
141
+ };
142
+ };
143
+
144
+ const KINDS: Readonly<Record<string, string>> = {
145
+ "100644": "a file",
146
+ "100755": "an executable file",
147
+ "120000": "a symlink",
148
+ "160000": "a submodule",
149
+ };
150
+
151
+ const kindOf = (mode: string): string => KINDS[mode] ?? `mode ${mode}`;
152
+
153
+ const sideTable = (section: string, side: string): ReadonlyArray<Table> =>
154
+ Option.toArray(
155
+ Option.map(diffRowsOfPatch(section), (rows) => ({ rows, maybeSide: Option.some(side) })),
156
+ );
157
+
158
+ // a file that became a symlink (or any other kind) is two sections for one path: the old
159
+ // kind deleted, then the new one added
160
+ const readTypeChange = (gone: string, added: string): Option.Option<PatchReading> => {
161
+ const [goneHead, addedHead] = [headOf(gone), headOf(added)];
162
+
163
+ return Option.flatMap(
164
+ Option.all({
165
+ before: valueAfter(goneHead, "deleted file mode "),
166
+ after: valueAfter(addedHead, "new file mode "),
167
+ }),
168
+ ({ before, after }) =>
169
+ goneHead[0] === addedHead[0]
170
+ ? Option.some({
171
+ maybeRenamedFrom: Option.none(),
172
+ notes: [`It was ${kindOf(before)} and is now ${kindOf(after)}.`],
173
+ tables: [...sideTable(gone, "as it was"), ...sideTable(added, "as it is")],
174
+ })
175
+ : Option.none(),
176
+ );
177
+ };
178
+
179
+ /** What `patchView` makes of a patch: the notes it prints and the tables it draws. */
180
+ export const readPatch = (patch: string): PatchReading => {
181
+ const text = patch.replaceAll("\r\n", "\n");
182
+
183
+ if (String.isEmpty(String.trim(text))) {
184
+ return { maybeRenamedFrom: Option.none(), notes: [SAME_NOTE], tables: [] };
185
+ } else {
186
+ const sections = sectionsOf(text);
187
+ const [first, second, ...rest] = sections;
188
+
189
+ if (first !== undefined && second === undefined) {
190
+ return readSection(first);
191
+ } else if (first !== undefined && second !== undefined && Array.isArrayEmpty(rest)) {
192
+ return Option.getOrElse(readTypeChange(first, second), () => unreadable);
193
+ } else {
194
+ return unreadable;
195
+ }
196
+ }
197
+ };
198
+
199
+ // DESCRIPTION
200
+
201
+ /** What a patch says beyond its lines, as `patchView` prints it. */
202
+ export type PatchDescription = Readonly<{
203
+ /** The path a rename left, unquoted: for a heading such as `old.txt → new.txt`. */
204
+ maybeRenamedFrom: Option.Option<string>;
205
+ /** The sentences `patchView` prints above the patch's lines, or in their place: a rename,
206
+ * a mode change, a type change, an empty or binary file, no difference, or a patch it
207
+ * cannot show as lines. */
208
+ notes: ReadonlyArray<string>;
209
+ }>;
210
+
211
+ /** What `patchView` says about a patch from `git diff`, read from its header: the notes it
212
+ * prints (`["Renamed from old.txt."]`), for a caller that prints them itself, and the path a
213
+ * rename left. */
214
+ export const describePatch = (patch: string): PatchDescription => {
215
+ const { maybeRenamedFrom, notes } = readPatch(patch);
216
+
217
+ return { maybeRenamedFrom, notes };
218
+ };
@@ -5151,6 +5151,12 @@ dialog.veil:focus {
5151
5151
  color: inherit;
5152
5152
  }
5153
5153
 
5154
+ /* a child Submodel's tools come back as one element; the group is no box of its own, so its
5155
+ buttons take the bar's gap as the others do */
5156
+ .window-tools {
5157
+ display: contents;
5158
+ }
5159
+
5154
5160
  .window-body {
5155
5161
  flex: 1;
5156
5162
  min-height: 0;
@@ -111,10 +111,12 @@ export const columnOf = (layout: Layout, columnId: string): Option.Option<Column
111
111
  const nextColumnId = (layout: Layout, columnIndex: number): Option.Option<string> =>
112
112
  Option.map(Array.get(layout, columnIndex + 1), (column) => column.id);
113
113
 
114
+ // a running mean, which never passes the largest weight, where the weights' sum may overflow
114
115
  const meanWeight = (panes: ReadonlyArray<Pane>): number =>
115
116
  Array.match(panes, {
116
117
  onEmpty: () => 1,
117
- onNonEmpty: (some) => Number.sumAll(Array.map(some, (pane) => pane.weight)) / some.length,
118
+ onNonEmpty: (some) =>
119
+ Array.reduce(some, 0, (mean, pane, index) => mean + (pane.weight - mean) / (index + 1)),
118
120
  });
119
121
 
120
122
  const othersIn = (column: Column, paneId: string): ReadonlyArray<Pane> =>
@@ -562,10 +564,21 @@ const PRECISION = 10_000;
562
564
 
563
565
  const rounded = (value: number): number => Math.round(value * PRECISION) / PRECISION;
564
566
 
567
+ /** `weight`, at least `floor`, to four decimals, or, where a step of four decimals is more than
568
+ * `floor` (a column whose weights total under a five-hundredth, or two panes that small), to
569
+ * the first power of ten no more than it, so that it never rounds to nothing. A weight too
570
+ * large or a floor too small to scale by that power is left as it is. */
571
+ const roundedOver = (weight: number, floor: number): number => {
572
+ const precision = Math.max(PRECISION, 10 ** Math.ceil(-Math.log10(floor)));
573
+ const scaled = weight * precision;
574
+ return globalThis.Number.isFinite(scaled) ? Math.round(scaled) / precision : weight;
575
+ };
576
+
565
577
  /** What moving `splitter` by `share` asks of the layout, from the sizes the layout holds (not
566
578
  * the sizes printed, which the stylesheet may hold at a minimum): a width splitter adds
567
579
  * `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. */
580
+ * the pane below it to the pane above, leaving each a weight above nothing however small the
581
+ * column. None when what it sizes is gone. */
569
582
  export const resizeOf = (
570
583
  layout: Layout,
571
584
  splitter: Splitter,
@@ -584,14 +597,24 @@ export const resizeOf = (
584
597
  Option.map(Array.get(column.panes, row + 1), (below) => {
585
598
  const total = Number.sumAll(Array.map(column.panes, ({ weight }) => weight));
586
599
  const pair = above.weight + below.weight;
587
- const floor = Math.min(total * SHARE_FLOOR, pair / 2);
600
+ // a column so small that a twentieth of it is no number keeps the least there is
601
+ const floor = Math.max(
602
+ Math.min(total * SHARE_FLOOR, pair / 2),
603
+ globalThis.Number.MIN_VALUE,
604
+ );
588
605
  const nextAbove = Number.clamp(above.weight + share * total, {
589
606
  minimum: floor,
590
607
  maximum: pair - floor,
591
608
  });
592
609
  return OutMessage.ResizedPanes({
593
610
  columnId: column.id,
594
- weights: { [above.id]: rounded(nextAbove), [below.id]: rounded(pair - nextAbove) },
611
+ weights: globalThis.Number.isFinite(total)
612
+ ? {
613
+ [above.id]: roundedOver(nextAbove, floor),
614
+ [below.id]: roundedOver(pair - nextAbove, floor),
615
+ }
616
+ : // weights that total past the largest number have no share to move: they stay
617
+ { [above.id]: above.weight, [below.id]: below.weight },
595
618
  });
596
619
  }),
597
620
  ),
@@ -20,7 +20,9 @@ export type ViewConfig<Message> = Readonly<{
20
20
  glyph?: GlyphName;
21
21
  /** A chip in the title bar: its state. */
22
22
  status?: ReadonlyArray<Html | string>;
23
- /** IconButtons in the title bar, before the close. */
23
+ /** IconButtons in the title bar, before the close. A child Submodel, whose view is one
24
+ * `Html`, hands back several in a `span.window-tools`: it is no box of its own
25
+ * (`display: contents`), so its buttons sit in the bar as the others do. */
24
26
  tools?: ReadonlyArray<Html>;
25
27
  /** The close glyph's Message; the glyph prints when it is given. */
26
28
  onClose?: Message;