@citisen/litearea 0.1.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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/README.zh.md +362 -0
  4. package/dist/grammars.cjs +1228 -0
  5. package/dist/grammars.cjs.map +1 -0
  6. package/dist/grammars.js +1213 -0
  7. package/dist/grammars.js.map +1 -0
  8. package/dist/index.cjs +3103 -0
  9. package/dist/index.cjs.map +1 -0
  10. package/dist/index.js +3040 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/react.cjs +3032 -0
  13. package/dist/react.cjs.map +1 -0
  14. package/dist/react.js +3010 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/styles.cjs +453 -0
  17. package/dist/styles.cjs.map +1 -0
  18. package/dist/styles.css +432 -0
  19. package/dist/styles.js +447 -0
  20. package/dist/styles.js.map +1 -0
  21. package/dist/types/core/complete.d.ts +70 -0
  22. package/dist/types/core/complete.d.ts.map +1 -0
  23. package/dist/types/core/format.d.ts +34 -0
  24. package/dist/types/core/format.d.ts.map +1 -0
  25. package/dist/types/core/grammar.d.ts +14 -0
  26. package/dist/types/core/grammar.d.ts.map +1 -0
  27. package/dist/types/core/hover.d.ts +23 -0
  28. package/dist/types/core/hover.d.ts.map +1 -0
  29. package/dist/types/core/index.d.ts +12 -0
  30. package/dist/types/core/index.d.ts.map +1 -0
  31. package/dist/types/core/inspect.d.ts +34 -0
  32. package/dist/types/core/inspect.d.ts.map +1 -0
  33. package/dist/types/core/rank.d.ts +82 -0
  34. package/dist/types/core/rank.d.ts.map +1 -0
  35. package/dist/types/core/scan.d.ts +51 -0
  36. package/dist/types/core/scan.d.ts.map +1 -0
  37. package/dist/types/core/segments.d.ts +44 -0
  38. package/dist/types/core/segments.d.ts.map +1 -0
  39. package/dist/types/core/text.d.ts +113 -0
  40. package/dist/types/core/text.d.ts.map +1 -0
  41. package/dist/types/core/types.d.ts +604 -0
  42. package/dist/types/core/types.d.ts.map +1 -0
  43. package/dist/types/core/vocabulary.d.ts +82 -0
  44. package/dist/types/core/vocabulary.d.ts.map +1 -0
  45. package/dist/types/dom/create.d.ts +17 -0
  46. package/dist/types/dom/create.d.ts.map +1 -0
  47. package/dist/types/dom/editing.d.ts +89 -0
  48. package/dist/types/dom/editing.d.ts.map +1 -0
  49. package/dist/types/dom/editor.d.ts +366 -0
  50. package/dist/types/dom/editor.d.ts.map +1 -0
  51. package/dist/types/dom/index.d.ts +9 -0
  52. package/dist/types/dom/index.d.ts.map +1 -0
  53. package/dist/types/dom/mirror.d.ts +107 -0
  54. package/dist/types/dom/mirror.d.ts.map +1 -0
  55. package/dist/types/dom/overlay.d.ts +52 -0
  56. package/dist/types/dom/overlay.d.ts.map +1 -0
  57. package/dist/types/dom/popup.d.ts +95 -0
  58. package/dist/types/dom/popup.d.ts.map +1 -0
  59. package/dist/types/dom/support.d.ts +41 -0
  60. package/dist/types/dom/support.d.ts.map +1 -0
  61. package/dist/types/dom/tooltip.d.ts +39 -0
  62. package/dist/types/dom/tooltip.d.ts.map +1 -0
  63. package/dist/types/grammars/dshFont.d.ts +127 -0
  64. package/dist/types/grammars/dshFont.d.ts.map +1 -0
  65. package/dist/types/grammars/dshSentry.d.ts +84 -0
  66. package/dist/types/grammars/dshSentry.d.ts.map +1 -0
  67. package/dist/types/grammars/index.d.ts +3 -0
  68. package/dist/types/grammars/index.d.ts.map +1 -0
  69. package/dist/types/index.d.ts +15 -0
  70. package/dist/types/index.d.ts.map +1 -0
  71. package/dist/types/react/index.d.ts +91 -0
  72. package/dist/types/react/index.d.ts.map +1 -0
  73. package/dist/types/styles.d.ts +29 -0
  74. package/dist/types/styles.d.ts.map +1 -0
  75. package/docs/architecture.md +316 -0
  76. package/docs/completion.md +320 -0
  77. package/docs/grammar.md +823 -0
  78. package/package.json +105 -0
  79. package/scripts/browser-check.mjs +838 -0
  80. package/scripts/build-css.mjs +35 -0
  81. package/scripts/release.mjs +91 -0
  82. package/scripts/verify-package.mjs +253 -0
  83. package/src/core/complete.ts +286 -0
  84. package/src/core/format.ts +71 -0
  85. package/src/core/grammar.ts +40 -0
  86. package/src/core/hover.ts +129 -0
  87. package/src/core/index.ts +98 -0
  88. package/src/core/inspect.ts +198 -0
  89. package/src/core/rank.ts +317 -0
  90. package/src/core/scan.ts +720 -0
  91. package/src/core/segments.ts +185 -0
  92. package/src/core/text.ts +238 -0
  93. package/src/core/types.ts +681 -0
  94. package/src/core/vocabulary.ts +196 -0
  95. package/src/dom/create.ts +31 -0
  96. package/src/dom/editing.ts +213 -0
  97. package/src/dom/editor.ts +1143 -0
  98. package/src/dom/index.ts +46 -0
  99. package/src/dom/mirror.ts +305 -0
  100. package/src/dom/overlay.ts +106 -0
  101. package/src/dom/popup.ts +323 -0
  102. package/src/dom/support.ts +88 -0
  103. package/src/dom/tooltip.ts +112 -0
  104. package/src/grammars/dshFont.ts +1004 -0
  105. package/src/grammars/dshSentry.ts +742 -0
  106. package/src/grammars/index.ts +57 -0
  107. package/src/index.ts +122 -0
  108. package/src/react/index.tsx +248 -0
  109. package/src/styles.ts +529 -0
