From 38074dbd55f70744aeff6feac38a2945d9cd49e4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Loi=CC=88c=20Correnson?= <loic.correnson@cea.fr>
Date: Mon, 11 Jan 2021 00:29:40 +0100
Subject: [PATCH] [ivette] extensible search hints API

---
 ivette/src/dome/renderer/frame/toolbars.tsx |  2 +-
 ivette/src/frama-c/states.ts                | 32 +++++++-
 ivette/src/ivette/index.tsx                 | 83 ++++++++++++--------
 ivette/src/ivette/prefs.tsx                 |  3 +-
 ivette/src/renderer/Application.tsx         | 21 +++--
 ivette/src/renderer/Extensions.tsx          | 86 +++++++++++++++++++++
 ivette/src/renderer/Globals.tsx             | 48 ++++++------
 ivette/tsconfig.json                        |  2 +
 ivette/webpack.renderer.js                  |  1 +
 9 files changed, 202 insertions(+), 76 deletions(-)
 create mode 100644 ivette/src/renderer/Extensions.tsx

diff --git a/ivette/src/dome/renderer/frame/toolbars.tsx b/ivette/src/dome/renderer/frame/toolbars.tsx
index 74696bf0d3f..d84c50b4a32 100644
--- a/ivette/src/dome/renderer/frame/toolbars.tsx
+++ b/ivette/src/dome/renderer/frame/toolbars.tsx
@@ -242,7 +242,7 @@ export interface SearchFieldProps<A> {
   onSearch?: (pattern: string) => void;
   /** Hint selection callback. */
   onHint?: (hint: Hint<A>) => void;
-  /** Event that triggers a focus request (defaults to [[Dome.find]]). */
+  /** Event that triggers a focus request (defaults to [[dome.find]]). */
   event?: null | Event<void>;
 }
 
diff --git a/ivette/src/frama-c/states.ts b/ivette/src/frama-c/states.ts
index 1feb79a7c84..a7580038100 100644
--- a/ivette/src/frama-c/states.ts
+++ b/ivette/src/frama-c/states.ts
@@ -13,7 +13,7 @@ import * as Dome from 'dome';
 import * as Json from 'dome/data/json';
 import { Order } from 'dome/data/compare';
 import { GlobalState, useGlobalState } from 'dome/data/states';
-import { useModel } from 'dome/table/models';
+import { Client, useModel } from 'dome/table/models';
 import { CompactModel } from 'dome/table/arrays';
 import * as Ast from 'frama-c/api/kernel/ast';
 import * as Server from './server';
