@gjermundgaraba-designs/pixel-paper 0.22.2 → 0.23.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 (160) hide show
  1. package/DESIGN.md +13 -13
  2. package/dist/src/adapters/mermaid.d.ts.map +1 -1
  3. package/dist/src/adapters/mermaid.js +0 -24
  4. package/dist/src/announcer/announcer.d.ts.map +1 -1
  5. package/dist/src/announcer/announcer.js +12 -12
  6. package/dist/src/board/board.d.ts +0 -10
  7. package/dist/src/board/board.d.ts.map +1 -1
  8. package/dist/src/board/board.js +36 -64
  9. package/dist/src/board/index.d.ts +2 -1
  10. package/dist/src/board/index.d.ts.map +1 -1
  11. package/dist/src/board/index.js +2 -1
  12. package/dist/src/button/button.d.ts +1 -1
  13. package/dist/src/button/button.d.ts.map +1 -1
  14. package/dist/src/commandPalette/commandPalette.d.ts.map +1 -1
  15. package/dist/src/commandPalette/commandPalette.js +78 -83
  16. package/dist/src/commandPalette/keys.d.ts +2 -2
  17. package/dist/src/commandPalette/keys.d.ts.map +1 -1
  18. package/dist/src/commandPalette/keys.js +2 -4
  19. package/dist/src/commandPalette/match.d.ts.map +1 -1
  20. package/dist/src/commandPalette/match.js +2 -2
  21. package/dist/src/confirmDialog/confirmDialog.d.ts.map +1 -1
  22. package/dist/src/confirmDialog/confirmDialog.js +1 -2
  23. package/dist/src/copyButton/copyButton.d.ts +0 -5
  24. package/dist/src/copyButton/copyButton.d.ts.map +1 -1
  25. package/dist/src/copyButton/copyButton.js +2 -2
  26. package/dist/src/crumbs/crumbs.d.ts +0 -2
  27. package/dist/src/crumbs/crumbs.d.ts.map +1 -1
  28. package/dist/src/crumbs/crumbs.js +2 -2
  29. package/dist/src/dialog/dialog.d.ts +2 -0
  30. package/dist/src/dialog/dialog.d.ts.map +1 -1
  31. package/dist/src/dialog/dialog.js +3 -2
  32. package/dist/src/drag/drag.d.ts +28 -0
  33. package/dist/src/drag/drag.d.ts.map +1 -1
  34. package/dist/src/drag/drag.js +39 -17
  35. package/dist/src/editableTitle/editableTitle.d.ts +4 -4
  36. package/dist/src/field/field.d.ts +7 -1
  37. package/dist/src/field/field.d.ts.map +1 -1
  38. package/dist/src/field/field.js +14 -6
  39. package/dist/src/fieldset/fieldset.d.ts.map +1 -1
  40. package/dist/src/fieldset/fieldset.js +2 -4
  41. package/dist/src/fileTree/fileTree.d.ts.map +1 -1
  42. package/dist/src/fileTree/fileTree.js +16 -16
  43. package/dist/src/inlineInput/inlineInput.d.ts +4 -0
  44. package/dist/src/inlineInput/inlineInput.d.ts.map +1 -1
  45. package/dist/src/inlineInput/inlineInput.js +9 -6
  46. package/dist/src/keyHelp/index.d.ts +1 -1
  47. package/dist/src/keyHelp/index.d.ts.map +1 -1
  48. package/dist/src/keyHelp/index.js +1 -1
  49. package/dist/src/keyHelp/keyHelp.d.ts +1 -3
  50. package/dist/src/keyHelp/keyHelp.d.ts.map +1 -1
  51. package/dist/src/keyHelp/keyHelp.js +3 -6
  52. package/dist/src/keyHint/keyLabel.d.ts +2 -0
  53. package/dist/src/keyHint/keyLabel.d.ts.map +1 -1
  54. package/dist/src/keyHint/keyLabel.js +3 -2
  55. package/dist/src/lineDiff/diff.d.ts +6 -3
  56. package/dist/src/lineDiff/diff.d.ts.map +1 -1
  57. package/dist/src/lineDiff/diff.js +40 -28
  58. package/dist/src/lineDiff/patch.d.ts.map +1 -1
  59. package/dist/src/lineDiff/patch.js +18 -13
  60. package/dist/src/linkState/linkState.d.ts.map +1 -1
  61. package/dist/src/linkState/linkState.js +1 -7
  62. package/dist/src/menu/slip.js +2 -2
  63. package/dist/src/meter/meter.d.ts.map +1 -1
  64. package/dist/src/meter/meter.js +22 -17
  65. package/dist/src/notice/notice.d.ts +2 -0
  66. package/dist/src/notice/notice.d.ts.map +1 -1
  67. package/dist/src/notice/notice.js +2 -1
  68. package/dist/src/passwordField/passwordField.d.ts.map +1 -1
  69. package/dist/src/passwordField/passwordField.js +3 -7
  70. package/dist/src/rail/rail.d.ts +17 -3
  71. package/dist/src/rail/rail.d.ts.map +1 -1
  72. package/dist/src/rail/rail.js +6 -6
  73. package/dist/src/reel/layout.d.ts +9 -5
  74. package/dist/src/reel/layout.d.ts.map +1 -1
  75. package/dist/src/reel/layout.js +11 -5
  76. package/dist/src/reel/reel.d.ts.map +1 -1
  77. package/dist/src/reel/reel.js +122 -68
  78. package/dist/src/scheme/scheme.d.ts +1 -1
  79. package/dist/src/scheme/scheme.d.ts.map +1 -1
  80. package/dist/src/segmented/segmented.d.ts +0 -3
  81. package/dist/src/segmented/segmented.d.ts.map +1 -1
  82. package/dist/src/segmented/segmented.js +1 -6
  83. package/dist/src/selectField/selectField.d.ts.map +1 -1
  84. package/dist/src/selectField/selectField.js +3 -10
  85. package/dist/src/sortable/index.d.ts +1 -1
  86. package/dist/src/sortable/index.d.ts.map +1 -1
  87. package/dist/src/sortable/index.js +1 -1
  88. package/dist/src/sortable/sortable.d.ts +39 -40
  89. package/dist/src/sortable/sortable.d.ts.map +1 -1
  90. package/dist/src/sortable/sortable.js +14 -31
  91. package/dist/src/stack/stack.d.ts +1 -1
  92. package/dist/src/stack/stack.d.ts.map +1 -1
  93. package/dist/src/stack/stack.js +1 -1
  94. package/dist/src/tabs/tabs.d.ts +1 -1
  95. package/dist/src/tabs/tabs.d.ts.map +1 -1
  96. package/dist/src/tabs/tabs.js +1 -1
  97. package/dist/src/textareaField/textareaField.d.ts.map +1 -1
  98. package/dist/src/textareaField/textareaField.js +3 -7
  99. package/dist/src/time/time.d.ts.map +1 -1
  100. package/dist/src/time/time.js +4 -2
  101. package/dist/src/tips/index.d.ts +1 -1
  102. package/dist/src/tips/index.d.ts.map +1 -1
  103. package/dist/src/toast/index.d.ts +1 -1
  104. package/dist/src/toast/index.d.ts.map +1 -1
  105. package/dist/src/toast/toast.d.ts +5 -5
  106. package/dist/src/toast/toast.d.ts.map +1 -1
  107. package/dist/src/toast/toast.js +1 -6
  108. package/dist/src/toolbar/toolbar.d.ts +0 -3
  109. package/dist/src/toolbar/toolbar.d.ts.map +1 -1
  110. package/dist/src/toolbar/toolbar.js +1 -2
  111. package/package.json +1 -1
  112. package/src/adapters/mermaid.ts +0 -24
  113. package/src/announcer/announcer.ts +13 -17
  114. package/src/board/board.ts +32 -80
  115. package/src/board/index.ts +1 -1
  116. package/src/button/button.ts +1 -1
  117. package/src/commandPalette/commandPalette.ts +21 -38
  118. package/src/commandPalette/keys.ts +2 -4
  119. package/src/commandPalette/match.ts +3 -3
  120. package/src/confirmDialog/confirmDialog.ts +5 -3
  121. package/src/copyButton/copyButton.ts +2 -5
  122. package/src/crumbs/crumbs.ts +2 -4
  123. package/src/dialog/dialog.ts +3 -2
  124. package/src/drag/drag.ts +54 -5
  125. package/src/field/field.ts +29 -7
  126. package/src/fieldset/fieldset.ts +2 -4
  127. package/src/fileTree/fileTree.ts +10 -12
  128. package/src/inlineInput/inlineInput.ts +11 -2
  129. package/src/keyHelp/index.ts +0 -1
  130. package/src/keyHelp/keyHelp.ts +3 -9
  131. package/src/keyHint/keyLabel.ts +5 -2
  132. package/src/lineDiff/diff.ts +70 -54
  133. package/src/lineDiff/patch.ts +27 -14
  134. package/src/linkState/linkState.ts +1 -8
  135. package/src/menu/slip.ts +2 -2
  136. package/src/meter/meter.ts +25 -15
  137. package/src/notice/notice.ts +2 -1
  138. package/src/passwordField/passwordField.ts +3 -7
  139. package/src/pixel-paper.css +82 -131
  140. package/src/rail/rail.ts +8 -6
  141. package/src/reel/layout.ts +14 -7
  142. package/src/reel/reel.ts +132 -63
  143. package/src/scheme/scheme.ts +1 -1
  144. package/src/segmented/segmented.ts +1 -9
  145. package/src/selectField/selectField.ts +3 -10
  146. package/src/sortable/index.ts +0 -1
  147. package/src/sortable/sortable.ts +35 -54
  148. package/src/stack/stack.ts +1 -1
  149. package/src/tabs/tabs.ts +1 -1
  150. package/src/textareaField/textareaField.ts +3 -7
  151. package/src/time/time.ts +6 -7
  152. package/src/tips/index.ts +1 -1
  153. package/src/toast/index.ts +1 -1
  154. package/src/toast/toast.ts +2 -8
  155. package/src/tokens.css +6 -0
  156. package/src/toolbar/toolbar.ts +0 -5
  157. package/dist/src/inlineInput/inline.d.ts +0 -6
  158. package/dist/src/inlineInput/inline.d.ts.map +0 -1
  159. package/dist/src/inlineInput/inline.js +0 -7
  160. package/src/inlineInput/inline.ts +0 -13
