[cleanup] migrate Ermine's topaz dependencies

This CL copies code from topaz so that it can be removed from that repo
in a follow up CL (fxr/378017).
- //topaz/lib/tiler => session_shells/ermine/tiler
- //topaz/public/lib/device => session_shells/ermine/device

Integration verified with:

        fx make-integration-patch

Change-Id: Iec542565d39faeb03188bcec7548151776b271dd
Reviewed-on: https://fuchsia-review.googlesource.com/c/experiences/+/378003
Reviewed-by: Chase Latta <chaselatta@google.com>
Reviewed-by: Sanjay Chouksey <sanjayc@google.com>
Commit-Queue: Jason Campbell <jasoncampbell@google.com>
diff --git a/session_shells/BUILD.gn b/session_shells/BUILD.gn
index 08c16a3..7ca3b83 100644
--- a/session_shells/BUILD.gn
+++ b/session_shells/BUILD.gn
@@ -18,6 +18,7 @@
     _flutter_tester_tests += [
       "ermine/settings:ermine_settings_unittests($host_toolchain)",
       "ermine/shell:ermine_unittests($host_toolchain)",
+      "ermine/tiler:tiler_unittests($host_toolchain)",
       "ermine/keyboard_shortcuts:keyboard_shortcuts_unittests($host_toolchain)",
     ]
   }
diff --git a/session_shells/ermine/device/BUILD.gn b/session_shells/ermine/device/BUILD.gn
new file mode 100644
index 0000000..b378533
--- /dev/null
+++ b/session_shells/ermine/device/BUILD.gn
@@ -0,0 +1,20 @@
+# Copyright 2017 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/dart/dart_library.gni")
+
+dart_library("device") {
+  package_name = "lib.device.dart"
+
+  sources = [
+    "device.dart",
+    "src/base_shell_impl.dart",
+  ]
+
+  deps = [
+    "//sdk/fidl/fuchsia.auth",
+    "//sdk/fidl/fuchsia.modular",
+    "//topaz/public/dart/fidl",
+  ]
+}
diff --git a/session_shells/ermine/device/analysis_options.yaml b/session_shells/ermine/device/analysis_options.yaml
new file mode 100644
index 0000000..54917c0
--- /dev/null
+++ b/session_shells/ermine/device/analysis_options.yaml
@@ -0,0 +1,5 @@
+# Copyright 2017 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+include: ../../../analysis_options.yaml
diff --git a/session_shells/ermine/device/lib/device.dart b/session_shells/ermine/device/lib/device.dart
new file mode 100644
index 0000000..ec19bc8
--- /dev/null
+++ b/session_shells/ermine/device/lib/device.dart
@@ -0,0 +1,5 @@
+// Copyright 2017 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export 'src/base_shell_impl.dart';
diff --git a/session_shells/ermine/device/lib/src/base_shell_impl.dart b/session_shells/ermine/device/lib/src/base_shell_impl.dart
new file mode 100644
index 0000000..cdef730
--- /dev/null
+++ b/session_shells/ermine/device/lib/src/base_shell_impl.dart
@@ -0,0 +1,89 @@
+// Copyright 2017 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import 'package:fidl/fidl.dart';
+import 'package:fidl_fuchsia_auth/fidl_async.dart';
+import 'package:fidl_fuchsia_modular/fidl_async.dart';
+import 'package:fidl_fuchsia_ui_policy/fidl_async.dart';
+
+/// Called when [BaseShell.initialize] occurs.
+typedef OnBaseShellReady = void Function(
+  UserProvider userProvider,
+  BaseShellContext baseShellContext,
+  Presentation presentation,
+);
+
+/// Called when [Lifecycle.terminate] occurs.
+typedef OnBaseShellStop = void Function();
+
+/// Implements a BaseShell for receiving the services a [BaseShell] needs to
+/// operate.
+class BaseShellImpl extends BaseShell implements Lifecycle {
+  final BaseShellContextProxy _baseShellContextProxy =
+      BaseShellContextProxy();
+  final UserProviderProxy _userProviderProxy = UserProviderProxy();
+  final PresentationProxy _presentationProxy = PresentationProxy();
+  final Set<AuthenticationUiContextBinding> _authUiContextBindingSet =
+      <AuthenticationUiContextBinding>{};
+
+  /// Called when [initialize] occurs.
+  final OnBaseShellReady onReady;
+
+  /// Called when the [BaseShell] terminates.
+  final OnBaseShellStop onStop;
+
+  /// The [AuthenticationUiContext] is a new interface from
+  /// |fuchsia::auth::TokenManager| service that provides a new authentication
+  /// UI context to display signin and permission screens when requested.
+  final AuthenticationUiContext authenticationUiContext;
+
+  /// Constructor.
+  BaseShellImpl({
+    this.authenticationUiContext,
+    this.onReady,
+    this.onStop,
+  });
+  @override
+  Future<void> initialize(
+    InterfaceHandle<BaseShellContext> baseShellContextHandle,
+    BaseShellParams baseShellParams,
+  ) async {
+    if (onReady != null) {
+      _baseShellContextProxy.ctrl.bind(baseShellContextHandle);
+      await _baseShellContextProxy
+          .getUserProvider(_userProviderProxy.ctrl.request());
+      await _baseShellContextProxy
+          .getPresentation(_presentationProxy.ctrl.request());
+      onReady(_userProviderProxy, _baseShellContextProxy, _presentationProxy);
+    }
+  }
+
+  @override
+  Future<void> terminate() async {
+    onStop?.call();
+    _userProviderProxy.ctrl.close();
+    _baseShellContextProxy.ctrl.close();
+    for (AuthenticationUiContextBinding binding in _authUiContextBindingSet) {
+      binding.close();
+    }
+  }
+
+  @override
+  Future<void> getAuthenticationUiContext(
+    InterfaceRequest<AuthenticationUiContext> request,
+  ) async {
+    AuthenticationUiContextBinding binding =
+        AuthenticationUiContextBinding()
+          ..bind(authenticationUiContext, request);
+    _authUiContextBindingSet.add(binding);
+  }
+
+  /// Closes all bindings to authentication contexts, effectively cancelling any ongoing
+  /// authorization flows.
+  void closeAuthenticationUiContextBindings() {
+    for (AuthenticationUiContextBinding binding in _authUiContextBindingSet) {
+      binding.close();
+    }
+    _authUiContextBindingSet.clear();
+  }
+}
diff --git a/session_shells/ermine/device/pubspec.yaml b/session_shells/ermine/device/pubspec.yaml
new file mode 100644
index 0000000..3a809f4
--- /dev/null
+++ b/session_shells/ermine/device/pubspec.yaml
@@ -0,0 +1,3 @@
+# Copyright 2017 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
diff --git a/session_shells/ermine/login_shell/BUILD.gn b/session_shells/ermine/login_shell/BUILD.gn
index c6db19c..a044972 100644
--- a/session_shells/ermine/login_shell/BUILD.gn
+++ b/session_shells/ermine/login_shell/BUILD.gn
@@ -39,6 +39,7 @@
   ]
 
   deps = [
+    "//src/experiences/session_shells/ermine/device",
     "//sdk/fidl/fuchsia.modular",
     "//sdk/fidl/fuchsia.modular.auth",
     "//sdk/fidl/fuchsia.netstack",
@@ -50,7 +51,6 @@
     "//topaz/public/dart/fuchsia_logger",
     "//topaz/public/dart/fuchsia_scenic_flutter",
     "//topaz/public/dart/widgets:lib.widgets",
-    "//topaz/public/lib/device/dart",
     "//zircon/system/fidl/fuchsia-device-manager",
   ]
 }
