Przeglądaj źródła

Merge pull request #79 from blink1073/activenotebook-tests

Active notebook tests
A. Darian 9 lat temu
rodzic
commit
e172d0c679

+ 1 - 0
package.json

@@ -34,6 +34,7 @@
     "phosphor-tabs": "^1.0.0-rc.2",
     "phosphor-widget": "^1.0.0-rc.1",
     "sanitizer": "^0.1.3",
+    "simulate-event": "^1.2.0",
     "xterm": "^0.33.0"
   },
   "devDependencies": {

+ 14 - 0
src/notebook/cells/model.ts

@@ -108,6 +108,20 @@ interface ICodeCellModel extends ICellModel {
 }
 
 
+/**
+ * The definition of a markdown cell.
+ */
+export
+interface IMarkdownCellModel extends ICellModel { }
+
+
+/**
+ * The definition of a raw cell.
+ */
+export
+interface IRawCellModel extends ICellModel { }
+
+
 /**
  * An implementation of the cell model.
  */

+ 25 - 25
src/notebook/notebook/actions.ts

@@ -23,7 +23,7 @@ import {
 } from './nbformat';
 
 import {
-  ActiveNotebook
+  Notebook
 } from './widget';
 
 
@@ -42,7 +42,7 @@ namespace NotebookActions {
    * Split the active cell into two cells.
    */
   export
-  function splitCell(widget: ActiveNotebook): void {
+  function splitCell(widget: Notebook): void {
     Private.deselectCells(widget);
     let nbModel = widget.model;
     let index = widget.activeCellIndex;
@@ -68,7 +68,7 @@ namespace NotebookActions {
    * Merge selected cells.
    */
   export
-  function mergeCells(widget: ActiveNotebook): void {
+  function mergeCells(widget: Notebook): void {
     let toMerge: string[] = [];
     let toDelete: ICellModel[] = [];
     let model = widget.model;
@@ -130,7 +130,7 @@ namespace NotebookActions {
    * Delete the selected cells.
    */
   export
-  function deleteCells(widget: ActiveNotebook): void {
+  function deleteCells(widget: Notebook): void {
     let model = widget.model;
     let cells = model.cells;
     // Delete the cells as one undo event.
@@ -154,7 +154,7 @@ namespace NotebookActions {
    * Insert a new code cell above the current cell.
    */
   export
-  function insertAbove(widget: ActiveNotebook): void {
+  function insertAbove(widget: Notebook): void {
     let cell = widget.model.createCodeCell();
     widget.model.cells.insert(widget.activeCellIndex, cell);
     Private.deselectCells(widget);
@@ -164,7 +164,7 @@ namespace NotebookActions {
    * Insert a node code cell below the current cell.
    */
   export
-  function insertBelow(widget: ActiveNotebook): void {
+  function insertBelow(widget: Notebook): void {
     let cell = widget.model.createCodeCell();
     widget.model.cells.insert(widget.activeCellIndex + 1, cell);
     Private.deselectCells(widget);
@@ -174,7 +174,7 @@ namespace NotebookActions {
    * Change the selected cell type(s).
    */
   export
-  function changeCellType(widget: ActiveNotebook, value: string): void {
+  function changeCellType(widget: Notebook, value: string): void {
     let model = widget.model;
     model.cells.beginCompoundOperation();
     for (let i = 0; i < widget.childCount(); i++) {
@@ -211,7 +211,7 @@ namespace NotebookActions {
    * Run the selected cell(s).
    */
   export
-  function run(widget: ActiveNotebook, kernel?: IKernel): void {
+  function run(widget: Notebook, kernel?: IKernel): void {
     let selected: BaseCellWidget[] = [];
     for (let i = 0; i < widget.childCount(); i++) {
       let child = widget.childAt(i);
@@ -238,7 +238,7 @@ namespace NotebookActions {
    * edit mode and selected.
    */
   export
-  function runAndAdvance(widget: ActiveNotebook, kernel?: IKernel): void {
+  function runAndAdvance(widget: Notebook, kernel?: IKernel): void {
     run(widget, kernel);
     let model = widget.model;
     if (widget.activeCellIndex === widget.childCount() - 1) {
@@ -256,7 +256,7 @@ namespace NotebookActions {
    * Run the selected cell(s) and insert a new code cell below in edit mode.
    */
   export
-  function runAndInsert(widget: ActiveNotebook, kernel?: IKernel): void {
+  function runAndInsert(widget: Notebook, kernel?: IKernel): void {
     run(widget, kernel);
     let model = widget.model;
     let cell = model.createCodeCell();
@@ -270,7 +270,7 @@ namespace NotebookActions {
    * Run all of the cells in the notebook.
    */
   export
-  function runAll(widget: ActiveNotebook, kernel?: IKernel): void {
+  function runAll(widget: Notebook, kernel?: IKernel): void {
     for (let i = 0; i < widget.childCount(); i++) {
       Private.runCell(widget.childAt(i), kernel);
     }
@@ -282,7 +282,7 @@ namespace NotebookActions {
    * Select the cell below the active cell.
    */
   export
-  function selectBelow(widget: ActiveNotebook): void {
+  function selectBelow(widget: Notebook): void {
     if (widget.activeCellIndex === widget.childCount() - 1) {
       return;
     }
@@ -295,7 +295,7 @@ namespace NotebookActions {
    * Select the above the active cell.
    */
   export
-  function selectAbove(widget: ActiveNotebook): void {
+  function selectAbove(widget: Notebook): void {
     if (widget.activeCellIndex === 0) {
       return;
     }
@@ -308,7 +308,7 @@ namespace NotebookActions {
    * Extend the selection to the cell above.
    */
   export
-  function extendSelectionAbove(widget: ActiveNotebook): void {
+  function extendSelectionAbove(widget: Notebook): void {
     // Do not wrap around.
     if (widget.activeCellIndex === 0) {
       return;
@@ -336,7 +336,7 @@ namespace NotebookActions {
    * Extend the selection to the cell below.
    */
   export
-  function extendSelectionBelow(widget: ActiveNotebook): void {
+  function extendSelectionBelow(widget: Notebook): void {
     // Do not wrap around.
     if (widget.activeCellIndex === widget.childCount() - 1) {
       return;
@@ -364,7 +364,7 @@ namespace NotebookActions {
    * Copy the selected cells to a clipboard.
    */
   export
-  function copy(widget: ActiveNotebook, clipboard: IClipboard): void {
+  function copy(widget: Notebook, clipboard: IClipboard): void {
     clipboard.clear();
     let data: nbformat.IBaseCell[] = [];
     for (let i = 0; i < widget.childCount(); i++) {
@@ -381,7 +381,7 @@ namespace NotebookActions {
    * Cut the selected cells to a clipboard.
    */
   export
-  function cut(widget: ActiveNotebook, clipboard: IClipboard): void {
+  function cut(widget: Notebook, clipboard: IClipboard): void {
     clipboard.clear();
     let data: nbformat.IBaseCell[] = [];
     let model = widget.model;
@@ -408,7 +408,7 @@ namespace NotebookActions {
    * Paste cells from a clipboard.
    */
   export
-  function paste(widget: ActiveNotebook, clipboard: IClipboard): void {
+  function paste(widget: Notebook, clipboard: IClipboard): void {
     if (!clipboard.hasData(JUPYTER_CELL_MIME)) {
       return;
     }
@@ -437,7 +437,7 @@ namespace NotebookActions {
    * Undo a cell action.
    */
   export
-  function undo(widget: ActiveNotebook): void {
+  function undo(widget: Notebook): void {
     widget.mode = 'command';
     widget.model.cells.undo();
   }
@@ -446,7 +446,7 @@ namespace NotebookActions {
    * Redo a cell action.
    */
   export
-  function redo(widget: ActiveNotebook): void {
+  function redo(widget: Notebook): void {
     widget.mode = 'command';
     widget.model.cells.redo();
   }
@@ -455,7 +455,7 @@ namespace NotebookActions {
    * Toggle line numbers on the selected cell(s).
    */
   export
-  function toggleLineNumbers(widget: ActiveNotebook): void {
+  function toggleLineNumbers(widget: Notebook): void {
     let cell = widget.childAt(widget.activeCellIndex);
     let editor = cell.editor.editor;
     let lineNumbers = editor.getOption('lineNumbers');
@@ -472,7 +472,7 @@ namespace NotebookActions {
    * Toggle the line number of all cells.
    */
   export
-  function toggleAllLineNumbers(widget: ActiveNotebook): void {
+  function toggleAllLineNumbers(widget: Notebook): void {
     let cell = widget.childAt(widget.activeCellIndex);
     let editor = cell.editor.editor;
     let lineNumbers = editor.getOption('lineNumbers');
@@ -487,7 +487,7 @@ namespace NotebookActions {
    * Clear the outputs of the currently selected cells.
    */
   export
-  function clearOutputs(widget: ActiveNotebook): void {
+  function clearOutputs(widget: Notebook): void {
     let cells = widget.model.cells;
     for (let i = 0; i < cells.length; i++) {
       let cell = cells.get(i) as CodeCellModel;
@@ -503,7 +503,7 @@ namespace NotebookActions {
    * Clear the code outputs on the widget.
    */
   export
-  function clearAllOutputs(widget: ActiveNotebook): void {
+  function clearAllOutputs(widget: Notebook): void {
     let cells = widget.model.cells;
     for (let i = 0; i < cells.length; i++) {
       let cell = cells.get(i) as CodeCellModel;
@@ -524,7 +524,7 @@ namespace Private {
    * Deselect all of the cells.
    */
   export
-  function deselectCells(widget: ActiveNotebook): void {
+  function deselectCells(widget: Notebook): void {
     for (let i = 0; i < widget.childCount(); i++) {
       let child = widget.childAt(i);
       widget.deselect(child);

+ 126 - 80
src/notebook/notebook/model.ts

@@ -21,7 +21,8 @@ import {
 } from '../../docregistry';
 
 import {
-  ICellModel, CodeCellModel, RawCellModel, MarkdownCellModel
+  ICellModel, ICodeCellModel, IRawCellModel, IMarkdownCellModel,
+  CodeCellModel, RawCellModel, MarkdownCellModel
 } from '../cells/model';
 
 import {
@@ -45,7 +46,7 @@ import {
  * The definition of a model object for a notebook widget.
  */
 export
-interface INotebookModel extends IDocumentModel {
+interface INotebookModel extends IDocumentModel, ICellModelFactory {
   /**
    * A signal emitted when a model state changes.
    */
@@ -91,45 +92,45 @@ interface INotebookModel extends IDocumentModel {
 
   /**
    * List the metadata namespace keys for the notebook.
-   *
-   * #### Notes
-   * Metadata associated with the nbformat are not included.
    */
   listMetadata(): string[];
+}
+
 
+/**
+ * A factory for creating cell models.
+ */
+export
+interface ICellModelFactory {
   /**
-   * A factory for creating a new code cell.
+   * Create a new code cell.
    *
    * @param source - The data to use for the original source data.
    *
    * @returns A new code cell. If a source cell is provided, the
    *   new cell will be intialized with the data from the source.
-   *
-   * #### Notes
-   * If the source argument does not give an input mimetype, the code cell
-   * defaults to the notebook [[defaultMimetype]].
    */
-  createCodeCell(source?: nbformat.IBaseCell): CodeCellModel;
+  createCodeCell(source?: nbformat.IBaseCell): ICodeCellModel;
 
   /**
-   * A factory for creating a new Markdown cell.
+   * Create a new markdown cell.
    *
    * @param source - The data to use for the original source data.
    *
    * @returns A new markdown cell. If a source cell is provided, the
    *   new cell will be intialized with the data from the source.
    */
-  createMarkdownCell(source?: nbformat.IBaseCell): MarkdownCellModel;
+  createMarkdownCell(source?: nbformat.IBaseCell): IMarkdownCellModel;
 
   /**
-   * A factory for creating a new raw cell.
+   * Create a new raw cell.
    *
    * @param source - The data to use for the original source data.
    *
    * @returns A new raw cell. If a source cell is provided, the
    *   new cell will be intialized with the data from the source.
    */
-  createRawCell(source?: nbformat.IBaseCell): RawCellModel;
+  createRawCell(source?: nbformat.IBaseCell): IRawCellModel;
 }
 
 
@@ -141,21 +142,24 @@ class NotebookModel extends DocumentModel implements INotebookModel {
   /**
    * Construct a new notebook model.
    */
-  constructor(languagePreference?: string) {
-    super(languagePreference);
+  constructor(options: NotebookModel.IOptions = {}) {
+    super(options.languagePreference);
+    this._factory = options.cellModelFactory || Private.defaultFactory;
     this._cells = new ObservableUndoableList<ICellModel>((data: nbformat.IBaseCell) => {
       switch (data.cell_type) {
         case 'code':
-          return this.createCodeCell(data);
+          return this._factory.createCodeCell(data);
         case 'markdown':
-          return this.createMarkdownCell(data);
+          return this._factory.createMarkdownCell(data);
         default:
-          return this.createRawCell(data);
+          return this._factory.createRawCell(data);
       }
     });
-    this._cells.changed.connect(this.onCellsChanged, this);
-    if (languagePreference) {
-      this._metadata['language_info'] = { name: languagePreference };
+    // Add an initial code cell by default.
+    this._cells.add(this._factory.createCodeCell());
+    this._cells.changed.connect(this._onCellsChanged, this);
+    if (options.languagePreference) {
+      this._metadata['language_info'] = { name: options.languagePreference };
     }
   }
 
@@ -322,7 +326,7 @@ class NotebookModel extends DocumentModel implements INotebookModel {
         continue;
       }
       if (!(key in metadata)) {
-        this.setCursorData(key, null);
+        this._setCursorData(key, null);
         delete this._metadata[key];
         if (this._cursors[key]) {
           this._cursors[key].dispose();
@@ -331,7 +335,7 @@ class NotebookModel extends DocumentModel implements INotebookModel {
       }
     }
     for (let key in metadata) {
-      this.setCursorData(key, (metadata as any)[key]);
+      this._setCursorData(key, (metadata as any)[key]);
     }
     this.dirty = true;
   }
@@ -345,82 +349,96 @@ class NotebookModel extends DocumentModel implements INotebookModel {
   }
 
   /**
-   * A factory for creating a new code cell.
+   * Get a metadata cursor for the notebook.
+   *
+   * #### Notes
+   * Metadata associated with the nbformat spec are set directly
+   * on the model.  This method is used to interact with a namespaced
+   * set of metadata on the notebook.
+   */
+  getMetadata(name: string): IMetadataCursor {
+    if (name in this._cursors) {
+      return this._cursors[name];
+    }
+    let cursor = new MetadataCursor(
+      name,
+      () => {
+        return this._metadata[name];
+      },
+      (value: string) => {
+        this._setCursorData(name, value);
+      }
+    );
+    this._cursors[name] = cursor;
+    return cursor;
+  }
+
+  /**
+   * List the metadata namespace keys for the notebook.
+   */
+  listMetadata(): string[] {
+    return Object.keys(this._metadata);
+  }
+
+  /**
+   * Create a new code cell.
    *
    * @param source - The data to use for the original source data.
    *
    * @returns A new code cell. If a source cell is provided, the
    *   new cell will be intialized with the data from the source.
    */
-  createCodeCell(source?: nbformat.IBaseCell): CodeCellModel {
-    return new CodeCellModel(source);
+  createCodeCell(source?: nbformat.IBaseCell): ICodeCellModel {
+    return this._factory.createCodeCell(source);
   }
 
   /**
-   * A factory for creating a new Markdown cell.
+   * Create a new markdown cell.
    *
    * @param source - The data to use for the original source data.
    *
    * @returns A new markdown cell. If a source cell is provided, the
    *   new cell will be intialized with the data from the source.
    */
-  createMarkdownCell(source?: nbformat.IBaseCell): MarkdownCellModel {
-    return new MarkdownCellModel(source);
+  createMarkdownCell(source?: nbformat.IBaseCell): IMarkdownCellModel {
+    return this._factory.createMarkdownCell(source);
   }
 
   /**
-   * A factory for creating a new raw cell.
+   * Create a new raw cell.
    *
    * @param source - The data to use for the original source data.
    *
    * @returns A new raw cell. If a source cell is provided, the
    *   new cell will be intialized with the data from the source.
    */
-  createRawCell(source?: nbformat.IBaseCell): RawCellModel {
-    return new RawCellModel(source);
+  createRawCell(source?: nbformat.IBaseCell): IRawCellModel {
+    return this._factory.createRawCell(source);
   }
 
   /**
-   * Get a metadata cursor for the notebook.
-   *
-   * #### Notes
-   * Metadata associated with the nbformat spec are set directly
-   * on the model.  This method is used to interact with a namespaced
-   * set of metadata on the notebook.
+   * Set the cursor data for a given field.
    */
-  getMetadata(name: string): IMetadataCursor {
-    if (name in this._cursors) {
-      return this._cursors[name];
+  private _setCursorData(name: string, newValue: any): void {
+    let oldValue = this._metadata[name];
+    if (deepEqual(oldValue, newValue)) {
+      return;
     }
-    let cursor = new MetadataCursor(
-      name,
-      () => {
-        return this._metadata[name];
-      },
-      (value: string) => {
-        this.setCursorData(name, value);
-      }
-    );
-    this._cursors[name] = cursor;
-    return cursor;
-  }
-
-  /**
-   * List the metadata namespace keys for the notebook.
-   */
-  listMetadata(): string[] {
-    return Object.keys(this._metadata);
+    this._metadata[name] = newValue;
+    this.dirty = true;
+    this.contentChanged.emit(void 0);
+    this.metadataChanged.emit({ name, oldValue, newValue });
   }
 
   /**
    * Handle a change in the cells list.
    */
-  protected onCellsChanged(list: IObservableList<ICellModel>, change: IListChangedArgs<ICellModel>): void {
+  private _onCellsChanged(list: IObservableList<ICellModel>, change: IListChangedArgs<ICellModel>): void {
     let cell: ICellModel;
     switch (change.type) {
     case ListChangeType.Add:
       cell = change.newValue as ICellModel;
-      cell.contentChanged.connect(this.onCellChanged, this);
+      cell.contentChanged.connect(this._onCellChanged, this);
       break;
     case ListChangeType.Remove:
       (change.oldValue as ICellModel).dispose();
@@ -428,7 +446,7 @@ class NotebookModel extends DocumentModel implements INotebookModel {
     case ListChangeType.Replace:
       let newValues = change.newValue as ICellModel[];
       for (cell of newValues) {
-        cell.contentChanged.connect(this.onCellChanged, this);
+        cell.contentChanged.connect(this._onCellChanged, this);
       }
       let oldValues = change.oldValue as ICellModel[];
       for (cell of oldValues) {
@@ -437,7 +455,7 @@ class NotebookModel extends DocumentModel implements INotebookModel {
       break;
     case ListChangeType.Set:
       cell = change.newValue as ICellModel;
-      cell.contentChanged.connect(this.onCellChanged, this);
+      cell.contentChanged.connect(this._onCellChanged, this);
       if (change.oldValue) {
         (change.oldValue as ICellModel).dispose();
       }
@@ -452,26 +470,13 @@ class NotebookModel extends DocumentModel implements INotebookModel {
   /**
    * Handle a change to a cell state.
    */
-  protected onCellChanged(cell: ICellModel, change: any): void {
+  private _onCellChanged(cell: ICellModel, change: any): void {
     this.dirty = true;
     this.contentChanged.emit(void 0);
   }
 
-  /**
-   * Set the cursor data for a given field.
-   */
-  protected setCursorData(name: string, newValue: any): void {
-    let oldValue = this._metadata[name];
-    if (deepEqual(oldValue, newValue)) {
-      return;
-    }
-    this._metadata[name] = newValue;
-    this.dirty = true;
-    this.contentChanged.emit(void 0);
-    this.metadataChanged.emit({ name, oldValue, newValue });
-  }
-
   private _cells: ObservableUndoableList<ICellModel> = null;
+  private _factory: ICellModelFactory = null;
   private _metadata: { [key: string]: any } = Private.createMetadata();
   private _cursors: { [key: string]: MetadataCursor } = Object.create(null);
   private _nbformat = nbformat.MAJOR_VERSION;
@@ -479,6 +484,31 @@ class NotebookModel extends DocumentModel implements INotebookModel {
 }
 
 
+/**
+ * The namespace for the `NotebookModel` class statics.
+ */
+export
+namespace NotebookModel {
+  /**
+   * An options object for initializing a notebook model.
+   */
+  export
+  interface IOptions {
+    /**
+     * The language preference for the model.
+     */
+    languagePreference?: string;
+
+    /**
+     * A factory for creating cell models.
+     *
+     * The default is a shared factory instance.
+     */
+    cellModelFactory?: ICellModelFactory;
+  }
+}
+
+
 /**
  * A private namespace for notebook model data.
  */
@@ -489,6 +519,22 @@ namespace Private {
   export
   const metadataChangedSignal = new Signal<IDocumentModel, IChangedArgs<any>>();
 
+  /**
+   * The default `ICellModelFactory` instance.
+   */
+  export
+  const defaultFactory: ICellModelFactory = {
+    createCodeCell: (source?: nbformat.IBaseCell) => {
+      return new CodeCellModel(source);
+    },
+    createMarkdownCell: (source?: nbformat.IBaseCell) => {
+      return new MarkdownCellModel(source);
+    },
+    createRawCell: (source?: nbformat.IBaseCell) => {
+      return new RawCellModel(source);
+    }
+  };
+
   /**
    * Create the default metadata for the notebook.
    */

+ 1 - 4
src/notebook/notebook/modelfactory.ts

@@ -61,10 +61,7 @@ class NotebookModelFactory implements IModelFactory {
    * @returns A new document model.
    */
   createNew(languagePreference?: string): INotebookModel {
-    let model = new NotebookModel(languagePreference);
-    let cell = model.createCodeCell();
-    model.cells.add(cell);
-    return model;
+    return new NotebookModel({ languagePreference });
   }
 
   /**

+ 8 - 6
src/notebook/notebook/panel.ts

@@ -50,7 +50,7 @@ import {
 } from './toolbar';
 
 import {
-  ActiveNotebook
+  Notebook
 } from './widget';
 
 
@@ -82,8 +82,10 @@ class NotebookPanel extends Widget {
   /**
    * Create a new content area for the notebook.
    */
-  static createContent(model: INotebookModel, rendermime: RenderMime<Widget>): ActiveNotebook {
-    return new ActiveNotebook(model, rendermime);
+  static createContent(model: INotebookModel, rendermime: RenderMime<Widget>): Notebook {
+    let widget = new Notebook({ rendermime });
+    widget.model = model;
+    return widget;
   }
 
   /**
@@ -178,7 +180,7 @@ class NotebookPanel extends Widget {
    * #### Notes
    * This is a read-only property.
    */
-  get content(): ActiveNotebook {
+  get content(): Notebook {
     return this._content;
   }
 
@@ -281,7 +283,7 @@ class NotebookPanel extends Widget {
   /**
    * Handle a change in the content area.
    */
-  protected onContentChanged(sender: ActiveNotebook, args: IChangedArgs<any>): void {
+  protected onContentChanged(sender: Notebook, args: IChangedArgs<any>): void {
     switch (args.name) {
     case 'activeCellIndex':
       let cell = this._content.childAt(args.oldValue);
@@ -371,7 +373,7 @@ class NotebookPanel extends Widget {
   private _rendermime: RenderMime<Widget> = null;
   private _context: IDocumentContext = null;
   private _model: INotebookModel = null;
-  private _content: ActiveNotebook = null;
+  private _content: Notebook = null;
   private _toolbar: NotebookToolbar = null;
   private _clipboard: IClipboard = null;
   private _completion: CompletionWidget = null;

+ 216 - 118
src/notebook/notebook/widget.ts

@@ -30,13 +30,14 @@ import {
 } from 'phosphor-signaling';
 
 import {
-  Widget
+  ChildMessage, Widget
 } from 'phosphor-widget';
 
 import {
   ICellModel, BaseCellWidget, MarkdownCellModel,
   CodeCellWidget, MarkdownCellWidget,
-  CodeCellModel, RawCellWidget
+  CodeCellModel, RawCellWidget, RawCellModel,
+  ICodeCellModel, IMarkdownCellModel, IRawCellModel
 } from '../cells';
 
 import {
@@ -99,48 +100,45 @@ type NotebookMode = 'command' | 'edit';
 
 
 /**
- * A widget which renders notebooks.
+ * A widget which renders static non-interactive notebooks.
  */
 export
-class NotebookRenderer extends Widget {
-  /**
-   * Create a new cell widget given a cell model.
-   */
-  static createCell(cell: ICellModel, rendermime: RenderMime<Widget>): BaseCellWidget {
-    switch (cell.type) {
-    case 'code':
-      return new CodeCellWidget(cell as CodeCellModel, rendermime);
-    case 'markdown':
-      return new MarkdownCellWidget(cell, rendermime);
-    // If there are any issues, just return a raw
-    // widget so the lists stay in sync.
-    default:
-      return new RawCellWidget(cell);
-    }
-  }
-
+class StaticNotebook extends Widget {
   /**
    * Construct a notebook widget.
    */
-  constructor(model: INotebookModel, rendermime: RenderMime<Widget>) {
+  constructor(options: StaticNotebook.IOptions) {
     super();
     this.node.tabIndex = -1;  // Allow the widget to take focus.
     this.addClass(NB_CLASS);
-    this._model = model;
-    this._rendermime = rendermime;
+    this._rendermime = options.rendermime;
     this.layout = new PanelLayout();
-    this._initialized = false;
+    this._factory = options.cellWidgetFactory || Private.defaultFactory;
   }
 
   /**
-   * Get the model for the widget.
-   *
-   * #### Notes
-   * This is a read-only property.
+   * A signal emitted when the model of the notebook changes.
+   */
+  get modelChanged(): ISignal<StaticNotebook, INotebookModel> {
+    return Private.modelChangedSignal.bind(this);
+  }
+
+  /**
+   * The model for the widget.
    */
   get model(): INotebookModel {
     return this._model;
   }
+  set model(newValue: INotebookModel) {
+    newValue = newValue || null;
+    if (this._model === newValue) {
+      return;
+    }
+    let oldValue = this._model;
+    this._model = newValue;
+    this._changeModel(oldValue, newValue);
+    this.modelChanged.emit(newValue);
+  }
 
   /**
    * Get the rendermime instance used by the widget.
@@ -178,52 +176,87 @@ class NotebookRenderer extends Widget {
     }
     this._model = null;
     this._rendermime = null;
+    this._factory = null;
     super.dispose();
   }
 
+
   /**
-   * Handle `after_attach` messages for the widget.
+   * Handle a `child-added` message.
    */
-  protected onAfterAttach(msg: Message): void {
-    if (!this._initialized) {
-      this.initialize();
-      this._initialized = true;
-    }
+  protected onChildAdded(msg: ChildMessage): void {
+    msg.child.addClass(NB_CELL_CLASS);
   }
 
   /**
-   * It should initialize the contents of the widget.
+   * Handle a `child-removed` message.
    */
-  protected initialize(): void {
-    let model = this.model;
-    let rendermime = this.rendermime;
+  protected onChildRemoved(msg: ChildMessage): void {
+    msg.child.dispose();
+  }
 
-    // Add the current cells.
-    if (model.cells.length === 0) {
-      // Add a new code cell if there are no cells.
-      let cell = model.createCodeCell();
-      model.cells.add(cell);
+  /**
+   * Handle a new model on the widget.
+   */
+  private _changeModel(oldValue: INotebookModel, newValue: INotebookModel): void {
+    let layout = this.layout as PanelLayout;
+    if (oldValue) {
+      oldValue.cells.changed.disconnect(this._onCellsChanged, this);
+      oldValue.metadataChanged.disconnect(this._onMetadataChanged, this);
+      // TODO: reuse existing cell widgets if possible.
+      for (let i = 0; i < layout.childCount(); i++) {
+        layout.removeChild(layout.childAt(0));
+      }
     }
+    let cells = newValue.cells;
+    for (let i = 0; i < cells.length; i++) {
+      layout.addChild(this._createWidget(cells.get(i)));
+    }
+    this._setChildMimetypes();
+    cells.changed.connect(this._onCellsChanged, this);
+    newValue.metadataChanged.connect(this._onMetadataChanged, this);
+  }
+
+  /**
+   * Create a widget from a model using the appropriate factory.
+   */
+  private _createWidget(model: ICellModel): BaseCellWidget {
+    switch (model.type) {
+    case 'code':
+      let codeFactory = this._factory.createCodeCell;
+      return codeFactory(model as CodeCellModel, this._rendermime);
+    case 'markdown':
+      let mdFactory = this._factory.createMarkdownCell;
+      return mdFactory(model as MarkdownCellModel, this._rendermime);
+    default:
+      let rawFactory = this._factory.createRawCell;
+      return rawFactory(model as RawCellModel);
+    }
+  }
+
+  /**
+   * Set the mimetype of the child code widgets.
+   */
+  private _setChildMimetypes(): void {
+    let cursor = this.model.getMetadata('language_info');
+    let info = cursor.getValue() as nbformat.ILanguageInfoMetadata;
+    let mimetype = mimetypeForLanguage(info as IKernelLanguageInfo);
     let layout = this.layout as PanelLayout;
-    let constructor = this.constructor as typeof NotebookRenderer;
-    let factory = constructor.createCell;
-    for (let i = 0; i < model.cells.length; i++) {
-      let widget = factory(model.cells.get(i), rendermime);
-      this.initializeCellWidget(widget);
-      layout.addChild(widget);
+    for (let i = 0; i < layout.childCount(); i++) {
+      let widget = layout.childAt(i) as CodeCellWidget;
+      if (widget instanceof CodeCellWidget) {
+        widget.mimetype = mimetype;
+      }
     }
-    this.updateMimetypes();
-    model.cells.changed.connect(this.onCellsChanged, this);
-    model.metadataChanged.connect(this.onMetadataChanged, this);
   }
 
   /**
    * Handle changes to the notebook model.
    */
-  protected onMetadataChanged(model: INotebookModel, args: IChangedArgs<any>): void {
+  private _onMetadataChanged(model: INotebookModel, args: IChangedArgs<any>): void {
     switch (args.name) {
     case 'language_info':
-      this.updateMimetypes();
+      this._setChildMimetypes();
       break;
     default:
       break;
@@ -233,79 +266,95 @@ class NotebookRenderer extends Widget {
   /**
    * Handle a change cells event.
    */
-  protected onCellsChanged(sender: IObservableList<ICellModel>, args: IListChangedArgs<ICellModel>) {
+  private _onCellsChanged(sender: IObservableList<ICellModel>, args: IListChangedArgs<ICellModel>) {
     let layout = this.layout as PanelLayout;
-    let constructor = this.constructor as typeof NotebookRenderer;
-    let factory = constructor.createCell;
-    let widget: BaseCellWidget;
+    let model: ICellModel;
     switch (args.type) {
     case ListChangeType.Add:
-      widget = factory(args.newValue as ICellModel, this._rendermime);
-      this.initializeCellWidget(widget);
-      layout.insertChild(args.newIndex, widget);
+      model = args.newValue as ICellModel;
+      layout.insertChild(args.newIndex, this._createWidget(model));
       break;
     case ListChangeType.Move:
       layout.insertChild(args.newIndex, layout.childAt(args.oldIndex));
       break;
     case ListChangeType.Remove:
-      widget = layout.childAt(args.oldIndex) as BaseCellWidget;
-      layout.removeChild(widget);
-      widget.dispose();
+      layout.childAt(args.oldIndex).parent = null;
       break;
     case ListChangeType.Replace:
       let oldValues = args.oldValue as ICellModel[];
       for (let i = 0; i < oldValues.length; i++) {
-        widget = layout.childAt(args.oldIndex) as BaseCellWidget;
-        layout.removeChild(widget);
-        widget.dispose();
+        layout.childAt(args.oldIndex).parent = null;
       }
       let newValues = args.newValue as ICellModel[];
       for (let i = newValues.length; i > 0; i--) {
-        widget = factory(newValues[i - 1], this._rendermime);
-        this.initializeCellWidget(widget);
-        layout.insertChild(args.newIndex, widget);
+        model = newValues[i - 1];
+        layout.insertChild(args.newIndex, this._createWidget(model));
       }
       break;
     case ListChangeType.Set:
-      widget = layout.childAt(args.newIndex) as BaseCellWidget;
-      layout.removeChild(widget);
-      widget.dispose();
-      widget = factory(args.newValue as ICellModel, this._rendermime);
-      layout.insertChild(args.newIndex, widget);
-      this.initializeCellWidget(widget);
+      layout.childAt(args.newIndex).parent = null;
+      model = args.newValue as ICellModel;
+      layout.insertChild(args.newIndex, this._createWidget(model));
       break;
     default:
       return;
     }
-    this.update();
   }
 
+  private _model: INotebookModel = null;
+  private _rendermime: RenderMime<Widget> = null;
+  private _factory: StaticNotebook.ICellWidgetFactory = null;
+}
+
+
+/**
+ * The namespace for the `StaticNotebook` class statics.
+ */
+export
+namespace StaticNotebook {
   /**
-   * Initialize a cell widget.
+   * An options object for initializing a static notebook.
    */
-  protected initializeCellWidget(widget: BaseCellWidget): void {
-    widget.addClass(NB_CELL_CLASS);
+  export
+  interface IOptions {
+    /**
+     * The rendermime instance used by the widget.
+     */
+    rendermime: RenderMime<Widget>;
+
+    /**
+     * The language preference for the model.
+     */
+    languagePreference?: string;
+
+    /**
+     * A factory for creating code cell widgets.
+     *
+     * The default is a shared factory instance.
+     */
+    cellWidgetFactory?: ICellWidgetFactory;
   }
 
   /**
-   * Update the mimetype of code widgets.
+   * A factory for creating code cell widgets.
    */
-  protected updateMimetypes(): void {
-    let cursor = this.model.getMetadata('language_info');
-    let info = cursor.getValue() as nbformat.ILanguageInfoMetadata;
-    let mimetype = mimetypeForLanguage(info as IKernelLanguageInfo);
-    let layout = this.layout as PanelLayout;
-    for (let i = 0; i < layout.childCount(); i++) {
-      let widget = layout.childAt(i) as CodeCellWidget;
-      if (widget instanceof CodeCellWidget) {
-        widget.mimetype = mimetype;
-      }
-    }
+  export
+  interface ICellWidgetFactory {
+    /**
+     * Create a new code cell widget.
+     */
+    createCodeCell(model: ICodeCellModel, rendermime: RenderMime<Widget>): CodeCellWidget;
+
+    /**
+     * Create a new markdown cell widget.
+     */
+    createMarkdownCell(model: IMarkdownCellModel, rendermime: RenderMime<Widget>): MarkdownCellWidget;
+
+    /**
+     * Create a new raw cell widget.
+     */
+    createRawCell(model: IRawCellModel): RawCellWidget;
   }
-
-  private _model: INotebookModel = null;
-  private _rendermime: RenderMime<Widget> = null;
-  private _initialized = false;
 }
 
 
@@ -313,11 +362,11 @@ class NotebookRenderer extends Widget {
  * A notebook widget that supports interactivity.
  */
 export
-class ActiveNotebook extends NotebookRenderer {
+class Notebook extends StaticNotebook {
   /**
    * A signal emitted when the state of the notebook changes.
    */
-  get stateChanged(): ISignal<ActiveNotebook, IChangedArgs<any>> {
+  get stateChanged(): ISignal<Notebook, IChangedArgs<any>> {
     return Private.stateChangedSignal.bind(this);
   }
 
@@ -347,11 +396,17 @@ class ActiveNotebook extends NotebookRenderer {
 
   /**
    * The active cell index of the notebook.
+   *
+   * #### Notes
+   * The index will be clamped to the bounds of the notebook cells.
    */
   get activeCellIndex(): number {
-    return this._activeCellIndex;
+    return this.model.cells.length ? this._activeCellIndex : -1;
   }
   set activeCellIndex(newValue: number) {
+    if (!this.model.cells.length) {
+      return;
+    }
     newValue = Math.max(newValue, 0);
     newValue = Math.min(newValue, this.model.cells.length - 1);
     if (newValue === this._activeCellIndex) {
@@ -359,12 +414,6 @@ class ActiveNotebook extends NotebookRenderer {
     }
     let oldValue = this._activeCellIndex;
     this._activeCellIndex = newValue;
-    let widget = (this.layout as PanelLayout).childAt(newValue);
-    if (widget instanceof MarkdownCellWidget) {
-      if (this.mode === 'edit') {
-        widget.rendered = false;
-      }
-    }
     this.stateChanged.emit({ name: 'activeCellIndex', oldValue, newValue });
     this.update();
   }
@@ -417,6 +466,9 @@ class ActiveNotebook extends NotebookRenderer {
     case 'dblclick':
       this._evtDblClick(event as MouseEvent);
       break;
+    case 'focus':
+      this._evtFocus(event as FocusEvent);
+      break;
     default:
       break;
     }
@@ -429,6 +481,7 @@ class ActiveNotebook extends NotebookRenderer {
     super.onAfterAttach(msg);
     this.node.addEventListener('click', this);
     this.node.addEventListener('dblclick', this);
+    this.node.addEventListener('focus', this, true);
     this.update();
   }
 
@@ -438,6 +491,7 @@ class ActiveNotebook extends NotebookRenderer {
   protected onBeforeDetach(msg: Message): void {
     this.node.removeEventListener('click', this);
     this.node.removeEventListener('dblclick', this);
+    this.node.removeEventListener('focus', this, true);
   }
 
   /**
@@ -452,9 +506,9 @@ class ActiveNotebook extends NotebookRenderer {
       this.removeClass(COMMAND_CLASS);
       if (widget) {
         widget.focus();
-      }
-      if (widget instanceof MarkdownCellWidget) {
-        (widget as MarkdownCellWidget).rendered = false;
+        if (widget instanceof MarkdownCellWidget) {
+          (widget as MarkdownCellWidget).rendered = false;
+        }
       }
     } else {
       this.addClass(COMMAND_CLASS);
@@ -463,8 +517,11 @@ class ActiveNotebook extends NotebookRenderer {
     }
     if (widget) {
       widget.addClass(ACTIVE_CLASS);
-      Private.scrollIfNeeded(this.parent.node, widget.node);
+      if (this.parent) {
+        Private.scrollIfNeeded(this.parent.node, widget.node);
+      }
     }
+
     let count = 0;
     for (let i = 0; i < layout.childCount(); i++) {
       widget = layout.childAt(i) as BaseCellWidget;
@@ -487,17 +544,27 @@ class ActiveNotebook extends NotebookRenderer {
   }
 
   /**
-   * Initialize a cell widget.
+   * Handle a `child-added` message.
    */
-  protected initializeCellWidget(widget: BaseCellWidget): void {
-    super.initializeCellWidget(widget);
-    widget.editor.edgeRequested.connect(this.onEdgeRequest, this);
+  protected onChildAdded(msg: ChildMessage): void {
+    super.onChildAdded(msg);
+    let widget = msg.child as BaseCellWidget;
+    widget.editor.edgeRequested.connect(this._onEdgeRequest, this);
+    this.update();
+  }
+
+  /**
+   * Handle a `child-removed` message.
+   */
+  protected onChildRemoved(msg: ChildMessage): void {
+    msg.child.dispose();
+    this.update();
   }
 
   /**
    * Handle edge request signals from cells.
    */
-  protected onEdgeRequest(widget: Widget, location: EdgeLocation): void {
+  private _onEdgeRequest(widget: Widget, location: EdgeLocation): void {
     if (location === 'top') {
       this.activeCellIndex--;
     } else {
@@ -542,7 +609,6 @@ class ActiveNotebook extends NotebookRenderer {
       return;
     }
     this.activeCellIndex = i;
-    this.mode = document.activeElement === this.node ? 'command' : 'edit';
   }
 
   /**
@@ -559,10 +625,19 @@ class ActiveNotebook extends NotebookRenderer {
     }
     let cell = model.cells.get(i) as MarkdownCellModel;
     let widget = (this.layout as PanelLayout).childAt(i) as MarkdownCellWidget;
-    if (cell.type !== 'markdown' || !widget.rendered) {
+    if (cell.type === 'markdown') {
+      widget.rendered = false;
       return;
     }
-    if (widget.node.contains(event.target as HTMLElement)) {
+  }
+
+  /**
+   * Handle `focus` events for the widget.
+   */
+  private _evtFocus(event: FocusEvent): void {
+    if (event.target === this.node) {
+      this.mode = 'command';
+    } else {
       this.mode = 'edit';
     }
   }
@@ -585,11 +660,34 @@ namespace Private {
     value: false
   });
 
+  /**
+   * A signal emitted when the model changes on the notebook.
+   */
+  export
+  const modelChangedSignal = new Signal<StaticNotebook, INotebookModel>();
+
+
   /**
    * A signal emitted when the state changes on the notebook.
    */
   export
-  const stateChangedSignal = new Signal<ActiveNotebook, IChangedArgs<any>>();
+  const stateChangedSignal = new Signal<Notebook, IChangedArgs<any>>();
+
+  /**
+   * The default `ICellWidgetFactory` instance.
+   */
+  export
+  const defaultFactory: StaticNotebook.ICellWidgetFactory = {
+    createCodeCell: (model: ICodeCellModel, rendermime: RenderMime<Widget>) => {
+      return new CodeCellWidget(model, rendermime);
+    },
+    createMarkdownCell: (model: IMarkdownCellModel, rendermime: RenderMime<Widget>) => {
+      return new MarkdownCellWidget(model, rendermime);
+    },
+    createRawCell: (model: IRawCellModel) => {
+      return new RawCellWidget(model);
+    }
+  };
 
  /**
   * Scroll an element into view if needed.

+ 91 - 123
test/src/notebook/notebook/model.spec.ts

@@ -4,11 +4,7 @@
 import expect = require('expect.js');
 
 import {
-  ObservableList, IListChangedArgs
-} from 'phosphor-observablelist';
-
-import {
-  ICellModel
+  CodeCellModel
 } from '../../../../lib/notebook/cells/model';
 
 import {
@@ -27,29 +23,6 @@ import {
 const DEFAULT_CONTENT: nbformat.INotebookContent = require('../../../../examples/notebook/test.ipynb') as nbformat.INotebookContent;
 
 
-/**
- * A notebook model which tests protected methods.
- */
-class LogNotebookModel extends NotebookModel {
-  methods: string[] = [];
-
-  protected onCellChanged(cell: ICellModel, change: any): void {
-    super.onCellChanged(cell, change);
-    this.methods.push('onCellChanged');
-  }
-
-  protected onCellsChanged(list: ObservableList<ICellModel>, change: IListChangedArgs<ICellModel>): void {
-    super.onCellsChanged(list, change);
-    this.methods.push('onCellsChanged');
-  }
-
-  protected setCursorData(name: string, newValue: any): void {
-    super.setCursorData(name, newValue);
-    this.methods.push('setCursorData');
-  }
-}
-
-
 describe('notebook/notebook', () => {
 
   describe('NotebookModel', () => {
@@ -62,12 +35,18 @@ describe('notebook/notebook', () => {
       });
 
       it('should accept an optional language preference', () => {
-        let model = new NotebookModel('python');
+        let model = new NotebookModel({ languagePreference: 'python' });
         let cursor = model.getMetadata('language_info');
         let lang = cursor.getValue() as nbformat.ILanguageInfoMetadata;
         expect(lang.name).to.be('python');
       });
 
+      it('should add a single code cell by default', () => {
+        let model = new NotebookModel();
+        expect(model.cells.length).to.be(1);
+        expect(model.cells.get(0)).to.be.a(CodeCellModel);
+      });
+
     });
 
     describe('#metadataChanged', () => {
@@ -106,9 +85,10 @@ describe('notebook/notebook', () => {
         expect(model.cells).to.be.an(ObservableUndoableList);
       });
 
-      it('should default to an empty list', () => {
+      it('should add an empty code cell by default', () => {
         let model = new NotebookModel();
-        expect(model.cells.length).to.be(0);
+        expect(model.cells.length).to.be(1);
+        expect(model.cells.get(0)).to.be.a(CodeCellModel);
       });
 
       it('should be reset when loading from disk', () => {
@@ -127,9 +107,9 @@ describe('notebook/notebook', () => {
         model.cells.add(cell);
         model.fromJSON(DEFAULT_CONTENT);
         model.cells.undo();
-        expect(model.cells.length).to.be(1);
-        expect(model.cells.get(0).source).to.be('foo');
-        expect(model.cells.get(0)).to.not.be(cell);  // should be a clone.
+        expect(model.cells.length).to.be(2);
+        expect(model.cells.get(1).source).to.be('foo');
+        expect(model.cells.get(1)).to.not.be(cell);  // should be a clone.
       });
 
       it('should be read-only', () => {
@@ -137,6 +117,65 @@ describe('notebook/notebook', () => {
         expect(() => { model.cells = null; }).to.throwError();
       });
 
+      context('cells `changed` signal', () => {
+
+        it('should emit a `contentChanged` signal', () => {
+          let model = new NotebookModel();
+          let cell = model.createCodeCell();
+          let called = false;
+          model.contentChanged.connect(() => { called = true; });
+          model.cells.add(cell);
+          expect(called).to.be(true);
+        });
+
+        it('should set the dirty flag', () => {
+          let model = new NotebookModel();
+          let cell = model.createCodeCell();
+          model.cells.add(cell);
+          expect(model.dirty).to.be(true);
+        });
+
+        it('should dispose of old cells', () => {
+          let model = new NotebookModel();
+          let cell = model.createCodeCell();
+          model.cells.add(cell);
+          model.cells.clear();
+          expect(cell.isDisposed).to.be(true);
+        });
+
+      });
+
+      describe('cell `changed` signal', () => {
+
+        it('should be called when a cell content changes', () => {
+          let model = new NotebookModel();
+          let cell = model.createCodeCell();
+          model.cells.add(cell);
+          cell.source = 'foo';
+        });
+
+        it('should emit the `contentChanged` signal', () => {
+          let model = new NotebookModel();
+          let cell = model.createCodeCell();
+          model.cells.add(cell);
+          let called = false;
+          model.contentChanged.connect(() => { called = true; });
+          let cursor = cell.getMetadata('foo');
+          cursor.setValue('bar');
+          expect(called).to.be(true);
+        });
+
+        it('should set the dirty flag', () => {
+          let model = new NotebookModel();
+          let cell = model.createCodeCell();
+          model.cells.add(cell);
+          model.dirty = false;
+          cell.source = 'foo';
+          expect(model.dirty).to.be(true);
+        });
+
+      });
+
     });
 
     describe('#nbformat', () => {
@@ -203,7 +242,7 @@ describe('notebook/notebook', () => {
       });
 
       it('should be set from the constructor arg', () => {
-        let model = new NotebookModel('foo');
+        let model = new NotebookModel({ languagePreference: 'foo' });
         expect(model.defaultKernelLanguage).to.be('foo');
       });
 
@@ -401,108 +440,24 @@ describe('notebook/notebook', () => {
         expect(cursor1.getValue()).to.be(1);
       });
 
-    });
-
-    describe('#listMetadata()', () => {
-
-      it('should list the metadata namespace keys for the notebook', () => {
+      it('should set the dirty flag', () => {
         let model = new NotebookModel();
-        let keys = ['kernelspec', 'language_info', 'orig_nbformat'];
-        expect(model.listMetadata()).to.eql(keys);
         let cursor = model.getMetadata('foo');
-        expect(model.listMetadata()).to.eql(keys);
-        cursor.setValue(1);
-        keys.push('foo');
-        expect(model.listMetadata()).to.eql(keys);
-      });
-
-    });
-
-    describe('#onCellsChanged()', () => {
-
-      it('should emit a `contentChanged` signal', () => {
-        let model = new LogNotebookModel();
-        let cell = model.createCodeCell();
-        let called = false;
-        model.contentChanged.connect(() => { called = true; });
-        model.cells.add(cell);
-        expect(model.methods.indexOf('onCellsChanged')).to.not.be(-1);
-        expect(called).to.be(true);
-      });
-
-      it('should set the dirty flag', () => {
-        let model = new LogNotebookModel();
-        let cell = model.createCodeCell();
-        model.cells.add(cell);
-        expect(model.methods.indexOf('onCellsChanged')).to.not.be(-1);
-        expect(model.dirty).to.be(true);
-      });
-
-      it('should dispose of old cells', () => {
-        let model = new LogNotebookModel();
-        let cell = model.createCodeCell();
-        model.cells.add(cell);
-        model.cells.clear();
-        expect(cell.isDisposed).to.be(true);
-      });
-
-    });
-
-    describe('#onCellChanged()', () => {
-
-      it('should be called when a cell content changes', () => {
-        let model = new LogNotebookModel();
-        let cell = model.createCodeCell();
-        model.cells.add(cell);
-        cell.source = 'foo';
-        expect(model.methods.indexOf('onCellChanged')).to.not.be(-1);
-      });
-
-      it('should emit the `contentChanged` signal', () => {
-        let model = new LogNotebookModel();
-        let cell = model.createCodeCell();
-        model.cells.add(cell);
-        let called = false;
-        model.contentChanged.connect(() => { called = true; });
-        let cursor = cell.getMetadata('foo');
         cursor.setValue('bar');
-        expect(model.methods.indexOf('onCellChanged')).to.not.be(-1);
-        expect(called).to.be(true);
-      });
-
-      it('should set the dirty flag', () => {
-        let model = new LogNotebookModel();
-        let cell = model.createCodeCell();
-        model.cells.add(cell);
-        model.dirty = false;
-        cell.source = 'foo';
-        expect(model.methods.indexOf('onCellChanged')).to.not.be(-1);
         expect(model.dirty).to.be(true);
       });
 
-    });
-
-    describe('#setCursorData()', () => {
-
-      it('should set the dirty flag', () => {
-        let model = new LogNotebookModel();
-        let cursor = model.getMetadata('foo');
-        cursor.setValue('bar');
-        expect(model.methods.indexOf('setCursorData')).to.not.be(-1);
-      });
-
       it('should emit the `contentChanged` signal', () => {
-        let model = new LogNotebookModel();
+        let model = new NotebookModel();
         let cursor = model.getMetadata('foo');
         let called = false;
         model.contentChanged.connect(() => { called = true; });
         cursor.setValue('bar');
-        expect(model.methods.indexOf('setCursorData')).to.not.be(-1);
         expect(called).to.be(true);
       });
 
       it('should emit the `metadataChanged` signal', () => {
-        let model = new LogNotebookModel();
+        let model = new NotebookModel();
         let cursor = model.getMetadata('foo');
         let called = false;
         model.metadataChanged.connect((sender, args) => {
@@ -513,9 +468,22 @@ describe('notebook/notebook', () => {
           called = true;
         });
         cursor.setValue('bar');
-        expect(model.methods.indexOf('setCursorData')).to.not.be(-1);
         expect(called).to.be(true);
       });
+    });
+
+    describe('#listMetadata()', () => {
+
+      it('should list the metadata namespace keys for the notebook', () => {
+        let model = new NotebookModel();
+        let keys = ['kernelspec', 'language_info', 'orig_nbformat'];
+        expect(model.listMetadata()).to.eql(keys);
+        let cursor = model.getMetadata('foo');
+        expect(model.listMetadata()).to.eql(keys);
+        cursor.setValue(1);
+        keys.push('foo');
+        expect(model.listMetadata()).to.eql(keys);
+      });
 
     });
 

+ 659 - 150
test/src/notebook/notebook/widget.spec.ts

@@ -15,16 +15,28 @@ import {
   IChangedArgs
 } from 'phosphor-properties';
 
+import {
+  ChildMessage, Widget
+} from 'phosphor-widget';
+
+import {
+  simulate
+} from 'simulate-event';
+
 import {
   BaseCellWidget, CodeCellWidget, ICellModel, MarkdownCellWidget, RawCellWidget
 } from '../../../../lib/notebook/cells';
 
+import {
+  EdgeLocation
+} from '../../../../lib/notebook/cells/editor';
+
 import {
   INotebookModel, NotebookModel
 } from '../../../../lib/notebook/notebook/model';
 
 import {
-  NotebookRenderer
+  Notebook, StaticNotebook
 } from '../../../../lib/notebook/notebook/widget';
 
 import {
@@ -39,113 +51,243 @@ import {
 const DEFAULT_CONTENT: nbformat.INotebookContent = require('../../../../examples/notebook/test.ipynb') as nbformat.INotebookContent;
 
 
-function createWidget(): LogNotebookRenderer {
+function createWidget(): LogStaticNotebook {
   let model = new NotebookModel();
   let rendermime = defaultRenderMime();
-  return new LogNotebookRenderer(model, rendermime);
+  let widget = new LogStaticNotebook({ rendermime });
+  widget.model = model;
+  return widget;
 }
 
 
-class LogNotebookRenderer extends NotebookRenderer {
+class LogStaticNotebook extends StaticNotebook {
 
   methods: string[] = [];
 
-  protected onAfterAttach(msg: Message): void {
-    super.onAfterAttach(msg);
-    this.methods.push('onAfterAttach');
-  }
-
   protected onUpdateRequest(msg: Message): void {
-    super.onAfterAttach(msg);
+    super.onUpdateRequest(msg);
     this.methods.push('onUpdateRequest');
   }
 
-  protected initialize(): void {
-    super.initialize();
-    this.methods.push('initialize');
+  protected onChildAdded(msg: ChildMessage): void {
+    super.onChildAdded(msg);
+    this.methods.push('onChildAdded');
   }
 
-  protected onMetadataChanged(model: INotebookModel, args: IChangedArgs<any>): void {
-    super.onMetadataChanged(model, args);
-    this.methods.push('onMetadataChanged');
+  protected onChildRemoved(msg: ChildMessage): void {
+    super.onChildRemoved(msg);
+    this.methods.push('onChildRemoved');
   }
+}
+
+
+class LogNotebook extends Notebook {
 
-  protected onCellsChanged(sender: IObservableList<ICellModel>, args: IListChangedArgs<ICellModel>) {
-    super.onCellsChanged(sender, args);
-    this.methods.push('onCellsChanged');
+  events: string[] = [];
+
+  methods: string[] = [];
+
+  handleEvent(event: Event): void {
+    this.events.push(event.type);
+    super.handleEvent(event);
   }
 
-  protected initializeCellWidget(widget: BaseCellWidget): void {
-    super.initializeCellWidget(widget);
-    this.methods.push('initializeCellWidget');
+  protected onAfterAttach(msg: Message): void {
+    super.onAfterAttach(msg);
+    this.methods.push('onAfterAttach');
   }
 
-  protected updateMimetypes(): void {
-    this.methods.push('updateMimetypes');
-    return super.updateMimetypes();
+  protected onBeforeDetach(msg: Message): void {
+    super.onBeforeDetach(msg);
+    this.methods.push('onBeforeDetach');
   }
-}
 
+  protected onUpdateRequest(msg: Message): void {
+    super.onUpdateRequest(msg);
+    this.methods.push('onUpdateRequest');
+  }
 
-describe('notebook/notebook/widget', () => {
+  protected onChildAdded(msg: ChildMessage): void {
+    super.onChildAdded(msg);
+    this.methods.push('onChildAdded');
+  }
 
-  describe('NotebookRenderer', () => {
+  protected onChildRemoved(msg: ChildMessage): void {
+    super.onChildRemoved(msg);
+    this.methods.push('onChildRemoved');
+  }
+}
 
-    describe('.createCell()', () => {
 
-      it('should create a new code cell widget given a cell model', () => {
-        let model = new NotebookModel();
-        let rendermime = defaultRenderMime();
-        let cell = model.createCodeCell();
-        let widget = NotebookRenderer.createCell(cell, rendermime);
-        expect(widget).to.be.a(CodeCellWidget);
-      });
+function createActiveWidget(): LogNotebook {
+  let model = new NotebookModel();
+  let rendermime = defaultRenderMime();
+  let widget = new LogNotebook({ rendermime });
+  widget.model = model;
+  return widget;
+}
 
-      it('should create a new raw cell widget given a cell model', () => {
-        let model = new NotebookModel();
-        let rendermime = defaultRenderMime();
-        let cell = model.createRawCell();
-        let widget = NotebookRenderer.createCell(cell, rendermime);
-        expect(widget).to.be.a(RawCellWidget);
-      });
 
-      it('should create a new markdown cell widget given a cell model', () => {
-        let model = new NotebookModel();
-        let rendermime = defaultRenderMime();
-        let cell = model.createMarkdownCell();
-        let widget = NotebookRenderer.createCell(cell, rendermime);
-        expect(widget).to.be.a(MarkdownCellWidget);
-      });
+describe('notebook/notebook/widget', () => {
 
-    });
+  describe('StaticNotebook', () => {
 
     describe('#constructor()', () => {
 
       it('should create a notebook widget', () => {
         let rendermime = defaultRenderMime();
-        let widget = new NotebookRenderer(new NotebookModel(), rendermime);
-        expect(widget).to.be.a(NotebookRenderer);
+        let widget = new StaticNotebook({ rendermime });
+        expect(widget).to.be.a(StaticNotebook);
       });
 
       it('should add the `jp-Notebook` class', () => {
         let rendermime = defaultRenderMime();
-        let widget = new NotebookRenderer(new NotebookModel(), rendermime);
+        let widget = new StaticNotebook({ rendermime });
         expect(widget.hasClass('jp-Notebook')).to.be(true);
       });
 
     });
 
+    describe('#modelChanged', () => {
+
+      it('should be emitted when the model changes', () => {
+        let widget = new StaticNotebook({ rendermime: defaultRenderMime() });
+        let model = new NotebookModel();
+        let called = false;
+        widget.modelChanged.connect((sender, args) => {
+          expect(sender).to.be(widget);
+          expect(args).to.be(model);
+          called = true;
+        });
+        widget.model = model;
+        expect(called).to.be(true);
+      });
+
+    });
+
     describe('#model', () => {
 
       it('should get the model for the widget', () => {
+        let widget = new StaticNotebook({ rendermime: defaultRenderMime() });
+        expect(widget.model).to.be(null);
+      });
+
+      it('should set the model for the widget', () => {
+        let widget = new StaticNotebook({ rendermime: defaultRenderMime() });
         let model = new NotebookModel();
-        let widget = new NotebookRenderer(model, defaultRenderMime());
+        widget.model = model;
         expect(widget.model).to.be(model);
       });
 
-      it('should be read-only', () => {
-        let widget = createWidget();
-        expect(() => { widget.model = null; }).to.throwError();
+      it('should emit the `modelChanged` signal', () => {
+        let widget = new StaticNotebook({ rendermime: defaultRenderMime() });
+        let model = new NotebookModel();
+        widget.model = model;
+        let called = false;
+        widget.modelChanged.connect(() => { called = true; });
+        widget.model = new NotebookModel();
+        expect(called).to.be(true);
+      });
+
+      it('should be a no-op if the value does not change', () => {
+        let widget = new StaticNotebook({ rendermime: defaultRenderMime() });
+        let model = new NotebookModel();
+        widget.model = model;
+        let called = false;
+        widget.modelChanged.connect(() => { called = true; });
+        widget.model = model;
+        expect(called).to.be(false);
+      });
+
+      it('should add the model cells to the layout', () => {
+        let widget = new LogStaticNotebook({ rendermime: defaultRenderMime() });
+        let model = new NotebookModel();
+        model.fromJSON(DEFAULT_CONTENT);
+        widget.model = model;
+        expect(widget.childCount()).to.be(6);
+      });
+
+      it('should set the mime types of the cell widgets', () => {
+        let widget = new LogStaticNotebook({ rendermime: defaultRenderMime() });
+        let model = new NotebookModel();
+        let cursor = model.getMetadata('language_info');
+        cursor.setValue({ name: 'python', codemirror_mode: 'python' });
+        widget.model = model;
+        let child = widget.childAt(0);
+        expect(child.mimetype).to.be('text/x-python');
+      });
+
+      context('`cells.changed` signal', () => {
+
+        let widget: LogStaticNotebook;
+
+        beforeEach(() => {
+          widget = createWidget();
+          widget.model.fromJSON(DEFAULT_CONTENT);
+        });
+
+        afterEach(() => {
+          widget.dispose();
+        });
+
+        it('should handle changes to the model cell list', () => {
+          widget = createWidget();
+          widget.model.cells.clear();
+          expect(widget.childCount()).to.be(0);
+        });
+
+        it('should handle a remove', () => {
+          let cell = widget.model.cells.get(1);
+          widget.model.cells.remove(cell);
+          expect(cell.isDisposed).to.be(true);
+        });
+
+        it('should handle an add', () => {
+          let cell = widget.model.createCodeCell();
+          widget.model.cells.add(cell);
+          expect(widget.childCount()).to.be(7);
+        });
+
+        it('should handle a move', () => {
+          let child = widget.childAt(1);
+          widget.model.cells.move(1, 2);
+          expect(widget.childAt(2)).to.be(child);
+        });
+
+        it('should handle a replace', () => {
+          let cell = widget.model.createCodeCell();
+          widget.model.cells.replace(0, 6, [cell]);
+          expect(widget.childCount()).to.be(1);
+        });
+
+      });
+
+      describe('`metdataChanged` signal', () => {
+
+        it('should be called when the metadata on the notebook changes', () => {
+          let widget = createWidget();
+          let called = false;
+          widget.model.metadataChanged.connect(() => {
+            called = true;
+          });
+          let cursor = widget.model.getMetadata('foo');
+          cursor.setValue(1);
+          expect(called).to.be(true);
+        });
+
+        it('should update the cell widget mimetype based on language info', () => {
+          let widget = createWidget();
+          let called = false;
+          widget.model.metadataChanged.connect(() => {
+            let child = widget.childAt(0);
+            expect(child.mimetype).to.be('text/x-python');
+            called = true;
+          });
+          let cursor = widget.model.getMetadata('language_info');
+          cursor.setValue({ name: 'python', mimetype: 'text/x-python' });
+          expect(called).to.be(true);
+        });
+
       });
 
     });
@@ -154,7 +296,7 @@ describe('notebook/notebook/widget', () => {
 
       it('should be the rendermime instance used by the widget', () => {
         let rendermime = defaultRenderMime();
-        let widget = new NotebookRenderer(new NotebookModel(), rendermime);
+        let widget = new StaticNotebook({ rendermime });
         expect(widget.rendermime).to.be(rendermime);
       });
 
@@ -167,15 +309,10 @@ describe('notebook/notebook/widget', () => {
 
     describe('#childAt()', () => {
 
-      it('should get the child widget at a specified index', (done) => {
+      it('should get the child widget at a specified index', () => {
         let widget = createWidget();
-        widget.attach(document.body);
-        requestAnimationFrame(() => {
-          let child = widget.childAt(0);
-          expect(child).to.be.a(CodeCellWidget);
-          widget.dispose();
-          done();
-        });
+        let child = widget.childAt(0);
+        expect(child).to.be.a(CodeCellWidget);
       });
 
       it('should return `undefined` if out of range', () => {
@@ -188,16 +325,11 @@ describe('notebook/notebook/widget', () => {
 
     describe('#childCount()', () => {
 
-      it('should get the number of child widgets', (done) => {
+      it('should get the number of child widgets', () => {
         let widget = createWidget();
-        expect(widget.childCount()).to.be(0);
+        expect(widget.childCount()).to.be(1);
         widget.model.fromJSON(DEFAULT_CONTENT);
-        widget.attach(document.body);
-        requestAnimationFrame(() => {
-          expect(widget.childCount()).to.be(6);
-          widget.dispose();
-          done();
-        });
+        expect(widget.childCount()).to.be(6);
       });
 
     });
@@ -222,157 +354,534 @@ describe('notebook/notebook/widget', () => {
 
     });
 
-    describe('#onAfterAttach()', () => {
+    describe('#onChildAdded()', () => {
+
+      it('should add the `jp-Notebook-cell` class', () => {
+        let widget = createWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        expect(widget.methods.indexOf('onChildAdded')).to.not.be(-1);
+      });
+
+    });
 
-      it('should initialize the widget', (done) => {
+    describe('#onChildRemoved()', () => {
+
+      it('should dispose of the cell', () => {
         let widget = createWidget();
+        let cell = widget.model.cells.get(0);
+        let child = widget.childAt(0);
+        widget.model.cells.remove(cell);
+        expect(widget.methods.indexOf('onChildRemoved')).to.not.be(-1);
+        expect(child.isDisposed).to.be(true);
+      });
+
+    });
+
+  });
+
+  describe('Notebook', () => {
+
+    describe('#stateChanged', () => {
+
+      it('should be emitted when the state of the notebook changes', () => {
+        let widget = createActiveWidget();
+        let called = false;
+        widget.stateChanged.connect((sender, args) => {
+          expect(sender).to.be(widget);
+          expect(args.name).to.be('mode');
+          expect(args.oldValue).to.be('command');
+          expect(args.newValue).to.be('edit');
+          called = true;
+        });
+        widget.mode = 'edit';
+        expect(called).to.be(true);
+      });
+
+    });
+
+    describe('#mode', () => {
+
+      it('should get the interactivity mode of the notebook', () => {
+        let widget = createActiveWidget();
+        expect(widget.mode).to.be('command');
+      });
+
+      it('should set the interactivity mode of the notebook', () => {
+        let widget = createActiveWidget();
+        widget.mode = 'edit';
+        expect(widget.mode).to.be('edit');
+      });
+
+      it('should emit the `stateChanged` signal', () => {
+        let widget = createActiveWidget();
+        let called = false;
+        widget.stateChanged.connect((sender, args) => {
+          expect(sender).to.be(widget);
+          expect(args.name).to.be('mode');
+          expect(args.oldValue).to.be('command');
+          expect(args.newValue).to.be('edit');
+          called = true;
+        });
+        widget.mode = 'edit';
+        expect(called).to.be(true);
+      });
+
+      it('should be a no-op if the value does not change', () => {
+        let widget = createActiveWidget();
+        let called = false;
+        widget.stateChanged.connect(() => { called = true; });
+        widget.mode = 'command';
+        expect(called).to.be(false);
+      });
+
+      it('should post an update request', (done) => {
+        let widget = createActiveWidget();
+        requestAnimationFrame(() => {
+          expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
+          done();
+        });
+        widget.mode = 'edit';
+      });
+
+      it('should deselect all cells if switching to edit mode', (done) => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
         widget.attach(document.body);
         requestAnimationFrame(() => {
-          expect(widget.methods.indexOf('onAfterAttach')).to.not.be(-1);
-          expect(widget.methods.indexOf('initialize')).to.not.be(-1);
+          for (let i = 0; i < widget.childCount(); i++) {
+            let cell = widget.childAt(i);
+            widget.select(cell);
+            expect(widget.isSelected(cell)).to.be(true);
+          }
+          widget.mode = 'edit';
+          for (let i = 0; i < widget.childCount(); i++) {
+            if (i === widget.activeCellIndex) {
+              continue;
+            }
+            let cell = widget.childAt(i);
+            expect(widget.isSelected(cell)).to.be(false);
+          }
+          widget.dispose();
           done();
         });
+
       });
 
     });
 
-    describe('#initialize()', () => {
+    describe('#activeCellIndex', () => {
 
-      it('should add the cells to the initial layout', (done) => {
-        let widget = createWidget();
+      it('should get the active cell index of the notebook', () => {
+        let widget = createActiveWidget();
+        expect(widget.activeCellIndex).to.be(0);
+      });
+
+      it('should set the active cell index of the notebook', () => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        widget.activeCellIndex = 1;
+        expect(widget.activeCellIndex).to.be(1);
+      });
+
+      it('should clamp the index to the bounds of the notebook cells', () => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        widget.activeCellIndex = -2;
+        expect(widget.activeCellIndex).to.be(0);
+        widget.activeCellIndex = 100;
+        expect(widget.activeCellIndex).to.be(5);
+      });
+
+      it('should emit the `stateChanged` signal', () => {
+        let widget = createActiveWidget();
+        let called = false;
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        widget.stateChanged.connect((sender, args) => {
+          expect(sender).to.be(widget);
+          expect(args.name).to.be('activeCellIndex');
+          expect(args.oldValue).to.be(0);
+          expect(args.newValue).to.be(1);
+          called = true;
+        });
+        widget.activeCellIndex = 1;
+        expect(called).to.be(true);
+      });
+
+      it('should be a no-op if the value does not change', () => {
+        let widget = createActiveWidget();
+        let called = false;
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        widget.stateChanged.connect(() => { called = true; });
+        widget.activeCellIndex = 0;
+        expect(called).to.be(false);
+      });
+
+      it('should post an update request', (done) => {
+        let widget = createActiveWidget();
         widget.model.fromJSON(DEFAULT_CONTENT);
-        widget.attach(document.body);
         requestAnimationFrame(() => {
-          expect(widget.childCount()).to.be(6);
-          expect(widget.methods.indexOf('initialize')).to.not.be(-1);
-          widget.dispose();
+          expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
           done();
         });
+        widget.activeCellIndex = 1;
       });
 
     });
 
-    describe('#onMetadataChanged()', () => {
+    describe('#select()', () => {
 
-      it('should be called when the metadata on the notebook changes', (done) => {
-        let widget = createWidget();
+      it('should select a cell widget', () => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        let cell = widget.childAt(0);
+        widget.select(cell);
+        expect(widget.isSelected(cell)).to.be(true);
+      });
+
+      it('should allow multiple widgets to be selected', () => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        for (let i = 0; i < widget.childCount(); i++) {
+          let cell = widget.childAt(i);
+          widget.select(cell);
+          expect(widget.isSelected(cell)).to.be(true);
+        }
+      });
+
+    });
+
+    describe('#deselect()', () => {
+
+      it('should deselect a cell', () => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        for (let i = 0; i < widget.childCount(); i++) {
+          if (i === widget.activeCellIndex) {
+            continue;
+          }
+          let cell = widget.childAt(i);
+          widget.select(cell);
+          expect(widget.isSelected(cell)).to.be(true);
+          widget.deselect(cell);
+          expect(widget.isSelected(cell)).to.be(false);
+        }
+      });
+
+      it('should have no effect on the active cell', () => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        let cell = widget.childAt(widget.activeCellIndex);
+        expect(widget.isSelected(cell)).to.be(true);
+        widget.deselect(cell);
+        expect(widget.isSelected(cell)).to.be(true);
+      });
+
+    });
+
+    describe('#isSelected()', () => {
+
+      it('should get whether the cell is selected', () => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        for (let i = 0; i < widget.childCount(); i++) {
+          let cell = widget.childAt(i);
+          if (i === widget.activeCellIndex) {
+            expect(widget.isSelected(cell)).to.be(true);
+          } else {
+            expect(widget.isSelected(cell)).to.be(false);
+          }
+        }
+      });
+
+    });
+
+    describe('#handleEvent()', () => {
+
+      let widget: LogNotebook;
+
+      beforeEach((done) => {
+        widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        widget.attach(document.body);
+        requestAnimationFrame(() => { done(); });
+      });
+
+      afterEach(() => {
+        widget.dispose();
+      });
+
+      context('click', () => {
+
+        it('should set the active cell index', () => {
+          let child = widget.childAt(1);
+          simulate(child.node, 'click');
+          expect(widget.events.indexOf('click')).to.not.be(-1);
+          expect(widget.activeCellIndex).to.be(1);
+        });
+
+        it('should be a no-op if the model is read only', () => {
+          let child = widget.childAt(1);
+          widget.model.readOnly = true;
+          simulate(child.node, 'click');
+          expect(widget.events.indexOf('click')).to.not.be(-1);
+          expect(widget.activeCellIndex).to.be(0);
+        });
+
+        it('should be a no-op if not not a cell', () => {
+          simulate(widget.node, 'click');
+          expect(widget.events.indexOf('click')).to.not.be(-1);
+          expect(widget.activeCellIndex).to.be(0);
+        });
+
+      });
+
+      context('dblclick', () => {
+
+        it('should unrender a markdown cell', () => {
+          let cell = widget.model.createMarkdownCell();
+          widget.model.cells.add(cell);
+          let child = widget.childAt(widget.childCount() - 1) as MarkdownCellWidget;
+          expect(child.rendered).to.be(true);
+          simulate(child.node, 'dblclick');
+          expect(child.rendered).to.be(false);
+        });
+
+        it('should be a no-op if the model is read only', () => {
+          let cell = widget.model.createMarkdownCell();
+          widget.model.cells.add(cell);
+          widget.model.readOnly = true;
+          let child = widget.childAt(widget.childCount() - 1) as MarkdownCellWidget;
+          expect(child.rendered).to.be(true);
+          simulate(child.node, 'dblclick');
+          expect(child.rendered).to.be(true);
+        });
+
+      });
+
+      context('focus', () => {
+
+        it('should change to edit mode if a child cell takes focus', () => {
+          let child = widget.childAt(0);
+          simulate(child.editor.node, 'focus');
+          expect(widget.events.indexOf('focus')).to.not.be(-1);
+          expect(widget.mode).to.be('edit');
+        });
+
+        it('should change to command mode if the widget takes focus', () => {
+          let child = widget.childAt(0);
+          simulate(child.editor.node, 'focus');
+          expect(widget.events.indexOf('focus')).to.not.be(-1);
+          expect(widget.mode).to.be('edit');
+          widget.events = [];
+          simulate(widget.node, 'focus');
+          expect(widget.events.indexOf('focus')).to.not.be(-1);
+          expect(widget.mode).to.be('command');
+        });
+
+      });
+
+    });
+
+    describe('#onAfterAttach()', () => {
+
+      it('should add event listeners', (done) => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
         widget.attach(document.body);
+        let child = widget.childAt(0);
         requestAnimationFrame(() => {
-          widget.model.metadataChanged.connect(() => {
-            expect(widget.methods.indexOf('onMetadataChanged')).to.not.be(-1);
-            widget.dispose();
-            done();
-          });
-          let cursor = widget.model.getMetadata('foo');
-          cursor.setValue(1);
+          expect(widget.methods.indexOf('onAfterAttach')).to.not.be(-1);
+          simulate(widget.node, 'click');
+          expect(widget.events.indexOf('click')).to.not.be(-1);
+          simulate(widget.node, 'dblclick');
+          expect(widget.events.indexOf('dblclick')).to.not.be(-1);
+          simulate(child.node, 'focus');
+          expect(widget.events.indexOf('focus')).to.not.be(-1);
+          widget.dispose();
+          done();
         });
       });
 
-      it('should update the cell widget mimetype based on language info', (done) => {
-        let widget = createWidget();
+      it('should post an update request', (done) => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
         widget.attach(document.body);
         requestAnimationFrame(() => {
-          widget.model.metadataChanged.connect(() => {
-            expect(widget.methods.indexOf('onMetadataChanged')).to.not.be(-1);
-            let child = widget.childAt(0);
-            expect(child.mimetype).to.be('text/x-python');
+          expect(widget.methods.indexOf('onAfterAttach')).to.not.be(-1);
+          requestAnimationFrame(() => {
+            expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
             widget.dispose();
             done();
           });
-          let cursor = widget.model.getMetadata('language_info');
-          cursor.setValue({ name: 'python', mimetype: 'text/x-python' });
         });
       });
 
     });
 
-    describe('#onCellsChanged()', () => {
+    describe('#onBeforeDetach()', () => {
 
-      let widget: LogNotebookRenderer;
+      it('should remove event listeners', (done) => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        widget.attach(document.body);
+        let child = widget.childAt(0);
+        requestAnimationFrame(() => {
+          widget.detach();
+          expect(widget.methods.indexOf('onBeforeDetach')).to.not.be(-1);
+          widget.events = [];
+          simulate(widget.node, 'click');
+          expect(widget.events.indexOf('click')).to.be(-1);
+          simulate(widget.node, 'dblclick');
+          expect(widget.events.indexOf('dblclick')).to.be(-1);
+          simulate(child.node, 'focus');
+          expect(widget.events.indexOf('focus')).to.be(-1);
+          widget.dispose();
+          done();
+        });
+      });
+
+    });
+
+    describe('#onUpdateRequest()', () => {
+
+      let widget: LogNotebook;
 
       beforeEach((done) => {
-        widget = createWidget();
+        widget = createActiveWidget();
         widget.model.fromJSON(DEFAULT_CONTENT);
         widget.attach(document.body);
-        requestAnimationFrame(() => { done(); });
+        requestAnimationFrame(() => {  done(); });
       });
 
       afterEach(() => {
         widget.dispose();
       });
 
-      it('should handle changes to the model cell list', () => {
-        expect(widget.methods.indexOf('onCellsChanged')).to.be(-1);
-        widget.model.cells.clear();
-        expect(widget.methods.indexOf('onCellsChanged')).to.not.be(-1);
+      it('should apply the command class if in command mode', () => {
+        expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
+        expect(widget.hasClass('jp-mod-commandMode')).to.be(true);
       });
 
-      it('should handle a remove', () => {
-        let cell = widget.model.cells.get(1);
-        widget.model.cells.remove(cell);
-        expect(widget.methods.indexOf('onCellsChanged')).to.not.be(-1);
-        expect(cell.isDisposed).to.be(true);
+      it('should focus the widget if in command mode', () => {
+        expect(widget.node).to.be(document.activeElement);
+      });
+
+      it('should apply the edit class if in edit mode', (done) => {
+        widget.mode = 'edit';
+        requestAnimationFrame(() => {
+          expect(widget.hasClass('jp-mod-editMode')).to.be(true);
+          done();
+        });
+      });
+
+      it('should focus the cell if in edit mode', (done) => {
+        widget.mode = 'edit';
+        let cell = widget.childAt(widget.activeCellIndex);
+        requestAnimationFrame(() => {
+          expect(cell.node.contains(document.activeElement)).to.be(true);
+          done();
+        });
       });
 
-      it('should handle an add', () => {
-        let cell = widget.model.createCodeCell();
+      it('should unrender a markdown cell in edit mode', (done) => {
+        let cell = widget.model.createMarkdownCell();
         widget.model.cells.add(cell);
-        expect(widget.methods.indexOf('onCellsChanged')).to.not.be(-1);
-        expect(widget.childCount()).to.be(7);
+        let child = widget.childAt(widget.childCount() - 1) as MarkdownCellWidget;
+        expect(child.rendered).to.be(true);
+        widget.activeCellIndex = widget.childCount() - 1;
+        widget.mode = 'edit';
+        requestAnimationFrame(() => {
+          expect(child.rendered).to.be(false);
+          done();
+        });
       });
 
-      it('should handle a move', () => {
-        let child = widget.childAt(1);
-        widget.model.cells.move(1, 2);
-        expect(widget.methods.indexOf('onCellsChanged')).to.not.be(-1);
-        expect(widget.childAt(2)).to.be(child);
+      it('should add the active class to the active widget', () => {
+        let cell = widget.childAt(widget.activeCellIndex);
+        expect(cell.hasClass('jp-mod-active')).to.be(true);
       });
 
-      it('should handle a replace', () => {
-        let cell = widget.model.createCodeCell();
-        widget.model.cells.replace(0, 6, [cell]);
-        expect(widget.methods.indexOf('onCellsChanged')).to.not.be(-1);
-        expect(widget.childCount()).to.be(1);
+      it('should set the selected class on the selected widgets', (done) => {
+        widget.select(widget.childAt(1));
+        requestAnimationFrame(() => {
+          for (let i = 0; i < 2; i++) {
+            let cell = widget.childAt(i);
+            expect(cell.hasClass('jp-mod-selected')).to.be(true);
+            done();
+          }
+        });
       });
 
-      it('should post an update-request', (done) => {
-        widget.model.cells.clear();
-        widget.methods = [];
+      it('should add the multi select class if there is more than one widget', (done) => {
+        widget.select(widget.childAt(1));
+        expect(widget.hasClass('jp-mod-multSelected')).to.be(false);
         requestAnimationFrame(() => {
-          expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
+          expect(widget.hasClass('jp-mod-multSelected')).to.be(false);
           done();
         });
       });
 
     });
 
-    describe('#initializeCellWidget()', () => {
+    describe('#onChildAdded()', () => {
 
-      it('should add the `jp-Notebook-cell` class', (done) => {
-        let widget = createWidget();
+      it('should add the `jp-Notebook-cell` class', () => {
+        let widget = createActiveWidget();
         widget.model.fromJSON(DEFAULT_CONTENT);
-        widget.attach(document.body);
+        expect(widget.methods.indexOf('onChildAdded')).to.not.be(-1);
+      });
+
+      it('should post an `update-request', (done) => {
+        let widget = createActiveWidget();
+        widget.model.fromJSON(DEFAULT_CONTENT);
+        expect(widget.methods.indexOf('onChildAdded')).to.not.be(-1);
         requestAnimationFrame(() => {
-          expect(widget.methods.indexOf('initializeCellWidget')).to.not.be(-1);
-          widget.dispose();
+          expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
           done();
         });
       });
 
+      context('`edgeRequested` signal', () => {
+
+        it('should activate the previous cell if top is requested', () => {
+          let widget = createActiveWidget();
+          widget.model.fromJSON(DEFAULT_CONTENT);
+          widget.activeCellIndex = 1;
+          let child = widget.childAt(widget.activeCellIndex);
+          child.editor.edgeRequested.emit('top');
+          expect(widget.activeCellIndex).to.be(0);
+        });
+
+        it('should activate the next cell if bottom is requested', ()  => {
+          let widget = createActiveWidget();
+          widget.model.fromJSON(DEFAULT_CONTENT);
+          let child = widget.childAt(widget.activeCellIndex);
+          child.editor.edgeRequested.emit('bottom');
+          expect(widget.activeCellIndex).to.be(1);
+        });
+
+      });
+
     });
 
-    describe('#updateMimetypes()', () => {
+    describe('#onChildRemoved()', () => {
 
-      it('should set the mime types of the code cells', (done) => {
+      it('should dispose of the cell', () => {
         let widget = createWidget();
-        let cursor = widget.model.getMetadata('language_info');
-        cursor.setValue({ name: 'python', codemirror_mode: 'python' });
-        widget.attach(document.body);
+        let cell = widget.model.cells.get(0);
+        let child = widget.childAt(0);
+        widget.model.cells.remove(cell);
+        expect(widget.methods.indexOf('onChildRemoved')).to.not.be(-1);
+        expect(child.isDisposed).to.be(true);
+      });
+
+      it('should post an `update-request', (done) => {
+        let widget = createActiveWidget();
+        let cell = widget.model.cells.get(0);
+        widget.model.cells.remove(cell);
+        expect(widget.methods.indexOf('onChildRemoved')).to.not.be(-1);
         requestAnimationFrame(() => {
-          expect(widget.methods.indexOf('updateMimetypes')).to.not.be(-1);
-          let child = widget.childAt(0);
-          expect(child.mimetype).to.be('text/x-python');
-          widget.dispose();
+          expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
           done();
         });
       });