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