diff --git a/session_shells/ermine/shell/BUILD.gn b/session_shells/ermine/shell/BUILD.gn
index abff93e..dbf2951 100644
--- a/session_shells/ermine/shell/BUILD.gn
+++ b/session_shells/ermine/shell/BUILD.gn
@@ -103,13 +103,13 @@
     "//src/experiences/session_shells/ermine/internationalization",
     "//src/experiences/session_shells/ermine/keyboard_shortcuts",
     "//src/experiences/session_shells/ermine/settings",
+    "//src/experiences/session_shells/ermine/tiler",
     "//src/sys/component_index/fidl:index",
     "//third_party/dart-pkg/git/flutter/packages/flutter",
     "//third_party/dart-pkg/git/flutter/packages/flutter_localizations",
     "//third_party/dart-pkg/pub/flutter_svg",
     "//third_party/dart-pkg/pub/uuid",
     "//third_party/dart/third_party/pkg/intl",
-    "//topaz/lib/tiler",
     "//topaz/public/dart/fuchsia_inspect",
     "//topaz/public/dart/fuchsia_logger",
     "//topaz/public/dart/fuchsia_scenic_flutter",
diff --git a/session_shells/ermine/tiler/BUILD.gn b/session_shells/ermine/tiler/BUILD.gn
new file mode 100644
index 0000000..c2d58d9
--- /dev/null
+++ b/session_shells/ermine/tiler/BUILD.gn
@@ -0,0 +1,36 @@
+# Copyright 2018 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/dart/dart_library.gni")
+import("//topaz/runtime/dart/flutter_test.gni")
+
+dart_library("tiler") {
+  package_name = "tiler"
+
+  sources = [
+    "tiler.dart",
+    "src/sizer.dart",
+    "src/tile.dart",
+    "src/tile_model.dart",
+    "src/tiler.dart",
+    "src/tiler_model.dart",
+    "src/utils.dart",
+  ]
+
+  deps = [
+    "//third_party/dart-pkg/git/flutter/packages/flutter",
+  ]
+}
+
+flutter_test("tiler_unittests") {
+  sources = [
+    "tiler_test.dart",
+  ]
+
+  deps = [
+    ":tiler",
+    "//third_party/dart-pkg/git/flutter/packages/flutter_test",
+  ]
+}
+
diff --git a/session_shells/ermine/tiler/analysis_options.yaml b/session_shells/ermine/tiler/analysis_options.yaml
new file mode 100644
index 0000000..28306ea
--- /dev/null
+++ b/session_shells/ermine/tiler/analysis_options.yaml
@@ -0,0 +1,5 @@
+# Copyright 2018 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+include: ../../../analysis_options.yaml
diff --git a/session_shells/ermine/tiler/lib/src/sizer.dart b/session_shells/ermine/tiler/lib/src/sizer.dart
new file mode 100644
index 0000000..d709898
--- /dev/null
+++ b/session_shells/ermine/tiler/lib/src/sizer.dart
@@ -0,0 +1,56 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+
+import 'tile_model.dart';
+
+typedef TileSizerBuilder = Widget Function(
+  BuildContext context,
+  Axis direction,
+  TileModel tileBefore,
+  TileModel tileAfter,
+);
+
+class Sizer extends StatelessWidget {
+  final Axis direction;
+  final TileSizerBuilder sizerBuilder;
+  final TileModel tileBefore;
+  final TileModel tileAfter;
+  final bool horizontal;
+
+  const Sizer({
+    this.direction,
+    this.tileBefore,
+    this.tileAfter,
+    this.sizerBuilder,
+  }) : horizontal = direction == Axis.horizontal;
+
+  @override
+  Widget build(BuildContext context) {
+    final sizer = sizerBuilder?.call(context, direction, tileBefore, tileAfter);
+    if (sizer == null) {
+      return SizedBox.shrink();
+    } else {
+      return Listener(
+        onPointerMove: _onPointerMove,
+        child: Container(
+          color: Colors.transparent,
+          child: sizer,
+        ),
+      );
+    }
+  }
+
+  void _onPointerMove(PointerMoveEvent event) {
+    double delta =
+        direction == Axis.horizontal ? event.delta.dy : event.delta.dx;
+    tileBefore.resize(delta);
+    tileAfter.resize(-delta);
+    if (tileBefore.overflowed || tileAfter.overflowed) {
+      tileBefore.resize(-delta);
+      tileAfter.resize(delta);
+    }
+  }
+}
diff --git a/session_shells/ermine/tiler/lib/src/tile.dart b/session_shells/ermine/tiler/lib/src/tile.dart
new file mode 100644
index 0000000..b992f4d
--- /dev/null
+++ b/session_shells/ermine/tiler/lib/src/tile.dart
@@ -0,0 +1,208 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+
+import 'sizer.dart';
+import 'tile_model.dart';
+import 'utils.dart';
+
+typedef TileChromeBuilder<T> = Widget Function(
+    BuildContext context, TileModel<T> tile);
+typedef CustomTilesBuilder<T> = Widget Function(
+    BuildContext context, List<TileModel<T>> tiles);
+
+/// A [Widget] that renders a tile give its [TileModel]. If the tile type is
+/// [TileType.content], it calls [chromeBuilder] to build a widget to render
+/// the tile. Otherwise, it renders the [tiles] children in row or column
+/// order. It calls [sizerBuilder] to get a sizing widget to display between
+/// rows or columns of tiles.
+class Tile<T> extends StatelessWidget {
+  final TileModel<T> model;
+  final TileChromeBuilder<T> chromeBuilder;
+  final TileSizerBuilder sizerBuilder;
+  final CustomTilesBuilder<T> customTilesBuilder;
+  final double sizerThickness;
+  final ValueChanged<TileModel<T>> onFloat;
+
+  const Tile({
+    @required this.model,
+    @required this.chromeBuilder,
+    this.customTilesBuilder,
+    this.sizerBuilder,
+    this.sizerThickness,
+    this.onFloat,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedBuilder(
+      animation: model,
+      builder: (context, child) {
+        final type = model.type;
+        if (model.isContent) {
+          return chromeBuilder(context, model);
+        } else if (model.tiles.isEmpty) {
+          return SizedBox.shrink();
+        } else {
+          return LayoutBuilder(
+            builder: (context, constraints) {
+              assert(constraints.isTight);
+
+              // Filter out tiles that are floating.
+              final availableTiles = model.tiles;
+
+              /// Filter out tiles that don't fit based on their minSize.
+              final availableSize =
+                  _availableSize(type, availableTiles, constraints.biggest);
+              final tiles = _fit(type, availableTiles, availableSize).toList();
+              bool fit = tiles.length == availableTiles.length;
+
+              if (type != TileType.custom &&
+                  (fit || customTilesBuilder == null)) {
+                // Float the tiles that did not fit.
+                availableTiles
+                    .where((t) => !tiles.contains(t))
+                    .toList()
+                    .forEach(onFloat?.call);
+
+                // All tiles fit, set flex,width and height and return tiles.
+                final flex = _totalFlex(tiles);
+                for (var tile in tiles) {
+                  tile
+                    ..flex = tile.flex / flex
+                    ..width = availableSize.width
+                    ..height = availableSize.height;
+                }
+
+                // Generate tile widgets with sizer widgets interleaved.
+                final count = tiles.length + tiles.length - 1;
+                final tileWidgets = List<Widget>.generate(count, (index) {
+                  if (index.isOdd) {
+                    return Sizer(
+                      direction: model.type == TileType.row
+                          ? Axis.horizontal
+                          : Axis.vertical,
+                      tileBefore: tiles[index ~/ 2],
+                      tileAfter: tiles[index ~/ 2 + 1],
+                      sizerBuilder: sizerBuilder,
+                    );
+                  } else {
+                    return _tileBuilder(
+                      tile: tiles[index ~/ 2],
+                      chromeBuilder: chromeBuilder,
+                      sizerBuilder: sizerBuilder,
+                      customTilesBuilder: customTilesBuilder,
+                      sizerThickness: sizerThickness,
+                      onFloat: onFloat,
+                    );
+                  }
+                });
+
+                return model.type == TileType.row
+                    ? Column(
+                        crossAxisAlignment: CrossAxisAlignment.stretch,
+                        children: tileWidgets,
+                      )
+                    : Row(
+                        crossAxisAlignment: CrossAxisAlignment.stretch,
+                        children: tileWidgets,
+                      );
+              } else {
+                // Flatten all tiles to group them for tabbing between them.
+                final allTiles = flatten<T>(availableTiles).toList();
+                // Display tabs only when we have more than 1 tile.
+                if (allTiles.length > 1) {
+                  return customTilesBuilder?.call(context, allTiles) ??
+                      Offstage();
+                } else {
+                  return _tileBuilder(
+                      tile: allTiles.first,
+                      chromeBuilder: chromeBuilder,
+                      sizerBuilder: sizerBuilder,
+                      customTilesBuilder: customTilesBuilder,
+                      sizerThickness: sizerThickness,
+                      onFloat: onFloat);
+                }
+              }
+            },
+          );
+        }
+      },
+    );
+  }
+
+  Widget _tileBuilder({
+    TileModel<T> tile,
+    TileChromeBuilder<T> chromeBuilder,
+    TileSizerBuilder sizerBuilder,
+    CustomTilesBuilder<T> customTilesBuilder,
+    ValueChanged<TileModel<T>> onFloat,
+    double sizerThickness,
+  }) =>
+      AnimatedBuilder(
+        animation: tile,
+        child: Tile(
+          model: tile,
+          chromeBuilder: chromeBuilder,
+          sizerBuilder: sizerBuilder,
+          customTilesBuilder: customTilesBuilder,
+          onFloat: onFloat,
+          sizerThickness: sizerThickness,
+        ),
+        builder: (context, child) => SizedBox(
+              width: tile.width,
+              height: tile.height,
+              child: child,
+            ),
+      );
+
+  /// Fit as many tiles as possible within [size].
+  Iterable<TileModel<T>> _fit(
+      TileType type, Iterable<TileModel<T>> tiles, Size size) {
+    if (tiles.isEmpty) {
+      return tiles;
+    }
+
+    // Calculate the normalized flex of each tile.
+    final flex = _totalFlex(tiles);
+    final width = size.width;
+    final height = size.height;
+
+    // Find tiles that don't fit based on their minSize.
+    final unfitableTiles = tiles.where((tile) {
+      double tileFlex = tile.flex / flex;
+      double tileWidth = type == TileType.column ? tileFlex * width : width;
+      double tileHeight = type == TileType.row ? tileFlex * height : height;
+      return type == TileType.column
+          ? tile.minSize.width > tileWidth
+          : tile.minSize.height > tileHeight;
+    });
+
+    if (unfitableTiles.isEmpty) {
+      return tiles;
+    } else {
+      // Remove the last tile in unfittableTiles from tiles and run fit again.
+      return _fit(type, tiles.where((t) => t != unfitableTiles.last), size);
+    }
+  }
+
+  // Returns the size available after sizers thickness is accounted for.
+  Size _availableSize(TileType type, Iterable<TileModel<T>> tiles, Size size) {
+    final numTiles = tiles.length;
+    final numSizers = numTiles - 1;
+
+    // Calculate the width/height of a tile minus sizer thickness.
+    final width = type == TileType.column
+        ? (size.width - sizerThickness * numSizers)
+        : size.width;
+    final height = type == TileType.row
+        ? (size.height - sizerThickness * numSizers)
+        : size.height;
+    return Size(width, height);
+  }
+
+  double _totalFlex(Iterable<TileModel<T>> tiles) =>
+      tiles.map((t) => t.flex).reduce((f1, f2) => f1 + f2);
+}
diff --git a/session_shells/ermine/tiler/lib/src/tile_model.dart b/session_shells/ermine/tiler/lib/src/tile_model.dart
new file mode 100644
index 0000000..1179cb2
--- /dev/null
+++ b/session_shells/ermine/tiler/lib/src/tile_model.dart
@@ -0,0 +1,100 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:math';
+import 'package:flutter/material.dart';
+
+import 'utils.dart';
+
+enum TileType { content, row, column, custom }
+
+/// Defines a model for a tile. It is a tree data structure where each tile
+/// model holds a reference to its parent and a list of children. If the type
+/// of tile, [TileType] is [TileType.content], it is a leaf node tile.
+///
+/// The [content] of tile holds a reference to an arbitrary object, that is
+/// passed to the caller during the construction of the tile's chrome widget.
+class TileModel<T> extends ChangeNotifier {
+  TileModel<T> parent;
+  TileType _type;
+  T content;
+  double flex;
+  Offset position;
+  final Size _minSize;
+  List<TileModel<T>> tiles;
+
+  TileModel({
+    @required TileType type,
+    this.parent,
+    this.content,
+    this.tiles,
+    this.flex = 1,
+    this.position = Offset.zero,
+    Size minSize = Size.zero,
+  })  : _minSize = minSize,
+        _type = type {
+    tiles ??= <TileModel<T>>[];
+    traverse(tile: this, callback: (tile, parent) => tile.parent = parent);
+  }
+
+  TileType get type => _type;
+  set type(TileType value) {
+    _type = value;
+    notifyListeners();
+  }
+
+  bool get isContent => type == TileType.content;
+  bool get isFloating => parent == null;
+  bool get isRow => type == TileType.row;
+  bool get isColumn => type == TileType.column;
+  bool get isCustom => type == TileType.custom;
+  bool get isCollection => isRow || isColumn || isCustom;
+  bool get isEmpty =>
+      isCollection && tiles.isEmpty || isContent && content == null;
+
+  double _width = 0;
+  double get width => parent?.type == TileType.column ? _width * flex : _width;
+  set width(double value) => _width = value;
+
+  double _height = 0;
+  double get height => parent?.type == TileType.row ? _height * flex : _height;
+  set height(double value) => _height = value;
+
+  void insert(int index, TileModel<T> tile) {
+    tile.parent = this;
+    tiles.insert(index, tile);
+    notifyListeners();
+  }
+
+  void add(TileModel<T> tile) => insert(tiles.length, tile);
+
+  void remove(TileModel<T> tile) {
+    assert(tiles.contains(tile));
+    tile.parent = null;
+    tiles.remove(tile);
+    notifyListeners();
+  }
+
+  void resize(double delta) {
+    parent.type == TileType.column
+        ? flex += flex / width * delta
+        : flex += flex / height * delta;
+
+    notifyListeners();
+  }
+
+  Size get minSize => isContent
+      ? _minSize
+      : tiles.map((tile) => tile.minSize).reduce(
+          (s1, s2) => Size(max(s1.width, s2.width), max(s1.height, s2.height)));
+
+  bool get overflowed => parent.type == TileType.column
+      ? width < minSize.width
+      : height < minSize.height;
+
+  void notify() => notifyListeners();
+
+  @override
+  String toString() => isContent ? '$content' : '$type $tiles';
+}
diff --git a/session_shells/ermine/tiler/lib/src/tiler.dart b/session_shells/ermine/tiler/lib/src/tiler.dart
new file mode 100644
index 0000000..5444a92
--- /dev/null
+++ b/session_shells/ermine/tiler/lib/src/tiler.dart
@@ -0,0 +1,94 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:math';
+import 'package:flutter/material.dart';
+
+import 'sizer.dart';
+import 'tile.dart';
+import 'tile_model.dart';
+import 'tiler_model.dart';
+
+/// Defines a widget to arrange tiles supplied in [model]. For tiles that are
+/// leaf nodes in the [model], it calls the [chromeBuilder] to build their
+/// widget. The [sizerBuilder] is called to display a sizing widget between
+/// two tiles. If [sizerBuilder] returns null, no space is created between
+/// the tiles. The supplied [sizerThickness] is used during layout calculations.
+class Tiler<T> extends StatelessWidget {
+  final TilerModel<T> model;
+  final TileChromeBuilder<T> chromeBuilder;
+  final TileSizerBuilder sizerBuilder;
+  final CustomTilesBuilder<T> customTilesBuilder;
+  final double sizerThickness;
+  final ValueChanged<TileModel<T>> onFloat;
+
+  const Tiler({
+    @required this.model,
+    @required this.chromeBuilder,
+    this.customTilesBuilder,
+    this.sizerBuilder,
+    this.sizerThickness = 0,
+    this.onFloat,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedBuilder(
+      animation: model,
+      builder: (_, __) {
+        return model.root != null
+            ? Stack(
+                children: <Widget>[
+                  Positioned.fill(
+                    child: Tile<T>(
+                      model: model.root,
+                      chromeBuilder: chromeBuilder,
+                      sizerBuilder: sizerBuilder,
+                      customTilesBuilder: customTilesBuilder,
+                      sizerThickness: sizerThickness,
+                      onFloat: onFloat ?? model.floatTile,
+                    ),
+                  ),
+                  Positioned.fill(
+                    child: LayoutBuilder(
+                      builder: (context, constraint) {
+                        Offset offset = Offset.zero;
+                        List<Widget> children = [];
+                        for (final tile in model.floats) {
+                          tile
+                            ..position ??= offset
+                            ..width = max(tile.minSize.width, 300)
+                            ..height = max(tile.minSize.height, 300);
+                          final widget = Positioned(
+                            left: tile.position.dx,
+                            top: tile.position.dy,
+                            child: SizedBox(
+                              width: tile.width,
+                              height: tile.height,
+                              child: Tile<T>(
+                                model: tile,
+                                chromeBuilder: chromeBuilder,
+                                sizerBuilder: sizerBuilder,
+                                customTilesBuilder: customTilesBuilder,
+                                sizerThickness: sizerThickness,
+                                onFloat: model.floatTile,
+                              ),
+                            ),
+                          );
+                          children.add(widget);
+                          offset += Offset(20, 20);
+                        }
+                        return Stack(
+                          children: children,
+                        );
+                      },
+                    ),
+                  ),
+                ],
+              )
+            : Offstage();
+      },
+    );
+  }
+}
diff --git a/session_shells/ermine/tiler/lib/src/tiler_model.dart b/session_shells/ermine/tiler/lib/src/tiler_model.dart
new file mode 100644
index 0000000..d93e4f9
--- /dev/null
+++ b/session_shells/ermine/tiler/lib/src/tiler_model.dart
@@ -0,0 +1,268 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+
+import 'tile_model.dart';
+import 'utils.dart';
+
+/// Defines a model to hold a tree of [TileModel]s. The leaf nodes are tiles
+/// of type [TileType.content], branches can be [TileType.column] or
+/// [TileType.row]. The member [root] holds the reference to the root tile.
+class TilerModel<T> extends ChangeNotifier {
+  final TileModel<T> _root;
+  final List<TileModel<T>> _floats;
+
+  TilerModel({TileModel<T> root, List<TileModel<T>> floats})
+      : _root = root ?? TileModel<T>(type: TileType.column),
+        _floats = floats ?? <TileModel<T>>[] {
+    if (!_root.isCollection) {
+      throw ArgumentError('root of tiles should be a collection type');
+    }
+    traverse(tile: _root, callback: (tile, parent) => tile.parent = parent);
+  }
+
+  TileModel<T> get root => _root;
+
+  List<TileModel<T>> get floats => _floats;
+
+  /// Returns the tile next to [tile] in the given [direction].
+  TileModel<T> navigate(AxisDirection direction, [TileModel<T> tile]) {
+    if (tile == null) {
+      return _first(root);
+    } else if (tile.isFloating && floats.contains(tile)) {
+      if (axisDirectionIsReversed(direction)) {
+        return floats.first != tile ? floats[floats.indexOf(tile) - 1] : null;
+      } else {
+        return floats.last != tile ? floats[floats.indexOf(tile) + 1] : null;
+      }
+    }
+
+    switch (direction) {
+      case AxisDirection.left:
+        return _last(_previous(tile, TileType.column));
+      case AxisDirection.up:
+        return _last(_previous(tile, TileType.row));
+      case AxisDirection.right:
+        return _first(_next(tile, TileType.column));
+      case AxisDirection.down:
+        return _first(_next(tile, TileType.row));
+      default:
+        return null;
+    }
+  }
+
+  /// Adds a new tile with [content] next to currently focused tile in the
+  /// [direction] specified.
+  TileModel<T> add({
+    TileModel<T> nearTile,
+    T content,
+    AxisDirection direction = AxisDirection.right,
+    Size minSize = Size.zero,
+  }) {
+    nearTile ??= root;
+
+    // Create a TileModel for [content].
+    final tile = TileModel<T>(
+      type: TileType.content,
+      content: content,
+      minSize: minSize,
+    );
+
+    final type = _isHorizontal(direction) ? TileType.column : TileType.row;
+
+    void _insert(TileModel<T> nearTile, TileModel<T> tile) {
+      final parent = nearTile.parent;
+      if (parent == null) {
+        // [nearTile] is root and needs to be kept as such. Create a copy of it
+        // and move it one level down along with [tile].
+        assert(nearTile.isCollection);
+        if (nearTile.type == type) {
+          _setFlex(nearTile, tile);
+
+          nearTile.insert(
+              axisDirectionIsReversed(direction) ? 0 : nearTile.tiles.length,
+              tile);
+        } else if (nearTile.tiles.isEmpty) {
+          nearTile
+            ..insert(0, tile)
+            ..type = type;
+        } else {
+          final copy = TileModel<T>(
+            type: nearTile.type,
+          );
+          // Move child tiles from [nearTile] to [copy].
+          for (final tile in nearTile.tiles.toList()) {
+            nearTile.remove(tile);
+            copy.add(tile);
+          }
+          nearTile
+            ..add(axisDirectionIsReversed(direction) ? tile : copy)
+            ..add(axisDirectionIsReversed(direction) ? copy : tile)
+            ..type = type;
+        }
+      } else {
+        if (parent.type == type || parent.tiles.length == 1) {
+          _setFlex(parent, tile);
+          int index = parent.tiles.indexOf(nearTile);
+          parent
+            ..insert(
+                axisDirectionIsReversed(direction) ? index : index + 1, tile)
+            ..type = type;
+        } else {
+          // The parent's type is not aligned with direction. Walk up the tree
+          // to find an ancestor with matching type (or creating one, if none is
+          // found at the root).
+          _insert(parent, tile);
+        }
+      }
+    }
+
+    _insert(nearTile, tile);
+    return tile;
+  }
+
+  /// Returns a new tile after splitting the supplied [tile] in the [direction].
+  /// The [tile] is split such that new tile has supplied [flex].
+  TileModel<T> split({
+    @required TileModel<T> tile,
+    T content,
+    AxisDirection direction = AxisDirection.right,
+    double flex = 0.5,
+    Size minSize = Size.zero,
+  }) {
+    assert(tile != null && tile.isContent);
+    assert(flex > 0 && flex < 1);
+
+    final newTile = TileModel<T>(
+      parent: tile,
+      type: TileType.content,
+      content: content,
+      flex: flex,
+      minSize: minSize,
+    );
+
+    final parent = tile.parent;
+    int index = parent?.tiles?.indexOf(tile) ?? 0;
+    parent?.remove(tile);
+
+    final newParent = TileModel<T>(
+      type: _isHorizontal(direction) ? TileType.column : TileType.row,
+      flex: tile.flex,
+      tiles: axisDirectionIsReversed(direction)
+          ? [newTile, tile]
+          : [tile, newTile],
+    );
+
+    parent?.insert(index, newParent);
+    tile.flex = 1 - flex;
+
+    return newTile;
+  }
+
+  /// Removes the [tile] and it's parent if it is empty, recursively. Does not
+  /// remove the root. If root type was [TileType.content], converts it to
+  /// a collection type [TileType.column].
+  void remove(TileModel<T> tile) {
+    assert(tile != null);
+
+    void _remove(TileModel<T> tile) {
+      final parent = tile.parent;
+      // Don't remove root tile.
+      if (parent != null) {
+        parent.remove(tile);
+        // Remove empty parent.
+        if (parent.tiles.isEmpty) {
+          _remove(parent);
+        } else if (parent.tiles.length == 1 && parent.parent != null) {
+          // Move the only child to it's grandparent.
+          final child = parent.tiles.first;
+          final grandparent = parent.parent;
+          final index = grandparent.tiles.indexOf(parent);
+          parent.remove(child);
+          grandparent
+            ..remove(parent)
+            ..insert(index, child);
+        }
+      }
+    }
+
+    _remove(tile);
+  }
+
+  void floatTile(TileModel<T> tile) {
+    tile.parent.tiles.remove(tile);
+    tile.parent = null;
+    floats.add(tile);
+
+    SchedulerBinding.instance.scheduleTask(notifyListeners, Priority.idle);
+  }
+
+  /// Returns the tile next to [tile] in a column or row. If [tile] is the last
+  /// tile in the parent, it returns the next ancestor column or row. This is
+  /// usefule for finding the tile to the right or below the given [tile].
+  TileModel _next(TileModel tile, TileType type) {
+    if (tile == null || tile.parent == null) {
+      return null;
+    }
+    assert(tile.parent.tiles.contains(tile));
+
+    final tiles = tile.parent.tiles;
+    if (tile.parent.type == type && tile != tiles.last) {
+      int index = tiles.indexOf(tile);
+      return tiles[index + 1];
+    }
+    return _next(tile.parent, type);
+  }
+
+  /// Returns the tile previous to [tile] in a column or row. If [tile] is the
+  /// last tile in the parent, it returns the previous ancestor column or row.
+  /// This is useful for finding the tile to the left or above the given [tile].
+  TileModel _previous(TileModel tile, TileType type) {
+    if (tile == null || tile.parent == null) {
+      return null;
+    }
+    assert(tile.parent.tiles.contains(tile));
+
+    final tiles = tile.parent.tiles;
+    if (tile.parent.type == type && tile != tiles.first) {
+      int index = tiles.indexOf(tile);
+      return tiles[index - 1];
+    }
+    return _previous(tile.parent, type);
+  }
+
+  /// Returns the leaf tile node given a [tile] using depth first search.
+  TileModel _first(TileModel tile) {
+    if (tile == null || tile.isContent) {
+      return tile;
+    }
+    return _first(tile.tiles.first);
+  }
+
+  /// Returns the leaf tile node given a [tile] using depth last search.
+  TileModel _last(TileModel tile) {
+    if (tile == null || tile.isContent) {
+      return tile;
+    }
+    return _last(tile.tiles.last);
+  }
+
+  bool _isHorizontal(AxisDirection direction) =>
+      direction == AxisDirection.left || direction == AxisDirection.right;
+
+  // ignore:unused_element
+  bool _isVertical(AxisDirection direction) =>
+      direction == AxisDirection.up || direction == AxisDirection.down;
+
+  void _setFlex(TileModel<T> parent, TileModel<T> tile) {
+    // If all existing children have same flex, then set the [tile]'s
+    // flex to be the same, thus equally sub-dividing the parent.
+    if (!parent.isEmpty &&
+        parent.tiles.every((tile) => parent.tiles.first.flex == tile.flex)) {
+      tile.flex = parent.tiles.first.flex;
+    }
+  }
+}
diff --git a/session_shells/ermine/tiler/lib/src/utils.dart b/session_shells/ermine/tiler/lib/src/utils.dart
new file mode 100644
index 0000000..f505bc8
--- /dev/null
+++ b/session_shells/ermine/tiler/lib/src/utils.dart
@@ -0,0 +1,51 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:collection';
+
+import 'tile_model.dart';
+import 'tiler_model.dart';
+
+Iterable<TileModel<T>> flatten<T>(Iterable<TileModel<T>> tiles) {
+  return tiles
+      .map((tile) => tile.isContent ? [tile] : flatten(tile.tiles))
+      .expand((t) => t);
+}
+
+/// A co-routine to iterate over the TilerModel tree.
+Iterable<TileModel> tilerWalker(TilerModel a) sync* {
+  final nodes = ListQueue<TileModel>()..add(a.root);
+  while (nodes.isNotEmpty) {
+    nodes.addAll(nodes.first.tiles);
+    yield nodes.removeFirst();
+  }
+}
+
+/// Get the content nodes of a layout tree.
+List<TileModel> getTileContent(TilerModel a) => tilerWalker(a)
+    .where((t) => t.type == TileType.content)
+    .fold(<TileModel>[], (l, t) => l..add(t));
+
+enum Traversal { depthFirst, depthLast }
+
+/// Traverse the tree rooted at [tile] in [order] and call [callback] at each
+/// node, unless [contentOnly] was set to [true].
+void traverse<T>({
+  TileModel<T> tile,
+  Traversal order = Traversal.depthFirst,
+  bool contentOnly = false,
+  void callback(TileModel<T> tile, TileModel<T> parent),
+}) {
+  void _traverse(TileModel<T> t, TileModel<T> p) {
+    if (t.isContent || !contentOnly) {
+      callback(t, p);
+    }
+    final it = order == Traversal.depthFirst ? t.tiles : t.tiles.reversed;
+    for (final c in it) {
+      _traverse(c, t);
+    }
+  }
+
+  _traverse(tile, tile.parent);
+}
diff --git a/session_shells/ermine/tiler/lib/tiler.dart b/session_shells/ermine/tiler/lib/tiler.dart
new file mode 100644
index 0000000..c46236b
--- /dev/null
+++ b/session_shells/ermine/tiler/lib/tiler.dart
@@ -0,0 +1,10 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export 'src/sizer.dart';
+export 'src/tile.dart';
+export 'src/tile_model.dart';
+export 'src/tiler.dart';
+export 'src/tiler_model.dart';
+export 'src/utils.dart';
diff --git a/session_shells/ermine/tiler/pubspec.yaml b/session_shells/ermine/tiler/pubspec.yaml
new file mode 100644
index 0000000..94f1bb5
--- /dev/null
+++ b/session_shells/ermine/tiler/pubspec.yaml
@@ -0,0 +1,10 @@
+# Copyright 2019 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+name: tiler
+
+dependencies:
+  flutter:
+    sdk: flutter
+
diff --git a/session_shells/ermine/tiler/test/tiler_test.dart b/session_shells/ermine/tiler/test/tiler_test.dart
new file mode 100644
index 0000000..50f1b05
--- /dev/null
+++ b/session_shells/ermine/tiler/test/tiler_test.dart
@@ -0,0 +1,401 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:tiler/tiler.dart';
+
+void main() {
+  group('init', () {
+    test('should create an instance of tiler', () {
+      expect(
+          Tiler(
+            model: null,
+            chromeBuilder: null,
+            sizerBuilder: null,
+          ),
+          isNotNull);
+    });
+    test('should create an instance of TilerModel with non-null root', () {
+      final tiler = TilerModel();
+      expect(tiler, isNotNull);
+      expect(tiler.root, isNotNull);
+      expect(tiler.root.isEmpty, true);
+    });
+    test('should return empty for a content tile', () {
+      final tiler = TilerModel<String>()..add();
+      expect(tiler.root.isCollection, true);
+      expect(tiler.root.tiles.first.isContent, true);
+      expect(tiler.root.tiles.first.isEmpty, true);
+    });
+    test('should create an instance of TilerModel with content tile', () {
+      final tiler = TilerModel<String>()..add(content: 'x');
+      expect(tiler.root.isCollection, true);
+      expect(tiler.root.tiles.first.isContent, true);
+      expect(tiler.root.tiles.first.content, 'x');
+    });
+    test('should allow initializing with empty row or column', () {
+      final tiler = TilerModel(root: TileModel(type: TileType.row));
+      expect(tiler.root.type, TileType.row);
+      expect(tiler.root.tiles.isEmpty, true);
+    });
+  });
+
+  group('add tile', () {
+    test('should not allow content root', () {
+      expect(
+          () => TilerModel(
+                  root: TileModel(
+                type: TileType.content,
+                content: 'root',
+              )),
+          throwsArgumentError);
+    });
+    test('should allow specifying nearTile = null with non-null root', () {
+      final tiler = TilerModel(
+        root: TileModel(
+          type: TileType.column,
+          tiles: [
+            TileModel(
+              type: TileType.content,
+              content: 'root',
+            )
+          ],
+        ),
+      )..add(content: 'new');
+      expect(tiler.root.type, TileType.column);
+      expect(tiler.root.tiles.length, 2);
+      expect(tiler.root.tiles.first.content, 'root');
+      expect(tiler.root.tiles.last.content, 'new');
+    });
+    test('should allow specifying nearTile = null with null root', () {
+      final tiler = TilerModel<String>()..add(content: 'new');
+      expect(tiler.root.isCollection, true);
+      expect(tiler.root.tiles.first.isContent, true);
+      expect(tiler.root.tiles.first.content, 'new');
+    });
+    test('should add to the right of given tile in a column', () {
+      final tiler = TilerModel(
+        root: TileModel(
+          type: TileType.column,
+          tiles: [
+            TileModel(
+              type: TileType.content,
+              content: 'near',
+            ),
+          ],
+        ),
+      );
+      final nearTile = tiler.root.tiles.first;
+      tiler.add(
+        nearTile: nearTile,
+        direction: AxisDirection.right,
+        content: 'right',
+      );
+      expect(nearTile, tiler.root.tiles.first);
+      expect(tiler.root.tiles.first.content, 'near');
+      expect(tiler.root.tiles.last.content, 'right');
+    });
+    test('should add to the left of given tile in a column', () {
+      final tiler = TilerModel(
+        root: TileModel(type: TileType.column, tiles: [
+          TileModel(
+            type: TileType.content,
+            content: 'near',
+          ),
+        ]),
+      );
+      final nearTile = tiler.root.tiles.first;
+      tiler.add(
+        nearTile: nearTile,
+        direction: AxisDirection.left,
+        content: 'left',
+      );
+      expect(nearTile, tiler.root.tiles.last);
+      expect(tiler.root.tiles.last.content, 'near');
+      expect(tiler.root.tiles.first.content, 'left');
+    });
+    test('should add to the top of given tile in a column', () {
+      final tiler = TilerModel(
+        root: TileModel(type: TileType.column, tiles: [
+          TileModel(type: TileType.content, content: 'a'),
+          TileModel(type: TileType.content, content: 'b'),
+        ]),
+      );
+      final nearTile = tiler.root.tiles.first;
+      tiler.add(
+        nearTile: nearTile,
+        direction: AxisDirection.up,
+        content: 'up',
+      );
+
+      // Expected layout:
+      // TileType.row [
+      //   TileType.content('up'),
+      //   TileType.column [
+      //     TileType.content('a')
+      //     TileType.content('b')
+      //   ]
+      // ]
+      expect(nearTile, tiler.root.tiles.last.tiles.first);
+      expect(tiler.root.tiles.last.tiles.first.content, 'a');
+      expect(tiler.root.tiles.first.content, 'up');
+    });
+    test('up should change from column to row for only one child', () {
+      final tiler = TilerModel(
+        root: TileModel(type: TileType.column, tiles: [
+          TileModel(type: TileType.content, content: 'a'),
+        ]),
+      );
+      final nearTile = tiler.root.tiles.first;
+      tiler.add(
+        nearTile: nearTile,
+        direction: AxisDirection.up,
+        content: 'up',
+      );
+
+      // Expected layout:
+      // TileType.row [
+      //   TileType.content('up'),
+      //   TileType.content('a'),
+      // ]
+      expect(nearTile, tiler.root.tiles.last);
+      expect(tiler.root.tiles.last.content, 'a');
+      expect(tiler.root.tiles.first.content, 'up');
+    });
+    test('should add to the bottom of given tile in a column', () {
+      final tiler = TilerModel(
+        root: TileModel(type: TileType.column, tiles: [
+          TileModel(type: TileType.content, content: 'a'),
+          TileModel(type: TileType.content, content: 'b'),
+        ]),
+      );
+      final nearTile = tiler.root.tiles.first;
+      tiler.add(
+        nearTile: nearTile,
+        direction: AxisDirection.down,
+        content: 'down',
+      );
+
+      // Expected layout:
+      // TileType.row [
+      //   TileType.column [
+      //     TileType.content('a')
+      //     TileType.content('b'),
+      //   ],
+      //   TileType.content('down')
+      // ]
+      expect(nearTile, tiler.root.tiles.first.tiles.first);
+      expect(tiler.root.tiles.first.tiles.first.content, 'a');
+      expect(tiler.root.tiles.last.content, 'down');
+    });
+    test('down should change from column to row for only one child', () {
+      final tiler = TilerModel(
+        root: TileModel(type: TileType.column, tiles: [
+          TileModel(type: TileType.content, content: 'a'),
+        ]),
+      );
+      final nearTile = tiler.root.tiles.first;
+      tiler.add(
+        nearTile: nearTile,
+        direction: AxisDirection.down,
+        content: 'down',
+      );
+
+      // Expected layout:
+      // TileType.row [
+      //   TileType.content('a'),
+      //   TileType.content('down'),
+      // ]
+      expect(nearTile, tiler.root.tiles.first);
+      expect(tiler.root.tiles.first.content, 'a');
+      expect(tiler.root.tiles.last.content, 'down');
+    });
+  });
+
+  group('split tile', () {
+    test('should not allow splitting a null tile', () {
+      expect(() => TilerModel()..split(tile: null), throwsAssertionError);
+    });
+    test('should not allow splitting root or collection tile', () {
+      final tiler = TilerModel(
+        root: TileModel(
+          type: TileType.column,
+          tiles: [
+            TileModel(
+              type: TileType.content,
+              content: 'first',
+            )
+          ],
+        ),
+      );
+      expect(() => tiler.split(tile: tiler.root), throwsAssertionError);
+    });
+    test('should reparent tile if split in same direction', () {
+      final tiler = TilerModel(
+        root: TileModel(
+          type: TileType.column,
+          tiles: [
+            TileModel(
+              type: TileType.content,
+              content: 'first',
+            )
+          ],
+        ),
+      );
+      final tile = tiler.root.tiles.first;
+      tiler.split(
+        tile: tile,
+        content: 'new',
+        direction: AxisDirection.right,
+      );
+      // Expected layout:
+      // column [column [(first), (new)]]
+      expect(tiler.root.type, TileType.column);
+      expect(tiler.root.tiles.length, 1);
+      expect(tiler.root.tiles.first.type, TileType.column);
+      expect(tiler.root.tiles.first.tiles.length, 2);
+      expect(tiler.root.tiles.first.tiles.first.content, 'first');
+      expect(tiler.root.tiles.first.tiles.last.content, 'new');
+      expect(tiler.root.tiles.first.tiles.first, tile);
+    });
+    test('should reparent tile if split in cross direction(up)', () {
+      final tiler = TilerModel(
+        root: TileModel(
+          type: TileType.column,
+          tiles: [
+            TileModel(
+              type: TileType.content,
+              content: 'first',
+            )
+          ],
+        ),
+      );
+      final tile = tiler.root.tiles.first;
+      tiler.split(
+        tile: tile,
+        content: 'new',
+        direction: AxisDirection.up,
+      );
+      // Expected layout:
+      // column [row [content(new), content(first)]]
+      expect(tiler.root.type, TileType.column);
+      expect(tiler.root.tiles.length, 1);
+      expect(tiler.root.tiles.first.type, TileType.row);
+      expect(tiler.root.tiles.first.tiles.length, 2);
+      expect(tiler.root.tiles.first.tiles.first.content, 'new');
+      expect(tiler.root.tiles.first.tiles.last.content, 'first');
+      expect(tiler.root.tiles.first.tiles.last, tile);
+    });
+    test('should reparent tile if split in cross direction(down)', () {
+      final tiler = TilerModel(
+        root: TileModel(
+          type: TileType.column,
+          tiles: [
+            TileModel(
+              type: TileType.content,
+              content: 'first',
+            )
+          ],
+        ),
+      );
+      final tile = tiler.root.tiles.first;
+      tiler.split(
+        tile: tile,
+        content: 'new',
+        direction: AxisDirection.down,
+      );
+      // Expected layout:
+      // column [row [content(first), content(new)]]
+      expect(tiler.root.type, TileType.column);
+      expect(tiler.root.tiles.length, 1);
+      expect(tiler.root.tiles.first.type, TileType.row);
+      expect(tiler.root.tiles.first.tiles.length, 2);
+      expect(tiler.root.tiles.first.tiles.first.content, 'first');
+      expect(tiler.root.tiles.first.tiles.last.content, 'new');
+      expect(tiler.root.tiles.first.tiles.first, tile);
+    });
+  });
+
+  group('remove tile', () {
+    test('should not allow removing a null tile', () {
+      expect(() => TilerModel()..remove(null), throwsAssertionError);
+    });
+    test('should allow removing the root', () {
+      final tiler = TilerModel(
+        root: TileModel(
+          type: TileType.column,
+          tiles: [
+            TileModel(
+              type: TileType.content,
+              content: 'first',
+            )
+          ],
+        ),
+      );
+      tiler.remove(tiler.root);
+      expect(tiler.root.isCollection, true);
+    });
+    test('should remove empty parent, but not root', () {
+      final tiler = TilerModel(
+        root: TileModel(
+          type: TileType.row,
+          tiles: [
+            TileModel(
+              type: TileType.column,
+              tiles: [
+                TileModel(type: TileType.content),
+              ],
+            ),
+          ],
+        ),
+      );
+
+      tiler.remove(tiler.root.tiles.first.tiles.first);
+      print(tiler.root);
+      expect(tiler.root.isCollection, true);
+      expect(tiler.root.type, TileType.row);
+      expect(tiler.root.tiles.isEmpty, true);
+    });
+    test('should remove one tile', () {
+      final tiler = TilerModel(
+        root: TileModel(
+          type: TileType.row,
+          tiles: [
+            TileModel(type: TileType.content, content: 'first'),
+            TileModel(type: TileType.content, content: 'second'),
+          ],
+        ),
+      );
+
+      tiler.remove(tiler.root.tiles.last);
+      expect(tiler.root.type, TileType.row);
+      expect(tiler.root.tiles.first.content, 'first');
+    });
+    test('should remove parent if left with one tile', () {
+      final tiler = TilerModel(
+        root: TileModel(
+          type: TileType.row,
+          tiles: [
+            TileModel(type: TileType.content, content: 'first'),
+            TileModel(
+              type: TileType.row,
+              tiles: [
+                TileModel(type: TileType.content, content: 'second'),
+                TileModel(type: TileType.content, content: 'third'),
+              ],
+            ),
+          ],
+        ),
+      );
+
+      tiler.remove(tiler.root.tiles.last.tiles.last);
+      expect(tiler.root.type, TileType.row);
+      expect(tiler.root.tiles.length, 2);
+      expect(tiler.root.tiles.first.content, 'first');
+      expect(tiler.root.tiles.last.content, 'second');
+    });
+  });
+}