Line data Source code
1 : import * as dashql from '@ankoh/dashql-core';
2 :
3 : import { Range } from '@codemirror/state';
4 : import { EditorView, Decoration, DecorationSet, WidgetType, ViewPlugin, ViewUpdate } from '@codemirror/view';
5 :
6 : import { DashQLCompletionState, DashQLProcessorPlugin } from './dashql_processor.js';
7 : import { CompletionPatch, PATCH_DELETE_TEXT, PATCH_INSERT_TEXT, CompletionPatchTarget, TextAnchor, comparePatches } from './dashql_completion_patches.js';
8 :
9 : import symbols from '@ankoh/dashql-svg-symbols';
10 :
11 : import * as styles from './dashql_completion_hint.module.css';
12 :
13 : type Hint = CompletionPatch & {
14 : /// Should we render the category controls for the user?
15 : /// We want to hint the user that he can click certain keys to apply a patch.
16 : controls: boolean;
17 : };
18 :
19 : interface CompletionHints {
20 : /// The candidate completion hint
21 : candidateHints: Hint[];
22 : /// The qualifier for the candidate
23 : catalogObjectHints: Hint[];
24 : }
25 :
26 1 : function selectControls(hints: Hint[], preferFirst: boolean) {
27 1 : let firstInsert: number | null = null;
28 1 : let firstDelete: number | null = null;
29 1 : let lastInsert: number | null = null;
30 1 : let lastDelete: number | null = null;
31 :
32 : // Determine the first inserts & deletes
33 1 : for (let i = 0; i < hints.length; ++i) {
34 1 : const hint = hints[i];
35 1 : if (hint.type == PATCH_INSERT_TEXT && firstInsert == null) {
36 1 : firstInsert = i;
37 : }
38 1 : if (hint.type == PATCH_DELETE_TEXT && firstDelete == null) {
39 0 : firstDelete = i;
40 : }
41 1 : if (firstInsert != null && firstDelete != null) {
42 0 : break;
43 : }
44 1 : }
45 :
46 : // Determine the last inserts & deletes
47 1 : for (let i = hints.length - 1; i >= 0; --i) {
48 1 : const hint = hints[i];
49 1 : if (hint.type == PATCH_INSERT_TEXT && lastInsert == null) {
50 1 : lastInsert = i;
51 : }
52 1 : if (hint.type == PATCH_DELETE_TEXT && lastDelete == null) {
53 0 : lastDelete = i;
54 : }
55 1 : if (lastInsert != null && lastDelete != null) {
56 0 : break;
57 : }
58 1 : }
59 : // Select the hint with controls
60 1 : let controlsAt = null;
61 1 : if (preferFirst) {
62 1 : controlsAt = firstInsert != null ? firstInsert : firstDelete;
63 : } else {
64 1 : controlsAt = lastInsert != null ? lastInsert : lastDelete;
65 : }
66 1 : if (controlsAt != null) {
67 1 : hints[controlsAt].controls = true;
68 : }
69 1 : }
70 :
71 : /// Helper to compute the completion hints given a completion candidate a new editor state
72 1 : export function deriveCompletionHints(state: DashQLCompletionState): CompletionHints {
73 1 : const hints: CompletionHints = {
74 1 : candidateHints: state.candidatePatch.map(p => ({ ...p, controls: false })),
75 1 : catalogObjectHints: state.catalogObjectPatch.map(p => ({ ...p, controls: false })),
76 1 : };
77 1 : selectControls(hints.candidateHints, false);
78 1 : selectControls(hints.catalogObjectHints, true);
79 1 : return hints;
80 1 : }
81 :
82 1 : const INSERT_CLASSNAMES = [
83 1 : styles.hint_candidate_insert,
84 1 : styles.hint_qualification_insert,
85 1 : ];
86 1 : const DELETE_CLASSNAMES = [
87 1 : styles.hint_candidate_delete,
88 1 : styles.hint_qualification_delete,
89 1 : ];
90 1 : function getInsertClassNameForCategory(category: CompletionPatchTarget): string {
91 1 : return INSERT_CLASSNAMES[category as number - 1];
92 1 : }
93 0 : function getDeleteClassNameForCategory(category: CompletionPatchTarget): string {
94 0 : return DELETE_CLASSNAMES[category as number - 1];
95 0 : }
96 :
97 1 : export class InsertPatchWidget extends WidgetType {
98 1 : constructor(
99 1 : protected text: string,
100 1 : protected className: string
101 1 : ) {
102 1 : super();
103 1 : }
104 1 : eq(other: InsertPatchWidget): boolean {
105 1 : return this.text === other.text;
106 1 : }
107 1 : toDOM(): HTMLElement {
108 1 : const span = document.createElement('span');
109 1 : span.className = this.className;
110 1 : span.textContent = this.text;
111 1 : span.style.lineHeight = '0';
112 1 : return span;
113 1 : }
114 1 : get estimatedHeight(): number {
115 : // Inline hints must not make an otherwise empty editor line taller.
116 1 : return 0;
117 1 : }
118 1 : }
119 :
120 1 : class HintKeyWidget extends WidgetType {
121 1 : constructor(
122 1 : protected n: number | null,
123 1 : ) {
124 1 : super();
125 1 : }
126 1 : eq(other: HintKeyWidget): boolean {
127 1 : return this.n == other.n;
128 1 : }
129 1 : toDOM(): HTMLElement {
130 1 : const span = document.createElement('span');
131 :
132 : // Create the inner span with hint_ctrl_overlap_container class
133 1 : const innerSpan = document.createElement('span');
134 1 : innerSpan.className = styles.hint_ctrl_overlap_container;
135 :
136 : // Create the label for the counter (if we have one)
137 1 : if (this.n != null) {
138 0 : const labelSpan = document.createElement('span');
139 0 : labelSpan.className = styles.hint_ctrl_label;
140 0 : labelSpan.textContent = String(this.n);
141 0 : innerSpan.appendChild(labelSpan);
142 0 : }
143 :
144 : // Create the SVG element
145 1 : const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
146 1 : svg.setAttribute('width', '10px');
147 1 : svg.setAttribute('height', '10px');
148 1 : const svgSymbol = document.createElementNS('http://www.w3.org/2000/svg', 'use');
149 1 : svgSymbol.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', `${symbols}#keyboard_tab_24`);
150 1 : svg.appendChild(svgSymbol);
151 :
152 1 : innerSpan.appendChild(svg);
153 1 : span.appendChild(innerSpan);
154 :
155 1 : span.className = styles.hint_ctrl_container;
156 1 : return span;
157 1 : }
158 0 : get estimatedHeight(): number {
159 0 : return -1; // Use line height
160 0 : }
161 1 : }
162 :
163 1 : function computeCompletionHintDecorations(viewUpdate: ViewUpdate): DecorationSet {
164 1 : const processor = viewUpdate.state.field(DashQLProcessorPlugin);
165 :
166 : // Find the selected completion
167 1 : if (processor.scriptCompletion == null || processor.editorUpdate?.primaryCursorState == null) {
168 1 : return Decoration.none;
169 : }
170 1 : const isPassive = processor.scriptCompletion.passiveHint;
171 :
172 : // Suppress the inline hint when the selected candidate has an incompatible suffix.
173 1 : const completionBuffer = processor.scriptCompletion.buffer.read();
174 1 : const selectedCandidate = completionBuffer.candidates(processor.scriptCompletion.candidateId);
175 1 : if (selectedCandidate != null
176 1 : && (selectedCandidate.candidateTags()! & dashql.buffers.completion.CandidateTag.SUFFIX_INCOMPATIBLE) != 0) {
177 1 : return Decoration.none;
178 : }
179 :
180 : // Compute the new completion hints
181 1 : const hints = deriveCompletionHints(processor.scriptCompletion);
182 1 : if (hints == null) {
183 1 : return Decoration.none;
184 : }
185 :
186 : // Merge all hints.
187 : // Codemirror requires Decorations to be ordered by `at` & `side`
188 1 : const mergedPatches = [
189 1 : ...hints.candidateHints,
190 1 : ...hints.catalogObjectHints
191 1 : ];
192 1 : mergedPatches.sort(comparePatches);
193 :
194 : // Create decorations
195 1 : const decorations: Range<Decoration>[] = [];
196 1 : for (const patch of mergedPatches) {
197 1 : switch (patch.type) {
198 1 : case PATCH_INSERT_TEXT: {
199 1 : const side = (patch.value.textAnchor == TextAnchor.Left ? 1 : -1) * (patch.target as number);
200 :
201 : /// Construct the insert widget
202 1 : const insertClassname = getInsertClassNameForCategory(patch.target);
203 1 : const insertWidget = new InsertPatchWidget(patch.value.text, insertClassname);
204 1 : const insertDeco = Decoration.widget({ widget: insertWidget, side }).range(patch.value.at);
205 1 : decorations.push(insertDeco);
206 :
207 : // Insert controls after?
208 1 : if (patch.controls && !isPassive) {
209 1 : const controlsWidget = new HintKeyWidget(null);
210 1 : const controlDeco = Decoration.widget({ widget: controlsWidget, side }).range(patch.value.at);
211 1 : decorations.push(controlDeco);
212 1 : }
213 1 : break;
214 1 : }
215 1 : case PATCH_DELETE_TEXT:
216 : // Construct the deletion widget
217 1 : const deleteClassName = getDeleteClassNameForCategory(patch.target);
218 1 : const deleteDeco = Decoration.mark({ class: deleteClassName }).range(patch.value.at, patch.value.at + patch.value.length);
219 1 : decorations.push(deleteDeco);
220 :
221 : // Emit controls?
222 1 : if (patch.controls && !isPassive) {
223 1 : const controlsWidget = new HintKeyWidget(null);
224 1 : const controlDeco = Decoration.widget({ widget: controlsWidget }).range(patch.value.at);
225 1 : decorations.push(controlDeco);
226 1 : }
227 : break;
228 1 : }
229 : }
230 1 : return Decoration.set(decorations);
231 1 : };
232 :
233 1 : export const DashQLCompletionHintPlugin = ViewPlugin.fromClass(
234 1 : class {
235 1 : decorations: DecorationSet;
236 1 : constructor(readonly view: EditorView) {
237 1 : this.decorations = Decoration.none;
238 1 : }
239 1 : update(u: ViewUpdate) {
240 1 : const next = computeCompletionHintDecorations(u);
241 1 : if (next !== this.decorations) {
242 1 : this.decorations = next;
243 : }
244 1 : }
245 1 : },
246 1 : { decorations: v => v.decorations }
247 1 : );
|