@@ -89,7 +89,8 @@ export type UpdateContext = Readonly<{
89
89
  isCloseHeld: boolean;
90
90
  }>;
91
91
 
92
- const FREE: UpdateContext = { isCloseHeld: false };
92
+ /** The context of a dialog whose close is not held, the default. */
93
+ export const FREE: UpdateContext = { isCloseHeld: false };
93
94
 
94
95
  // NOTE: @foldkit/ui gap: Dialog has no way to hold a close. Escape, a press on the veil and a
95
96
  // control given the `closeButton` group all send `RequestedClose`, so a held dialog reads it
@@ -248,7 +249,7 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
248
249
  } = viewInputs;
249
250
 
250
251
  const actionBar = childAttributes([
251
- h.DataAttribute("pixel-paper-action-bar", ""),
252
+ h.Attribute(ACTION_BAR_ATTRIBUTE, ""),
252
253
  h.OnMount(BindActionKeys()),
253
254
  ]);
254
255
 
package/src/drag/drag.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  // Shared by Board and Sortable, the two users of @foldkit/ui's DragAndDrop, and Reel, which
2
- // holds its own: the phase a drag is in, the DragAndDrop Messages both build, what a press
3
- // lifts, Escape taken before anything else, the pressing pointer's events, the lifted copy's
4
- // measure, and the closest-centre drop target a sortable resolves for a pointer. A splitter's
5
- // handlers are the Reel's and the Rail's.
2
+ // holds its own: the phase a drag is in, what a hold keeps and how a step settles it, the
3
+ // DragAndDrop Messages both build, what a press lifts, Escape taken before anything else, the
4
+ // pressing pointer's events, the lifted copy's measure, and the closest-centre drop target a
5
+ // sortable resolves for a pointer. A splitter's handlers are the Reel's and the Rail's.
6
6
 
