Line data Source code
1 : import * as dashql from '@ankoh/dashql-core';
2 :
3 : import { CompletionCandidateType, getCandidateTypeSymbolColor, getCandidateTypeSymbolText } from './completion_candidate_type.js';
4 :
5 : import * as styles from './completion_list.module.css';
6 : import icons from '@ankoh/dashql-svg-symbols';
7 :
8 :
9 : // This file renders a completion list with a side pane, for the editor and for the shell.
10 : // The rendering deliberately does NOT use React.
11 : // We're implementing the view consolidation manually here.
12 : //
13 : // It knows nothing about its host: the host reads a completion buffer with collectCandidates, passes the position of
14 : // the text that is replaced to placeBelowOrAbove, and keeps the focus in its input.
15 :
16 :
17 : export interface Position {
18 : /// The top offset (used when rendering below the cursor)
19 : top: number | null;
20 : /// The bottom offset (used when rendering above the cursor)
21 : bottom: number | null;
22 : /// The left offset
23 : left: number;
24 : }
25 :
26 : /// An entry of a table in the side pane
27 : interface CandidateDetailsField {
28 : /// The name of a parameter or column, or the label of a fact
29 : name: string;
30 : /// The type, or the value of a fact
31 : detail: string | null;
32 : /// The prose
33 : description: string | null;
34 : }
35 :
36 : /// What the side pane shows about a candidate
37 : interface CandidateDetails {
38 : /// One line: a signature, a type, a table
39 : detail: string | null;
40 : /// The prose
41 : description: string | null;
42 : /// What the candidate takes
43 : parameters: CandidateDetailsField[];
44 : /// What a result holds
45 : resultDescription: string | null;
46 : /// The columns of a result or a table
47 : columns: CandidateDetailsField[];
48 : /// A call that shows how to write it
49 : example: string | null;
50 : /// Short facts
51 : facts: CandidateDetailsField[];
52 : }
53 :
54 : export interface VirtualCandidate {
55 : /// The candidate type.
56 : /// Either the object type of the selected object or the first one.
57 : candidateType: CompletionCandidateType;
58 : /// The candidate text
59 : candidateLabel: string;
60 : /// What the side pane shows, only read for the selected candidate and of its selected object
61 : details: CandidateDetails | null;
62 : /// Is selected?
63 : isSelected: boolean;
64 : /// The total catalog objects
65 : totalObjectCount: number;
66 : /// The selected catalog object
67 : selectedCatalogObject: number | null;
68 : }
69 :
70 : /// Derive the type of a candidate from an object of it, a candidate without objects has only its name tags
71 1 : function getCandidateType(
72 1 : candidate: dashql.buffers.completion.CompletionCandidate,
73 1 : object: dashql.buffers.completion.CompletionCandidateObject | null,
74 1 : ): CompletionCandidateType {
75 1 : if (candidate.candidateTags() & dashql.buffers.completion.CandidateTag.IDENTITY) {
76 1 : return CompletionCandidateType.IDENTITY;
77 : }
78 1 : if (object != null) {
79 : // A table parameter reads the result of a script or an attachment
80 1 : switch (object.tableParameter()) {
81 0 : case dashql.buffers.completion.CompletionTableParameter.SCRIPT:
82 0 : return CompletionCandidateType.SCRIPT;
83 1 : case dashql.buffers.completion.CompletionTableParameter.ATTACHMENT:
84 1 : return CompletionCandidateType.ATTACHMENT;
85 1 : }
86 1 : return (object.objectType() as number) as CompletionCandidateType;
87 1 : }
88 1 : const nameTags = candidate.nameTags();
89 1 : if (nameTags & dashql.buffers.analyzer.NameTag.FUNCTION_NAME) return CompletionCandidateType.FUNCTION;
90 1 : if (nameTags & dashql.buffers.analyzer.NameTag.SCHEMA_NAME) return CompletionCandidateType.SCHEMA;
91 1 : if (nameTags & dashql.buffers.analyzer.NameTag.OPTION_NAME) return CompletionCandidateType.OPTION;
92 1 : return CompletionCandidateType.KEYWORD;
93 1 : }
94 :
95 : /// The inline Markdown of a description: code, bold and links
96 1 : const INLINE_MARKDOWN = /`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\([^)\s]+\)/g;
97 :
98 : /// Render a description into an element.
99 : /// A description is text, everything goes through text nodes. A link shows its label, a click must not leave the input.
100 1 : function renderDescription(element: HTMLElement, description: string) {
101 1 : const nodes: Node[] = [];
102 1 : let offset = 0;
103 1 : for (const match of description.matchAll(INLINE_MARKDOWN)) {
104 1 : nodes.push(document.createTextNode(description.slice(offset, match.index)));
105 1 : offset = match.index + match[0].length;
106 1 : if (match[1] !== undefined) {
107 1 : const code = document.createElement('code');
108 1 : code.textContent = match[1];
109 1 : nodes.push(code);
110 1 : } else if (match[2] !== undefined) {
111 1 : const strong = document.createElement('strong');
112 1 : strong.textContent = match[2];
113 1 : nodes.push(strong);
114 1 : } else {
115 1 : nodes.push(document.createTextNode(match[3]));
116 : }
117 1 : }
118 1 : nodes.push(document.createTextNode(description.slice(offset)));
119 1 : element.replaceChildren(...nodes);
120 1 : }
121 :
122 : /// Read the details of a candidate
123 1 : function readCandidateDetails(info: dashql.buffers.completion.CompletionCandidateInfo): CandidateDetails {
124 1 : const tmpField = new dashql.buffers.completion.CompletionCandidateField();
125 1 : const readFields = (length: number, field: (index: number, tmp: dashql.buffers.completion.CompletionCandidateField) => dashql.buffers.completion.CompletionCandidateField | null) => {
126 1 : const out: CandidateDetailsField[] = [];
127 1 : for (let i = 0; i < length; ++i) {
128 1 : const f = field(i, tmpField)!;
129 1 : out.push({ name: f.name()!, detail: f.detail(), description: f.description() });
130 1 : }
131 1 : return out;
132 1 : };
133 1 : return {
134 1 : detail: info.detail(),
135 1 : description: info.description(),
136 1 : parameters: readFields(info.parametersLength(), (i, tmp) => info.parameters(i, tmp)),
137 1 : resultDescription: info.resultDescription(),
138 1 : columns: readFields(info.columnsLength(), (i, tmp) => info.columns(i, tmp)),
139 1 : example: info.example(),
140 1 : facts: readFields(info.factsLength(), (i, tmp) => info.facts(i, tmp)),
141 1 : };
142 1 : }
143 :
144 : /// Create an element with a class
145 1 : function createDetailsElement(tag: string, className: string, text: string | null = null): HTMLElement {
146 1 : const element = document.createElement(tag);
147 1 : element.classList.add(className);
148 1 : element.textContent = text;
149 1 : return element;
150 1 : }
151 :
152 : /// Render a table of the side pane
153 1 : function renderDetailsFields(tableClass: string, fields: CandidateDetailsField[]): HTMLElement {
154 1 : const table = createDetailsElement('div', tableClass);
155 : // A table with prose has a third column, every row fills it so that the rows line up
156 1 : const described = fields.some(field => field.description != null);
157 1 : table.classList.toggle(styles.details_fields_described, described);
158 1 : for (const field of fields) {
159 1 : table.appendChild(createDetailsElement('div', styles.details_field_name, field.name));
160 1 : table.appendChild(createDetailsElement('div', styles.details_field_detail, field.detail));
161 1 : if (described) {
162 1 : const description = createDetailsElement('div', styles.details_field_description);
163 1 : renderDescription(description, field.description ?? '');
164 1 : table.appendChild(description);
165 1 : }
166 1 : }
167 1 : return table;
168 1 : }
169 :
170 : /// The pane next to the list, with what is known about the selected candidate
171 1 : class CandidateDetailsRenderer {
172 : /// What is rendered
173 1 : renderedKey: string | null = null;
174 : /// Is the pane shown?
175 1 : visible: boolean = true;
176 :
177 : /// The pane
178 1 : public readonly rootElement: HTMLDivElement;
179 :
180 1 : constructor() {
181 1 : this.rootElement = document.createElement('div');
182 1 : this.rootElement.classList.add(styles.details_container);
183 1 : this.hide();
184 1 : }
185 :
186 : /// Is the pane shown?
187 1 : public get isVisible() { return this.visible; }
188 : /// Hide the pane (if shown)
189 1 : public hide() {
190 1 : if (this.visible) {
191 1 : this.rootElement.classList.add(styles.hidden);
192 1 : this.visible = false;
193 1 : }
194 1 : }
195 : /// Show the pane (if hidden)
196 1 : protected show() {
197 1 : if (!this.visible) {
198 1 : this.rootElement.classList.remove(styles.hidden);
199 1 : this.visible = true;
200 1 : }
201 1 : }
202 : /// Render the selected candidate.
203 : /// A candidate that nothing is known about has no pane.
204 1 : public render(selected: VirtualCandidate | null) {
205 1 : const details = selected?.details ?? null;
206 1 : if (details == null) {
207 1 : this.hide();
208 1 : return;
209 1 : }
210 : // A selection and a keystroke can both change the details, so compare what they say
211 1 : const key = JSON.stringify([details, selected!.candidateType]);
212 1 : if (key != this.renderedKey) {
213 1 : this.rootElement.replaceChildren(...this.renderContent(selected!.candidateType, details));
214 1 : this.renderedKey = key;
215 1 : this.rootElement.scrollTop = 0;
216 1 : }
217 1 : this.show();
218 1 : }
219 : /// Render the pane: a header that says what the candidate is, and a section for everything else
220 1 : protected renderContent(type: CompletionCandidateType, details: CandidateDetails): HTMLElement[] {
221 1 : const out: HTMLElement[] = [];
222 1 : if (details.detail != null || details.description != null) {
223 1 : const header = createDetailsElement('div', styles.details_header);
224 1 : if (details.detail != null) {
225 : // The table below has the same parameters, the description should stay in view
226 1 : const detail = createDetailsElement('div', styles.details_detail, details.detail);
227 1 : detail.classList.toggle(styles.details_detail_clamped, details.parameters.length > 0);
228 1 : header.appendChild(detail);
229 1 : }
230 1 : if (details.description != null) {
231 1 : const description = createDetailsElement('div', styles.details_description);
232 1 : renderDescription(description, details.description);
233 1 : header.appendChild(description);
234 1 : }
235 1 : out.push(header);
236 1 : }
237 1 : const addSection = (title: string, ...content: HTMLElement[]) => {
238 1 : const section = createDetailsElement('div', styles.details_section);
239 1 : section.appendChild(createDetailsElement('div', styles.details_title, title));
240 1 : section.append(...content);
241 1 : out.push(section);
242 1 : };
243 1 : if (details.parameters.length > 0) {
244 1 : addSection(type == CompletionCandidateType.OPTION ? 'Attributes' : 'Parameters', renderDetailsFields(styles.details_fields, details.parameters));
245 : }
246 1 : if (details.resultDescription != null || details.columns.length > 0) {
247 1 : const content: HTMLElement[] = [];
248 1 : if (details.resultDescription != null) {
249 1 : const description = createDetailsElement('div', styles.details_description);
250 1 : renderDescription(description, details.resultDescription);
251 1 : content.push(description);
252 1 : }
253 1 : if (details.columns.length > 0) {
254 1 : content.push(renderDetailsFields(styles.details_fields, details.columns));
255 : }
256 1 : addSection(type == CompletionCandidateType.FUNCTION ? 'Result' : 'Columns', ...content);
257 1 : }
258 1 : if (details.example != null) {
259 1 : addSection('Example', createDetailsElement('div', styles.details_example, details.example));
260 : }
261 1 : if (details.facts.length > 0) {
262 1 : addSection('Details', renderDetailsFields(styles.details_facts, details.facts));
263 : }
264 1 : return out;
265 1 : }
266 1 : }
267 :
268 1 : class CandidateRenderer {
269 : /// The currently rendered candidate
270 1 : rendered: VirtualCandidate | null;
271 :
272 : /// Info element visible?
273 1 : infoVisible: boolean;
274 : /// Selected object count visible?
275 1 : objectContainerVisible: boolean;
276 : /// Selected object index visible?
277 1 : objectSelectionVisible: boolean;
278 1 : navVisible: boolean;
279 :
280 : /// The entry element
281 1 : public readonly rootElement: HTMLDivElement;
282 : /// The icon element
283 1 : readonly iconElement: HTMLSpanElement;
284 : /// The name element
285 1 : readonly nameElement: HTMLSpanElement;
286 : /// The info element
287 1 : readonly infoElement: HTMLDivElement;
288 :
289 : /// The nav container element
290 1 : readonly navContainerElement: HTMLDivElement;
291 : /// The left arrow
292 1 : readonly navArrowLeftElement: SVGElement;
293 : /// The right arrow
294 1 : readonly navArrowRightElement: SVGElement;
295 : /// The container for the object count
296 1 : readonly objectContainerElement: HTMLDivElement;
297 : /// The span for the selected catalog object
298 1 : readonly objectSelectedSpan: HTMLSpanElement;
299 : /// The span for the catalog object count
300 1 : readonly objectTotalSpan: HTMLSpanElement;
301 :
302 1 : constructor(candidate: VirtualCandidate, selectPreviousVariant: () => void, selectNextVariant: () => void) {
303 1 : this.rendered = null;
304 1 : this.rootElement = document.createElement('div');
305 1 : this.iconElement = document.createElement('span');
306 1 : this.nameElement = document.createElement('span');
307 1 : this.infoElement = document.createElement('div');
308 1 : this.infoVisible = true;
309 1 : this.objectContainerVisible = true;
310 1 : this.objectSelectionVisible = true;
311 1 : this.navVisible = true;
312 :
313 1 : this.navContainerElement = document.createElement('div');
314 1 : this.navArrowLeftElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
315 1 : this.navArrowRightElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
316 :
317 1 : const navArrowLeftUse = document.createElementNS('http://www.w3.org/2000/svg', 'use');
318 1 : this.navArrowLeftElement.appendChild(navArrowLeftUse);
319 1 : this.navArrowLeftElement.setAttribute('width', '13px');
320 1 : this.navArrowLeftElement.setAttribute('height', '13px');
321 1 : navArrowLeftUse.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', `${icons}#arrow_left_16`);
322 :
323 1 : const navArrowRightUse = document.createElementNS('http://www.w3.org/2000/svg', 'use');
324 1 : this.navArrowRightElement.appendChild(navArrowRightUse);
325 1 : this.navArrowRightElement.setAttribute('width', '13px');
326 1 : this.navArrowRightElement.setAttribute('height', '13px');
327 1 : navArrowRightUse.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', `${icons}#arrow_right_16`);
328 1 : this.navArrowLeftElement.setAttribute('role', 'button');
329 1 : this.navArrowLeftElement.setAttribute('aria-label', 'Previous catalog object');
330 1 : this.navArrowRightElement.setAttribute('role', 'button');
331 1 : this.navArrowRightElement.setAttribute('aria-label', 'Next catalog object');
332 1 : this.navArrowLeftElement.addEventListener('mousedown', event => {
333 1 : event.preventDefault();
334 1 : selectPreviousVariant();
335 1 : });
336 1 : this.navArrowRightElement.addEventListener('mousedown', event => {
337 1 : event.preventDefault();
338 1 : selectNextVariant();
339 1 : });
340 :
341 1 : this.objectContainerElement = document.createElement('div');
342 1 : this.objectSelectedSpan = document.createElement('span');
343 1 : this.objectSelectedSpan.classList.add(styles.info_selected_count);
344 1 : this.objectTotalSpan = document.createElement('span');
345 :
346 1 : const objectLogoSVG = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
347 1 : const objectLogoUse = document.createElementNS('http://www.w3.org/2000/svg', 'use');
348 1 : objectLogoSVG.setAttribute('width', '12px');
349 1 : objectLogoSVG.setAttribute('height', '12px');
350 1 : objectLogoUse.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', `${icons}#versions_16`);
351 1 : objectLogoSVG.appendChild(objectLogoUse);
352 :
353 : // Set up containers
354 1 : this.rootElement.classList.add(styles.candidate_container);
355 1 : this.iconElement.classList.add(styles.candidate_icon);
356 1 : this.nameElement.classList.add(styles.candidate_name);
357 1 : this.infoElement.classList.add(styles.info_container);
358 1 : this.navContainerElement.classList.add(styles.info_nav_container);
359 1 : this.navArrowLeftElement.classList.add(styles.info_nav_left);
360 1 : this.navArrowRightElement.classList.add(styles.info_nav_right);
361 1 : this.objectContainerElement.classList.add(styles.info_object_container);
362 :
363 1 : this.iconElement.textContent = getCandidateTypeSymbolText(candidate.candidateType);
364 1 : this.iconElement.style.backgroundColor = getCandidateTypeSymbolColor(candidate.candidateType);
365 :
366 : // Wire containers
367 1 : this.navContainerElement.appendChild(this.navArrowLeftElement);
368 1 : this.navContainerElement.appendChild(this.navArrowRightElement);
369 1 : this.objectContainerElement.appendChild(objectLogoSVG);
370 1 : this.objectContainerElement.appendChild(this.objectSelectedSpan);
371 1 : this.objectContainerElement.appendChild(this.objectTotalSpan);
372 1 : this.infoElement.appendChild(this.navContainerElement);
373 1 : this.infoElement.appendChild(this.objectContainerElement);
374 1 : this.rootElement.appendChild(this.iconElement);
375 1 : this.rootElement.appendChild(this.nameElement);
376 1 : this.rootElement.appendChild(this.infoElement);
377 :
378 1 : this.render(candidate);
379 1 : }
380 :
381 : // Destroy the node
382 1 : public destroy() {
383 1 : this.rootElement.remove();
384 1 : }
385 :
386 : /// Helper to hide the candidate info (if not already hidden)
387 1 : protected hideInfoContainer() {
388 1 : if (this.infoVisible) {
389 1 : this.infoElement.classList.add(styles.hidden);
390 1 : this.infoVisible = false;
391 1 : }
392 1 : }
393 : /// Helper to hide the object selection (if not already hidden)
394 1 : protected hideSelectedObject() {
395 1 : if (this.objectSelectionVisible) {
396 1 : this.objectSelectedSpan.classList.add(styles.hidden);
397 1 : this.objectSelectionVisible = false;
398 1 : }
399 1 : }
400 : /// Helper to hide the object container (if not already hidden)
401 1 : protected hideObjectContainer() {
402 1 : if (this.objectContainerVisible) {
403 1 : this.objectContainerElement.classList.add(styles.hidden);
404 1 : this.objectContainerVisible = false;
405 1 : }
406 1 : }
407 : /// Helper to show the candidate info (if not already hidden)
408 1 : protected showInfoContainer() {
409 1 : if (!this.infoVisible) {
410 1 : this.infoElement.classList.remove(styles.hidden);
411 1 : this.infoVisible = true;
412 1 : }
413 1 : }
414 : /// Helper to show the object selection (if not already hidden)
415 1 : protected showSelectedObject() {
416 1 : if (!this.objectSelectionVisible) {
417 1 : this.objectSelectedSpan.classList.remove(styles.hidden);
418 1 : this.objectSelectionVisible = true;
419 1 : }
420 1 : }
421 : /// Helper to show the object container (if not already hidden)
422 1 : protected showObjectContainer() {
423 1 : if (!this.objectContainerVisible) {
424 1 : this.objectContainerElement.classList.remove(styles.hidden);
425 1 : this.objectContainerVisible = true;
426 1 : }
427 1 : }
428 1 : protected hideNavigation() {
429 1 : if (!this.navVisible) return;
430 1 : this.navContainerElement.classList.add(styles.hidden);
431 1 : this.navVisible = false;
432 1 : }
433 1 : protected showNavigation() {
434 1 : if (this.navVisible) return;
435 1 : this.navContainerElement.classList.remove(styles.hidden);
436 1 : this.navVisible = true;
437 1 : }
438 1 : public render(candidate: VirtualCandidate) {
439 : // Is the element selected?
440 1 : if (candidate.isSelected != this.rendered?.isSelected) {
441 1 : if (candidate.isSelected) {
442 1 : this.rootElement.classList.add(styles.selected);
443 : } else {
444 1 : this.rootElement.classList.remove(styles.selected);
445 : }
446 1 : }
447 : // Does the label differ?
448 1 : if (candidate.candidateLabel != this.rendered?.candidateLabel) {
449 1 : this.nameElement.textContent = candidate.candidateLabel;
450 : }
451 : // Does the object type differ?
452 1 : if (candidate.candidateType != this.rendered?.candidateType) {
453 1 : this.iconElement.textContent = getCandidateTypeSymbolText(candidate.candidateType);
454 1 : this.iconElement.style.backgroundColor = getCandidateTypeSymbolColor(candidate.candidateType);
455 1 : }
456 : // Is selected and has selectable?
457 1 : const anySelectable = candidate.totalObjectCount > 0;
458 1 : if (candidate.isSelected && anySelectable) {
459 1 : this.showInfoContainer();
460 : } else {
461 1 : this.hideInfoContainer();
462 : }
463 : // Update selected object?
464 1 : if (candidate.selectedCatalogObject == null) {
465 1 : this.hideSelectedObject();
466 1 : } else if (candidate.selectedCatalogObject != this.rendered?.selectedCatalogObject) {
467 1 : this.showSelectedObject();
468 1 : this.objectSelectedSpan.textContent = (candidate.selectedCatalogObject + 1).toString();
469 1 : }
470 : // Update the total object count
471 1 : if (candidate.totalObjectCount != this.rendered?.totalObjectCount) {
472 1 : this.objectTotalSpan.textContent = candidate.totalObjectCount.toString();
473 : }
474 : // Check object totals
475 1 : if (candidate.totalObjectCount > 0) {
476 1 : this.showObjectContainer();
477 : } else {
478 1 : this.hideObjectContainer();
479 : }
480 1 : if (candidate.totalObjectCount > 1) {
481 1 : this.showNavigation();
482 : } else {
483 1 : this.hideNavigation();
484 : }
485 1 : this.rendered = candidate;
486 1 : }
487 1 : }
488 :
489 1 : export class CandidateListRenderer {
490 : /// Is the list shown?
491 1 : rootVisible: boolean;
492 : /// The list position
493 1 : rootPosition: Position;
494 :
495 : /// The overlay container
496 1 : public readonly rootElement: HTMLDivElement;
497 : /// The list container
498 1 : readonly listContainer: HTMLDivElement;
499 : /// The list entries
500 1 : readonly renderedCandidates: CandidateRenderer[];
501 : /// The pane next to the list
502 1 : readonly details: CandidateDetailsRenderer;
503 1 : readonly selectPreviousVariant: () => void;
504 1 : readonly selectNextVariant: () => void;
505 :
506 1 : constructor(selectPreviousVariant: () => void, selectNextVariant: () => void) {
507 1 : this.rootVisible = true;
508 1 : this.rootPosition = { top: null, bottom: null, left: -1 };
509 1 : this.selectPreviousVariant = selectPreviousVariant;
510 1 : this.selectNextVariant = selectNextVariant;
511 :
512 1 : this.rootElement = document.createElement('div');
513 1 : this.rootElement.className = styles.overlay_container;
514 1 : this.rootElement.dataset.dashqlCompletionList = 'true';
515 1 : this.listContainer = document.createElement('div');
516 1 : this.listContainer.className = styles.list_container;
517 1 : this.details = new CandidateDetailsRenderer();
518 1 : this.rootElement.appendChild(this.listContainer);
519 1 : this.rootElement.appendChild(this.details.rootElement);
520 : // A click must not take the focus from the input, that ends the completion. Scrolling still works.
521 1 : this.rootElement.addEventListener('mousedown', event => event.preventDefault());
522 :
523 1 : this.renderedCandidates = [];
524 :
525 1 : this.hide();
526 1 : }
527 :
528 1 : public destroy() {
529 1 : this.rootElement.remove();
530 1 : }
531 :
532 : /// Is hidden?
533 1 : public get isHidden() { return !this.rootVisible; }
534 : /// Hide the list (if shown)
535 1 : public hide() {
536 1 : if (this.rootVisible) {
537 1 : this.rootElement.classList.add(styles.hidden);
538 1 : this.rootVisible = false;
539 1 : }
540 1 : }
541 : /// Show the list (if hidden)
542 1 : public show() {
543 1 : if (!this.rootVisible) {
544 1 : this.rootElement.classList.remove(styles.hidden);
545 1 : this.rootVisible = true;
546 1 : }
547 1 : }
548 : /// Update the position
549 1 : public updatePosition(position: Position) {
550 1 : if (this.rootPosition.top !== position.top || this.rootPosition.bottom !== position.bottom || this.rootPosition.left !== position.left) {
551 1 : if (position.bottom !== null) {
552 1 : this.rootElement.style.top = '';
553 1 : this.rootElement.style.bottom = `${position.bottom}px`;
554 1 : } else {
555 1 : this.rootElement.style.bottom = '';
556 1 : this.rootElement.style.top = `${position.top ?? 0}px`;
557 1 : }
558 1 : this.rootElement.style.left = `${position.left}px`;
559 1 : this.rootPosition = position;
560 1 : }
561 1 : }
562 : /// Update the candidates
563 1 : public updateCandidates(candidates: VirtualCandidate[]) {
564 : // Reuse rendered candidates
565 1 : const n = Math.min(candidates.length, this.renderedCandidates.length);
566 1 : for (let i = 0; i < n; ++i) {
567 1 : const rendered = this.renderedCandidates[i];
568 1 : rendered.render(candidates[i]);
569 : }
570 : // Delete excess rendered
571 1 : const dead = this.renderedCandidates.splice(n, this.renderedCandidates.length - n);
572 1 : for (let i = 0; i < dead.length; ++i) {
573 1 : this.listContainer.removeChild(dead[i].rootElement);
574 1 : dead[i].destroy();
575 1 : }
576 : // Create new rendered
577 1 : for (let i = n; i < candidates.length; ++i) {
578 1 : const entry = new CandidateRenderer(candidates[i], this.selectPreviousVariant, this.selectNextVariant);
579 1 : this.listContainer.appendChild(entry.rootElement);
580 1 : this.renderedCandidates.push(entry);
581 1 : }
582 : // Render what is known about the selected candidate, and keep that candidate in view
583 1 : const selected = candidates.findIndex(c => c.isSelected);
584 1 : this.details.render(candidates[selected] ?? null);
585 1 : this.renderedCandidates[selected]?.rootElement.scrollIntoView({ block: 'nearest' });
586 1 : }
587 : /// Fit the rendered list into the window: the pane is the first to go, then the list moves away from the cursor
588 1 : public fitIntoWindow(position: Position): Position {
589 1 : const windowWidth = window.innerWidth;
590 : // The pane shrinks to its min-width first, the content then overflows the list
591 1 : if (this.details.isVisible && this.rootElement.scrollWidth > windowWidth) {
592 0 : this.details.hide();
593 : }
594 1 : const left = Math.max(0, Math.min(position.left, windowWidth - this.rootElement.offsetWidth));
595 1 : return { ...position, left };
596 1 : }
597 1 : }
598 :
599 : /// The position of the list for the box of the replaced text, in viewport coordinates.
600 : /// Below the text, or above if there is less space below than the list needs. The width is only known after rendering,
601 : /// see fitIntoWindow.
602 1 : export function placeBelowOrAbove(anchor: { left: number, top: number, bottom: number }): Position {
603 : // The height for overflow checks
604 1 : const boxHeight = 240; // max-height from CSS
605 :
606 : // The anchor has viewport coordinates, which map 1:1 to position:fixed.
607 1 : const windowHeight = window.innerHeight;
608 :
609 : // Flip to above if there's insufficient space below.
610 : // Use CSS `bottom` positioning so the list anchors above the cursor line.
611 1 : const spaceBelow = windowHeight - (anchor.bottom + 5);
612 1 : const spaceAbove = anchor.top - 5;
613 1 : const renderAbove = spaceBelow < boxHeight && spaceAbove > spaceBelow;
614 1 : const left = anchor.left;
615 :
616 1 : if (renderAbove) {
617 : // position:fixed bottom = distance from viewport bottom to list's bottom edge.
618 : // We want the list's bottom edge 5px above the cursor's top.
619 1 : const bottom = windowHeight - anchor.top + 5;
620 1 : return { top: null, bottom: Math.max(0, bottom), left };
621 1 : } else {
622 1 : const top = anchor.bottom + 5;
623 1 : return { top, bottom: null, left };
624 : }
625 1 : }
626 :
627 : /// Collect the candidates
628 1 : export function collectCandidates(completion: dashql.buffers.completion.Completion, selectedCandidate: number, selectedCatalogObject: number | null): VirtualCandidate[] {
629 1 : const out: VirtualCandidate[] = [];
630 1 : const tmpCandidate = new dashql.buffers.completion.CompletionCandidate();
631 1 : const tmpCatalogObject = new dashql.buffers.completion.CompletionCandidateObject();
632 1 : const tmpInfo = new dashql.buffers.completion.CompletionCandidateInfo();
633 :
634 1 : for (let i = 0; i < completion.candidatesLength(); ++i) {
635 1 : const ca = completion.candidates(i, tmpCandidate)!;
636 1 : const isSelected = i === selectedCandidate;
637 1 : const objectCount = ca.catalogObjectsLength();
638 : // The selected candidate shows its selected object, the others their first one
639 1 : const selectedObject = isSelected && objectCount > 0 && selectedCatalogObject != null && selectedCatalogObject < objectCount
640 1 : ? selectedCatalogObject
641 1 : : null;
642 1 : const object = objectCount > 0 ? ca.catalogObjects(selectedObject ?? 0, tmpCatalogObject)! : null;
643 1 : const info = object != null ? object.info(tmpInfo) : ca.info(tmpInfo);
644 1 : out.push({
645 1 : candidateLabel: ca.displayText()!,
646 1 : candidateType: getCandidateType(ca, object),
647 : // An info only exists if it says something, the core doesn't pack an empty one
648 1 : details: isSelected && info != null ? readCandidateDetails(info) : null,
649 1 : isSelected,
650 1 : totalObjectCount: objectCount,
651 1 : selectedCatalogObject: selectedObject,
652 1 : });
653 1 : }
654 1 : return out;
655 1 : }
|