@gjermundgaraba-designs/pixel-paper 0.22.2 → 0.24.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 (176) hide show
  1. package/DESIGN.md +18 -17
  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/iconButton/iconButton.d.ts +6 -1
  44. package/dist/src/iconButton/iconButton.d.ts.map +1 -1
  45. package/dist/src/iconButton/iconButton.js +7 -2
  46. package/dist/src/inlineInput/inlineInput.d.ts +4 -0
  47. package/dist/src/inlineInput/inlineInput.d.ts.map +1 -1
  48. package/dist/src/inlineInput/inlineInput.js +9 -6
  49. package/dist/src/keyHelp/index.d.ts +1 -1
  50. package/dist/src/keyHelp/index.d.ts.map +1 -1
  51. package/dist/src/keyHelp/index.js +1 -1
  52. package/dist/src/keyHelp/keyHelp.d.ts +1 -3
  53. package/dist/src/keyHelp/keyHelp.d.ts.map +1 -1
  54. package/dist/src/keyHelp/keyHelp.js +3 -6
  55. package/dist/src/keyHint/keyLabel.d.ts +2 -0
  56. package/dist/src/keyHint/keyLabel.d.ts.map +1 -1
  57. package/dist/src/keyHint/keyLabel.js +3 -2
  58. package/dist/src/lineDiff/diff.d.ts +6 -3
  59. package/dist/src/lineDiff/diff.d.ts.map +1 -1
  60. package/dist/src/lineDiff/diff.js +40 -28
  61. package/dist/src/lineDiff/patch.d.ts.map +1 -1
  62. package/dist/src/lineDiff/patch.js +18 -13
  63. package/dist/src/linkState/linkState.d.ts.map +1 -1
  64. package/dist/src/linkState/linkState.js +1 -7
  65. package/dist/src/menu/slip.js +2 -2
  66. package/dist/src/meter/meter.d.ts.map +1 -1
  67. package/dist/src/meter/meter.js +22 -17
  68. package/dist/src/notice/notice.d.ts +2 -0
  69. package/dist/src/notice/notice.d.ts.map +1 -1
  70. package/dist/src/notice/notice.js +2 -1
  71. package/dist/src/panel/panel.d.ts +3 -0
  72. package/dist/src/panel/panel.d.ts.map +1 -1
  73. package/dist/src/panel/panel.js +2 -1
  74. package/dist/src/passwordField/passwordField.d.ts.map +1 -1
  75. package/dist/src/passwordField/passwordField.js +3 -7
  76. package/dist/src/rail/index.d.ts +1 -1
  77. package/dist/src/rail/index.d.ts.map +1 -1
  78. package/dist/src/rail/index.js +1 -1
  79. package/dist/src/rail/rail.d.ts +95 -20
  80. package/dist/src/rail/rail.d.ts.map +1 -1
  81. package/dist/src/rail/rail.js +103 -46
  82. package/dist/src/reel/layout.d.ts +9 -5
  83. package/dist/src/reel/layout.d.ts.map +1 -1
  84. package/dist/src/reel/layout.js +11 -5
  85. package/dist/src/reel/reel.d.ts.map +1 -1
  86. package/dist/src/reel/reel.js +122 -68
  87. package/dist/src/scheme/scheme.d.ts +1 -1
  88. package/dist/src/scheme/scheme.d.ts.map +1 -1
  89. package/dist/src/segmented/segmented.d.ts +0 -3
  90. package/dist/src/segmented/segmented.d.ts.map +1 -1
  91. package/dist/src/segmented/segmented.js +1 -6
  92. package/dist/src/selectField/selectField.d.ts.map +1 -1
  93. package/dist/src/selectField/selectField.js +3 -10
  94. package/dist/src/sortable/index.d.ts +1 -1
  95. package/dist/src/sortable/index.d.ts.map +1 -1
  96. package/dist/src/sortable/index.js +1 -1
  97. package/dist/src/sortable/sortable.d.ts +39 -40
  98. package/dist/src/sortable/sortable.d.ts.map +1 -1
  99. package/dist/src/sortable/sortable.js +14 -31
  100. package/dist/src/stack/stack.d.ts +1 -1
  101. package/dist/src/stack/stack.d.ts.map +1 -1
  102. package/dist/src/stack/stack.js +1 -1
  103. package/dist/src/tabs/tabs.d.ts +1 -1
  104. package/dist/src/tabs/tabs.d.ts.map +1 -1
  105. package/dist/src/tabs/tabs.js +1 -1
  106. package/dist/src/textareaField/textareaField.d.ts.map +1 -1
  107. package/dist/src/textareaField/textareaField.js +3 -7
  108. package/dist/src/time/time.d.ts.map +1 -1
  109. package/dist/src/time/time.js +4 -2
  110. package/dist/src/tips/index.d.ts +1 -1
  111. package/dist/src/tips/index.d.ts.map +1 -1
  112. package/dist/src/tips/tips.d.ts +30 -1
  113. package/dist/src/tips/tips.d.ts.map +1 -1
  114. package/dist/src/tips/tips.js +53 -31
  115. package/dist/src/toast/index.d.ts +1 -1
  116. package/dist/src/toast/index.d.ts.map +1 -1
  117. package/dist/src/toast/toast.d.ts +5 -5
  118. package/dist/src/toast/toast.d.ts.map +1 -1
  119. package/dist/src/toast/toast.js +1 -6
  120. package/dist/src/toolbar/toolbar.d.ts +0 -3
  121. package/dist/src/toolbar/toolbar.d.ts.map +1 -1
  122. package/dist/src/toolbar/toolbar.js +1 -2
  123. package/package.json +1 -1
  124. package/src/adapters/mermaid.ts +0 -24
  125. package/src/announcer/announcer.ts +13 -17
  126. package/src/board/board.ts +32 -80
  127. package/src/board/index.ts +1 -1
  128. package/src/button/button.ts +1 -1
  129. package/src/commandPalette/commandPalette.ts +21 -38
  130. package/src/commandPalette/keys.ts +2 -4
  131. package/src/commandPalette/match.ts +3 -3
  132. package/src/confirmDialog/confirmDialog.ts +5 -3
  133. package/src/copyButton/copyButton.ts +2 -5
  134. package/src/crumbs/crumbs.ts +2 -4
  135. package/src/dialog/dialog.ts +3 -2
  136. package/src/drag/drag.ts +54 -5
  137. package/src/field/field.ts +29 -7
  138. package/src/fieldset/fieldset.ts +2 -4
  139. package/src/fileTree/fileTree.ts +10 -12
  140. package/src/iconButton/iconButton.ts +19 -2
  141. package/src/inlineInput/inlineInput.ts +11 -2
  142. package/src/keyHelp/index.ts +0 -1
  143. package/src/keyHelp/keyHelp.ts +3 -9
  144. package/src/keyHint/keyLabel.ts +5 -2
  145. package/src/lineDiff/diff.ts +70 -54
  146. package/src/lineDiff/patch.ts +27 -14
  147. package/src/linkState/linkState.ts +1 -8
  148. package/src/menu/slip.ts +2 -2
  149. package/src/meter/meter.ts +25 -15
  150. package/src/notice/notice.ts +2 -1
  151. package/src/panel/panel.ts +5 -0
  152. package/src/passwordField/passwordField.ts +3 -7
  153. package/src/pixel-paper.css +200 -134
  154. package/src/rail/index.ts +2 -0
  155. package/src/rail/rail.ts +124 -44
  156. package/src/reel/layout.ts +14 -7
  157. package/src/reel/reel.ts +132 -63
  158. package/src/scheme/scheme.ts +1 -1
  159. package/src/segmented/segmented.ts +1 -9
  160. package/src/selectField/selectField.ts +3 -10
  161. package/src/sortable/index.ts +0 -1
  162. package/src/sortable/sortable.ts +35 -54
  163. package/src/stack/stack.ts +1 -1
  164. package/src/tabs/tabs.ts +1 -1
  165. package/src/textareaField/textareaField.ts +3 -7
  166. package/src/time/time.ts +6 -7
  167. package/src/tips/index.ts +1 -1
  168. package/src/tips/tips.ts +71 -35
  169. package/src/toast/index.ts +1 -1
  170. package/src/toast/toast.ts +2 -8
  171. package/src/tokens.css +7 -0
  172. package/src/toolbar/toolbar.ts +0 -5
  173. package/dist/src/inlineInput/inline.d.ts +0 -6
  174. package/dist/src/inlineInput/inline.d.ts.map +0 -1
  175. package/dist/src/inlineInput/inline.js +0 -7
  176. package/src/inlineInput/inline.ts +0 -13
