Parcourir la source

Merge pull request #5523 from perrinjerome/feat/csv-viewer-find-go-to-line

Add "Go to Line" and "Find" capabilities to csvviewer
Steven Silvester il y a 6 ans
Parent
commit
a64f6198e9

+ 70 - 0
packages/apputils/src/dialog.ts

@@ -717,6 +717,76 @@ export namespace Dialog {
    * The default renderer instance.
    */
   export const defaultRenderer = new Renderer();
+
+  /** A simple type for prompt widget */
+  type PromptValueType = string | number | boolean;
+  /**
+   * Create and show a prompt dialog
+   */
+  class PromptWidget<T extends PromptValueType> extends Widget {
+    constructor(value: T) {
+      let body = document.createElement('div');
+      let input = document.createElement('input');
+      if (typeof value === 'string') {
+        input.type = 'text';
+        if (value) {
+          input.value = value;
+        }
+      }
+      if (typeof value === 'number') {
+        input.type = 'number';
+        if (value) {
+          input.value = value.toFixed(2);
+        }
+      }
+      if (typeof value === 'boolean') {
+        input.type = 'checkbox';
+        input.checked = value;
+      }
+      body.appendChild(input);
+      super({ node: body });
+    }
+
+    /**
+     * Get the input text node.
+     */
+    get inputNode(): HTMLInputElement {
+      return this.node.getElementsByTagName('input')[0] as HTMLInputElement;
+    }
+
+    /**
+     * Get the value of the widget.
+     */
+    getValue(): T {
+      if (this.inputNode.type === 'number') {
+        // In this branch T extends number.
+        return parseFloat(this.inputNode.value) as T;
+      }
+      if (this.inputNode.type === 'checkbox') {
+        // In this branch T extends boolean.
+        return this.inputNode.checked as T;
+      }
+      return this.inputNode.value as T;
+    }
+  }
+
+  /**
+   * Simple dialog to prompt for a value
+   * @param prompt Text to show on the prompt
+   * @param defaultValue Initial value
+   * @returns a Promise which will resolve with the value entered by user.
+   */
+  export function prompt<T extends PromptValueType>(
+    prompt: string,
+    defaultValue: PromptValueType
+  ): Promise<Dialog.IResult<T>> {
+    return showDialog({
+      title: prompt,
+      body: new PromptWidget<T>(defaultValue as T),
+      buttons: [Dialog.cancelButton(), Dialog.okButton()],
+      focusNodeSelector: 'input'
+    });
+  }
 }
 
 /**

+ 0 - 4
packages/codemirror-extension/src/index.ts

@@ -370,10 +370,6 @@ function activateEditorCommands(
   // Add go to line capabilities to the edit menu.
   mainMenu.editMenu.goToLiners.add({
     tracker,
-    find: (widget: IDocumentWidget<FileEditor>) => {
-      let editor = widget.content.editor as CodeMirrorEditor;
-      editor.execCommand('jumpToLine');
-    },
     goToLine: (widget: IDocumentWidget<FileEditor>) => {
       let editor = widget.content.editor as CodeMirrorEditor;
       editor.execCommand('jumpToLine');

+ 3 - 1
packages/csvviewer-extension/package.json

@@ -34,7 +34,9 @@
     "@jupyterlab/apputils": "^0.19.1",
     "@jupyterlab/csvviewer": "^0.19.1",
     "@jupyterlab/docregistry": "^0.19.1",
-    "@phosphor/datagrid": "^0.1.6"
+    "@jupyterlab/mainmenu": "^0.8.1",
+    "@phosphor/datagrid": "^0.1.6",
+    "@phosphor/widgets": "^1.6.0"
   },
   "devDependencies": {
     "rimraf": "~2.6.2",

+ 72 - 21
packages/csvviewer-extension/src/index.ts

@@ -7,17 +7,20 @@ import {
   JupyterLabPlugin
 } from '@jupyterlab/application';
 
-import { InstanceTracker, IThemeManager } from '@jupyterlab/apputils';
+import { InstanceTracker, IThemeManager, Dialog } from '@jupyterlab/apputils';
 
 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 { IMainMenu, IEditMenu } from '@jupyterlab/mainmenu';
 
 /**
  * The name of the factories that creates widgets.
@@ -28,11 +31,10 @@ const FACTORY_TSV = 'TSVTable';
 /**
  * The CSV file handler extension.
  */
-
 const csv: JupyterLabPlugin<void> = {
   activate: activateCsv,
   id: '@jupyterlab/csvviewer-extension:csv',
-  requires: [ILayoutRestorer, IThemeManager],
+  requires: [ILayoutRestorer, IThemeManager, IMainMenu],
   autoStart: true
 };
 