@@ -0,0 +1,320 @@
1
+ # Completion reference
2
+
3
+ How a caret becomes a list, and how a list becomes an edit. Two pure functions do
4
+ the work and the editor only drives them:
5
+
6
+ ```ts
7
+ complete(inspection, grammar, request): Completion | undefined
8
+ applyCompletion(text, range, item): AppliedCompletion
9
+ ```
10
+
11
+ They are separate because they fail differently. `complete` answers "what could go
12
+ here?" and returns rows; `applyCompletion` answers "what does the document
13
+ become?" and returns text plus a caret. Keeping them apart is what makes the hard
14
+ half — the interaction — testable in Node without a browser, and it is where the
15
+ two bugs that make a completer feel broken have somewhere to live:
16
+
17
+ - **A source that swaps mid-typing loses the thread.** The list opens over a family
18
+ name, the user types `=` and it becomes a value list, and the row they were
19
+ looking at is gone. So once a source has answered, it keeps answering for as long
20
+ as it stays eligible, and priority only decides which source *opens* the list.
21
+ - **A range that does not grow leaves text behind.** The range is recomputed from
22
+ the caret on every filter, never held over: type `ru`, accept `running`, and the
23
+ range has to cover both letters. An earlier version of this library held the range
24
+ resolved when the list opened, which replaces only the `r` and leaves `running u`
25
+ behind — a bug invisible to a test that only ever types one character before
26
+ accepting, and the reason this rule is written down here.
27
+ - **A caret computed from the wrong end lands in the wrong place.** Every offset
28
+ here is counted in the same coordinate system as the document, and the final
29
+ caret is computed from the pieces actually written, never re-derived by
30
+ searching the result.
31
+
32
+ ## How the editor decides to open the list
33
+
34
+ | Event | What happens |
35
+ | --- | --- |
36
+ | A word character is typed | The list opens, in `'auto'` mode. The predicate is the grammar's `wordChars` |
37
+ | A character typed through `triggerCharacters` | The list opens, in `'auto'` mode. Default `' '`, and that default is doing real work: a value usually follows a name, so a space is exactly where the next word becomes guessable |
38
+ | More characters are typed while the list is open | Re-resolved: the same source answers again if it is still eligible, the range is recomputed from the caret, and the rows are re-filtered |
39
+ | A character is deleted while the list is open | Re-filtered. A backspace with no list open opens nothing, because a deletion is not a request for suggestions |
40
+ | `Ctrl+Space` / `Cmd+Space` | Opens with no needle (`'explicit'`), or closes the list when it is already open |
41
+ | A row's commit character is typed | The row is accepted and the character is written after it. The list then closes, and whatever is typed next is an ordinary `'auto'` trigger |
42
+ | `Escape` | Closes the list. With no list open it hides the tooltip instead |
43
+ | The caret walks out of the range the list last resolved | Closes the list. A caret move raises no `input` event, so the range is not recomputed for it |
44
+ | An IME composition starts | The list closes and no completion runs until the composition ends |
45
+ | The editor itself is writing | Nothing re-opens: the editor knows its own edit from the user's |
46
+ | Any `input` event while `readOnly` | Nothing |
47
+
48
+ `auto: false` (or `completion: false` on the editor) switches off every row above
49
+ except `Ctrl+Space`, `Escape`, and the arrows; `showCompletions()` and
50
+ `hideCompletions()` drive the same open and close by hand, and `currentCompletion`
51
+ reads what is on screen.
52
+
53
+ ## The request
54
+
55
+ | `CompletionRequest` field | Notes |
56
+ | --- | --- |
57
+ | `text` | The document. It is passed rather than taken from the inspection so the two can never disagree about which version is being completed |
58
+ | `caret` | The caret offset; clamped into the text |
59
+ | `trigger` | `'auto'` (a keystroke) or `'explicit'` (the user asked). There is no `'commit'`: a commit character accepts the row and closes the list |
60
+ | `previousSourceId` | The source that answered last time, when a list is already open. The same source answers again while it stays eligible; the range is not part of it |
61
+ | `limit` | The most rows to return. Default 100 |
62
+
63
+ ## The source
64
+
65
+ | `CompletionSource` field | Notes |
66
+ | --- | --- |
67
+ | `id` | Required and stable. It is how a re-open finds the same source again, and what the host sees in `Completion.sourceId` |
68
+ | `when` | Eligible only when this holds. Absent, always eligible |
69
+ | `range` | The range a chosen row replaces: a `Range` or `(context) => Range`. A function's result is normalised so `from <= to` |
70
+ | `items` | `(context) => readonly SuggestionItem[]` — the rows, in whatever order the grammar wants them scored |
71
+ | `priority` | Higher wins when several sources could open the list. Default 0, and ties go to the source declared first |
72
+ | `merge` | Adds this source's rows to the winner's instead of competing with it |
73
+
74
+ Several sources may be declared, and **priority only decides which one opens the
75
+ list**. Once a source has answered, it is the one asked again while the list is
76
+ open, for as long as its `when` still holds; only when it stops being eligible does
77
+ the list fall back to the highest-priority eligible source. That is what keeps the
78
+ rows from changing identity under the reader's hands — typing a character that makes
79
+ a different source eligible does not swap the list out from under them.
80
+
81
+ Rows from a source that sets `merge: true` are appended to the winner's rows, which
82
+ is how a document-wide source (every property already used elsewhere) sits alongside
83
+ a closed vocabulary without having to win anything.
84
+
85
+ The range is a separate question, and it is answered fresh on every call:
86
+
87
+ ```ts
88
+ // The range is recomputed from the caret on every filter. Never reused.
89
+ // 1. the caret sits at the end of `ru`, and the source's range is the word `ru`
90
+ // 2. the list offers `running` and the user accepts it
91
+ // 3. the range covers both letters, so the entry becomes `running`
92
+ //
93
+ // A range held over from the keystroke that OPENED the list would cover only the
94
+ // `r` and leave `running u` behind. That was this library's first design, and it
95
+ // was wrong: the range has to describe the text the rows are being matched
96
+ // against now, not the text that happened to be there when the list appeared.
97
+ ```
98
+
99
+ The winner's `range` — a `Range`, or a `(context) => Range` whose result is
100
+ normalised so `from <= to` — is therefore resolved on every call, and only the
101
+ winner's is resolved at all, so two sources cannot disagree about it. A word-shaped
102
+ range grows with the word and an entry-shaped range grows with the entry.
103
+
104
+ The needle is the text the user has typed *inside* that range, clamped to the range
105
+ at both ends — `text.slice(clamp(range.from, 0, caret), clamp(caret, range.from,
106
+ range.to))`. Clamping the far end matters: a caret can sit past the range, after the
107
+ trailing space of an entry, and slicing straight to the caret there would pull the
108
+ separator into the needle and filter the list by a character nobody meant to search
109
+ for. Reading the needle inside the range is also the only reading that stays
110
+ meaningful when the range is a whole entry rather than a word.
111
+
112
+ ## The rows
113
+
114
+ `SuggestionItem`:
115
+
116
+ | Field | Notes |
117
+ | --- | --- |
118
+ | `label` | The text the row shows, and, unless `insert` says otherwise, the text it writes |
119
+ | `insert` | What is written. Defaults to `label` |
120
+ | `detail` | A short dimmed annotation at the right edge, and the first line of the documentation panel |
121
+ | `documentation` | The paragraph in the documentation panel |
122
+ | `kind` | A kind name, drawn as `litearea-kind-<kind>`: a small coloured dot whose colour comes from the stylesheet |
123
+ | `sortText` | The ranking bucket: rows are ordered by it first and by score only within an equal key. Set it on every row or on none — see the trap below |
124
+ | `filterText` | Matched against the needle instead of `label` |
125
+ | `mode` | `'replace'` (the default) writes over the range; `'before'` writes ahead of it and keeps it |
126
+ | `append` | Text written after the insert, such as `', '` to invite another entry |
127
+ | `commitCharacters` | Characters that accept this row when typed |
128
+ | `caretOffset` | Where the caret lands, counted back from the end of everything written. Negative moves it left |
129
+ | `data` | Opaque payload. Passed to `onAccept` inside the `item` |
130
+
131
+ `mode: 'before'` is how a value is placed in front of an existing one without
132
+ destroying it: promoting a fallback font, or adding a state above a line that
133
+ already exists. It inserts at the range's start, drops the whitespace that
134
+ followed the old entry and restores a single separating space when one is needed,
135
+ and reports `from` and `to` as the same offset, because nothing was removed.
136
+
137
+ `caretOffset` is one fixed offset, not a set of snippet stops — there is no
138
+ placeholder support, so a `caretOffset: -1` on `insert: '()'` is the whole of what
139
+ a snippet would be here.
140
+
141
+ ## The context
142
+
143
+ Every field a source may look at:
144
+
145
+ | `CompletionContext` field | What it is |
146
+ | --- | --- |
147
+ | `text`, `caret` | The document and the clamped caret |
148
+ | `word` | The word around the caret: `{ from, to, text, prefix, suffix }`. The word is expanded in both directions, so a caret at `Geist\|` is still inside `Geist` |
149
+ | `line` | `{ from, to, text, number, column, before, after }` |
150
+ | `tokens` | The tokens for the whole document |
151
+ | `diagnostics` | The diagnostics for the whole document |
152
+ | `state` | Whatever `analyze` returned |
153
+ | `scope` | The scope of the token under the caret, when the caret is inside one |
154
+ | `scopeBefore` | The scope of the nearest token before the caret. Note that a half-typed word *is* a token, so this is the scope before that word, not before the word you are inside |
155
+ | `firstOnLine` | Only whitespace precedes the caret on its line. A caret in a line's indentation counts |
156
+ | `firstWord` | The caret is inside the **first word** of its line, or before it |
157
+ | `firstToken` | The first non-whitespace token on the caret's line, when there is one |
158
+ | `trigger` | How the list came to be open |
159
+
160
+ `firstOnLine` and `firstWord` both exist because a line-oriented language puts
161
+ something special at the head of every line, and a completion for it has to stay
162
+ eligible while that head is being spelled out. `runn\|` is no longer "at the start
163
+ of the line" in the strict sense — the line already has content — but it is
164
+ unmistakably completing the first word. A source that asked `firstOnLine` would
165
+ switch itself off after the very first keystroke, which is the bug the second field
166
+ exists to prevent. `dshSentryStyleGrammar` asks `firstWord` for exactly this
167
+ reason, and it is the difference between a list that helps and a list that keeps
168
+ disappearing.
169
+
170
+ ## Ranking
171
+
172
+ Filtering and ordering are different jobs, and only the second one decides how a
173
+ completer feels. Typing `ruboc` should not be able to push an exact match below a
174
+ coincidence, so the ordering is a **tier** plus a local score rather than one
175
+ arithmetic blend: a single blend is untestable, because nobody can say what the
176
+ number 47.3 means. A tier is worth a million points, so a local bonus can never
177
+ cross one.
178
+
179
+ | Tier | Value | How it is reached | Example |
180
+ | --- | --- | --- | --- |
181
+ | exact | 5 | The needle is the label, in the same case | `Inter` → `Inter` |
182
+ | exact-fold | 4 | The needle is the label, ignoring case | `inter` → `Inter` |
183
+ | prefix | 3 | The label starts with the needle | `inte` → `Inter` |
184
+ | boundary | 2 | Every needle character starts a word or continues a run the match already started | `IPM` → `IBM Plex Mono`, `fm` → `Fira Mono`, `jbmo` → `JetBrains Mono` |
185
+ | substring | 1 | The needle appears as one unbroken run inside the label | `lex` → `Plex Inter`, `bm` → `IBM Plex Mono` |
186
+ | subsequence | 0 | The needle's characters appear in order, with gaps | `ton` → `Noto Sans` |
187
+
188
+ Two details of that table are worth pulling out. `IPM` finds `IBM Plex Mono`
189
+ without being a substring of anything: the gap search tries *every* position the
190
+ first character could have started at, rather than only the first hit, because a
191
+ greedy match would take the `P` of `Plex` and then fail to find an `M` when the
192
+ `M` of `Mono` was available all along. And `jbmo` reaches the boundary tier rather
193
+ than the subsequence one — every one of its characters starts a word or continues
194
+ the run before it — so the subsequence tier is reached only when a matched
195
+ character sits inside a word without continuing the match, as the `t` of `Noto`
196
+ does.
197
+
198
+ Case follows the needle: a needle with any uppercase character is matched
199
+ case-sensitively, so `Inter` does not quietly match `inter`, while an all-lowercase
200
+ needle matches anything. That is the "smart case" a terminal has used for decades.
201
+
202
+ `sortText` is the primary key and the score is the tie-break inside a group. It is
203
+ how a grammar puts a whole group on top — the value already in effect, the shipped
204
+ rate — without pretending its label starts with a `0`:
205
+
206
+ | Rows | `sortText` | Result |
207
+ | --- | --- | --- |
208
+ | `IBM Plex Mono`, `Inter`, `Inter Tight`, `Iosevka` | `'0'`, `'1'`, `'1'`, — (falls back to the label) | `IBM Plex Mono` first, then the `'1'` group by score, then `Iosevka` |
209
+
210
+ That last cell is the trap, and the type warns about it in the same words: a row with
211
+ no `sortText` is keyed by its **label**, so a grammar that sets `sortText` on some
212
+ rows and not others is comparing its sort keys against other rows' labels — two
213
+ different kinds of string, in one sort. Set it on every row, or on none.
214
+
215
+ With no `sortText` anywhere, rows are sorted by score alone on a stable sort,
216
+ which keeps the order the grammar declared for everything the needle does not
217
+ separate. That is load-bearing: an empty needle must show a catalogue in catalogue
218
+ order — what `Ctrl+Space` is for — and an alphabetical tie-break would quietly
219
+ rearrange a list the grammar had already ranked. An empty needle keeps every row,
220
+ with no emphasis.
221
+
222
+ `filterText` is matched instead of `label`, so a row can be found by text it does
223
+ not display. The matched offsets are only meaningful against the label, so a row
224
+ that matched on different text is ranked without an emphasis rather than with the
225
+ wrong one. `highlightSegments(label, indices)` is exported for the caller that
226
+ builds the row: it returns alternating plain and matched pieces rather than
227
+ markup, because the caller is building DOM nodes, not HTML, and a font family
228
+ called `<b>` must be shown as `<b>`.
229
+
230
+ `rank(items, needle, options)` is the exported entry point (`options` is
231
+ `label`, `filterText?`, `sortText?`), and `fuzzyMatch(needle, label)` returns the
232
+ raw `{ score, indices, tier }` for one pair. `isWordStart` is exported too: it is
233
+ the word test the ranking uses, where a word starts at the beginning, after a
234
+ separator (`\s-_./:@()+[]`), or at a lower-to-upper transition — which is what
235
+ makes camelCase names searchable by their humps.
236
+
237
+ ## Applying a row
238
+
239
+ `applyCompletion(text, range, item)` clamps the range into the text and returns:
240
+
241
+ | `AppliedCompletion` field | What it is |
242
+ | --- | --- |
243
+ | `text` | The document after the edit |
244
+ | `caret` | Where the caret belongs in it |
245
+ | `range` | What changed, in the **new** document, for a host that wants to scroll the result into view. The editor writes the edit from `from`/`to`/`insert` and never reads this field |
246
+ | `from` | The offset the edit starts at in the **original** document |
247
+ | `to` | The offset the edit ends at in the original document |
248
+ | `insert` | The text written between those two offsets |
249
+
250
+ `from`, `to`, and `insert` exist alongside `text` because an editor must write only
251
+ the range that changed. Assigning the whole recomputed `text` would be simpler and
252
+ would throw away the browser's undo history, which is the one thing this library
253
+ exists to protect. The editor therefore writes exactly `insert` over `[from, to)`
254
+ through the editing pipeline, then puts the caret at `caret` with
255
+ `setSelectionRange` — a selection write touches no text, so it cannot disturb the
256
+ history. Accepting a completion with `Enter` or `Tab` is therefore one undoable
257
+ edit, and the browser check asserts that undo restores the text as it was before
258
+ the accept and that redo puts it back. A commit character is written as a second
259
+ edit through the same pipeline, so whether the browser groups the two is the
260
+ browser's business rather than a promise this library makes.
261
+
262
+ `append` is skipped when the document already says it:
263
+
264
+ | Call | Result | Why |
265
+ | --- | --- | --- |
266
+ | `applyCompletion('one two', { from: 3, to: 3 }, { label: 'three', append: ' ' })` | `onethree two` | The append is whitespace and the text after the range already starts with whitespace |
267
+ | `applyCompletion('one two', { from: 3, to: 3 }, { label: 'three', append: ' ' })` | `onethree two` | Same rule: whitespace is compared as whitespace, so a tab would count too |
268
+ | `applyCompletion('a, b', { from: 1, to: 1 }, { label: 'a', append: ', ' })` | `aa, b` | The text after the range already starts with `, ` |
269
+ | `applyCompletion('Inter', { from: 0, to: 5 }, { label: 'Geist', mode: 'before', append: ', ' })` | `Geist, Inter`, caret 7 | Nothing was removed; the new family is written ahead of the old one and keeps the comma that invites the next fallback |
270
+
271
+ `append` is the grammar's business and not the engine's: inviting another entry
272
+ with a `, ` is a fact about font stacks, and an engine that appended one by
273
+ default would be guessing about every other language.
274
+
275
+ ## Against VSCode
276
+
277
+ | VSCode | litearea |
278
+ | --- | --- |
279
+ | Typing triggers suggestions | A word character or one of `triggerCharacters` (default `' '`) opens the list; `auto: false` turns it off |
280
+ | `Ctrl+Space` | `Ctrl+Space`/`Cmd+Space` opens on demand, and closes when the list is already open |
281
+ | Typing filters the list | The needle is the text between the source's current range start and the caret, matched fuzzily |
282
+ | `Enter` accepts | `Enter` accepts the active row |
283
+ | `Tab` accepts | `Tab` accepts |
284
+ | `Escape` dismisses | `Escape` closes the list; with no list open it hides the tooltip |
285
+ | Arrows move without wrapping | `ArrowDown`/`ArrowUp` clamp at the ends |
286
+ | `PageUp`/`PageDown` | Move the active row by eight |
287
+ | A click picks without stealing focus | The list listens for `mousedown`, calls `preventDefault`, and accepts — so the field never blurs and the caret is not lost |
288
+ | The documentation panel | The active row's `detail` and `documentation` render in a panel PINNED below the list, outside the element that scrolls; `showDocumentation: false` removes it. Pinning is what makes the list usable with the keyboard: a panel inside the scroll range is unreachable when the list is long and the explanation is long, because the arrows move the active row rather than the scrollbar. The panel bounds its own height and scrolls itself, so the rows stay put while the text changes in place |
289
+ | Kind icons | `kind` becomes a coloured dot with the class `litearea-kind-<kind>`; the stylesheet decides the colour |
290
+ | Commit characters | A row opts in with `commitCharacters`; typing one accepts the row, writes the character, and closes the list |
291
+ | `sortText` | The primary sort key, exactly as VSCode uses it |
292
+ | `filterText` | Matched against the needle instead of the label |
293
+ | `preselect` | Not supported: the first row is always the active one |
294
+
295
+ ## Limits
296
+
297
+ - **Sources are synchronous.** `items` returns rows, not a promise; there is no
298
+ `isIncomplete` and no re-query after the accept.
299
+ - **The list closes on accept.** A commit character writes the row and then the
300
+ character and leaves no list open, so a grammar cannot chain two completions
301
+ through a commit. There is no `'commit'` trigger for the same reason: a union
302
+ member nothing can return is a promise the code does not keep.
303
+ - **No snippet stops.** `caretOffset` is one offset from the end of what was
304
+ written, so a grammar can land the caret inside a pair of parentheses and
305
+ nowhere else.
306
+ - **The source is sticky while it stays eligible.** Only the source that answered
307
+ first is asked again, and `priority` decides only which source opens a closed
308
+ list; a source that sets `merge: true` still contributes its rows either way.
309
+ - **The whole list is re-derived on every keystroke.** The winning source is asked
310
+ for all of its rows again and the merged list is ranked again, then sliced to
311
+ `limit`. A source that builds a thousand rows pays for them on each keystroke.
312
+ - **`limit` applies after ranking**, so it caps what is returned without saving the
313
+ work of producing it.
314
+ - **Rows can come back empty.** `complete` returns `undefined` when no source is
315
+ eligible or when the winning source produced no rows at all, and a `Completion`
316
+ with an empty `rows` array when the needle filtered every row out. The editor
317
+ treats both as "close the list", not as "show an empty panel".
318
+ - **`onAccept` is called after the edit**, with `{ text, caret, item, state }` where
319
+ `state` is the analysis of the resulting text. It is the place to sync a host's
320
+ own state without re-parsing.