@@ -398,7 +398,7 @@ class SyncArray<K, A> {
 
 const syncArrays = new Map<string, SyncArray<any, any>>();
 
-function getSyncArray<K, A>(
+function lookupSyncArray<K, A>(
   array: Array<K, A>,
 ): SyncArray<K, A> {
   const id = `${currentProject}@${array.name}`;
@@ -418,7 +418,7 @@ Server.onShutdown(() => syncArrays.clear());
 
 /** Force a Synchronized Array to reload. */
 export function reloadArray<K, A>(arr: Array<K, A>) {
-  getSyncArray(arr).reload();
+  lookupSyncArray(arr).reload();
 }
 
 /**
@@ -435,13 +435,37 @@ export function useSyncArray<K, A>(
   sync = true,
 ): CompactModel<K, A> {
   Dome.useUpdate(PROJECT);
-  const st = getSyncArray(arr);
+  const st = lookupSyncArray(arr);
   React.useEffect(st.update);
   Server.useSignal(arr.signal, st.fetch);
   useModel(st.model, sync);
   return st.model;
 }
 
+/**
+   Return the associated array model.
+*/
+export function getSyncArray<K, A>(
+  arr: Array<K, A>,
+): CompactModel<K, A> {
+  const st = lookupSyncArray(arr);
+  return st.model;
+}
+
+/**
+   Link on the associated array model.
+   @param onReload callback on reload event and update event if not specified.
+   @param onUpdate callback on update event.
+ */
+export function onSyncArray<K, A>(
+  arr: Array<K, A>,
+  onReload?: () => void,
+  onUpdate?: () => void,
+): Client {
+  const st = lookupSyncArray(arr);
+  return st.model.link(onReload, onUpdate);
+}
+
 // --------------------------------------------------------------------------
 // --- Selection
 // --------------------------------------------------------------------------
diff --git a/ivette/src/ivette/index.tsx b/ivette/src/ivette/index.tsx
index d20e90d0bb9..3542f6c52ec 100644
--- a/ivette/src/ivette/index.tsx
+++ b/ivette/src/ivette/index.tsx
@@ -1,6 +1,6 @@
-// --------------------------------------------------------------------------
-// ---  Lab View Component
-// --------------------------------------------------------------------------
+/* --------------------------------------------------------------------------*/
+/* --- Lab View Component                                                 ---*/
+/* --------------------------------------------------------------------------*/
 
 /**
    @packageDocumentation
@@ -11,13 +11,8 @@ import React from 'react';
 import { Label } from 'dome/controls/labels';
 import { DefineElement } from 'dome/layout/dispatch';
 import { GridItem, GridHbox, GridVbox } from 'dome/layout/grids';
-import {
-  Rankify,
-  useGroupContext,
-  useLibraryItem,
-  addLibraryItem,
-  useTitleContext,
-} from 'ivette@lab';
+import * as Lab from 'ivette@lab';
+import * as Ext from 'ivette@ext';
 
 /* --------------------------------------------------------------------------*/
 /* --- Fragments                                                          ---*/
@@ -36,15 +31,15 @@ export interface FragmentProps {
  */
 export function Fragment(props: FragmentProps) {
   const { group, rank, children } = props;
-  const context = useGroupContext();
+  const context = Lab.useGroupContext();
   const base = context.order ?? [];
   return (
-    <Rankify
+    <Lab.Rankify
       group={group ?? context.group}
       order={rank === undefined ? base : [...base, rank]}
     >
       {children}
-    </Rankify>
+    </Lab.Rankify>
   );
 }
 
@@ -76,7 +71,7 @@ export interface ContentProps extends ItemProps {
    Empty groups are not displayed.
  */
 export function registerGroup(group: ItemProps) {
-  addLibraryItem('groups', group);
+  Lab.addLibraryItem('groups', group);
 }
 
 /**
@@ -89,20 +84,20 @@ export function registerGroup(group: ItemProps) {
  */
 export function Group(props: ContentProps) {
   const { children, ...group } = props;
-  const context = useLibraryItem('groups', group);
+  const context = Lab.useLibraryItem('groups', group);
   return (
-    <Rankify
+    <Lab.Rankify
       group={props.id}
       order={context.order ?? []}
     >
       {children}
-    </Rankify>
+    </Lab.Rankify>
   );
 }
 
-// --------------------------------------------------------------------------
-// --- View Layout
-// --------------------------------------------------------------------------
+/* --------------------------------------------------------------------------*/
+/* --- View Layout                                                        ---*/
+/* --------------------------------------------------------------------------*/
 
 export type Layout = string | { hsplit: Layout[] } | { vsplit: Layout[] };
 
@@ -146,7 +141,7 @@ export interface ViewLayoutProps extends ItemProps {
 /** Register a new View. */
 export function registerView(view: ViewLayoutProps) {
   const { id, label, title, defaultView, layout } = view;
-  addLibraryItem('view', {
+  Lab.addLibraryItem('view', {
     id,
     label,
     title,
@@ -155,9 +150,9 @@ export function registerView(view: ViewLayoutProps) {
   });
 }
 
-// --------------------------------------------------------------------------
-// --- Deprecated Views
-// --------------------------------------------------------------------------
+/* --------------------------------------------------------------------------*/
+/* --- Deprecated View                                                    ---*/
+/* --------------------------------------------------------------------------*/
 
 export interface ViewProps extends ContentProps {
   /** Use this view by default. */
@@ -180,13 +175,13 @@ export interface ViewProps extends ContentProps {
    @deprecated Use [[registerView]] instead.
  */
 export function View(props: ViewProps) {
-  useLibraryItem('views', props);
+  Lab.useLibraryItem('views', props);
   return null;
 }
 
-// --------------------------------------------------------------------------
-// --- Components
-// --------------------------------------------------------------------------
+/* --------------------------------------------------------------------------*/
+/* --- Components                                                         ---*/
+/* --------------------------------------------------------------------------*/
 
 export interface ComponentProps extends ContentProps {
   /** Group attachment. */
@@ -200,7 +195,7 @@ export interface ComponentProps extends ContentProps {
    Components are sorted by rank and identifier among each group.
  */
 export function registerComponent(props: ComponentProps) {
-  addLibraryItem('components', props);
+  Lab.addLibraryItem('components', props);
 }
 
 /**
@@ -212,7 +207,7 @@ export function registerComponent(props: ComponentProps) {
    @deprecated Use [[registerComponent]] instead.
  */
 export function Component(props: ComponentProps) {
-  useLibraryItem('components', props);
+  Lab.useLibraryItem('components', props);
   return null;
 }
 
@@ -240,7 +235,7 @@ export interface TitleBarProps {
  */
 export function TitleBar(props: TitleBarProps) {
   const { icon, label, title, children } = props;
-  const context = useTitleContext();
+  const context = Lab.useTitleContext();
   if (!context.id) return null;
   return (
     <DefineElement id={`labview.title.${context.id}`}>
@@ -255,4 +250,30 @@ export function TitleBar(props: TitleBarProps) {
   );
 }
 
+/* --------------------------------------------------------------------------*/
+/* --- Search Hints                                                       ---*/
+/* --------------------------------------------------------------------------*/
+
+export interface Hint {
+  id: string;
+  label: string | JSX.Element;
+  title?: string;
+  rank?: number;
+  onSelection: () => void;
+}
+
+/**
+   Register a hint search engine for the Ivette toolbar.
+*/
+export function registerHints(
+  id: string,
+  lookup: (pattern: string) => Promise<Hint[]>,
+) {
+  const adaptor = (h: Hint): Ext.SearchHint => (
+    { ...h, value: () => h.onSelection() }
+  );
+  const search = (p: string) => lookup(p).then((hs) => hs.map(adaptor));
+  Ext.registerHints({ id, search });
+}
+
 // --------------------------------------------------------------------------
diff --git a/ivette/src/ivette/prefs.tsx b/ivette/src/ivette/prefs.tsx
index 21fb66c7824..49a13dbb15e 100644
--- a/ivette/src/ivette/prefs.tsx
+++ b/ivette/src/ivette/prefs.tsx
@@ -2,8 +2,7 @@
 // --- Main React Component rendered by './index.js'
 // --------------------------------------------------------------------------
 
-/*
-   Ivette Preferences
+/**
    @packageDocumentation
    @module ivette/prefs
  */
diff --git a/ivette/src/renderer/Application.tsx b/ivette/src/renderer/Application.tsx
index 7abf1a04a01..346c0dfa820 100644
--- a/ivette/src/renderer/Application.tsx
+++ b/ivette/src/renderer/Application.tsx
@@ -14,7 +14,6 @@ import { GridHbox, GridItem } from 'dome/layout/grids';
 
 // --- Ivette
 
-import { LabView } from 'ivette@lab';
 import { View, Group } from 'ivette';
 
 // --- Frama-C
@@ -29,7 +28,9 @@ import Values from 'frama-c/plugins/eva';
 import Dive from 'frama-c/plugins/dive';
 
 import * as Controller from './Controller';
-import Globals, { GlobalHint, useHints } from './Globals';
+import * as Extensions from './Extensions';
+import { LabView } from './LabView';
+import Globals from './Globals';
 
 import 'frama-c/kernel/style.css';
 
@@ -70,13 +71,9 @@ export default (() => {
     Dome.useFlipSettings('frama-c.sidebar.unfold', true);
   const [viewbar, flipViewbar] =
     Dome.useFlipSettings('frama-c.viewbar.unfold', true);
-  const [hints, onSearchHint] = useHints();
-  const [, setSelection] = States.useSelection();
-  const onGlobalHint = (h: GlobalHint) => {
-    setSelection({ location: h.value });
-  };
-  const onSelectHint = () => {
-    if (hints.length === 1) onGlobalHint(hints[0]);
+  const hints = Extensions.useSearchHints();
+  const onSelectedHints = () => {
+    if (hints.length === 1) Extensions.onSearchHint(hints[0]);
   };
 
   return (
@@ -94,9 +91,9 @@ export default (() => {
         <Toolbar.SearchField
           placeholder="Search…"
           hints={hints}
-          onSearch={onSearchHint}
-          onSelect={onSelectHint}
-          onHint={onGlobalHint}
+          onSearch={Extensions.searchHints}
+          onHint={Extensions.onSearchHint}
+          onSelect={onSelectedHints}
         />
         <Toolbar.Button
           icon="ITEMS.GRID"
diff --git a/ivette/src/renderer/Extensions.tsx b/ivette/src/renderer/Extensions.tsx
new file mode 100644
index 00000000000..5dab10ff7c5
--- /dev/null
+++ b/ivette/src/renderer/Extensions.tsx
@@ -0,0 +1,86 @@
+/* --------------------------------------------------------------------------*/
+/* --- Ivette Extensions                                                  ---*/
+/* --------------------------------------------------------------------------*/
+
+import React from 'react';
+import * as Dome from 'dome';
+import { Hint } from 'dome/frame/toolbars';
+
+/* --------------------------------------------------------------------------*/
+/* --- Search Hints                                                       ---*/
+/* --------------------------------------------------------------------------*/
+
+export interface HintCallback {
+  (): void;
+}
+
+export interface SearchHint extends Hint<HintCallback> {
+  rank?: number;
+}
+
+function bySearchHint(a: SearchHint, b: SearchHint) {
+  const ra = a.rank ?? 0;
+  const rb = b.rank ?? 0;
+  if (ra < rb) return -1;
+  if (ra > rb) return +1;
+  return 0;
+}
+
+export interface SearchEngine {
+  id: string;
+  search: (pattern: string) => Promise<SearchHint[]>;
+}
+
+const NEWHINTS = new Dome.Event('ivette.hints');
+const HINTLOOKUP = new Map<string, SearchEngine>();
+const HINTS = new Map<string, SearchHint[]>();
+let CURRENT = '';
+
+export function updateHints() {
+  if (CURRENT !== '')
+    NEWHINTS.emit();
+}
+
+export function registerHints(E: SearchEngine) {
+  HINTLOOKUP.set(E.id, E);
+}
+
+export function searchHints(pattern: string) {
+  if (pattern === '') {
+    CURRENT = '';
+    HINTS.clear();
+    NEWHINTS.emit();
+  } else {
+    const REF = pattern;
+    CURRENT = pattern;
+    HINTLOOKUP.forEach((E: SearchEngine) => {
+      E.search(REF).then((hs) => {
+        if (REF === CURRENT) {
+          HINTS.set(E.id, hs);
+          NEWHINTS.emit();
+        }
+      }).catch(() => {
+        if (REF === CURRENT) {
+          HINTS.delete(E.id);
+          NEWHINTS.emit();
+        }
+      });
+    });
+  }
+}
+
+export function onSearchHint(h: SearchHint) {
+  h.value();
+}
+
+export function useSearchHints() {
+  const [hints, setHints] = React.useState<SearchHint[]>([]);
+  Dome.useEvent(NEWHINTS, () => {
+    let hs: SearchHint[] = [];
+    HINTS.forEach((rhs) => { hs = hs.concat(rhs); });
+    setHints(hs.sort(bySearchHint));
+  });
+  return hints;
+}
+
+/* --------------------------------------------------------------------------*/
diff --git a/ivette/src/renderer/Globals.tsx b/ivette/src/renderer/Globals.tsx
index 0a4d754b4fd..341fb2e2d73 100644
--- a/ivette/src/renderer/Globals.tsx
+++ b/ivette/src/renderer/Globals.tsx
@@ -3,44 +3,39 @@
 // --------------------------------------------------------------------------
 
 import React from 'react';
-import { Section, Item } from 'dome/frame/sidebars';
-import type { Hint } from 'dome/frame/toolbars';
+import * as Dome from 'dome';
 import { classes } from 'dome/misc/utils';
-import * as States from 'frama-c/states';
-import { useFlipSettings } from 'dome';
 import { alpha } from 'dome/data/compare';
+import { Section, Item } from 'dome/frame/sidebars';
+import * as Ivette from 'ivette';
+
+import * as States from 'frama-c/states';
 import { functions, functionsData } from 'frama-c/api/kernel/ast';
 import { isComputed } from 'frama-c/api/plugins/eva/general';
-import * as Dome from 'dome';
 
 // --------------------------------------------------------------------------
 // --- Global Search Hints
 // --------------------------------------------------------------------------
 
-export type GlobalHint = Hint<States.Location>;
-
-const makeHint = (fct: functionsData): GlobalHint => ({
-  id: fct.key,
-  label: fct.name,
-  title: fct.signature,
-  value: { fct: fct.name },
-});
-
-export function useHints(): [GlobalHint[], (pattern: string) => void] {
-  const fcts = States.useSyncArray(functions).getArray();
-  const [hints, setHints] = React.useState<GlobalHint[]>([]);
-  const onSearch = (pattern: string) => {
-    if (pattern === '') setHints([]);
-    else {
-      const p = pattern.toLowerCase();
-      setHints(fcts.filter((fn) => (
-        0 <= fn.name.toLowerCase().indexOf(p)
-      )).map(makeHint));
-    }
+function makeFunctionHint(fct: functionsData): Ivette.Hint {
+  return {
+    id: fct.key,
+    label: fct.name,
+    title: fct.signature,
+    onSelection: () => States.setSelection({ fct: fct.name }),
   };
-  return [hints, onSearch];
 }
 
+async function lookupGlobals(pattern: string): Promise<Ivette.Hint[]> {
+  const lookup = pattern.toLowerCase();
+  const fcts = States.getSyncArray(functions).getArray();
+  return fcts.filter((fn) => (
+    0 <= fn.name.toLowerCase().indexOf(lookup)
+  )).map(makeFunctionHint);
+}
+
+Ivette.registerHints('frama-c.globals', lookupGlobals);
+
 // --------------------------------------------------------------------------
 // --- Function Item
 // --------------------------------------------------------------------------
@@ -85,6 +80,7 @@ export default () => {
   const fcts = States.useSyncArray(functions).getArray().sort(
     (f, g) => alpha(f.name, g.name),
   );
+  const { useFlipSettings } = Dome;
   const [stdlib, flipStdlib] =
     useFlipSettings('ivette.globals.stdlib', false);
   const [builtin, flipBuiltin] =
diff --git a/ivette/tsconfig.json b/ivette/tsconfig.json
index 1d61c4ca54a..18d37128204 100644
--- a/ivette/tsconfig.json
+++ b/ivette/tsconfig.json
@@ -43,6 +43,7 @@
     "resolveJsonModule": true,                /* Allow to load JSON files as module. */
     "baseUrl": ".",                           /* Base directory to resolve non-absolute module names. */
     "paths": {                                /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+      "ivette@ext": [ "src/renderer/Extensions.tsx" ],
       "ivette@lab": [ "src/renderer/LabView.tsx" ],
       "ivette": [ "src/ivette/index.tsx" ],
       "ivette/*": [ "src/ivette/*" ],
@@ -104,6 +105,7 @@
     "inputFiles": [
       "doc/pages",
       "src/ivette/index.tsx",
+      "src/ivette/prefs.tsx",
       "src/frama-c/server.ts",
       "src/frama-c/states.ts",
       "src/frama-c/utils.ts",
diff --git a/ivette/webpack.renderer.js b/ivette/webpack.renderer.js
index 601b8b50dbd..6bd133984c3 100644
--- a/ivette/webpack.renderer.js
+++ b/ivette/webpack.renderer.js
@@ -29,6 +29,7 @@ module.exports = {
     alias: {
       'frama-c/api':  path.resolve( __dirname , 'src/frama-c/api/generated' ),
       'frama-c':      path.resolve( __dirname , 'src/frama-c' ),
+      'ivette@ext':   path.resolve( __dirname , 'src/renderer/Extensions' ),
       'ivette@lab':   path.resolve( __dirname , 'src/renderer/LabView' ),
       'ivette':       path.resolve( __dirname , 'src/ivette' ),
       'dome/misc':    path.resolve( DOME , 'misc' ),
-- 
GitLab