@@ -42,17 +44,53 @@ const csv: JupyterLabPlugin<void> = {
 const tsv: JupyterLabPlugin<void> = {
   activate: activateTsv,
   id: '@jupyterlab/csvviewer-extension:tsv',
-  requires: [ILayoutRestorer, IThemeManager],
+  requires: [ILayoutRestorer, IThemeManager, IMainMenu],
   autoStart: true
 };
 
+/**
+ * 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>) => {
+      return Dialog.prompt<string>(
+        'Search Text',
+        widget.content.searchService.searchText
+      ).then(value => {
+        if (value.button.accept) {
+          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,
+    goToLine: (widget: IDocumentWidget<CSVViewer>) => {
+      return Dialog.prompt<number>('Go to Line', 0).then(value => {
+        if (value.button.accept) {
+          widget.content.goToLine(value.value);
+        }
+      });
+    }
+  } as IEditMenu.IGoToLiner<IDocumentWidget<CSVViewer>>);
+}
+
 /**
  * Activate cssviewer extension for CSV files
  */
 function activateCsv(
   app: JupyterLab,
   restorer: ILayoutRestorer,
-  themeManager: IThemeManager
+  themeManager: IThemeManager,
+  mainMenu: IMainMenu
 ): void {
   const factory = new CSVViewerFactory({
     name: FACTORY_CSV,
@@ -66,7 +104,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, {
@@ -91,20 +129,24 @@ 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);
+
+  addMenuEntries(mainMenu, tracker);
 }
 
 /**
@@ -113,7 +155,8 @@ function activateCsv(
 function activateTsv(
   app: JupyterLab,
   restorer: ILayoutRestorer,
-  themeManager: IThemeManager
+  themeManager: IThemeManager,
+  mainMenu: IMainMenu
 ): void {
   const factory = new TSVViewerFactory({
     name: FACTORY_TSV,
@@ -127,7 +170,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, {
@@ -152,20 +195,24 @@ 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);
+
+  addMenuEntries(mainMenu, tracker);
 }
 
 /**
@@ -204,18 +251,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'
-  });
+  };
 }

+ 3 - 0
packages/csvviewer-extension/tsconfig.json

@@ -17,6 +17,9 @@
     },
     {
       "path": "../docregistry"
+    },
+    {
+      "path": "../mainmenu"
     }
   ]
 }

+ 155 - 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,127 @@ 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
+            let scrollX = this._grid.scrollX;
+            let scrollY = this._grid.scrollY;
+            /* see also https://github.com/jupyterlab/jupyterlab/pull/5523#issuecomment-432621391 */
+            for (let i = scrollY; i < this._row - 1; i++) {
+              scrollY += this._grid.sectionSize('row', i);
+            }
+            for (let j = scrollX; j < this._column - 1; j++) {
+              scrollX += this._grid.sectionSize('column', j);
+            }
+            this._grid.scrollTo(scrollX, scrollY);
+          }
+          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 +183,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 +239,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;
   }
 
   /**
@@ -135,6 +268,21 @@ export class CSVViewer extends Widget {
     super.dispose();
   }
 
+  /**
+   * Go to line
+   */
+  goToLine(lineNumber: number) {
+    let scrollY = this._grid.scrollY;
+    /* The lines might not all have uniform height, so we can't just scroll to lineNumber * this._grid.baseRowSize
+    see https://github.com/jupyterlab/jupyterlab/pull/5523#issuecomment-432621391 for discussions around
+    this. It would be nice if DataGrid had a method to scroll to cell, which could be implemented more efficiently
+    because datagrid knows more about the shape of the cells. */
+    for (let i = scrollY; i < lineNumber - 1; i++) {
+      scrollY += this._grid.sectionSize('row', i);
+    }
+    this._grid.scrollTo(this._grid.scrollX, scrollY);
+  }
+
   /**
    * Handle `'activate-request'` messages.
    */
@@ -158,6 +306,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>();

+ 1 - 0
tests/test-csvviewer/package.json

@@ -21,6 +21,7 @@
     "@jupyterlab/services": "^3.2.1",
     "@jupyterlab/testutils": "^0.3.1",
     "@phosphor/coreutils": "^1.3.0",
+    "@phosphor/datagrid": "^0.1.6",
     "@phosphor/widgets": "^1.6.0",
     "chai": "~4.1.2",
     "csv-spectrum": "~1.0.0",

+ 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');
+    });
+  });
 });