LCOV - code coverage report
Current view: top level - dashql-app/src/views/completion_list - completion_list_view.ts Coverage Total Hit
Test: DashQL d805a4b Lines: 99.3 % 447 444
Test Date: 2026-11-09 20:22:13

            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 : }
        

Generated by: LCOV version 2.0-1