7
7
  import { DragAndDrop as UiDragAndDrop } from "@foldkit/ui";
8
- import { Array, Effect, Option, Order, Stream, pipe } from "effect";
8
+ import { Array, Effect, Option, Order, Schema, Stream, pipe } from "effect";
9
+ import type * as Command from "foldkit/command";
9
10
  import * as Dom from "foldkit/dom";
10
11
  import type { Attribute, HtmlBuilder } from "foldkit/html";
12
+ import * as Subscription from "foldkit/subscription";
13
+ import * as Update from "foldkit/update";
11
14
 
12
15
  import { isPlain } from "../keys/keys.ts";
13
16
 
@@ -36,6 +39,38 @@ export const phaseOf = (dragAndDrop: UiDragAndDrop.Model): Phase => {
36
39
  export const isCarrying = (phase: Phase): boolean =>
37
40
  phase === "PointerDragging" || phase === "KeyboardDragging";
38
41
 
42
+ // HOLD
43
+
44
+ /** What a Board's and a Sortable's Model share: the DragAndDrop, whether items may move, and
45
+ * what the list keeps while an item is pressed or held. */
46
+ type Holder = Readonly<{
47
+ dragAndDrop: UiDragAndDrop.Model;
48
+ isMovable: boolean;
49
+ maybeHeld: Option.Option<unknown>;
50
+ }>;
51
+
52
+ /** Whether an item is pressed or held. While it is, the arrow keys, Space and Escape belong to
53
+ * the list: pause the page's own bindings for them. The list takes Escape before anything
54
+ * else sees it. */
55
+ export const isHolding = (model: Holder): boolean => phaseOf(model.dragAndDrop) !== "Idle";
56
+
57
+ /** Whether a press may lift an item now: items may move and none is pressed or held. */
58
+ export const canLift = (model: Holder): boolean => model.isMovable && !isHolding(model);
59
+
60
+ /** Settles a DragAndDrop step: forgets the held item once it is down, and runs what the step
61
+ * says (`maybeAnnouncement`) after the step's own Commands. */
62
+ export const settle = <Model extends Holder, Message, OutMessage>(
63
+ step: Update.ReturnWithOutMessage<Model, Message, OutMessage>,
64
+ maybeAnnouncement: Option.Option<Command.Command<Message>>,
65
+ ): Update.ReturnWithOutMessage<Model, Message, OutMessage> =>
66
+ Update.withOutMessage(
67
+ {
68
+ model: isHolding(step.model) ? step.model : { ...step.model, maybeHeld: Option.none() },
69
+ commands: [...(step.commands ?? []), ...Array.fromOption(maybeAnnouncement)],
70
+ },
71
+ step.outMessage,
72
+ );
73
+
39
74
  // CHILD MESSAGES
40
75
 
41
76
  // NOTE: @foldkit/ui gap: DragAndDrop exposes no update capability for a press, a keyboard
@@ -114,6 +149,20 @@ export const escapeKeys = <Message>(message: Message): Stream.Stream<Message> =>
114
149
  options: { capture: true },
115
150
  });
116
151
 
152
+ /** A list's `escape` Subscription: `escapeKeys` as `message` while an item is pressed or
153
+ * held. */
154
+ export const escapeSubscriptions = <Model extends Holder, Message>(message: Message) =>
155
+ Subscription.make<Model, Message>()((entry) => ({
156
+ escape: entry(
157
+ { isHolding: Schema.Boolean },
158
+ {
159
+ modelToDependencies: (model) => ({ isHolding: isHolding(model) }),
160
+ dependenciesToStream: ({ isHolding: isHoldingNow }) =>
161
+ isHoldingNow ? escapeKeys(message) : Stream.empty,
162
+ },
163
+ ),
164
+ }));
165
+
117
166
  // POINTER
118
167
 