@@ -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
 
@@ -2,8 +2,10 @@ import { Button as UiButton } from "@foldkit/ui";
2
2
  import type { Html, HtmlBuilder } from "foldkit/html";
3
3
 
4
4
  import type { Attributes } from "../attributes/attributes.ts";
5
+ import type { KeyHintConfig } from "../button/button.ts";
5
6
  import * as Glyph from "../glyph/glyph.ts";
6
7
  import type { GlyphName } from "../glyph/glyphs.ts";
8
+ import { keyLabel } from "../keyHint/keyLabel.ts";
7
9
 
8
10
  // VIEW
9
11
 
@@ -17,13 +19,25 @@ export type ViewConfig<Message> = Readonly<{
17
19
  isQuiet?: boolean;
18
20
  /** Unavailable but focusable (`aria-disabled`), as in `@foldkit/ui`. */
19
21
  isDisabled?: boolean;
22
+ /** The key that does the same, printed in the tip after the label (`data-tip-key`): the
23
+ * button has no words to print it beside. */
24
+ keyHint?: KeyHintConfig;
20
25
  /** Further attributes for the `<button>`, never a class. */
21
26
  attributes?: Attributes<Message>;
22
27
  }>;
23
28
 
24
- /** A bare glyph button, 24px square. Its label is its accessible name and its tip. */
29
+ /** A bare glyph button, 24px square. Its label is its accessible name and its tip, and its
30
+ * key, where it has one, prints in the tip after the label. */
25
31
  export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Message>): Html => {
26
- const { glyph, label, onClick, isQuiet = false, isDisabled = false, attributes = [] } = config;
32
+ const {
33
+ glyph,
34
+ label,
35
+ onClick,
36
+ isQuiet = false,
37
+ isDisabled = false,
38
+ keyHint,
39
+ attributes = [],
40
+ } = config;
27
41
 
28
42
  return UiButton.view(
29
43
  {
@@ -36,6 +50,9 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
36
50
  ...groups.button,
37
51
  h.AriaLabel(label),
38
52
  h.DataAttribute("tip", label),
53
+ ...(keyHint === undefined
54
+ ? []
55
+ : [h.DataAttribute("tip-key", keyLabel(keyHint.keys, keyHint.platform))]),
39
56
  h.Class(isQuiet ? "iconbtn quiet" : "iconbtn"),
40
57
  ],
41
58
  [Glyph.view({ name: glyph }, h)],
@@ -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>(
@@ -105,32 +105,41 @@ const numbersText = (value: number, max: number, unit: string | undefined): stri
105
105
  return unit === undefined ? numbers : `${numbers} ${unit}`;
106
106
  };
107
107
 
108
+ /** How high a reading is: full at `max`, high from `HIGH_SHARE` of it, calm below. The
109
+ * percentage and a toned meter's cells both print it. */
110
+ type Level = "Full" | "High" | "Calm";
111
+
112
+ const levelOf = (value: number, max: number): Level => {
113
+ if (value >= max) {
114
+ return "Full";
115
+ } else if (value >= max * HIGH_SHARE) {
116
+ return "High";
117
+ } else {
118
+ return "Calm";
119
+ }
120
+ };
121
+
108
122
  const percentView = <Message>(
109
123
  config: MeterConfig,
110
124
  percent: number,
111
- isFull: boolean,
125
+ level: Level,
112
126
  h: HtmlBuilder<Message>,
113
127
  ): Html => {
114
128
  const percentText = `${percent}%`;
115
- const high = config.max * HIGH_SHARE;
116
129
 
117
- if (isFull) {
130
+ if (level === "Full") {
118
131
  return Chip.view({ label: config.full ?? percentText, tone: "Blocked" }, h);
119
- } else if (config.value >= high) {
132
+ } else if (level === "High") {
120
133
  return Mark.readingView({ label: percentText }, h);
121
134
  } else {
122
135
  return h.span([h.Class("pct")], [percentText]);
123
136
  }
124
137
  };
125
138
 
126
- const toneClass = (value: number, max: number, isFull: boolean): string => {
127
- if (isFull) {
128
- return " toned full";
129
- } else if (value >= max * HIGH_SHARE) {
130
- return " toned high";
131
- } else {
132
- return " toned";
133
- }
139
+ const TONE_CLASS: Readonly<Record<Level, string>> = {
140
+ Full: " toned full",
141
+ High: " toned high",
142
+ Calm: " toned",
134
143
  };
135
144
 
136
145
  const meterView = <Message>(config: MeterConfig, h: HtmlBuilder<Message>): Html => {
@@ -146,9 +155,10 @@ const meterView = <Message>(config: MeterConfig, h: HtmlBuilder<Message>): Html
146
155
  isToned = false,
147
156
  } = config;
148
157
  const share = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0;
149
- const isFull = value >= max;
158
+ const level = levelOf(value, max);
159
+ const isFull = level === "Full";
150
160
  const numbers = config.valueText ?? numbersText(value, max, config.unit);
151
- const rowClass = `meter-row${isStale ? " stale" : ""}${isToned ? toneClass(value, max, isFull) : ""}`;
161
+ const rowClass = `meter-row${isStale ? " stale" : ""}${isToned ? TONE_CLASS[level] : ""}`;
152
162
 
153
163
  return UiMeter.view(
154
164
  {
@@ -179,7 +189,7 @@ const meterView = <Message>(config: MeterConfig, h: HtmlBuilder<Message>): Html
179
189
  [...groups.meter, h.Class("cells")],
180
190
  cellRowView(METER_CELLS, scaled(share, METER_CELLS, isFull), h),
181
191
  ),
182
- percentView(config, scaled(share, PERCENT, isFull), isFull, h),
192
+ percentView(config, scaled(share, PERCENT, isFull), level, h),
183
193
  ...(hasNumbers ? [h.span([h.Class("meter-num tnum")], [numbers])] : []),
184
194
  ],
185
195
  ),
@@ -22,7 +22,8 @@ const toneGlyph = (tone: Tone): GlyphName =>
22
22
  Match.exhaustive,
23
23
  );
24
24
 
25
- const TONE_CLASS: Readonly<Record<Tone, string>> = {
25
+ /** The class a tone adds to a notice's, a toast's or a glyph box's own. */
26
+ export const TONE_CLASS: Readonly<Record<Tone, string>> = {
26
27
  Warn: "",
27
28
  Error: " error",
28
29
  Ok: " ok",