Browse Source

Merge pull request #103 from blink1073/activenotebook-cleanup

Notebook cleanup
A. Darian 8 năm trước cách đây
mục cha
commit
356f8ebbdf

+ 84 - 15
src/notebook/notebook/actions.ts

@@ -43,6 +43,9 @@ namespace NotebookActions {
    */
   export
   function splitCell(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     Private.deselectCells(widget);
     let nbModel = widget.model;
     let index = widget.activeCellIndex;
@@ -69,6 +72,9 @@ namespace NotebookActions {
    */
   export
   function mergeCells(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     let toMerge: string[] = [];
     let toDelete: ICellModel[] = [];
     let model = widget.model;
@@ -131,6 +137,9 @@ namespace NotebookActions {
    */
   export
   function deleteCells(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     let model = widget.model;
     let cells = model.cells;
     // Delete the cells as one undo event.
@@ -143,7 +152,7 @@ namespace NotebookActions {
       }
     }
     if (!model.cells.length) {
-      let cell = model.createCodeCell();
+      let cell = model.factory.createCodeCell();
       model.cells.add(cell);
     }
     model.cells.endCompoundOperation();
@@ -155,7 +164,10 @@ namespace NotebookActions {
    */
   export
   function insertAbove(widget: Notebook): void {
-    let cell = widget.model.createCodeCell();
+    if (!widget.model) {
+      return;
+    }
+    let cell = widget.model.factory.createCodeCell();
     widget.model.cells.insert(widget.activeCellIndex, cell);
     Private.deselectCells(widget);
   }
@@ -165,7 +177,10 @@ namespace NotebookActions {
    */
   export
   function insertBelow(widget: Notebook): void {
-    let cell = widget.model.createCodeCell();
+    if (!widget.model) {
+      return;
+    }
+    let cell = widget.model.factory.createCodeCell();
     widget.model.cells.insert(widget.activeCellIndex + 1, cell);
     Private.deselectCells(widget);
   }
@@ -175,6 +190,9 @@ namespace NotebookActions {
    */
   export
   function changeCellType(widget: Notebook, value: string): void {
+    if (!widget.model) {
+      return;
+    }
     let model = widget.model;
     model.cells.beginCompoundOperation();
     for (let i = 0; i < widget.childCount(); i++) {
@@ -188,13 +206,13 @@ namespace NotebookActions {
       let newCell: ICellModel;
       switch (value) {
       case 'code':
-        newCell = model.createCodeCell(child.model.toJSON());
+        newCell = model.factory.createCodeCell(child.model.toJSON());
         break;
       case 'markdown':
-        newCell = model.createMarkdownCell(child.model.toJSON());
+        newCell = model.factory.createMarkdownCell(child.model.toJSON());
         break;
       default:
-        newCell = model.createRawCell(child.model.toJSON());
+        newCell = model.factory.createRawCell(child.model.toJSON());
       }
       model.cells.replace(i, 1, [newCell]);
       if (value === 'markdown') {
@@ -212,6 +230,9 @@ namespace NotebookActions {
    */
   export
   function run(widget: Notebook, kernel?: IKernel): void {
+    if (!widget.model) {
+      return;
+    }
     let selected: BaseCellWidget[] = [];
     for (let i = 0; i < widget.childCount(); i++) {
       let child = widget.childAt(i);
@@ -239,10 +260,13 @@ namespace NotebookActions {
    */
   export
   function runAndAdvance(widget: Notebook, kernel?: IKernel): void {
+    if (!widget.model) {
+      return;
+    }
     run(widget, kernel);
     let model = widget.model;
     if (widget.activeCellIndex === widget.childCount() - 1) {
-      let cell = model.createCodeCell();
+      let cell = model.factory.createCodeCell();
       model.cells.add(cell);
       widget.mode = 'edit';
     } else {
@@ -257,9 +281,12 @@ namespace NotebookActions {
    */
   export
   function runAndInsert(widget: Notebook, kernel?: IKernel): void {
+    if (!widget.model) {
+      return;
+    }
     run(widget, kernel);
     let model = widget.model;
-    let cell = model.createCodeCell();
+    let cell = model.factory.createCodeCell();
     model.cells.insert(widget.activeCellIndex + 1, cell);
     widget.activeCellIndex++;
     widget.mode = 'edit';
@@ -271,6 +298,9 @@ namespace NotebookActions {
    */
   export
   function runAll(widget: Notebook, kernel?: IKernel): void {
+    if (!widget.model) {
+      return;
+    }
     for (let i = 0; i < widget.childCount(); i++) {
       Private.runCell(widget.childAt(i), kernel);
     }
@@ -283,6 +313,9 @@ namespace NotebookActions {
    */
   export
   function selectBelow(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     if (widget.activeCellIndex === widget.childCount() - 1) {
       return;
     }
@@ -296,6 +329,9 @@ namespace NotebookActions {
    */
   export
   function selectAbove(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     if (widget.activeCellIndex === 0) {
       return;
     }
@@ -309,6 +345,9 @@ namespace NotebookActions {
    */
   export
   function extendSelectionAbove(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     // Do not wrap around.
     if (widget.activeCellIndex === 0) {
       return;
@@ -337,6 +376,9 @@ namespace NotebookActions {
    */
   export
   function extendSelectionBelow(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     // Do not wrap around.
     if (widget.activeCellIndex === widget.childCount() - 1) {
       return;
@@ -365,6 +407,9 @@ namespace NotebookActions {
    */
   export
   function copy(widget: Notebook, clipboard: IClipboard): void {
+    if (!widget.model) {
+      return;
+    }
     clipboard.clear();
     let data: nbformat.IBaseCell[] = [];
     for (let i = 0; i < widget.childCount(); i++) {
@@ -382,6 +427,9 @@ namespace NotebookActions {
    */
   export
   function cut(widget: Notebook, clipboard: IClipboard): void {
+    if (!widget.model) {
+      return;
+    }
     clipboard.clear();
     let data: nbformat.IBaseCell[] = [];
     let model = widget.model;
@@ -396,7 +444,7 @@ namespace NotebookActions {
       }
     }
     if (!model.cells.length) {
-      let cell = model.createCodeCell();
+      let cell = model.factory.createCodeCell();
       model.cells.add(cell);
     }
     model.cells.endCompoundOperation();
@@ -409,6 +457,9 @@ namespace NotebookActions {
    */
   export
   function paste(widget: Notebook, clipboard: IClipboard): void {
+    if (!widget.model) {
+      return;
+    }
     if (!clipboard.hasData(JUPYTER_CELL_MIME)) {
       return;
     }
@@ -418,13 +469,13 @@ namespace NotebookActions {
     for (let value of values) {
       switch (value.cell_type) {
       case 'code':
-        cells.push(model.createCodeCell(value));
+        cells.push(model.factory.createCodeCell(value));
         break;
       case 'markdown':
-        cells.push(model.createMarkdownCell(value));
+        cells.push(model.factory.createMarkdownCell(value));
         break;
       default:
-        cells.push(model.createRawCell(value));
+        cells.push(model.factory.createRawCell(value));
         break;
       }
     }
@@ -438,6 +489,9 @@ namespace NotebookActions {
    */
   export
   function undo(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     widget.mode = 'command';
     widget.model.cells.undo();
   }
@@ -447,6 +501,9 @@ namespace NotebookActions {
    */
   export
   function redo(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     widget.mode = 'command';
     widget.model.cells.redo();
   }
@@ -456,6 +513,9 @@ namespace NotebookActions {
    */
   export
   function toggleLineNumbers(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     let cell = widget.childAt(widget.activeCellIndex);
     let editor = cell.editor.editor;
     let lineNumbers = editor.getOption('lineNumbers');
@@ -473,6 +533,9 @@ namespace NotebookActions {
    */
   export
   function toggleAllLineNumbers(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     let cell = widget.childAt(widget.activeCellIndex);
     let editor = cell.editor.editor;
     let lineNumbers = editor.getOption('lineNumbers');
@@ -488,6 +551,9 @@ namespace NotebookActions {
    */
   export
   function clearOutputs(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     let cells = widget.model.cells;
     for (let i = 0; i < cells.length; i++) {
       let cell = cells.get(i) as CodeCellModel;
@@ -504,6 +570,9 @@ namespace NotebookActions {
    */
   export
   function clearAllOutputs(widget: Notebook): void {
+    if (!widget.model) {
+      return;
+    }
     let cells = widget.model.cells;
     for (let i = 0; i < cells.length; i++) {
       let cell = cells.get(i) as CodeCellModel;
@@ -538,11 +607,11 @@ namespace Private {
   function cloneCell(model: INotebookModel, cell: ICellModel): ICellModel {
     switch (cell.type) {
     case 'code':
-      return model.createCodeCell(cell.toJSON());
+      return model.factory.createCodeCell(cell.toJSON());
     case 'markdown':
-      return model.createMarkdownCell(cell.toJSON());
+      return model.factory.createMarkdownCell(cell.toJSON());
     default:
-      return model.createRawCell(cell.toJSON());
+      return model.factory.createRawCell(cell.toJSON());
     }
   }
 

+ 24 - 14
src/notebook/notebook/default-toolbar.ts

@@ -5,10 +5,6 @@ import {
   IKernel, KernelStatus
 } from 'jupyter-js-services';
 
-import {
-  showDialog
-} from '../../dialog';
-
 import {
   IDocumentContext
 } from '../../docregistry';
@@ -268,18 +264,39 @@ class CellTypeSwitcher extends Widget {
   constructor(panel: NotebookPanel) {
     super();
     this.addClass(TOOLBAR_CELLTYPE);
+
     let select = this.node.firstChild as HTMLSelectElement;
-    // Set the initial value.
-    let index = panel.content.activeCellIndex;
-    select.value = panel.model.cells.get(index).type;
+    // Change current cell type on a change in the dropdown.
+    select.addEventListener('change', event => {
+      if (!this._changeGuard) {
+        NotebookActions.changeCellType(panel.content, select.value);
+      }
+    });
     // Follow the type of the current cell.
     panel.content.stateChanged.connect((sender, args) => {
+      if (!panel.model) {
+        return;
+      }
       if (args.name === 'activeCellIndex') {
         this._changeGuard = true;
         select.value = panel.model.cells.get(args.newValue).type;
         this._changeGuard = false;
       }
     });
+
+    panel.content.modelChanged.connect(() => {
+      this.followModel(panel);
+    });
+    if (panel.model) {
+      this.followModel(panel);
+    }
+  }
+
+  followModel(panel: NotebookPanel): void {
+    let select = this.node.firstChild as HTMLSelectElement;
+    // Set the initial value.
+    let index = panel.content.activeCellIndex;
+    select.value = panel.model.cells.get(index).type;
     // Follow a change in the cells.
     panel.content.model.cells.changed.connect((sender, args) => {
       index = panel.content.activeCellIndex;
@@ -287,15 +304,8 @@ class CellTypeSwitcher extends Widget {
       select.value = panel.model.cells.get(index).type;
       this._changeGuard = false;
     });
-    // Change current cell type on a change in the dropdown.
-    select.addEventListener('change', event => {
-      if (!this._changeGuard) {
-        NotebookActions.changeCellType(panel.content, select.value);
-      }
-    });
   }
 
-
   private _changeGuard = false;
 }
 

+ 69 - 55
src/notebook/notebook/model.ts

@@ -46,7 +46,7 @@ import {
  * The definition of a model object for a notebook widget.
  */
 export
-interface INotebookModel extends IDocumentModel, ICellModelFactory {
+interface INotebookModel extends IDocumentModel {
   /**
    * A signal emitted when a model state changes.
    */
@@ -65,6 +65,14 @@ interface INotebookModel extends IDocumentModel, ICellModelFactory {
    */
   cells: ObservableUndoableList<ICellModel>;
 
+  /**
+   * The cell model factory for the notebook.
+   *
+   * #### Notes
+   * This is a read-only propery.
+   */
+  factory: ICellModelFactory;
+
   /**
    * The major version number of the nbformat.
    *
@@ -144,7 +152,7 @@ class NotebookModel extends DocumentModel implements INotebookModel {
    */
   constructor(options: NotebookModel.IOptions = {}) {
     super(options.languagePreference);
-    this._factory = options.cellModelFactory || Private.defaultFactory;
+    this._factory = options.factory || NotebookModel.defaultFactory;
     this._cells = new ObservableUndoableList<ICellModel>((data: nbformat.IBaseCell) => {
       switch (data.cell_type) {
         case 'code':
@@ -180,6 +188,16 @@ class NotebookModel extends DocumentModel implements INotebookModel {
     return this._cells;
   }
 
+  /**
+   * The cell model factory for the notebook.
+   *
+   * #### Notes
+   * This is a read-only propery.
+   */
+  get factory(): ICellModelFactory {
+    return this._factory;
+  }
+
   /**
    * The major version number of the nbformat.
    *
@@ -380,42 +398,6 @@ class NotebookModel extends DocumentModel implements INotebookModel {
     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): ICodeCellModel {
-    return this._factory.createCodeCell(source);
-  }
-
-  /**
-   * 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): IMarkdownCellModel {
-    return this._factory.createMarkdownCell(source);
-  }
-
-  /**
-   * 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): IRawCellModel {
-    return this._factory.createRawCell(source);
-  }
-
   /**
    * Set the cursor data for a given field.
    */
@@ -504,8 +486,56 @@ namespace NotebookModel {
      *
      * The default is a shared factory instance.
      */
-    cellModelFactory?: ICellModelFactory;
+    factory?: ICellModelFactory;
+  }
+
+  /**
+   * The default implementation of an `ICellModelFactory`.
+   */
+  export
+  class Factory {
+    /**
+     * 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): ICodeCellModel {
+      return new CodeCellModel(source);
+    }
+
+    /**
+     * 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): IMarkdownCellModel {
+      return new MarkdownCellModel(source);
+    }
+
+    /**
+     * 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): IRawCellModel {
+     return new RawCellModel(source);
+    }
   }
+
+  /**
+   * The default `Factory` instance.
+   */
+  export
+  const defaultFactory = new Factory();
 }
 
 
@@ -519,22 +549,6 @@ 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.
    */

+ 12 - 0
src/notebook/notebook/panel.ts

@@ -266,6 +266,9 @@ class NotebookPanel extends Widget {
    * Handle a change in the kernel by updating the document metadata.
    */
   protected handleKernelChange(kernel: IKernel): void {
+    if (!this.model) {
+      return;
+    }
     kernel.kernelInfo().then(info => {
       let infoCursor = this.model.getMetadata('language_info');
       infoCursor.setValue(info.language_info);
@@ -305,6 +308,9 @@ class NotebookPanel extends Widget {
    * Handle a text changed signal from an editor.
    */
   protected onTextChange(editor: CellEditorWidget, change: ITextChange): void {
+    if (!this.model) {
+      return;
+    }
     let line = change.newValue.split('\n')[change.line];
     let model = this._completion.model;
     // If last character entered is not whitespace, update completion.
@@ -326,6 +332,9 @@ class NotebookPanel extends Widget {
    * Handle a completion requested signal from an editor.
    */
   protected onCompletionRequest(editor: CellEditorWidget, change: ICompletionRequest): void {
+    if (!this.model) {
+      return;
+    }
     let kernel = this.context.kernel;
     if (!kernel) {
       return;
@@ -362,6 +371,9 @@ class NotebookPanel extends Widget {
    * Handle a completion selected signal from the completion widget.
    */
   protected onCompletionSelect(widget: CompletionWidget, value: string): void {
+    if (!this.model) {
+      return;
+    }
     let patch = this._completion.model.createPatch(value);
     let cell = this._content.childAt(this._content.activeCellIndex);
     let editor = cell.editor.editor;

+ 3 - 0
src/notebook/notebook/trust.ts

@@ -28,6 +28,9 @@ const TRUST_MESSAGE = '<p>A trusted Jupyter notebook may execute hidden maliciou
  */
 export
 function trustNotebook(model: INotebookModel, host?: HTMLElement): Promise<void> {
+  if (!model) {
+    return;
+  }
   // Do nothing if already trusted.
   let cells = model.cells;
   let trusted = true;

+ 177 - 78
src/notebook/notebook/widget.ts

@@ -101,6 +101,12 @@ type NotebookMode = 'command' | 'edit';
 
 /**
  * A widget which renders static non-interactive notebooks.
+ *
+ * #### Notes
+ * The widget model must be set separately and can be changed
+ * at any time.  Consumers of the widget must account for a
+ * `null` model, and may want to listen to the `modelChanged`
+ * signal.
  */
 export
 class StaticNotebook extends Widget {
@@ -113,13 +119,13 @@ class StaticNotebook extends Widget {
     this.addClass(NB_CLASS);
     this._rendermime = options.rendermime;
     this.layout = new PanelLayout();
-    this._factory = options.cellWidgetFactory || Private.defaultFactory;
+    this._renderer = options.renderer || StaticNotebook.defaultRenderer;
   }
 
   /**
    * A signal emitted when the model of the notebook changes.
    */
-  get modelChanged(): ISignal<StaticNotebook, INotebookModel> {
+  get modelChanged(): ISignal<StaticNotebook, void> {
     return Private.modelChangedSignal.bind(this);
   }
 
@@ -136,8 +142,10 @@ class StaticNotebook extends Widget {
     }
     let oldValue = this._model;
     this._model = newValue;
-    this._changeModel(oldValue, newValue);
-    this.modelChanged.emit(newValue);
+    // Trigger private, protected, and public changes.
+    this._onModelChanged(oldValue, newValue);
+    this.onModelChanged(oldValue, newValue);
+    this.modelChanged.emit(void 0);
   }
 
   /**
@@ -150,6 +158,26 @@ class StaticNotebook extends Widget {
     return this._rendermime;
   }
 
+  /**
+   * Get the renderer used by the widget.
+   *
+   * #### Notes
+   * This is a read-only property.
+   */
+  get renderer(): StaticNotebook.IRenderer {
+    return this._renderer;
+  }
+
+  /**
+   * Get the mimetype for code cells.
+   *
+   * #### Notes
+   * This is a read-only property.
+   */
+  get codeMimetype(): string {
+    return this._mimetype;
+  }
+
   /**
    * Get the child widget at the specified index.
    */
@@ -176,91 +204,113 @@ class StaticNotebook extends Widget {
     }
     this._model = null;
     this._rendermime = null;
-    this._factory = null;
+    this._renderer = null;
     super.dispose();
   }
 
-
   /**
-   * Handle a `child-added` message.
+   * Handle a new model.
+   *
+   * #### Notes
+   * This method is called after the model change has been handled
+   * internally and before the `modelChanged` signal is emitted.
+   * The default implementation is a no-op.
    */
-  protected onChildAdded(msg: ChildMessage): void {
-    msg.child.addClass(NB_CELL_CLASS);
+  protected onModelChanged(oldValue: INotebookModel, newValue: INotebookModel): void {
+    // No-op.
   }
 
   /**
-   * Handle a `child-removed` message.
+   * Handle changes to the notebook model metadata.
    */
-  protected onChildRemoved(msg: ChildMessage): void {
-    msg.child.dispose();
+  protected onMetadataChanged(model: INotebookModel, args: IChangedArgs<any>): void {
+    switch (args.name) {
+    case 'language_info':
+      this._mimetype = this._renderer.getCodeMimetype(model);
+      this._updateChildren();
+      break;
+    default:
+      break;
+    }
   }
 
   /**
    * Handle a new model on the widget.
    */
-  private _changeModel(oldValue: INotebookModel, newValue: INotebookModel): void {
+  private _onModelChanged(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);
+      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));
+        this._removeChild(0);
       }
     }
+    if (!newValue) {
+      this._mimetype = 'text/plain';
+      return;
+    }
+    this._mimetype = this._renderer.getCodeMimetype(newValue);
     let cells = newValue.cells;
     for (let i = 0; i < cells.length; i++) {
-      layout.addChild(this._createWidget(cells.get(i)));
+      this._insertChild(i, cells.get(i));
     }
-    this._setChildMimetypes();
     cells.changed.connect(this._onCellsChanged, this);
-    newValue.metadataChanged.connect(this._onMetadataChanged, this);
+    newValue.metadataChanged.connect(this.onMetadataChanged, this);
   }
 
   /**
-   * Create a widget from a model using the appropriate factory.
+   * Create a child widget and insert into to the notebook.
    */
-  private _createWidget(model: ICellModel): BaseCellWidget {
-    switch (model.type) {
+  private _insertChild(index: number, cell: ICellModel): void {
+    let widget: BaseCellWidget;
+    switch (cell.type) {
     case 'code':
-      let codeFactory = this._factory.createCodeCell;
-      return codeFactory(model as CodeCellModel, this._rendermime);
+      let codeFactory = this._renderer.createCodeCell;
+      widget = codeFactory(cell as CodeCellModel, this._rendermime);
+      break;
     case 'markdown':
-      let mdFactory = this._factory.createMarkdownCell;
-      return mdFactory(model as MarkdownCellModel, this._rendermime);
+      let mdFactory = this._renderer.createMarkdownCell;
+      widget = mdFactory(cell as MarkdownCellModel, this._rendermime);
+      break;
     default:
-      let rawFactory = this._factory.createRawCell;
-      return rawFactory(model as RawCellModel);
+      widget = this._renderer.createRawCell(cell as RawCellModel);
     }
+    widget.addClass(NB_CELL_CLASS);
+    let layout = this.layout as PanelLayout;
+    layout.insertChild(index, widget);
+    this._updateChild(index);
   }
 
   /**
-   * Set the mimetype of the child code widgets.
+   * Update the child widgets.
    */
-  private _setChildMimetypes(): void {
-    let cursor = this.model.getMetadata('language_info');
-    let info = cursor.getValue() as nbformat.ILanguageInfoMetadata;
-    let mimetype = mimetypeForLanguage(info as IKernelLanguageInfo);
+  private _updateChildren(): void {
     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;
-      }
+      this._updateChild(i);
     }
   }
 
   /**
-   * Handle changes to the notebook model.
+   * Update a child widget.
    */
-  private _onMetadataChanged(model: INotebookModel, args: IChangedArgs<any>): void {
-    switch (args.name) {
-    case 'language_info':
-      this._setChildMimetypes();
-      break;
-    default:
-      break;
+  private _updateChild(index: number): void {
+    let layout = this.layout as PanelLayout;
+    let child = layout.childAt(index) as BaseCellWidget;
+    if (child instanceof CodeCellWidget) {
+      child.mimetype = this._mimetype;
     }
+    this._renderer.updateCell(child);
+  }
+
+  /**
+   * Remove a child widget.
+   */
+  private _removeChild(index: number): void {
+    let layout = this.layout as PanelLayout;
+    layout.childAt(index).dispose();
   }
 
   /**
@@ -268,42 +318,41 @@ class StaticNotebook extends Widget {
    */
   private _onCellsChanged(sender: IObservableList<ICellModel>, args: IListChangedArgs<ICellModel>) {
     let layout = this.layout as PanelLayout;
-    let model: ICellModel;
     switch (args.type) {
     case ListChangeType.Add:
-      model = args.newValue as ICellModel;
-      layout.insertChild(args.newIndex, this._createWidget(model));
+      this._insertChild(args.newIndex, args.newValue as ICellModel);
       break;
     case ListChangeType.Move:
       layout.insertChild(args.newIndex, layout.childAt(args.oldIndex));
       break;
     case ListChangeType.Remove:
-      layout.childAt(args.oldIndex).parent = null;
+      this._removeChild(args.oldIndex);
       break;
     case ListChangeType.Replace:
+      // TODO: reuse existing cell widgets if possible.
       let oldValues = args.oldValue as ICellModel[];
       for (let i = 0; i < oldValues.length; i++) {
-        layout.childAt(args.oldIndex).parent = null;
+        this._removeChild(args.oldIndex);
       }
       let newValues = args.newValue as ICellModel[];
       for (let i = newValues.length; i > 0; i--) {
-        model = newValues[i - 1];
-        layout.insertChild(args.newIndex, this._createWidget(model));
+        this._insertChild(args.newIndex, newValues[i - 1]);
       }
       break;
     case ListChangeType.Set:
-      layout.childAt(args.newIndex).parent = null;
-      model = args.newValue as ICellModel;
-      layout.insertChild(args.newIndex, this._createWidget(model));
+      // TODO: reuse existing widget if possible.
+      this._removeChild(args.newIndex);
+      this._insertChild(args.newIndex, args.newValue as ICellModel);
       break;
     default:
       return;
     }
   }
 
+  private _mimetype = 'text/plain';
   private _model: INotebookModel = null;
   private _rendermime: RenderMime<Widget> = null;
-  private _factory: StaticNotebook.ICellWidgetFactory = null;
+  private _renderer: StaticNotebook.IRenderer = null;
 }
 
 
@@ -328,18 +377,18 @@ namespace StaticNotebook {
     languagePreference?: string;
 
     /**
-     * A factory for creating code cell widgets.
+     * A renderer for a notebook.
      *
-     * The default is a shared factory instance.
+     * The default is a shared renderer instance.
      */
-    cellWidgetFactory?: ICellWidgetFactory;
+    renderer?: IRenderer;
   }
 
   /**
    * A factory for creating code cell widgets.
    */
   export
-  interface ICellWidgetFactory {
+  interface IRenderer {
     /**
      * Create a new code cell widget.
      */
@@ -354,7 +403,75 @@ namespace StaticNotebook {
      * Create a new raw cell widget.
      */
     createRawCell(model: IRawCellModel): RawCellWidget;
+
+    /**
+     * Update a cell widget.
+     */
+    updateCell(cell: BaseCellWidget): void;
+
+    /**
+     * Get the preferred mime type for code cells in the notebook.
+     *
+     * #### Notes
+     * The model is guaranteed to be non-null.
+     */
+    getCodeMimetype(model: INotebookModel): string;
+  }
+
+  /**
+   * The default implementation of an `IRenderer`.
+   */
+  export
+  class Renderer implements IRenderer {
+    /**
+     * Create a new code cell widget.
+     */
+    createCodeCell(model: ICodeCellModel, rendermime: RenderMime<Widget>): CodeCellWidget {
+      return new CodeCellWidget(model, rendermime);
+    }
+
+    /**
+     * Create a new markdown cell widget.
+     */
+    createMarkdownCell(model: IMarkdownCellModel, rendermime: RenderMime<Widget>): MarkdownCellWidget {
+      return new MarkdownCellWidget(model, rendermime);
+    }
+
+    /**
+     * Create a new raw cell widget.
+     */
+    createRawCell(model: IRawCellModel): RawCellWidget {
+      return new RawCellWidget(model);
+    }
+
+    /**
+     * Update a cell widget.
+     *
+     * #### Notes
+     * The base implementation is a no-op.
+     */
+    updateCell(cell: BaseCellWidget): void {
+      // No-op.
+    }
+
+    /**
+     * Get the preferred mimetype for code cells in the notebook.
+     *
+     * #### Notes
+     * The model is guaranteed to be non-null.
+     */
+    getCodeMimetype(model: INotebookModel): string {
+      let cursor = model.getMetadata('language_info');
+      let info = cursor.getValue() as nbformat.ILanguageInfoMetadata;
+      return mimetypeForLanguage(info as IKernelLanguageInfo);
+    }
   }
+
+  /**
+   * The default `IRenderer` instance.
+   */
+  export
+  const defaultRenderer = new Renderer();
 }
 
 
@@ -547,7 +664,6 @@ class Notebook extends StaticNotebook {
    * Handle a `child-added` message.
    */
   protected onChildAdded(msg: ChildMessage): void {
-    super.onChildAdded(msg);
     let widget = msg.child as BaseCellWidget;
     widget.editor.edgeRequested.connect(this._onEdgeRequest, this);
     this.update();
@@ -557,7 +673,6 @@ class Notebook extends StaticNotebook {
    * Handle a `child-removed` message.
    */
   protected onChildRemoved(msg: ChildMessage): void {
-    msg.child.dispose();
     this.update();
   }
 
@@ -664,7 +779,7 @@ namespace Private {
    * A signal emitted when the model changes on the notebook.
    */
   export
-  const modelChangedSignal = new Signal<StaticNotebook, INotebookModel>();
+  const modelChangedSignal = new Signal<StaticNotebook, void>();
 
 
   /**
@@ -673,22 +788,6 @@ namespace Private {
   export
   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.
   *

+ 107 - 87
test/src/notebook/notebook/model.spec.ts

@@ -47,6 +47,12 @@ describe('notebook/notebook', () => {
         expect(model.cells.get(0)).to.be.a(CodeCellModel);
       });
 
+      it('should accept an optional factory', () => {
+        let factory = new NotebookModel.Factory();
+        let model = new NotebookModel({ factory });
+        expect(model.factory).to.be(factory);
+      });
+
     });
 
     describe('#metadataChanged', () => {
@@ -93,7 +99,7 @@ describe('notebook/notebook', () => {
 
       it('should be reset when loading from disk', () => {
         let model = new NotebookModel();
-        let cell = model.createCodeCell();
+        let cell = model.factory.createCodeCell();
         model.cells.add(cell);
         model.fromJSON(DEFAULT_CONTENT);
         expect(model.cells.indexOf(cell)).to.be(-1);
@@ -102,7 +108,7 @@ describe('notebook/notebook', () => {
 
       it('should allow undoing a change', () => {
         let model = new NotebookModel();
-        let cell = model.createCodeCell();
+        let cell = model.factory.createCodeCell();
         cell.source = 'foo';
         model.cells.add(cell);
         model.fromJSON(DEFAULT_CONTENT);
@@ -121,7 +127,7 @@ describe('notebook/notebook', () => {
 
         it('should emit a `contentChanged` signal', () => {
           let model = new NotebookModel();
-          let cell = model.createCodeCell();
+          let cell = model.factory.createCodeCell();
           let called = false;
           model.contentChanged.connect(() => { called = true; });
           model.cells.add(cell);
@@ -130,14 +136,14 @@ describe('notebook/notebook', () => {
 
         it('should set the dirty flag', () => {
           let model = new NotebookModel();
-          let cell = model.createCodeCell();
+          let cell = model.factory.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();
+          let cell = model.factory.createCodeCell();
           model.cells.add(cell);
           model.cells.clear();
           expect(cell.isDisposed).to.be(true);
@@ -149,14 +155,14 @@ describe('notebook/notebook', () => {
 
         it('should be called when a cell content changes', () => {
           let model = new NotebookModel();
-          let cell = model.createCodeCell();
+          let cell = model.factory.createCodeCell();
           model.cells.add(cell);
           cell.source = 'foo';
         });
 
         it('should emit the `contentChanged` signal', () => {
           let model = new NotebookModel();
-          let cell = model.createCodeCell();
+          let cell = model.factory.createCodeCell();
           model.cells.add(cell);
           let called = false;
           model.contentChanged.connect(() => { called = true; });
@@ -167,7 +173,7 @@ describe('notebook/notebook', () => {
 
         it('should set the dirty flag', () => {
           let model = new NotebookModel();
-          let cell = model.createCodeCell();
+          let cell = model.factory.createCodeCell();
           model.cells.add(cell);
           model.dirty = false;
           cell.source = 'foo';
@@ -178,6 +184,98 @@ describe('notebook/notebook', () => {
 
     });
 
+    describe('#factory', () => {
+
+      it('should be the cell model factory used by the model', () => {
+        let model = new NotebookModel();
+        expect(model.factory).to.be(NotebookModel.defaultFactory);
+      });
+
+      it('should be read-only', () => {
+        let model = new NotebookModel();
+        expect(() => { model.factory = null; }).to.throwError();
+      });
+
+      context('createCodeCell()', () => {
+
+        it('should create a new code cell', () => {
+          let model = new NotebookModel();
+          let cell = model.factory.createCodeCell();
+          expect(cell.type).to.be('code');
+        });
+
+        it('should clone an existing code cell', () => {
+          let model = new NotebookModel();
+          let cell = model.factory.createCodeCell();
+          cell.source = 'foo';
+          let newCell = model.factory.createCodeCell(cell.toJSON());
+          expect(newCell.source).to.be('foo');
+        });
+
+        it('should clone an existing raw cell', () => {
+          let model = new NotebookModel();
+          let cell = model.factory.createRawCell();
+          cell.source = 'foo';
+          let newCell = model.factory.createCodeCell(cell.toJSON());
+          expect(newCell.source).to.be('foo');
+        });
+
+      });
+
+      context('createRawCell()', () => {
+
+        it('should create a new raw cell', () => {
+          let model = new NotebookModel();
+          let cell = model.factory.createRawCell();
+          expect(cell.type).to.be('raw');
+        });
+
+        it('should clone an existing raw cell', () => {
+          let model = new NotebookModel();
+          let cell = model.factory.createRawCell();
+          cell.source = 'foo';
+          let newCell = model.factory.createRawCell(cell.toJSON());
+          expect(newCell.source).to.be('foo');
+        });
+
+        it('should clone an existing code cell', () => {
+          let model = new NotebookModel();
+          let cell = model.factory.createCodeCell();
+          cell.source = 'foo';
+          let newCell = model.factory.createRawCell(cell.toJSON());
+          expect(newCell.source).to.be('foo');
+        });
+
+      });
+
+      describe('createMarkdownCell()', () => {
+
+        it('should create a new markdown cell', () => {
+          let model = new NotebookModel();
+          let cell = model.factory.createMarkdownCell();
+          expect(cell.type).to.be('markdown');
+        });
+
+        it('should clone an existing markdown cell', () => {
+          let model = new NotebookModel();
+          let cell = model.factory.createMarkdownCell();
+          cell.source = 'foo';
+          let newCell = model.factory.createMarkdownCell(cell.toJSON());
+          expect(newCell.source).to.be('foo');
+        });
+
+        it('should clone an existing raw cell', () => {
+          let model = new NotebookModel();
+          let cell = model.factory.createRawCell();
+          cell.source = 'foo';
+          let newCell = model.factory.createMarkdownCell(cell.toJSON());
+          expect(newCell.source).to.be('foo');
+        });
+
+      });
+
+    });
+
     describe('#nbformat', () => {
 
       it('should get the major version number of the nbformat', () => {
@@ -335,7 +433,7 @@ describe('notebook/notebook', () => {
 
       it('should initialize the model state', () => {
         let model = new NotebookModel();
-        let cell = model.createCodeCell();
+        let cell = model.factory.createCodeCell();
         model.cells.add(cell);
         expect(model.dirty).to.be(true);
         expect(model.cells.canUndo).to.be(true);
@@ -346,84 +444,6 @@ describe('notebook/notebook', () => {
 
     });
 
-    describe('#createCodeCell()', () => {
-
-      it('should create a new code cell', () => {
-        let model = new NotebookModel();
-        let cell = model.createCodeCell();
-        expect(cell.type).to.be('code');
-      });
-
-      it('should clone an existing code cell', () => {
-        let model = new NotebookModel();
-        let cell = model.createCodeCell();
-        cell.source = 'foo';
-        let newCell = model.createCodeCell(cell.toJSON());
-        expect(newCell.source).to.be('foo');
-      });
-
-      it('should clone an existing raw cell', () => {
-        let model = new NotebookModel();
-        let cell = model.createRawCell();
-        cell.source = 'foo';
-        let newCell = model.createCodeCell(cell.toJSON());
-        expect(newCell.source).to.be('foo');
-      });
-
-    });
-
-    describe('#createRawCell()', () => {
-
-      it('should create a new raw cell', () => {
-        let model = new NotebookModel();
-        let cell = model.createRawCell();
-        expect(cell.type).to.be('raw');
-      });
-
-      it('should clone an existing raw cell', () => {
-        let model = new NotebookModel();
-        let cell = model.createRawCell();
-        cell.source = 'foo';
-        let newCell = model.createRawCell(cell.toJSON());
-        expect(newCell.source).to.be('foo');
-      });
-
-      it('should clone an existing code cell', () => {
-        let model = new NotebookModel();
-        let cell = model.createCodeCell();
-        cell.source = 'foo';
-        let newCell = model.createRawCell(cell.toJSON());
-        expect(newCell.source).to.be('foo');
-      });
-
-    });
-
-    describe('#createMarkdownCell()', () => {
-
-      it('should create a new markdown cell', () => {
-        let model = new NotebookModel();
-        let cell = model.createMarkdownCell();
-        expect(cell.type).to.be('markdown');
-      });
-
-      it('should clone an existing markdown cell', () => {
-        let model = new NotebookModel();
-        let cell = model.createMarkdownCell();
-        cell.source = 'foo';
-        let newCell = model.createMarkdownCell(cell.toJSON());
-        expect(newCell.source).to.be('foo');
-      });
-
-      it('should clone an existing raw cell', () => {
-        let model = new NotebookModel();
-        let cell = model.createRawCell();
-        cell.source = 'foo';
-        let newCell = model.createMarkdownCell(cell.toJSON());
-        expect(newCell.source).to.be('foo');
-      });
-
-    });
-
     describe('#getMetadata()', () => {
 
       it('should get a metadata cursor for the notebook', () => {

+ 183 - 103
test/src/notebook/notebook/widget.spec.ts

@@ -7,16 +7,12 @@ import {
   Message
 } from 'phosphor-messaging';
 
-import {
-  IObservableList, IListChangedArgs
-} from 'phosphor-observablelist';
-
 import {
   IChangedArgs
 } from 'phosphor-properties';
 
 import {
-  ChildMessage, Widget
+  ChildMessage
 } from 'phosphor-widget';
 
 import {
@@ -24,13 +20,10 @@ import {
 } from 'simulate-event';
 
 import {
-  BaseCellWidget, CodeCellWidget, ICellModel, MarkdownCellWidget, RawCellWidget
+  CodeCellModel, CodeCellWidget, MarkdownCellModel, MarkdownCellWidget,
+  RawCellModel, RawCellWidget
 } from '../../../../lib/notebook/cells';
 
-import {
-  EdgeLocation
-} from '../../../../lib/notebook/cells/editor';
-
 import {
   INotebookModel, NotebookModel
 } from '../../../../lib/notebook/notebook/model';
@@ -48,12 +41,13 @@ import {
 } from '../../rendermime/rendermime.spec';
 
 
+const rendermime = defaultRenderMime();
+
 const DEFAULT_CONTENT: nbformat.INotebookContent = require('../../../../examples/notebook/test.ipynb') as nbformat.INotebookContent;
 
 
 function createWidget(): LogStaticNotebook {
   let model = new NotebookModel();
-  let rendermime = defaultRenderMime();
   let widget = new LogStaticNotebook({ rendermime });
   widget.model = model;
   return widget;
@@ -69,14 +63,14 @@ class LogStaticNotebook extends StaticNotebook {
     this.methods.push('onUpdateRequest');
   }
 
-  protected onChildAdded(msg: ChildMessage): void {
-    super.onChildAdded(msg);
-    this.methods.push('onChildAdded');
+  protected onModelChanged(oldValue: INotebookModel, newValue: INotebookModel): void {
+    super.onModelChanged(oldValue, newValue);
+    this.methods.push('onModelChanged');
   }
 
-  protected onChildRemoved(msg: ChildMessage): void {
-    super.onChildRemoved(msg);
-    this.methods.push('onChildRemoved');
+  protected onMetadataChanged(model: INotebookModel, args: IChangedArgs<any>): void {
+    super.onMetadataChanged(model, args);
+    this.methods.push('onMetadataChanged');
   }
 }
 
@@ -121,7 +115,6 @@ class LogNotebook extends Notebook {
 
 function createActiveWidget(): LogNotebook {
   let model = new NotebookModel();
-  let rendermime = defaultRenderMime();
   let widget = new LogNotebook({ rendermime });
   widget.model = model;
   return widget;
@@ -135,17 +128,21 @@ describe('notebook/notebook/widget', () => {
     describe('#constructor()', () => {
 
       it('should create a notebook widget', () => {
-        let rendermime = defaultRenderMime();
         let widget = new StaticNotebook({ rendermime });
         expect(widget).to.be.a(StaticNotebook);
       });
 
       it('should add the `jp-Notebook` class', () => {
-        let rendermime = defaultRenderMime();
         let widget = new StaticNotebook({ rendermime });
         expect(widget.hasClass('jp-Notebook')).to.be(true);
       });
 
+      it('should accept an optional render', () => {
+        let renderer = new StaticNotebook.Renderer();
+        let widget = new StaticNotebook({ rendermime, renderer });
+        expect(widget.renderer).to.be(renderer);
+      });
+
     });
 
     describe('#modelChanged', () => {
@@ -156,7 +153,7 @@ describe('notebook/notebook/widget', () => {
         let called = false;
         widget.modelChanged.connect((sender, args) => {
           expect(sender).to.be(widget);
-          expect(args).to.be(model);
+          expect(args).to.be(void 0);
           called = true;
         });
         widget.model = model;
@@ -238,14 +235,18 @@ describe('notebook/notebook/widget', () => {
 
         it('should handle a remove', () => {
           let cell = widget.model.cells.get(1);
+          let child = widget.childAt(1);
           widget.model.cells.remove(cell);
           expect(cell.isDisposed).to.be(true);
+          expect(child.isDisposed).to.be(true);
         });
 
         it('should handle an add', () => {
-          let cell = widget.model.createCodeCell();
+          let cell = widget.model.factory.createCodeCell();
           widget.model.cells.add(cell);
           expect(widget.childCount()).to.be(7);
+          let child = widget.childAt(0);
+          expect(child.hasClass('jp-Notebook-cell')).to.be(true);
         });
 
         it('should handle a move', () => {
@@ -255,47 +256,18 @@ describe('notebook/notebook/widget', () => {
         });
 
         it('should handle a replace', () => {
-          let cell = widget.model.createCodeCell();
+          let cell = widget.model.factory.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);
-        });
-
-      });
-
     });
 
     describe('#rendermime', () => {
 
       it('should be the rendermime instance used by the widget', () => {
-        let rendermime = defaultRenderMime();
         let widget = new StaticNotebook({ rendermime });
         expect(widget.rendermime).to.be(rendermime);
       });
@@ -307,6 +279,38 @@ describe('notebook/notebook/widget', () => {
 
     });
 
+    describe('#renderer', () => {
+
+      it('should be the cell widget renderer used by the widget', () => {
+        let widget = new StaticNotebook({ rendermime });
+        expect(widget.renderer).to.be(StaticNotebook.defaultRenderer);
+      });
+
+      it('should be read-only', () => {
+        let widget = createWidget();
+        expect(() => { widget.renderer = null; }).to.throwError();
+      });
+
+    });
+
+    describe('#codeMimetype', () => {
+
+      it('should get the mime type for code cells', () => {
+        let widget = new StaticNotebook({ rendermime });
+        expect(widget.codeMimetype).to.be('text/plain');
+      });
+
+      it('should be set from language metadata', () => {
+        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;
+        expect(widget.codeMimetype).to.be('text/x-python');
+      });
+
+    });
+
     describe('#childAt()', () => {
 
       it('should get the child widget at a specified index', () => {
@@ -354,25 +358,116 @@ describe('notebook/notebook/widget', () => {
 
     });
 
-    describe('#onChildAdded()', () => {
+    describe('#onModelChanged()', () => {
 
-      it('should add the `jp-Notebook-cell` class', () => {
+      it('should be called when the model changes', () => {
+        let widget = new LogStaticNotebook({ rendermime });
+        widget.model = new NotebookModel();
+        expect(widget.methods).to.contain('onModelChanged');
+      });
+
+      it('should not be called if the model does not change', () => {
         let widget = createWidget();
-        widget.model.fromJSON(DEFAULT_CONTENT);
-        expect(widget.methods.indexOf('onChildAdded')).to.not.be(-1);
+        widget.methods = [];
+        widget.model = widget.model;
+        expect(widget.methods).to.not.contain('onModelChanged');
       });
 
     });
 
-    describe('#onChildRemoved()', () => {
+    describe('#onMetadataChanged()', () => {
 
-      it('should dispose of the cell', () => {
+      it('should be called when the metadata on the notebook changes', () => {
         let widget = createWidget();
-        let cell = widget.model.cells.get(0);
+        let cursor = widget.model.getMetadata('foo');
+        cursor.setValue(1);
+        expect(widget.methods).to.contain('onMetadataChanged');
+      });
+
+      it('should update the `codeMimetype`', () => {
+        let widget = createWidget();
+        let cursor = widget.model.getMetadata('language_info');
+        cursor.setValue({ name: 'python', codemirror_mode: 'python' });
+        expect(widget.methods).to.contain('onMetadataChanged');
+        expect(widget.codeMimetype).to.be('text/x-python');
+      });
+
+      it('should update the cell widget mimetype', () => {
+        let widget = createWidget();
+        let cursor = widget.model.getMetadata('language_info');
+        cursor.setValue({ name: 'python', mimetype: 'text/x-python' });
+        expect(widget.methods).to.contain('onMetadataChanged');
         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);
+        expect(child.mimetype).to.be('text/x-python');
+      });
+
+    });
+
+    describe('.Renderer', () => {
+
+      describe('#createCodeCell()', () => {
+
+        it('should create a `CodeCellWidget`', () => {
+          let renderer = new StaticNotebook.Renderer();
+          let model = new CodeCellModel();
+          let widget = renderer.createCodeCell(model, rendermime);
+          expect(widget).to.be.a(CodeCellWidget);
+        });
+
+      });
+
+      describe('#createMarkdownCell()', () => {
+
+        it('should create a `MarkdownCellWidget`', () => {
+          let renderer = new StaticNotebook.Renderer();
+          let model = new MarkdownCellModel();
+          let widget = renderer.createMarkdownCell(model, rendermime);
+          expect(widget).to.be.a(MarkdownCellWidget);
+        });
+
+      });
+
+      describe('#createRawCell()', () => {
+
+        it('should create a `RawCellWidget`', () => {
+          let renderer = new StaticNotebook.Renderer();
+          let model = new RawCellModel();
+          let widget = renderer.createRawCell(model);
+          expect(widget).to.be.a(RawCellWidget);
+        });
+
+      });
+
+      describe('#updateCell()', () => {
+
+        it('should be a no-op', () => {
+          let renderer = new StaticNotebook.Renderer();
+          let model = new CodeCellModel();
+          let widget = renderer.createCodeCell(model, rendermime);
+          renderer.updateCell(widget);
+          expect(widget).to.be.a(CodeCellWidget);
+        });
+
+      });
+
+      describe('#getCodeMimetype()', () => {
+
+        it('should get the preferred mime for code cells in the notebook', () => {
+          let renderer = new StaticNotebook.Renderer();
+          let model = new NotebookModel();
+          let cursor = model.getMetadata('language_info');
+          cursor.setValue({ name: 'python', mimetype: 'text/x-python' });
+          expect(renderer.getCodeMimetype(model)).to.be('text/x-python');
+        });
+
+      });
+
+    });
+
+    describe('.defaultRenderer', () => {
+
+      it('should be an instance of `StaticNotebook.Renderer', () => {
+        expect(StaticNotebook.defaultRenderer).to.be.a(StaticNotebook.Renderer);
       });
 
     });
@@ -437,7 +532,7 @@ describe('notebook/notebook/widget', () => {
       it('should post an update request', (done) => {
         let widget = createActiveWidget();
         requestAnimationFrame(() => {
-          expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
+          expect(widget.methods).to.contain('onUpdateRequest');
           done();
         });
         widget.mode = 'edit';
@@ -520,7 +615,7 @@ describe('notebook/notebook/widget', () => {
         let widget = createActiveWidget();
         widget.model.fromJSON(DEFAULT_CONTENT);
         requestAnimationFrame(() => {
-          expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
+          expect(widget.methods).to.contain('onUpdateRequest');
           done();
         });
         widget.activeCellIndex = 1;
@@ -615,7 +710,7 @@ describe('notebook/notebook/widget', () => {
         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.events).to.contain('click');
           expect(widget.activeCellIndex).to.be(1);
         });
 
@@ -623,13 +718,13 @@ describe('notebook/notebook/widget', () => {
           let child = widget.childAt(1);
           widget.model.readOnly = true;
           simulate(child.node, 'click');
-          expect(widget.events.indexOf('click')).to.not.be(-1);
+          expect(widget.events).to.contain('click');
           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.events).to.contain('click');
           expect(widget.activeCellIndex).to.be(0);
         });
 
@@ -638,7 +733,7 @@ describe('notebook/notebook/widget', () => {
       context('dblclick', () => {
 
         it('should unrender a markdown cell', () => {
-          let cell = widget.model.createMarkdownCell();
+          let cell = widget.model.factory.createMarkdownCell();
           widget.model.cells.add(cell);
           let child = widget.childAt(widget.childCount() - 1) as MarkdownCellWidget;
           expect(child.rendered).to.be(true);
@@ -647,7 +742,7 @@ describe('notebook/notebook/widget', () => {
         });
 
         it('should be a no-op if the model is read only', () => {
-          let cell = widget.model.createMarkdownCell();
+          let cell = widget.model.factory.createMarkdownCell();
           widget.model.cells.add(cell);
           widget.model.readOnly = true;
           let child = widget.childAt(widget.childCount() - 1) as MarkdownCellWidget;
@@ -663,18 +758,18 @@ describe('notebook/notebook/widget', () => {
         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.events).to.contain('focus');
           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.events).to.contain('focus');
           expect(widget.mode).to.be('edit');
           widget.events = [];
           simulate(widget.node, 'focus');
-          expect(widget.events.indexOf('focus')).to.not.be(-1);
+          expect(widget.events).to.contain('focus');
           expect(widget.mode).to.be('command');
         });
 
@@ -690,13 +785,13 @@ describe('notebook/notebook/widget', () => {
         widget.attach(document.body);
         let child = widget.childAt(0);
         requestAnimationFrame(() => {
-          expect(widget.methods.indexOf('onAfterAttach')).to.not.be(-1);
+          expect(widget.methods).to.contain('onAfterAttach');
           simulate(widget.node, 'click');
-          expect(widget.events.indexOf('click')).to.not.be(-1);
+          expect(widget.events).to.contain('click');
           simulate(widget.node, 'dblclick');
-          expect(widget.events.indexOf('dblclick')).to.not.be(-1);
+          expect(widget.events).to.contain('dblclick');
           simulate(child.node, 'focus');
-          expect(widget.events.indexOf('focus')).to.not.be(-1);
+          expect(widget.events).to.contain('focus');
           widget.dispose();
           done();
         });
@@ -707,9 +802,9 @@ describe('notebook/notebook/widget', () => {
         widget.model.fromJSON(DEFAULT_CONTENT);
         widget.attach(document.body);
         requestAnimationFrame(() => {
-          expect(widget.methods.indexOf('onAfterAttach')).to.not.be(-1);
+          expect(widget.methods).to.contain('onAfterAttach');
           requestAnimationFrame(() => {
-            expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
+            expect(widget.methods).to.contain('onUpdateRequest');
             widget.dispose();
             done();
           });
@@ -727,14 +822,14 @@ describe('notebook/notebook/widget', () => {
         let child = widget.childAt(0);
         requestAnimationFrame(() => {
           widget.detach();
-          expect(widget.methods.indexOf('onBeforeDetach')).to.not.be(-1);
+          expect(widget.methods).to.contain('onBeforeDetach');
           widget.events = [];
           simulate(widget.node, 'click');
-          expect(widget.events.indexOf('click')).to.be(-1);
+          expect(widget.events).to.not.contain('click');
           simulate(widget.node, 'dblclick');
-          expect(widget.events.indexOf('dblclick')).to.be(-1);
+          expect(widget.events).to.not.contain('dblclick');
           simulate(child.node, 'focus');
-          expect(widget.events.indexOf('focus')).to.be(-1);
+          expect(widget.events).to.not.contain('focus');
           widget.dispose();
           done();
         });
@@ -758,7 +853,7 @@ describe('notebook/notebook/widget', () => {
       });
 
       it('should apply the command class if in command mode', () => {
-        expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
+        expect(widget.methods).to.contain('onUpdateRequest');
         expect(widget.hasClass('jp-mod-commandMode')).to.be(true);
       });
 
@@ -784,7 +879,7 @@ describe('notebook/notebook/widget', () => {
       });
 
       it('should unrender a markdown cell in edit mode', (done) => {
-        let cell = widget.model.createMarkdownCell();
+        let cell = widget.model.factory.createMarkdownCell();
         widget.model.cells.add(cell);
         let child = widget.childAt(widget.childCount() - 1) as MarkdownCellWidget;
         expect(child.rendered).to.be(true);
@@ -825,18 +920,12 @@ describe('notebook/notebook/widget', () => {
 
     describe('#onChildAdded()', () => {
 
-      it('should add the `jp-Notebook-cell` class', () => {
-        let widget = createActiveWidget();
-        widget.model.fromJSON(DEFAULT_CONTENT);
-        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);
+        expect(widget.methods).to.contain('onChildAdded');
         requestAnimationFrame(() => {
-          expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
+          expect(widget.methods).to.contain('onUpdateRequest');
           done();
         });
       });
@@ -866,22 +955,13 @@ describe('notebook/notebook/widget', () => {
 
     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);
-      });
-
       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);
+        expect(widget.methods).to.contain('onChildRemoved');
         requestAnimationFrame(() => {
-          expect(widget.methods.indexOf('onUpdateRequest')).to.not.be(-1);
+          expect(widget.methods).to.contain('onUpdateRequest');
           done();
         });
       });