119
168
  /** The moves, release and cancel of the pointer `pointerId`, on the document; another
@@ -1,5 +1,5 @@
1
1
  import { Input as UiInput } from "@foldkit/ui";
2
- import type { Html, HtmlBuilder, TextareaAttribute } from "foldkit/html";
2
+ import type { Attribute, Html, HtmlBuilder, TextareaAttribute } from "foldkit/html";
3
3
 
4
4
  import type { Attributes } from "../attributes/attributes.ts";
5
5
 
@@ -28,6 +28,32 @@ export const describedByError = <Message>(
28
28
  ? []
29
29
  : [h.AriaDescribedBy(hasHelp ? `${errorId(id)} ${UiInput.descriptionId(id)}` : errorId(id))];
30
30
 
31
+ /** A field's control, after the `@foldkit/ui` attributes: described by its error before its
32
+ * help line, and `aria-required` when the form is not sent without it. */
33
+ // NOTE: @foldkit/ui gap: Input, Select and Textarea have no required option, so the wrapper
34
+ // says it.
35
+ export const controlAttributes = <Message>(
36
+ id: string,
37
+ error: string | undefined,
38
+ help: string | undefined,
39
+ isRequired: boolean,
40
+ h: HtmlBuilder<Message>,
41
+ ): ReadonlyArray<TextareaAttribute<Message>> => [
42
+ ...describedByError(id, error, help !== undefined, h),
43
+ ...(isRequired ? [h.AriaRequired(true)] : []),
44
+ ];
45
+
46
+ // HELP
47
+
48
+ /** The sans sentence under a field's controls, given one, with the `@foldkit/ui`
49
+ * `description` group that makes it describe them. */
50
+ export const helpView = <Message>(
51
+ help: string | undefined,
52
+ description: ReadonlyArray<Attribute<Message>>,
53
+ h: HtmlBuilder<Message>,
54
+ ): ReadonlyArray<Html> =>
55
+ help === undefined ? [] : [h.p([...description, h.Class("field-help")], [help])];
56
+
31
57
  // VIEW
32
58
 
33
59
  /** Configuration for rendering a text field with `view`. */
@@ -92,17 +118,13 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
92
118
  [h.Class(`field${isBoxed || type === "date" ? " boxed" : ""}${isMono ? " mono" : ""}`)],
93
119
  [
94
120
  h.label([...groups.label, ...(isLabelHidden ? [h.Class("sr-only")] : [])], [label]),
95
- // NOTE: @foldkit/ui gap: Input has no required option, so the wrapper says it.
96
121
  h.input([
97
122
  ...inputAttributes,
98
123
  ...groups.input,
99
- ...describedByError(id, error, help !== undefined, h),
100
- ...(isRequired ? [h.AriaRequired(true)] : []),
124
+ ...controlAttributes(id, error, help, isRequired, h),
101
125
  ]),
102
126
  ...errorView(id, error, h),
103
- ...(help === undefined
104
- ? []
105
- : [h.p([...groups.description, h.Class("field-help")], [help])]),
127
+ ...helpView(help, groups.description, h),
106
128
  ],
107
129
  ),
108
130
  },
@@ -1,7 +1,7 @@
1
1
  import { Fieldset as UiFieldset } from "@foldkit/ui";
2
2
  import type { Html, HtmlBuilder } from "foldkit/html";
3
3
 
4
- import { describedByError, errorView } from "../field/field.ts";
4
+ import { describedByError, errorView, helpView } from "../field/field.ts";
5
5
 
6
6
  // VIEW
7
7
 
@@ -43,9 +43,7 @@ export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html
43
43
  h.legend([...groups.legend, h.Class("field-label")], [label]),
44
44
  ...body,
45
45
  ...errorView(id, error, h),
46
- ...(help === undefined
47
- ? []
48
- : [h.p([...groups.description, h.Class("field-help")], [help])]),
46
+ ...helpView(help, groups.description, h),
49
47
  ],
50
48
  ),
51
49
  },
@@ -209,18 +209,16 @@ const moveInTree = (tree: Element, event: KeyboardEvent): Option.Option<TreeKeyA
209
209
  const items = ownItems(tree);
210
210
 
211
211
  return pipe(
212
- Array.findFirstIndex(items, (item) => item === event.target),
213
- Option.flatMap((index) =>
214
- Option.flatMap(Array.get(items, index), (item) => {
215
- if (isTypeahead(event)) {
216
- return moveTo(itemStartingWith(items, index, event.key));
217
- } else if (isPlain(event)) {
218
- return actOnKey(items, index, item, event.key);
219
- } else {
220
- return Option.none();
221
- }
222
- }),
223
- ),
212
+ Array.findFirstWithIndex(items, (item) => item === event.target),
213
+ Option.flatMap(([item, index]) => {
214
+ if (isTypeahead(event)) {
215
+ return moveTo(itemStartingWith(items, index, event.key));
216
+ } else if (isPlain(event)) {
217
+ return actOnKey(items, index, item, event.key);
218
+ } else {
219
+ return Option.none();
220
+ }
221
+ }),
224
222
  );
225
223
  };
226
224
 
@@ -1,5 +1,5 @@
1
1
  import { Input as UiInput } from "@foldkit/ui";
2
- import { Effect, Match, Option, Schema } from "effect";
2
+ import { Effect, Match, Option, Schema, String, pipe } from "effect";
3
3
  import * as Command from "foldkit/command";
4
4
  import * as Dom from "foldkit/dom";
5
5
  import { type Html, type HtmlBuilder } from "foldkit/html";
@@ -12,7 +12,6 @@ import * as Update from "foldkit/update";
12
12
 
13
13
  import { isPlain } from "../keys/keys.ts";
14
14
  import * as Notice from "../notice/notice.ts";
15
- import { toWrite } from "./inline.ts";
16
15
 
