Browse Source

csvviewer: enable "Find" edit menu entry

- introduce a `GridSearchService` to keep the state of incremental
search and provide a function to highlight cells matching search text.
- replace light / dark theme TextRenderer by a new `TextRenderConfig`
class holding the config of cell rendering for different states.
Jérome Perrin 6 years ago
parent
commit
01f37274c3

+ 50 - 16
packages/csvviewer-extension/src/index.ts

@@ -16,13 +16,14 @@ import {
 
 import {
   CSVViewer,
+  TextRenderConfig,
   CSVViewerFactory,
   TSVViewerFactory
 } from '@jupyterlab/csvviewer';
 
 import { IDocumentWidget } from '@jupyterlab/docregistry';
 
-import { DataGrid, TextRenderer } from '@phosphor/datagrid';
+import { DataGrid } from '@phosphor/datagrid';
 import { Widget } from '@phosphor/widgets';
 
 import { IMainMenu, IEditMenu } from '@jupyterlab/mainmenu';
@@ -88,12 +89,37 @@ const tsv: JupyterLabPlugin<void> = {
 };
 
 /**
- * Connect menu entry for go to line.
+ * Connect menu entries for find and go to line.
  */
 function addMenuEntries(
   mainMenu: IMainMenu,
   tracker: InstanceTracker<IDocumentWidget<CSVViewer>>
 ) {
+  // Add find capability to the edit menu.
+  mainMenu.editMenu.findReplacers.add({
+    tracker,
+    find: (widget: IDocumentWidget<CSVViewer>) => {
+      const buttons = {
+        Cancel: Dialog.cancelButton(),
+        OK: Dialog.okButton({ label: 'Find' })
+      };
+      return showDialog({
+        title: 'Find...',
+        body: new PromptWidget(
+          'Search Text',
+          'text',
+          widget.content.searchService.searchText
+        ),
+        buttons: [buttons.Cancel, buttons.OK],
+        focusNodeSelector: 'input'
+      }).then(value => {
+        if (value.button === buttons.OK) {
+          widget.content.searchService.find(value.value);
+        }
+      });
+    }
+  } as IEditMenu.IFindReplacer<IDocumentWidget<CSVViewer>>);
+
   // Add go to line capability to the edit menu.
   mainMenu.editMenu.goToLiners.add({
     tracker,
@@ -137,7 +163,7 @@ function activateCsv(
 
   // The current styles for the data grids.
   let style: DataGrid.IStyle = Private.LIGHT_STYLE;
-  let renderer: TextRenderer = Private.LIGHT_RENDERER;
+  let rendererConfig: TextRenderConfig = Private.LIGHT_TEXT_CONFIG;
 
   // Handle state restoration.
   restorer.restore(tracker, {
@@ -162,17 +188,19 @@ function activateCsv(
     }
     // Set the theme for the new widget.
     widget.content.style = style;
-    widget.content.renderer = renderer;
+    widget.content.rendererConfig = rendererConfig;
   });
 
   // Keep the themes up-to-date.
   const updateThemes = () => {
     const isLight = themeManager.isLight(themeManager.theme);
     style = isLight ? Private.LIGHT_STYLE : Private.DARK_STYLE;
-    renderer = isLight ? Private.LIGHT_RENDERER : Private.DARK_RENDERER;
+    rendererConfig = isLight
+      ? Private.LIGHT_TEXT_CONFIG
+      : Private.DARK_TEXT_CONFIG;
     tracker.forEach(grid => {
       grid.content.style = style;
-      grid.content.renderer = renderer;
+      grid.content.rendererConfig = rendererConfig;
     });
   };
   themeManager.themeChanged.connect(updateThemes);
@@ -201,7 +229,7 @@ function activateTsv(
 
   // The current styles for the data grids.
   let style: DataGrid.IStyle = Private.LIGHT_STYLE;
-  let renderer: TextRenderer = Private.LIGHT_RENDERER;
+  let rendererConfig: TextRenderConfig = Private.LIGHT_TEXT_CONFIG;
 
   // Handle state restoration.
   restorer.restore(tracker, {
@@ -226,17 +254,19 @@ function activateTsv(
     }
     // Set the theme for the new widget.
     widget.content.style = style;
-    widget.content.renderer = renderer;
+    widget.content.rendererConfig = rendererConfig;
   });
 
   // Keep the themes up-to-date.
   const updateThemes = () => {
     const isLight = themeManager.isLight(themeManager.theme);
     style = isLight ? Private.LIGHT_STYLE : Private.DARK_STYLE;
-    renderer = isLight ? Private.LIGHT_RENDERER : Private.DARK_RENDERER;
+    rendererConfig = isLight
+      ? Private.LIGHT_TEXT_CONFIG
+      : Private.DARK_TEXT_CONFIG;
     tracker.forEach(grid => {
       grid.content.style = style;
-      grid.content.renderer = renderer;
+      grid.content.rendererConfig = rendererConfig;
     });
   };
   themeManager.themeChanged.connect(updateThemes);
@@ -280,18 +310,22 @@ namespace Private {
   };
 
   /**
-   * The light renderer for the data grid.
+   * The light config for the data grid renderer.
    */
-  export const LIGHT_RENDERER = new TextRenderer({
+  export const LIGHT_TEXT_CONFIG: TextRenderConfig = {
     textColor: '#111111',
+    matchBackgroundColor: '#FFFFE0',
+    currentMatchBackgroundColor: '#FFFF00',
     horizontalAlignment: 'right'
-  });
+  };
 
   /**
-   * The dark renderer for the data grid.
+   * The dark config for the data grid renderer.
    */
-  export const DARK_RENDERER = new TextRenderer({
+  export const DARK_TEXT_CONFIG: TextRenderConfig = {
     textColor: '#F5F5F5',
+    matchBackgroundColor: '#838423',
+    currentMatchBackgroundColor: '#A3807A',
     horizontalAlignment: 'right'
-  });
+  };
 }

+ 134 - 6
packages/csvviewer/src/widget.ts

@@ -12,7 +12,7 @@ import {
 
 import { PromiseDelegate } from '@phosphor/coreutils';
 
-import { DataGrid, TextRenderer } from '@phosphor/datagrid';
+import { DataGrid, TextRenderer, CellRenderer } from '@phosphor/datagrid';
 
 import { Message } from '@phosphor/messaging';
 
@@ -37,6 +37,121 @@ const CSV_GRID_CLASS = 'jp-CSVViewer-grid';
  */
 const RENDER_TIMEOUT = 1000;
 
+/**
+ * Configuration for cells textrenderer.
+ */
+export class TextRenderConfig {
+  /**
+   * default text color
+   */
+  textColor: string;
+  /**
+   * background color for a search match
+   */
+  matchBackgroundColor: string;
+  /**
+   * background color for the current search match.
+   */
+  currentMatchBackgroundColor: string;
+  /**
+   * horizontalAlignment of the text
+   */
+  horizontalAlignment: TextRenderer.HorizontalAlignment;
+}
+
+/**
+ * Search service remembers the search state and the location of the last
+ * match, for incremental searching.
+ * Search service is also responsible of providing a cell renderer function
+ * to set the background color of cells matching the search text.
+ */
+export class GridSearchService {
+  constructor(grid: DataGrid) {
+    this._grid = grid;
+    this._searchText = '';
+    this._row = 0;
+    this._column = 0;
+  }
+
+  /**
+   * Returs a cellrenderer config function to render each cell background.
+   * If cell match, background is matchBackgroundColor, if it's the current
+   * match, background is currentMatchBackgroundColor.
+   */
+  cellBackgroundColorRendererFunc(
+    config: TextRenderConfig
+  ): CellRenderer.ConfigFunc<string> {
+    return ({ value, row, column }) => {
+      if (this._searchText) {
+        if ((value as string).indexOf(this._searchText) !== -1) {
+          if (this._row === row && this._column === column) {
+            return config.currentMatchBackgroundColor;
+          }
+          return config.matchBackgroundColor;
+        }
+      }
+    };
+  }
+
+  /**
+   * incrementally look for searchText.
+   */
+  find(searchText: string) {
+    const model = this._grid.model;
+    if (this._searchText !== searchText) {
+      // reset search
+      this._row = 0;
+      this._column = -1;
+    }
+    this._column++; // incremental search
+    this._searchText = searchText;
+
+    // check if the match is in current viewport
+    const minRow = this._grid.scrollY / this._grid.baseRowSize;
+    const maxRow =
+      (this._grid.scrollY + this._grid.pageHeight) / this._grid.baseRowSize;
+    const minColumn = this._grid.scrollX / this._grid.baseColumnSize;
+    const maxColumn =
+      (this._grid.scrollX + this._grid.pageWidth) / this._grid.baseColumnSize;
+    const isMatchInViewport = () => {
+      return (
+        this._row >= minRow &&
+        this._row <= maxRow &&
+        this._column >= minColumn &&
+        this._column <= maxColumn
+      );
+    };
+
+    for (; this._row < model.rowCount('body'); this._row++) {
+      for (; this._column < model.columnCount('body'); this._column++) {
+        const cellData = model.data('body', this._row, this._column) as string;
+        if (cellData.indexOf(searchText) !== -1) {
+          // to update the background of matching cells.
+          this._grid.repaint();
+          if (!isMatchInViewport()) {
+            // scroll the matching cell into view
+            this._grid.scrollTo(
+              this._grid.baseColumnSize * this._column,
+              this._grid.baseRowSize * this._row
+            );
+          }
+          return;
+        }
+      }
+      this._column = 0;
+    }
+  }
+
+  get searchText(): string {
+    return this._searchText;
+  }
+
+  private _grid: DataGrid;
+  private _searchText: string;
+  private _row: number;
+  private _column: number;
+}
+
 /**
  * A viewer for CSV tables.
  */
@@ -62,6 +177,8 @@ export class CSVViewer extends Widget {
     this._grid.headerVisibility = 'all';
     layout.addWidget(this._grid);
 
+    this._searchService = new GridSearchService(this._grid);
+
     this._context.ready.then(() => {
       this._updateGrid();
       this._revealed.resolve(undefined);
@@ -116,13 +233,23 @@ export class CSVViewer extends Widget {
   }
 
   /**
-   * The text renderer used by the data grid.
+   * The config used to create text renderer.
    */
-  get renderer(): TextRenderer {
-    return this._grid.defaultRenderer as TextRenderer;
+  set rendererConfig(rendererConfig: TextRenderConfig) {
+    this._grid.defaultRenderer = new TextRenderer({
+      textColor: rendererConfig.textColor,
+      horizontalAlignment: rendererConfig.horizontalAlignment,
+      backgroundColor: this._searchService.cellBackgroundColorRendererFunc(
+        rendererConfig
+      )
+    });
   }
-  set renderer(value: TextRenderer) {
-    this._grid.defaultRenderer = value;
+
+  /**
+   * The search service
+   */
+  get searchService(): GridSearchService {
+    return this._searchService;
   }
 
   /**
@@ -168,6 +295,7 @@ export class CSVViewer extends Widget {
 
   private _context: DocumentRegistry.Context;
   private _grid: DataGrid;
+  private _searchService: GridSearchService;
   private _monitor: ActivityMonitor<any, any> | null = null;
   private _delimiter = ',';
   private _revealed = new PromiseDelegate<void>();

+ 63 - 1
tests/test-csvviewer/src/widget.spec.ts

@@ -7,13 +7,14 @@ import { UUID } from '@phosphor/coreutils';
 
 import { ServiceManager } from '@jupyterlab/services';
 
-import { CSVViewer } from '@jupyterlab/csvviewer';
+import { CSVViewer, GridSearchService } from '@jupyterlab/csvviewer';
 
 import {
   Context,
   DocumentRegistry,
   TextModelFactory
 } from '@jupyterlab/docregistry';
+import { JSONModel, DataGrid, CellRenderer } from '@phosphor/datagrid';
 
 function createContext(): Context<DocumentRegistry.IModel> {
   const factory = new TextModelFactory();
@@ -58,4 +59,65 @@ describe('csvviewer/widget', () => {
       });
     });
   });
+
+  describe('GridSearchService', () => {
+    function createModel(): JSONModel {
+      return new JSONModel({
+        data: [
+          { index: 0, a: 'other', b: 'match 1' },
+          { index: 1, a: 'other', b: 'match 2' }
+        ],
+        schema: {
+          primaryKey: ['index'],
+          fields: [
+            {
+              name: 'a'
+            },
+            { name: 'b' }
+          ]
+        }
+      });
+    }
+    function createGridSearchService(model: JSONModel): GridSearchService {
+      const grid = new DataGrid();
+      grid.model = model;
+      return new GridSearchService(grid);
+    }
+
+    it('searches incrementally and set background color', () => {
+      const model = createModel();
+      const searchService = createGridSearchService(model);
+
+      const cellRenderer = searchService.cellBackgroundColorRendererFunc({
+        matchBackgroundColor: 'anotherMatch',
+        currentMatchBackgroundColor: 'currentMatch',
+        textColor: '',
+        horizontalAlignment: 'right'
+      });
+
+      /**
+       * fake rendering a cell and returns the background color for this coordinate.
+       */
+      function fakeRenderCell(row: number, column: number) {
+        const cellConfig = {
+          value: model.data('body', row, column),
+          row,
+          column
+        } as CellRenderer.ICellConfig;
+        return cellRenderer(cellConfig);
+      }
+
+      // searching for "match", cells at (0,1) and (1,1) should match.
+      // (0,1) is the current match
+      searchService.find('match');
+      expect(fakeRenderCell(0, 1)).to.equal('currentMatch');
+      expect(fakeRenderCell(1, 1)).to.equal('anotherMatch');
+      expect(fakeRenderCell(0, 0)).to.equal(undefined);
+
+      // search again, the current match "moves" to be (1,1)
+      searchService.find('match');
+      expect(fakeRenderCell(0, 1)).to.equal('anotherMatch');
+      expect(fakeRenderCell(1, 1)).to.equal('currentMatch');
+    });
+  });
 });