@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.
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/README.zh.md +362 -0
- package/dist/grammars.cjs +1228 -0
- package/dist/grammars.cjs.map +1 -0
- package/dist/grammars.js +1213 -0
- package/dist/grammars.js.map +1 -0
- package/dist/index.cjs +3103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +3040 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +3032 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +3010 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.cjs +453 -0
- package/dist/styles.cjs.map +1 -0
- package/dist/styles.css +432 -0
- package/dist/styles.js +447 -0
- package/dist/styles.js.map +1 -0
- package/dist/types/core/complete.d.ts +70 -0
- package/dist/types/core/complete.d.ts.map +1 -0
- package/dist/types/core/format.d.ts +34 -0
- package/dist/types/core/format.d.ts.map +1 -0
- package/dist/types/core/grammar.d.ts +14 -0
- package/dist/types/core/grammar.d.ts.map +1 -0
- package/dist/types/core/hover.d.ts +23 -0
- package/dist/types/core/hover.d.ts.map +1 -0
- package/dist/types/core/index.d.ts +12 -0
- package/dist/types/core/index.d.ts.map +1 -0
- package/dist/types/core/inspect.d.ts +34 -0
- package/dist/types/core/inspect.d.ts.map +1 -0
- package/dist/types/core/rank.d.ts +82 -0
- package/dist/types/core/rank.d.ts.map +1 -0
- package/dist/types/core/scan.d.ts +51 -0
- package/dist/types/core/scan.d.ts.map +1 -0
- package/dist/types/core/segments.d.ts +44 -0
- package/dist/types/core/segments.d.ts.map +1 -0
- package/dist/types/core/text.d.ts +113 -0
- package/dist/types/core/text.d.ts.map +1 -0
- package/dist/types/core/types.d.ts +604 -0
- package/dist/types/core/types.d.ts.map +1 -0
- package/dist/types/core/vocabulary.d.ts +82 -0
- package/dist/types/core/vocabulary.d.ts.map +1 -0
- package/dist/types/dom/create.d.ts +17 -0
- package/dist/types/dom/create.d.ts.map +1 -0
- package/dist/types/dom/editing.d.ts +89 -0
- package/dist/types/dom/editing.d.ts.map +1 -0
- package/dist/types/dom/editor.d.ts +366 -0
- package/dist/types/dom/editor.d.ts.map +1 -0
- package/dist/types/dom/index.d.ts +9 -0
- package/dist/types/dom/index.d.ts.map +1 -0
- package/dist/types/dom/mirror.d.ts +107 -0
- package/dist/types/dom/mirror.d.ts.map +1 -0
- package/dist/types/dom/overlay.d.ts +52 -0
- package/dist/types/dom/overlay.d.ts.map +1 -0
- package/dist/types/dom/popup.d.ts +95 -0
- package/dist/types/dom/popup.d.ts.map +1 -0
- package/dist/types/dom/support.d.ts +41 -0
- package/dist/types/dom/support.d.ts.map +1 -0
- package/dist/types/dom/tooltip.d.ts +39 -0
- package/dist/types/dom/tooltip.d.ts.map +1 -0
- package/dist/types/grammars/dshFont.d.ts +127 -0
- package/dist/types/grammars/dshFont.d.ts.map +1 -0
- package/dist/types/grammars/dshSentry.d.ts +84 -0
- package/dist/types/grammars/dshSentry.d.ts.map +1 -0
- package/dist/types/grammars/index.d.ts +3 -0
- package/dist/types/grammars/index.d.ts.map +1 -0
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/react/index.d.ts +91 -0
- package/dist/types/react/index.d.ts.map +1 -0
- package/dist/types/styles.d.ts +29 -0
- package/dist/types/styles.d.ts.map +1 -0
- package/docs/architecture.md +316 -0
- package/docs/completion.md +320 -0
- package/docs/grammar.md +823 -0
- package/package.json +105 -0
- package/scripts/browser-check.mjs +838 -0
- package/scripts/build-css.mjs +35 -0
- package/scripts/release.mjs +91 -0
- package/scripts/verify-package.mjs +253 -0
- package/src/core/complete.ts +286 -0
- package/src/core/format.ts +71 -0
- package/src/core/grammar.ts +40 -0
- package/src/core/hover.ts +129 -0
- package/src/core/index.ts +98 -0
- package/src/core/inspect.ts +198 -0
- package/src/core/rank.ts +317 -0
- package/src/core/scan.ts +720 -0
- package/src/core/segments.ts +185 -0
- package/src/core/text.ts +238 -0
- package/src/core/types.ts +681 -0
- package/src/core/vocabulary.ts +196 -0
- package/src/dom/create.ts +31 -0
- package/src/dom/editing.ts +213 -0
- package/src/dom/editor.ts +1143 -0
- package/src/dom/index.ts +46 -0
- package/src/dom/mirror.ts +305 -0
- package/src/dom/overlay.ts +106 -0
- package/src/dom/popup.ts +323 -0
- package/src/dom/support.ts +88 -0
- package/src/dom/tooltip.ts +112 -0
- package/src/grammars/dshFont.ts +1004 -0
- package/src/grammars/dshSentry.ts +742 -0
- package/src/grammars/index.ts +57 -0
- package/src/index.ts +122 -0
- package/src/react/index.tsx +248 -0
- 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.
|