17
16
  // MODEL
18
17
 
@@ -141,6 +140,16 @@ const end = (model: Model, how: InlineEnd): UpdateReturn => ({
141
140
  outMessage: OutMessage.Ended({ how, maybeLastSavedId: model.maybeLastSavedId }),
142
141
  });
143
142
 
143
+ /** What Enter or leaving an inline field writes: the draft trimmed, or none (an untouched
144
+ * draft, so an edit made elsewhere to `value` is never overwritten; an empty one; or the
145
+ * text it replaces). */
146
+ export const toWrite = (maybeDraft: Option.Option<string>, value: string): Option.Option<string> =>
147
+ pipe(
148
+ maybeDraft,
149
+ Option.map(String.trim),
150
+ Option.filter((text) => String.isNonEmpty(text) && text !== value),
151
+ );
152
+
144
153
  const commit = (model: Model, trigger: SaveTrigger): UpdateReturn =>
145
154
  Option.match(toWrite(model.maybeDraft, model.replaced), {
146
155
  onNone: () => end(model, trigger),
@@ -3,7 +3,6 @@ export {
3
3
  Message,
4
4
  Model,
5
5
  OutMessage,
6
- close,
7
6
  id,
8
7
  helpBinding,
9
8
  init,
@@ -13,7 +13,7 @@ import * as Card from "../card/card.ts";
13
13
  import * as Dialog from "../dialog/dialog.ts";
14
14
  import { foldDialog } from "../dialog/fold.ts";
15
15
  import * as Field from "../field/field.ts";
16
- import { type KeyPlatform, keyLabel } from "../keyHint/keyLabel.ts";
16
+ import { type KeyPlatform, keyLabel, pressesOf } from "../keyHint/keyLabel.ts";
17
17
  import * as Keymap from "../keymap/keymap.ts";
18
18
 
19
19
  // MODEL
@@ -54,7 +54,7 @@ export type Message = typeof Message.Type;
54
54
  // OUT MESSAGE
55
55
 
56
56
  /** Union of OutMessages the key help reports: the reader closed it (Escape, a click on the
57
- * veil, a close button). Opening it, by `?` or `open`, and `close` report nothing. */
57
+ * veil, a close button). Opening it, by `?` or `open`, reports nothing. */
58
58
  export const OutMessage = defineMessageUnion({
59
59
  Closed: {},
60
60
  });
@@ -99,9 +99,6 @@ export const update = (model: Model, message: Message) =>
99
99
  export const open = (model: Model): Update.Return<Model, Message> =>
100
100
  dialogFold.open(clearQuery(model));
101
101
 
102
- /** Closes the key help without reporting `Closed`. */
103
- export const close = (model: Model): Update.Return<Model, Message> => dialogFold.close(model);
104
-
105
102
  // ACCESSORS
106
103
 
107
104
  /** The key help's id, unique in the document. */
@@ -134,12 +131,9 @@ export const subscriptions = Subscription.make<Model, Message>()((entry) => ({
134
131
 
135
132
  // LISTING
136
133
 
137
- const keysOf = (keys: KeySequence): ReadonlyArray<string> =>
138
- Predicate.isString(keys) ? [keys] : keys;
139
-
140
134
  const isFound = (binding: Binding, labels: string, query: string): boolean =>
141
135
  pipe(
142
- [labels, ...Array.flatMap(binding.keys, keysOf), binding.does, binding.group],
136
+ [labels, ...Array.flatMap(binding.keys, pressesOf), binding.does, binding.group],
143
137
  Array.join(" "),
144
138
  String.toLowerCase,
145
139
  String.includes(query),
@@ -110,6 +110,10 @@ const pressLabel = (press: string, platform: KeyPlatform): string => {
110
110
  return `${prefix}${keyName(key, Array.isReadonlyArrayNonEmpty(orderedModifiers))}`;
111
111
  };
112
112
 
113
+ /** The presses a binding's keys name: one, or a sequence. */
114
+ export const pressesOf = (keys: KeySequence): ReadonlyArray<string> =>
115
+ Predicate.isString(keys) ? [keys] : keys;
116
+
113
117
  /**
114
118
  * A key binding as the help and the key hints print it. Takes the same strings
115
119
  * `Dom.streamFromKeyBindings` matches: `"Mod+K"` prints `⌘K` on Apple and `Ctrl+K` elsewhere,
@@ -119,8 +123,7 @@ const pressLabel = (press: string, platform: KeyPlatform): string => {
119
123
  * named key keeps its spaces.
120
124
  */
121
125
  export const keyLabel = (keys: KeySequence, platform: KeyPlatform): string => {
122
- const presses = Predicate.isString(keys) ? [keys] : keys;
123
- const labels = Array.map(presses, (press) => pressLabel(press, platform));
126
+ const labels = Array.map(pressesOf(keys), (press) => pressLabel(press, platform));
124
127
 
125
128
  return Array.every(labels, isCharacter) ? Array.join(labels, "") : Array.join(labels, " ");
126
129
  };
@@ -49,6 +49,14 @@ const numberLine = (numbers: Numbers, kind: LineKind, text: string): [Numbers, D
49
49
  Match.exhaustive,
50
50
  );
51
51
 
52
+ type Line = Readonly<{ kind: LineKind; text: string }>;
53
+
54
+ const numbered = (
55
+ lines: ReadonlyArray<Line>,
56
+ from: Numbers,
57
+ ): readonly [Numbers, ReadonlyArray<DiffRow>] =>
58
+ Array.mapAccum(lines, from, (numbers, { kind, text }) => numberLine(numbers, kind, text));
59
+
52
60
  /** The line diff of two texts (Myers, through jsdiff), each line numbered on its side. A
53
61
  * missing final newline is not a change. */
54
62
  export const diffRowsOf = (before: string, after: string): ReadonlyArray<DiffRow> =>
@@ -60,21 +68,16 @@ export const diffRowsOf = (before: string, after: string): ReadonlyArray<DiffRow
60
68
  text,
61
69
  })),
62
70
  ),
63
- (lines) =>
64
- Array.mapAccum(lines, { old: 1, new: 1 }, (numbers, { kind, text }) =>
65
- numberLine(numbers, kind, text),
66
- ),
67
- ([, rows]) => rows,
71
+ (lines) => numbered(lines, { old: 1, new: 1 })[1],
68
72
  );
69
73
 
74
+ const isChange = (row: DiffRow): boolean => row._tag === "Added" || row._tag === "Removed";
75
+
70
76
  /** Whether a line was added or removed. */
71
- export const hasChanges = (rows: ReadonlyArray<DiffRow>): boolean =>
72
- Array.some(rows, (row) => row._tag === "Added" || row._tag === "Removed");
77
+ export const hasChanges = (rows: ReadonlyArray<DiffRow>): boolean => Array.some(rows, isChange);
73
78
 
74
79
  // PATCH
75
80
 
76
- type PatchState = Readonly<{ rows: ReadonlyArray<DiffRow>; numbers: Numbers }>;
77
-
78
81
  const LINE_KIND_BY_PREFIX: Readonly<Record<string, LineKind>> = {
79
82
  " ": "Same",
80
83
  "+": "Added",
@@ -86,23 +89,27 @@ const LINE_KIND_BY_PREFIX: Readonly<Record<string, LineKind>> = {
86
89
  const kindOf = (line: string): Option.Option<LineKind> =>
87
90
  line === "" ? Option.some("Same") : Option.fromUndefinedOr(LINE_KIND_BY_PREFIX[line.charAt(0)]);
88
91
 
89
- const readHunk = ({ rows, numbers }: PatchState, hunk: StructuredPatchHunk): PatchState => {
90
- const gap = hunk.oldStart - numbers.old;
91
- const gapRows = gap > 0 ? [DiffRow.Fold({ count: gap, firstOldNumber: numbers.old })] : [];
92
-
93
- return Array.reduce(
94
- hunk.lines,
95
- { rows: [...rows, ...gapRows], numbers: { old: hunk.oldStart, new: hunk.newStart } },
96
- (state: PatchState, line): PatchState =>
97
- // "" starts with a "\", a prefix that draws no row
98
- Option.match(kindOf(line), {
99
- onNone: () => state,
100
- onSome: (kind) => {
101
- const [nextNumbers, row] = numberLine(state.numbers, kind, line.slice(1));
102
- return { rows: [...state.rows, row], numbers: nextNumbers };
103
- },
104
- }),
92
+ // "" starts with a "\", a prefix that draws no row
93
+ const linesOf = (hunk: StructuredPatchHunk): ReadonlyArray<Line> =>
94
+ Array.getSomes(
95
+ Array.map(hunk.lines, (line) =>
96
+ Option.map(kindOf(line), (kind) => ({ kind, text: line.slice(1) })),
97
+ ),
105
98
  );
99
+
100
+ // each hunk is numbered from its own header; only the gap before it needs the old line the
101
+ // previous one ended on
102
+ const readHunk = (
103
+ nextOld: number,
104
+ hunk: StructuredPatchHunk,
105
+ ): readonly [number, ReadonlyArray<DiffRow>] => {
106
+ const gap = hunk.oldStart - nextOld;
107
+ const [end, rows] = numbered(linesOf(hunk), { old: hunk.oldStart, new: hunk.newStart });
108
+
109
+ return [
110
+ end.old,
111
+ gap > 0 ? [DiffRow.Fold({ count: gap, firstOldNumber: nextOld }), ...rows] : rows,
112
+ ];
106
113
  };
107
114
 
108
115
  const onlyHunksOf = <File extends Readonly<{ hunks: Array<StructuredPatchHunk> }>>(
@@ -116,35 +123,29 @@ const onlyHunksOf = <File extends Readonly<{ hunks: Array<StructuredPatchHunk> }
116
123
  : Option.none(),
117
124
  });
118
125
 
119
- /** The rows of one file's unified diff, numbered from its hunks' headers, with the unchanged
120
- * lines between hunks folded into a count. `None` unless it is one file with a hunk: text that
126
+ /** The hunks of one file's unified diff. `None` unless it is one file with a hunk: text that
121
127
  * is not a diff, a binary change and several files have no lines to show. */
128
+ export const hunksOfPatch = (
129
+ patch: string,
130
+ ): Option.Option<Array.NonEmptyReadonlyArray<StructuredPatchHunk>> =>
131
+ Option.flatMap(Option.liftThrowable(parsePatch)(patch), onlyHunksOf);
132
+
133
+ /** The rows of one file's unified diff, numbered from its hunks' headers, with the unchanged
134
+ * lines between hunks folded into a count. `None` where `hunksOfPatch` is. */
122
135
  export const diffRowsOfPatch = (patch: string): Option.Option<ReadonlyArray<DiffRow>> =>
123
- pipe(
124
- Option.liftThrowable(parsePatch)(patch.replaceAll("\r\n", "\n")),
125
- Option.flatMap(onlyHunksOf),
126
- Option.map(
127
- (hunks) => Array.reduce(hunks, { rows: [], numbers: { old: 1, new: 1 } }, readHunk).rows,
128
- ),
129
- );
136
+ Option.map(hunksOfPatch(patch), (hunks) => Array.flatten(Array.mapAccum(hunks, 1, readHunk)[1]));
130
137
 
131
138
  // FOLD
132
139
 
133
- type FoldState = Readonly<{
134
- rows: ReadonlyArray<DiffRow>;
135
- folded: ReadonlyArray<SameRow>;
136
- }>;
140
+ /** A run of unchanged lines being folded: its first line and how many there are. */
141
+ type Run = Readonly<{ first: SameRow; count: number }>;
137
142
 
138
143
  // a run of one stays a line: a row that says "1 unchanged line" takes the same room as the line
139
- const foldRow = (run: Array.NonEmptyReadonlyArray<SameRow>): DiffRow =>
140
- run.length === 1
141
- ? Array.headNonEmpty(run)
142
- : DiffRow.Fold({ count: run.length, firstOldNumber: Array.headNonEmpty(run).oldNumber });
143
-
144
- const flushFold = ({ rows, folded }: FoldState): ReadonlyArray<DiffRow> =>
145
- Array.match(folded, {
146
- onEmpty: () => rows,
147
- onNonEmpty: (run) => [...rows, foldRow(run)],
144
+ const runRows = (maybeRun: Option.Option<Run>): ReadonlyArray<DiffRow> =>
145
+ Option.match(maybeRun, {
146
+ onNone: () => [],
147
+ onSome: ({ first, count }) =>
148
+ count === 1 ? [first] : [DiffRow.Fold({ count, firstOldNumber: first.oldNumber })],
148
149
  });
149
150
 
150
151
  /** The diff's rows: every change with `context` unchanged lines around it, and each longer
@@ -153,14 +154,29 @@ export const foldUnchanged = (
153
154
  rows: ReadonlyArray<DiffRow>,
154
155
  context: number,
155
156
  ): ReadonlyArray<DiffRow> => {
157
+ // the changes before each index, so the changes in any window are one subtraction
158
+ const changesBefore = Array.scan(rows, 0, (count, row) => count + (isChange(row) ? 1 : 0));
156
159
  const isKept = (index: number): boolean =>
157
- hasChanges(rows.slice(Math.max(0, index - context), index + context + 1));
158
-
159
- return flushFold(
160
- Array.reduce(rows, { rows: [], folded: [] }, (state: FoldState, row, index): FoldState =>
160
+ Array.getUnsafe(changesBefore, Math.min(rows.length, index + context + 1)) -
161
+ Array.getUnsafe(changesBefore, Math.max(0, index - context)) >
162
+ 0;
163
+
164
+ const [maybeRun, kept] = Array.mapAccum(
165
+ rows,
166
+ Option.none<Run>(),
167
+ (maybeRun, row, index): readonly [Option.Option<Run>, ReadonlyArray<DiffRow>] =>
161
168
  row._tag === "Same" && !isKept(index)
162
- ? { rows: state.rows, folded: [...state.folded, row] }
163
- : { rows: [...flushFold(state), row], folded: [] },
164
- ),
169
+ ? [
170
+ Option.some(
171
+ Option.match(maybeRun, {
172
+ onNone: () => ({ first: row, count: 1 }),
173
+ onSome: (run) => ({ ...run, count: run.count + 1 }),
174
+ }),
175
+ ),
176
+ [],
177
+ ]
178
+ : [Option.none(), [...runRows(maybeRun), row]],
165
179
  );
180
+
181
+ return [...Array.flatten(kept), ...runRows(maybeRun)];
166
182
  };
@@ -1,6 +1,6 @@
1
1
  import { Array, Option, String, pipe } from "effect";
2
2
 
3
- import { type DiffRow, diffRowsOfPatch } from "./diff.ts";
3
+ import { type DiffRow, diffRowsOfPatch, hunksOfPatch } from "./diff.ts";
4
4
 
5
5
  // QUOTED PATHS
6
6
 
@@ -114,9 +114,12 @@ const lineNote = (head: ReadonlyArray<string>, hasRows: boolean): Option.Option<
114
114
  }
115
115
  };
116
116
 
117
- const readSection = (section: string): PatchReading => {
117
+ /** How a section's lines are read: `None` where it has none to show. */
118
+ type RowsOf = (section: string) => Option.Option<ReadonlyArray<DiffRow>>;
119
+
120
+ const readSection = (section: string, rowsOf: RowsOf): PatchReading => {
118
121
  const head = headOf(section);
119
- const maybeRows = diffRowsOfPatch(section);
122
+ const maybeRows = rowsOf(section);
120
123
  const maybeRenamedFrom = Option.map(valueAfter(head, "rename from "), unquoted);
121
124
  const notes = [
122
125
  ...Option.toArray(Option.map(maybeRenamedFrom, (from) => `Renamed from ${from}.`)),
@@ -150,14 +153,16 @@ const KINDS: Readonly<Record<string, string>> = {
150
153
 
151
154
  const kindOf = (mode: string): string => KINDS[mode] ?? `mode ${mode}`;
152
155
 
153
- const sideTable = (section: string, side: string): ReadonlyArray<Table> =>
154
- Option.toArray(
155
- Option.map(diffRowsOfPatch(section), (rows) => ({ rows, maybeSide: Option.some(side) })),
156
- );
156
+ const sideTable = (section: string, side: string, rowsOf: RowsOf): ReadonlyArray<Table> =>
157
+ Option.toArray(Option.map(rowsOf(section), (rows) => ({ rows, maybeSide: Option.some(side) })));
157
158
 
158
159
  // a file that became a symlink (or any other kind) is two sections for one path: the old
159
160
  // kind deleted, then the new one added
160
- const readTypeChange = (gone: string, added: string): Option.Option<PatchReading> => {
161
+ const readTypeChange = (
162
+ gone: string,
163
+ added: string,
164
+ rowsOf: RowsOf,
165
+ ): Option.Option<PatchReading> => {
161
166
  const [goneHead, addedHead] = [headOf(gone), headOf(added)];
162
167
 
163
168
  return Option.flatMap(
@@ -170,14 +175,16 @@ const readTypeChange = (gone: string, added: string): Option.Option<PatchReading
170
175
  ? Option.some({
171
176
  maybeRenamedFrom: Option.none(),
172
177
  notes: [`It was ${kindOf(before)} and is now ${kindOf(after)}.`],
173
- tables: [...sideTable(gone, "as it was"), ...sideTable(added, "as it is")],
178
+ tables: [
179
+ ...sideTable(gone, "as it was", rowsOf),
180
+ ...sideTable(added, "as it is", rowsOf),
181
+ ],
174
182
  })
175
183
  : Option.none(),
176
184
  );
177
185
  };
178
186
 
179
- /** What `patchView` makes of a patch: the notes it prints and the tables it draws. */
180
- export const readPatch = (patch: string): PatchReading => {
187
+ const readPatchWith = (patch: string, rowsOf: RowsOf): PatchReading => {
181
188
  const text = patch.replaceAll("\r\n", "\n");
182
189
 
183
190
  if (String.isEmpty(String.trim(text))) {
@@ -187,15 +194,18 @@ export const readPatch = (patch: string): PatchReading => {
187
194
  const [first, second, ...rest] = sections;
188
195
 
189
196
  if (first !== undefined && second === undefined) {
190
- return readSection(first);
197
+ return readSection(first, rowsOf);
191
198
  } else if (first !== undefined && second !== undefined && Array.isArrayEmpty(rest)) {
192
- return Option.getOrElse(readTypeChange(first, second), () => unreadable);
199
+ return Option.getOrElse(readTypeChange(first, second, rowsOf), () => unreadable);
193
200
  } else {
194
201
  return unreadable;
195
202
  }
196
203
  }
197
204
  };
198
205
 
206
+ /** What `patchView` makes of a patch: the notes it prints and the tables it draws. */
207
+ export const readPatch = (patch: string): PatchReading => readPatchWith(patch, diffRowsOfPatch);
208
+
199
209
  // DESCRIPTION
200
210
 
201
211
  /** What a patch says beyond its lines, as `patchView` prints it. */
@@ -212,7 +222,10 @@ export type PatchDescription = Readonly<{
212
222
  * prints (`["Renamed from old.txt."]`), for a caller that prints them itself, and the path a
213
223
  * rename left. */
214
224
  export const describePatch = (patch: string): PatchDescription => {
215
- const { maybeRenamedFrom, notes } = readPatch(patch);
225
+ // the notes need only whether a section has lines, so none are numbered
226
+ const { maybeRenamedFrom, notes } = readPatchWith(patch, (section) =>
227
+ Option.as(hunksOfPatch(section), []),
228
+ );
216
229
 
217
230
  return { maybeRenamedFrom, notes };
218
231
  };
@@ -27,13 +27,6 @@ export type ViewConfig = Readonly<{
27
27
  label?: string;
28
28
  }>;
29
29
 
30
- const STATE_CLASS: Readonly<Record<State, string>> = {
31
- Online: "",
32
- Connecting: " connecting",
33
- Reconnecting: " reconnecting",
34
- Offline: " offline",
35
- };
36
-
37
30
  /** The connection word, in a top bar or a status bar. It is a status (`role="status"`), so a
38
31
  * change of state is read out; while it connects it prints the working cursor. */
39
32
  export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html => {
@@ -41,7 +34,7 @@ export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html
41
34
  const isWorking = state === "Connecting" || state === "Reconnecting";
42
35
 
43
36
  return h.span(
44
- [h.Role("status"), h.Class(`link-state${STATE_CLASS[state]}`)],
37
+ [h.Role("status"), h.Class(`link-state${state === "Online" ? "" : ` ${stateWord(state)}`}`)],
45
38
  [label, ...(isWorking ? [h.span([h.Class("cursor"), h.AriaHidden(true)])] : [])],
46
39
  );
47
40
  };
package/src/menu/slip.ts CHANGED
@@ -58,8 +58,8 @@ const edgeAttributes = <Message>(
58
58
  id: string,
59
59
  h: HtmlBuilder<Message>,
60
60
  ) => [
61
- ...(Option.contains(context.firstId, id) ? [h.DataAttribute("pixel-paper-slip-first", "")] : []),
62
- ...(Option.contains(context.lastId, id) ? [h.DataAttribute("pixel-paper-slip-last", "")] : []),
61
+ ...(Option.contains(context.firstId, id) ? [h.Attribute(FIRST_ATTRIBUTE, "")] : []),
62
+ ...(Option.contains(context.lastId, id) ? [h.Attribute(LAST_ATTRIBUTE, "")] : []),
63
63
  ];
64
64
 
65
65
  const wordsView = <Message>(