diff --git a/lib/src/model/analysis/analysis_controller.dart b/lib/src/model/analysis/analysis_controller.dart index 0341665c95..81a0a94711 100644 --- a/lib/src/model/analysis/analysis_controller.dart +++ b/lib/src/model/analysis/analysis_controller.dart @@ -265,16 +265,24 @@ class AnalysisController extends _$AnalysisController { _setPath(path); } - void showAllVariations(UciPath path) { - final parent = _root.parentAt(path); - for (final node in parent.children) { - node.isHidden = false; + void expandVariations(UciPath path) { + final node = _root.nodeAt(path); + for (final child in node.children) { + child.isHidden = false; + for (final grandChild in child.children) { + grandChild.isHidden = false; + } } state = state.copyWith(root: _root.view); } - void hideVariation(UciPath path) { - _root.hideVariationAt(path); + void collapseVariations(UciPath path) { + final node = _root.parentAt(path); + + for (final child in node.children) { + child.isHidden = true; + } + state = state.copyWith(root: _root.view); } diff --git a/lib/src/model/common/node.dart b/lib/src/model/common/node.dart index dab74db865..f60f812f13 100644 --- a/lib/src/model/common/node.dart +++ b/lib/src/model/common/node.dart @@ -234,22 +234,6 @@ abstract class Node { parentAt(path).children.removeWhere((child) => child.id == path.last); } - /// Hides the variation from the node at the given path. - void hideVariationAt(UciPath path) { - final nodes = nodesOn(path).toList(); - for (int i = nodes.length - 2; i >= 0; i--) { - final node = nodes[i + 1]; - final parent = nodes[i]; - if (node is Branch && parent.children.length > 1) { - for (final child in parent.children) { - if (child.id == node.id) { - child.isHidden = true; - } - } - } - } - } - /// Promotes the node at the given path. void promoteAt(UciPath path, {required bool toMainline}) { final nodes = nodesOn(path).toList(); @@ -484,8 +468,8 @@ class Root extends Node { position: PgnGame.startingPosition(game.headers), ); - final List<({PgnNode from, Node to})> stack = [ - (from: game.moves, to: root), + final List<({PgnNode from, Node to, int nesting})> stack = [ + (from: game.moves, to: root, nesting: 1), ]; while (stack.isNotEmpty) { @@ -503,7 +487,7 @@ class Root extends Node { final branch = Branch( sanMove: SanMove(childFrom.data.san, move), position: newPos, - isHidden: hideVariations && childIdx > 0, + isHidden: frame.nesting > 2 || hideVariations && childIdx > 0, lichessAnalysisComments: isLichessAnalysis ? comments?.toList() : null, startingComments: isLichessAnalysis @@ -516,7 +500,17 @@ class Root extends Node { ); frame.to.addChild(branch); - stack.add((from: childFrom, to: branch)); + stack.add( + ( + from: childFrom, + to: branch, + nesting: frame.from.children.length == 1 || + // mainline continuation + (childIdx == 0 && frame.nesting == 1) + ? frame.nesting + : frame.nesting + 1, + ), + ); onVisitNode?.call(root, branch, isMainline); } diff --git a/lib/src/model/common/uci.dart b/lib/src/model/common/uci.dart index 4ff3c5a1e7..cc8c8b6716 100644 --- a/lib/src/model/common/uci.dart +++ b/lib/src/model/common/uci.dart @@ -90,6 +90,8 @@ class UciPath with _$UciPath { return UciPath(path.toString()); } + factory UciPath.join(UciPath a, UciPath b) => UciPath(a.value + b.value); + /// Creates a UciPath from a list of UCI moves. /// /// Throws an [ArgumentError] if any of the moves is invalid. diff --git a/lib/src/view/analysis/tree_view.dart b/lib/src/view/analysis/tree_view.dart index 9662c5410c..af43edf35a 100644 --- a/lib/src/view/analysis/tree_view.dart +++ b/lib/src/view/analysis/tree_view.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/cupertino.dart'; @@ -43,12 +44,12 @@ class AnalysisTreeView extends ConsumerStatefulWidget { class _InlineTreeViewState extends ConsumerState { final currentMoveKey = GlobalKey(); final _debounce = Debouncer(kFastReplayDebounceDelay); - late UciPath currentPath; + late UciPath pathToCurrentMove; @override void initState() { super.initState(); - currentPath = ref.read( + pathToCurrentMove = ref.read( analysisControllerProvider(widget.pgn, widget.options).select( (value) => value.currentPath, ), @@ -87,7 +88,7 @@ class _InlineTreeViewState extends ConsumerState { // the fast replay buttons _debounce(() { setState(() { - currentPath = state.currentPath; + pathToCurrentMove = state.currentPath; }); WidgetsBinding.instance.addPostFrameCallback((_) { if (currentMoveKey.currentContext != null) { @@ -119,38 +120,6 @@ class _InlineTreeViewState extends ConsumerState { analysisPreferencesProvider.select((value) => value.showAnnotations), ); - final List moveWidgets = _buildTreeWidget( - widget.pgn, - widget.options, - parent: root, - nodes: root.children, - shouldShowAnnotations: shouldShowAnnotations, - shouldShowComments: shouldShowComments, - inMainline: true, - startMainline: true, - startSideline: false, - initialPath: UciPath.empty, - ); - - // trick to make auto-scroll work when returning to the root position - moveWidgets.insert( - 0, - currentPath.isEmpty - ? SizedBox.shrink(key: currentMoveKey) - : const SizedBox.shrink(), - ); - - if (shouldShowComments && - rootComments?.any((c) => c.text?.isNotEmpty == true) == true) { - moveWidgets.insert( - 0, - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: _Comments(rootComments!), - ), - ); - } - return CustomScrollView( slivers: [ if (kOpeningAllowedVariants.contains(widget.options.variant)) @@ -162,124 +131,698 @@ class _InlineTreeViewState extends ConsumerState { ), SliverFillRemaining( hasScrollBody: false, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 10), - child: Wrap( - spacing: kInlineMoveSpacing, - children: moveWidgets, + child: _PgnTreeView( + root: root, + rootComments: rootComments, + params: ( + shouldShowAnnotations: shouldShowAnnotations, + shouldShowComments: shouldShowComments, + currentMoveKey: currentMoveKey, + pathToCurrentMove: pathToCurrentMove, + notifier: ref.read(ctrlProvider.notifier), ), ), ), ], ); } +} + +/// A group of parameters that are passed through various parts of the tree view +/// and ultimately evaluated in the InlineMove widget. Grouped in this record to improve readability. +typedef _PgnTreeViewParams = ({ + /// Path to the currently selected move in the tree. + UciPath pathToCurrentMove, - List _buildTreeWidget( - String pgn, - AnalysisOptions options, { - required ViewNode parent, - required IList nodes, - required bool inMainline, - required bool startMainline, - required bool startSideline, - required bool shouldShowAnnotations, - required bool shouldShowComments, - required UciPath initialPath, - }) { - if (nodes.isEmpty) return []; - final List widgets = []; - - final firstChild = nodes.first; - final newPath = initialPath + firstChild.id; - final currentMove = newPath == currentPath; - - // add the first child - widgets.add( - InlineMove( - pgn, - options, - path: newPath, - parent: parent, - branch: firstChild, - isCurrentMove: currentMove, - key: currentMove ? currentMoveKey : null, - shouldShowAnnotations: shouldShowAnnotations, - shouldShowComments: shouldShowComments, - isSideline: !inMainline, - startMainline: startMainline, - startSideline: startSideline, - endSideline: !inMainline && firstChild.children.isEmpty, + /// Whether to show NAG annotations like '!' and '??'. + bool shouldShowAnnotations, + + /// Whether to show comments associated with the moves. + bool shouldShowComments, + + /// Key that will we assigned to the widget corresponding to [pathToCurrentMove]. + /// Can be used e.g. to ensure that the current move is visible on the screen. + GlobalKey currentMoveKey, + + /// Callbacks for when the user interacts with the tree view, e.g. selecting a different move. + AnalysisController notifier, +}); + +/// Sidelines are usually rendered on a new line and indented. +/// However sidelines are rendered inline (in parantheses) if the side line has no branching and is less than 6 moves deep. +bool _displaySideLineAsInline(ViewBranch node, [int depth = 0]) { + if (depth == 6) return false; + if (node.children.isEmpty) return true; + if (node.children.length > 1) return false; + return _displaySideLineAsInline(node.children.first, depth + 1); +} + +/// Returns whether this node has a sideline that should not be displayed inline. +bool _hasNonInlineSideLine(ViewNode node) => + node.children.length > 2 || + (node.children.length == 2 && !_displaySideLineAsInline(node.children[1])); + +/// Splits the mainline into parts, where each part is a sequence of moves that are displayed on the same line. +/// A part ends when a mainline node has a sideline that should not be displayed inline. +Iterable> _mainlineParts(ViewRoot root) => + [root, ...root.mainline] + .splitAfter(_hasNonInlineSideLine) + .takeWhile((nodes) => nodes.firstOrNull?.children.isNotEmpty == true); + +/// Displays a tree-like view of a PGN game's moves. +/// +/// For example, the PGN 1. e4 e5 (1... d5) (1... Nc6) 2. Nf3 Nc6 (2... a5) 3. Bc4 * will be displayed as: +/// 1. e4 e5 // [_MainLinePart] +/// |- 1... d5 // [_SideLinePart] +/// |- 1... Nc6 // [_SideLinePart] +/// 2. Nf3 Nc6 (2... a5) 3. Bc4 // [_MainLinePart], with inline sideline +/// Short sidelines without any branching are displayed inline with their parent line. +/// Longer sidelines are displayed on a new line and indented. +/// The mainline is split into parts whenever a move has a non-inline sideline, this corresponds to the [_MainLinePart] widget. +/// Similarly, a [_SideLinePart] contains the moves sequence of a sideline where each node has only one child. +class _PgnTreeView extends StatefulWidget { + const _PgnTreeView({ + required this.root, + required this.rootComments, + required this.params, + }); + + /// Root of the PGN tree + final ViewRoot root; + + /// Comments associated with the root node + final IList? rootComments; + + final _PgnTreeViewParams params; + + @override + State<_PgnTreeView> createState() => _PgnTreeViewState(); +} + +typedef _Subtree = ({ + _MainLinePart mainLinePart, + + /// This is nullable since the very last mainline part might not have any sidelines. + _IndentedSideLines? sidelines, + bool containsCurrentMove, +}); + +class _PgnTreeViewState extends State<_PgnTreeView> { + /// Caches the result of [_mainlineParts], it only needs to be recalculated when the root changes, + /// but not when `params.pathToCurrentMove` changes. + List> mainlineParts = []; + + /// Caches the top-level subtrees obtained from the last `build()` method, where each subtree is a [_MainLinePart] and its sidelines. + /// Building the whole tree is expensive, so we cache the subtrees that did not change when the current move changes, + /// the framework will then skip the `build()` of each subtree since the widget reference is the same. + List<_Subtree> subtrees = []; + + UciPath _mainlinePartOfCurrentPath() { + var path = UciPath.empty; + for (final node in widget.root.mainline) { + if (!widget.params.pathToCurrentMove.contains(path + node.id)) { + break; + } + path = path + node.id; + } + return path; + } + + void _rebuildChangedSubtrees({required bool fullRebuild}) { + var path = UciPath.empty; + subtrees = mainlineParts.mapIndexed( + (i, mainlineNodes) { + final mainlineInitialPath = path; + + final sidelineInitialPath = UciPath.join( + path, + UciPath.fromIds( + mainlineNodes + .take(mainlineNodes.length - 1) + .map((n) => n.children.first.id), + ), + ); + + path = sidelineInitialPath; + if (mainlineNodes.last.children.isNotEmpty) { + path = path + mainlineNodes.last.children.first.id; + } + + final mainlinePartOfCurrentPath = _mainlinePartOfCurrentPath(); + final containsCurrentMove = + mainlinePartOfCurrentPath.size > mainlineInitialPath.size && + mainlinePartOfCurrentPath.size <= path.size; + + if (fullRebuild || + subtrees[i].containsCurrentMove || + containsCurrentMove) { + // Skip the first node which is the continuation of the mainline + final sidelineNodes = mainlineNodes.last.children.skip(1); + return ( + mainLinePart: _MainLinePart( + params: widget.params, + initialPath: mainlineInitialPath, + nodes: mainlineNodes, + ), + sidelines: sidelineNodes.isNotEmpty + ? _IndentedSideLines( + sidelineNodes, + parent: mainlineNodes.last, + params: widget.params, + initialPath: sidelineInitialPath, + nesting: 1, + ) + : null, + containsCurrentMove: containsCurrentMove, + ); + } else { + // Avoid expensive rebuilds ([State.build]) of the entire PGN tree by caching parts of the tree that did not change across a path change + return subtrees[i]; + } + }, + ).toList(); + } + + void _updateLines({required bool fullRebuild}) { + setState(() { + if (fullRebuild) { + mainlineParts = _mainlineParts(widget.root).toList(); + } + + _rebuildChangedSubtrees(fullRebuild: fullRebuild); + }); + } + + @override + void initState() { + super.initState(); + _updateLines(fullRebuild: true); + } + + @override + void didUpdateWidget(covariant _PgnTreeView oldWidget) { + super.didUpdateWidget(oldWidget); + _updateLines(fullRebuild: oldWidget.root != widget.root); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // trick to make auto-scroll work when returning to the root position + if (widget.params.pathToCurrentMove.isEmpty) + SizedBox.shrink(key: widget.params.currentMoveKey), + + if (widget.params.shouldShowComments && + widget.rootComments?.any((c) => c.text?.isNotEmpty == true) == + true) + Text.rich( + TextSpan( + children: + _comments(widget.rootComments!, textStyle: _baseTextStyle), + ), + ), + ...subtrees + .map( + (part) => [ + part.mainLinePart, + if (part.sidelines != null) part.sidelines!, + ], + ) + .flattened, + ], ), ); + } +} - // add the sidelines if present - for (var i = 1; i < nodes.length; i++) { - final node = nodes[i]; - if (node.isHidden) continue; - // start new sideline from mainline on a new line - if (inMainline) { - widgets.add( - SizedBox( - width: double.infinity, - child: Wrap( - spacing: kInlineMoveSpacing, - children: _buildTreeWidget( - pgn, - options, - parent: parent, - nodes: [nodes[i]].lockUnsafe, - shouldShowAnnotations: shouldShowAnnotations, - shouldShowComments: shouldShowComments, - inMainline: false, - startMainline: false, - startSideline: true, - initialPath: initialPath, - ), +List _buildInlineSideLine({ + required ViewBranch firstNode, + required ViewNode parent, + required UciPath initialPath, + required TextStyle textStyle, + required bool followsComment, + required _PgnTreeViewParams params, +}) { + textStyle = textStyle.copyWith( + fontSize: textStyle.fontSize != null ? textStyle.fontSize! - 2.0 : null, + ); + + final sidelineNodes = [firstNode, ...firstNode.mainline]; + + var path = initialPath; + return [ + if (followsComment) const WidgetSpan(child: SizedBox(width: 4.0)), + ...sidelineNodes.mapIndexedAndLast( + (i, node, last) { + final pathToNode = path; + path = path + node.id; + + return [ + if (i == 0) ...[ + if (followsComment) const WidgetSpan(child: SizedBox(width: 4.0)), + TextSpan( + text: '(', + style: textStyle, + ), + ], + ..._moveWithComment( + node, + lineInfo: ( + type: _LineType.inlineSideline, + startLine: i == 0 || sidelineNodes[i - 1].hasTextComment ), + pathToNode: pathToNode, + textStyle: textStyle, + params: params, ), - ); - } else { - widgets.addAll( - _buildTreeWidget( - pgn, - options, - parent: parent, - nodes: [nodes[i]].lockUnsafe, - shouldShowAnnotations: shouldShowAnnotations, - shouldShowComments: shouldShowComments, - inMainline: false, - startMainline: false, - startSideline: true, - initialPath: initialPath, + if (last) + TextSpan( + text: ')', + style: textStyle, + ), + ]; + }, + ).flattened, + const WidgetSpan(child: SizedBox(width: 4.0)), + ]; +} + +const _baseTextStyle = TextStyle( + fontSize: 16.0, + height: 1.5, +); + +/// The different types of lines (move sequences) that are displayed in the tree view. +enum _LineType { + /// (A part of) the game's main line. + mainline, + + /// A sideline branching off the main line or a parent sideline. + /// Each sideline is rendered on a new line and indented. + sideline, + + /// A short sideline without any branching, displayed in parantheses inline with it's parent line. + inlineSideline, +} + +/// Metadata about a move's role in the tree view. +typedef _LineInfo = ({_LineType type, bool startLine}); + +List _moveWithComment( + ViewBranch branch, { + required TextStyle textStyle, + required _LineInfo lineInfo, + required UciPath pathToNode, + required _PgnTreeViewParams params, + + /// Key that will be assigned to the move text. We use this to track the position of the first move of + /// a sideline, see [_SideLinePart.firstMoveKey] + GlobalKey? moveKey, +}) { + return [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: InlineMove( + branch: branch, + lineInfo: lineInfo, + path: pathToNode + branch.id, + key: moveKey, + textStyle: textStyle, + params: params, + ), + ), + if (params.shouldShowComments && branch.hasTextComment) + ..._comments(branch.comments!, textStyle: textStyle), + ]; +} + +/// A part of a sideline where each node only has one child +/// (or two children where the second child is rendered as an inline sideline +class _SideLinePart extends ConsumerWidget { + _SideLinePart( + this.nodes, { + required this.initialPath, + required this.firstMoveKey, + required this.params, + }) : assert(nodes.isNotEmpty); + + final List nodes; + + final UciPath initialPath; + + /// The key that will be assigned to the first move in this sideline. + /// This is needed so that the indent guidelines can be drawn correctly. + final GlobalKey firstMoveKey; + + final _PgnTreeViewParams params; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textStyle = _baseTextStyle.copyWith( + color: _textColor(context, 0.6), + fontSize: _baseTextStyle.fontSize! - 1.0, + ); + + var path = initialPath + nodes.first.id; + final moves = [ + ..._moveWithComment( + nodes.first, + lineInfo: ( + type: _LineType.sideline, + startLine: true, + ), + moveKey: firstMoveKey, + pathToNode: initialPath, + textStyle: textStyle, + params: params, + ), + ...nodes.take(nodes.length - 1).map( + (node) { + final moves = [ + ..._moveWithComment( + node.children.first, + lineInfo: ( + type: _LineType.sideline, + startLine: node.hasTextComment, + ), + pathToNode: path, + textStyle: textStyle, + params: params, + ), + if (node.children.length == 2 && + _displaySideLineAsInline(node.children[1])) + ..._buildInlineSideLine( + followsComment: node.children.first.hasTextComment, + firstNode: node.children[1], + parent: node, + initialPath: path, + textStyle: textStyle, + params: params, + ), + ]; + path = path + node.children.first.id; + return moves; + }, + ).flattened, + ]; + + return Text.rich( + TextSpan( + children: moves, + ), + ); + } +} + +/// A part of the mainline that will be rendered on the same line. See [_mainlineParts]. +class _MainLinePart extends ConsumerWidget { + const _MainLinePart({ + required this.initialPath, + required this.params, + required this.nodes, + }); + + final UciPath initialPath; + + final List nodes; + + final _PgnTreeViewParams params; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textStyle = _baseTextStyle.copyWith( + color: _textColor(context, 0.9), + ); + + var path = initialPath; + return Text.rich( + TextSpan( + children: nodes + .takeWhile((node) => node.children.isNotEmpty) + .mapIndexed( + (i, node) { + final mainlineNode = node.children.first; + final moves = [ + _moveWithComment( + mainlineNode, + lineInfo: ( + type: _LineType.mainline, + startLine: i == 0 || (node as ViewBranch).hasTextComment, + ), + pathToNode: path, + textStyle: textStyle, + params: params, + ), + if (node.children.length == 2 && + _displaySideLineAsInline(node.children[1])) ...[ + _buildInlineSideLine( + followsComment: mainlineNode.hasTextComment, + firstNode: node.children[1], + parent: node, + initialPath: path, + textStyle: textStyle, + params: params, + ), + ], + ]; + path = path + mainlineNode.id; + return moves.flattened; + }, + ) + .flattened + .toList(), + ), + ); + } +} + +/// A sideline where the moves are rendered on the same line (see [_SideLinePart]) until further branching is encountered, +/// at which point the children sidelines are rendered on new lines and indented (see [_IndentedSideLines]). +class _SideLine extends StatelessWidget { + const _SideLine({ + required this.firstNode, + required this.parent, + required this.firstMoveKey, + required this.initialPath, + required this.params, + required this.nesting, + }); + + final ViewBranch firstNode; + final ViewNode parent; + final GlobalKey firstMoveKey; + final UciPath initialPath; + final _PgnTreeViewParams params; + final int nesting; + + @override + Widget build(BuildContext context) { + final sidelineNodes = [firstNode]; + while (sidelineNodes.last.children.isNotEmpty && + !_hasNonInlineSideLine(sidelineNodes.last)) { + sidelineNodes.add(sidelineNodes.last.children.first); + } + + final children = sidelineNodes.last.children; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SideLinePart( + sidelineNodes.toList(), + firstMoveKey: firstMoveKey, + initialPath: initialPath, + params: params, + ), + if (children.isNotEmpty) + _IndentedSideLines( + children, + parent: sidelineNodes.last, + initialPath: UciPath.join( + initialPath, + UciPath.fromIds(sidelineNodes.map((node) => node.id)), + ), + params: params, + nesting: nesting + 1, ), - ); + ], + ); + } +} + +class _IndentPainter extends CustomPainter { + const _IndentPainter({ + required this.sideLineStartPositions, + required this.color, + required this.padding, + }); + + final List sideLineStartPositions; + + final Color color; + + final double padding; + + @override + void paint(Canvas canvas, Size size) { + if (sideLineStartPositions.isNotEmpty) { + final paint = Paint() + ..strokeWidth = 1.5 + ..color = color + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + + final origin = Offset(-padding, 0); + + final path = Path()..moveTo(origin.dx, origin.dy); + path.lineTo(origin.dx, sideLineStartPositions.last.dy); + for (final position in sideLineStartPositions) { + path.moveTo(origin.dx, position.dy); + path.lineTo(origin.dx + padding / 2, position.dy); } + canvas.drawPath(path, paint); } + } - // add the children of the first child - widgets.addAll( - _buildTreeWidget( - pgn, - options, - parent: firstChild, - nodes: firstChild.children, - shouldShowAnnotations: shouldShowAnnotations, - shouldShowComments: shouldShowComments, - inMainline: inMainline, - startMainline: false, - startSideline: false, - initialPath: newPath, - ), + @override + bool shouldRepaint(_IndentPainter oldDelegate) { + return oldDelegate.sideLineStartPositions != sideLineStartPositions || + oldDelegate.color != color; + } +} + +/// Displays one ore more sidelines indented on their own line and adds indent guides. +/// If there are hidden lines, a button is displayed to expand them. +class _IndentedSideLines extends StatefulWidget { + const _IndentedSideLines( + this.sideLines, { + required this.parent, + required this.initialPath, + required this.params, + required this.nesting, + }); + + final Iterable sideLines; + + final ViewNode parent; + + final UciPath initialPath; + + final _PgnTreeViewParams params; + + final int nesting; + + @override + State<_IndentedSideLines> createState() => _IndentedSideLinesState(); +} + +class _IndentedSideLinesState extends State<_IndentedSideLines> { + late List _keys; + + List _sideLineStartPositions = []; + + final GlobalKey _columnKey = GlobalKey(); + + void _redrawIndents() { + _keys = List.generate( + _visibleSideLines().length + (_hasHiddenLines() ? 1 : 0), + (_) => GlobalKey(), ); + WidgetsBinding.instance.addPostFrameCallback((_) { + final RenderBox? columnBox = + _columnKey.currentContext?.findRenderObject() as RenderBox?; + final Offset rowOffset = + columnBox?.localToGlobal(Offset.zero) ?? Offset.zero; + + final positions = _keys.map((key) { + final context = key.currentContext; + final renderBox = context?.findRenderObject() as RenderBox?; + final height = renderBox?.size.height ?? 0; + final offset = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + return Offset(offset.dx, offset.dy + height / 2) - rowOffset; + }).toList(); - return widgets; + setState(() { + _sideLineStartPositions = positions; + }); + }); + } + + bool _hasHiddenLines() => widget.sideLines.any((node) => node.isHidden); + + Iterable _visibleSideLines() => + widget.sideLines.whereNot((node) => node.isHidden); + + @override + void initState() { + super.initState(); + _redrawIndents(); + } + + @override + void didUpdateWidget(covariant _IndentedSideLines oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.sideLines != widget.sideLines) { + _redrawIndents(); + } + } + + @override + Widget build(BuildContext context) { + final sideLineWidgets = _visibleSideLines() + .mapIndexed( + (i, firstSidelineNode) => _SideLine( + firstNode: firstSidelineNode, + parent: widget.parent, + firstMoveKey: _keys[i], + initialPath: widget.initialPath, + params: widget.params, + nesting: widget.nesting, + ), + ) + .toList(); + + final padding = widget.nesting < 6 ? 12.0 : 0.0; + + return Padding( + padding: EdgeInsets.only(left: padding), + child: CustomPaint( + painter: _IndentPainter( + sideLineStartPositions: _sideLineStartPositions, + color: _textColor(context, 0.6)!, + padding: padding, + ), + child: Column( + key: _columnKey, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...sideLineWidgets, + if (_hasHiddenLines()) + GestureDetector( + child: Icon( + Icons.add_box, + color: _textColor(context, 0.6), + key: _keys.last, + size: _baseTextStyle.fontSize! + 5, + ), + onTap: () { + widget.params.notifier.expandVariations(widget.initialPath); + }, + ), + ], + ), + ), + ); } } Color? _textColor( BuildContext context, double opacity, { - bool isLichessGameAnalysis = true, int? nag, }) { final defaultColor = Theme.of(context).platform == TargetPlatform.android @@ -294,200 +837,143 @@ Color? _textColor( } class InlineMove extends ConsumerWidget { - const InlineMove( - this.pgn, - this.options, { - required this.path, - required this.parent, + const InlineMove({ required this.branch, - required this.shouldShowAnnotations, - required this.shouldShowComments, - required this.isCurrentMove, - required this.isSideline, + required this.path, + required this.textStyle, + required this.lineInfo, super.key, - this.startMainline = false, - this.startSideline = false, - this.endSideline = false, + required this.params, }); - final String pgn; - final AnalysisOptions options; - final UciPath path; - final ViewNode parent; final ViewBranch branch; - final bool shouldShowAnnotations; - final bool shouldShowComments; - final bool isCurrentMove; - final bool isSideline; - final bool startMainline; - final bool startSideline; - final bool endSideline; + final UciPath path; + + final TextStyle textStyle; + + final _LineInfo lineInfo; + + final _PgnTreeViewParams params; static const borderRadius = BorderRadius.all(Radius.circular(4.0)); - static const baseTextStyle = TextStyle( - fontSize: 16.0, - height: 1.5, - ); + + bool get isCurrentMove => params.pathToCurrentMove == path; @override Widget build(BuildContext context, WidgetRef ref) { - final ctrlProvider = analysisControllerProvider(pgn, options); - final move = branch.sanMove; - final ply = branch.position.ply; - final pieceNotation = ref.watch(pieceNotationProvider).maybeWhen( data: (value) => value, orElse: () => defaultAccountPreferences.pieceNotation, ); - final fontFamily = + final moveFontFamily = pieceNotation == PieceNotation.symbol ? 'ChessFont' : null; - final textStyle = isSideline - ? TextStyle( - fontFamily: fontFamily, - color: _textColor(context, 0.6), - ) - : baseTextStyle.copyWith( - fontFamily: fontFamily, - color: _textColor(context, 0.9), - fontWeight: FontWeight.w600, - ); + final moveTextStyle = textStyle.copyWith( + fontFamily: moveFontFamily, + fontWeight: isCurrentMove + ? FontWeight.bold + : lineInfo.type == _LineType.inlineSideline + ? FontWeight.normal + : FontWeight.w600, + ); - final indexTextStyle = baseTextStyle.copyWith( + final indexTextStyle = textStyle.copyWith( color: _textColor(context, 0.6), ); - final indexWidget = ply.isOdd - ? Text( - '${(ply / 2).ceil()}.', + final indexText = branch.position.ply.isOdd + ? TextSpan( + text: '${(branch.position.ply / 2).ceil()}. ', style: indexTextStyle, ) - : ((startMainline || startSideline) - ? Text( - '${(ply / 2).ceil()}...', + : (lineInfo.startLine + ? TextSpan( + text: '${(branch.position.ply / 2).ceil()}… ', style: indexTextStyle, ) : null); - final moveWithNag = move.san + - (branch.nags != null && shouldShowAnnotations + final moveWithNag = branch.sanMove.san + + (branch.nags != null && params.shouldShowAnnotations ? moveAnnotationChar(branch.nags!) : ''); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (startSideline) - Padding( - padding: const EdgeInsets.only(right: 4.0), - child: Text('(', style: textStyle), - ), - if (shouldShowComments && branch.hasStartingTextComment) - Flexible( - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: - _Comments(branch.startingComments!, isSideline: isSideline), - ), + final nag = params.shouldShowAnnotations ? branch.nags?.firstOrNull : null; + + final ply = branch.position.ply; + return AdaptiveInkWell( + key: isCurrentMove ? params.currentMoveKey : null, + borderRadius: borderRadius, + onTap: () => params.notifier.userJump(path), + onLongPress: () { + showAdaptiveBottomSheet( + context: context, + isDismissible: true, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => _MoveContextMenu( + notifier: params.notifier, + title: ply.isOdd + ? '${(ply / 2).ceil()}. $moveWithNag' + : '${(ply / 2).ceil()}... $moveWithNag', + path: path, + branch: branch, + isSideline: lineInfo.type != _LineType.mainline, ), - if (indexWidget != null) indexWidget, - if (indexWidget != null) const SizedBox(width: 1), - AdaptiveInkWell( - borderRadius: borderRadius, - onTap: () => ref.read(ctrlProvider.notifier).userJump(path), - onLongPress: () { - showAdaptiveBottomSheet( - context: context, - isDismissible: true, - isScrollControlled: true, - showDragHandle: true, - builder: (context) => _MoveContextMenu( - pgn, - options, - title: ply.isOdd - ? '${(ply / 2).ceil()}. $moveWithNag' - : '${(ply / 2).ceil()}... $moveWithNag', - path: path, - parent: parent, - branch: branch, - isSideline: isSideline, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0), + decoration: isCurrentMove + ? BoxDecoration( + color: Theme.of(context).platform == TargetPlatform.iOS + ? CupertinoColors.systemGrey3.resolveFrom(context) + : Theme.of(context).focusColor, + shape: BoxShape.rectangle, + borderRadius: borderRadius, + ) + : null, + child: Text.rich( + TextSpan( + children: [ + if (indexText != null) ...[ + indexText, + ], + TextSpan( + text: moveWithNag, + style: moveTextStyle.copyWith( + color: _textColor( + context, + isCurrentMove ? 1 : 0.9, + nag: nag, + ), + ), ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0), - decoration: isCurrentMove - ? BoxDecoration( - color: Theme.of(context).platform == TargetPlatform.iOS - ? CupertinoColors.systemGrey3.resolveFrom(context) - : Theme.of(context).focusColor, - shape: BoxShape.rectangle, - borderRadius: borderRadius, - ) - : null, - child: Text( - moveWithNag, - style: isCurrentMove - ? textStyle.copyWith( - fontWeight: FontWeight.bold, - color: _textColor( - context, - 1, - isLichessGameAnalysis: options.isLichessGameAnalysis, - nag: shouldShowAnnotations - ? branch.nags?.firstOrNull - : null, - ), - ) - : textStyle.copyWith( - color: _textColor( - context, - 0.9, - isLichessGameAnalysis: options.isLichessGameAnalysis, - nag: shouldShowAnnotations - ? branch.nags?.firstOrNull - : null, - ), - ), - ), + ], ), ), - if (shouldShowComments && branch.hasTextComment) - Flexible( - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: _Comments(branch.comments!, isSideline: isSideline), - ), - ), - if (endSideline) Text(')', style: textStyle), - ], + ), ); } } class _MoveContextMenu extends ConsumerWidget { - const _MoveContextMenu( - this.pgn, - this.options, { + const _MoveContextMenu({ required this.title, required this.path, - required this.parent, required this.branch, required this.isSideline, + required this.notifier, }); final String title; - final String pgn; - final AnalysisOptions options; final UciPath path; - final ViewNode parent; final ViewBranch branch; final bool isSideline; + final AnalysisController notifier; @override Widget build(BuildContext context, WidgetRef ref) { - final ctrlProvider = analysisControllerProvider(pgn, options); - return BottomSheetScrollableContainer( children: [ Padding( @@ -563,98 +1049,48 @@ class _MoveContextMenu extends ConsumerWidget { ), ), const PlatformDivider(indent: 0), - if (parent.children.any((c) => c.isHidden)) - BottomSheetContextMenuAction( - icon: Icons.subtitles, - child: Text(context.l10n.mobileShowVariations), - onPressed: () { - ref.read(ctrlProvider.notifier).showAllVariations(path); - }, - ), - if (isSideline) + if (isSideline) ...[ BottomSheetContextMenuAction( icon: Icons.subtitles_off, - child: Text(context.l10n.mobileHideVariation), - onPressed: () { - ref.read(ctrlProvider.notifier).hideVariation(path); - }, + child: Text(context.l10n.collapseVariations), + onPressed: () => notifier.collapseVariations(path), ), - if (isSideline) BottomSheetContextMenuAction( icon: Icons.expand_less, child: Text(context.l10n.promoteVariation), - onPressed: () { - ref.read(ctrlProvider.notifier).promoteVariation(path, false); - }, + onPressed: () => notifier.promoteVariation(path, false), ), - if (isSideline) BottomSheetContextMenuAction( icon: Icons.check, child: Text(context.l10n.makeMainLine), - onPressed: () { - ref.read(ctrlProvider.notifier).promoteVariation(path, true); - }, + onPressed: () => notifier.promoteVariation(path, true), ), + ], BottomSheetContextMenuAction( icon: Icons.delete, child: Text(context.l10n.deleteFromHere), - onPressed: () { - ref.read(ctrlProvider.notifier).deleteFromHere(path); - }, + onPressed: () => notifier.deleteFromHere(path), ), ], ); } } -class _Comments extends StatelessWidget { - _Comments(this.comments, {this.isSideline = false}) - : assert(comments.any((c) => c.text?.isNotEmpty == true)); - - final Iterable comments; - final bool isSideline; - - @override - Widget build(BuildContext context) { - return AdaptiveInkWell( - onTap: () { - showAdaptiveBottomSheet( - context: context, - isDismissible: true, - showDragHandle: true, - isScrollControlled: true, - builder: (context) => BottomSheetScrollableContainer( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, +List _comments( + IList comments, { + required TextStyle textStyle, +}) => + comments + .map( + (comment) => TextSpan( + text: comment.text, + style: textStyle.copyWith( + fontSize: textStyle.fontSize! - 2.0, + fontStyle: FontStyle.italic, ), - children: comments.map( - (comment) { - if (comment.text == null || comment.text!.isEmpty) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: Text(comment.text!.replaceAll('\n', ' ')), - ); - }, - ).toList(), ), - ); - }, - child: Text( - comments.map((c) => c.text ?? '').join(' ').replaceAll('\n', ' '), - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontStyle: FontStyle.italic, - color: - isSideline ? _textColor(context, 0.6) : _textColor(context, 0.7), - ), - ), - ); - } -} + ) + .toList(); class _OpeningHeaderDelegate extends SliverPersistentHeaderDelegate { const _OpeningHeaderDelegate( diff --git a/test/view/analysis/analysis_screen_test.dart b/test/view/analysis/analysis_screen_test.dart index 4c68c8476a..6d4fde1c06 100644 --- a/test/view/analysis/analysis_screen_test.dart +++ b/test/view/analysis/analysis_screen_test.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:chessground/chessground.dart'; import 'package:dartchess/dartchess.dart'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; @@ -47,10 +47,17 @@ void main() { expect(find.byType(Chessboard), findsOneWidget); expect(find.byType(PieceWidget), findsNWidgets(25)); - final currentMove = find.widgetWithText(InlineMove, 'Qe1#'); + final currentMove = find.textContaining('Qe1#'); expect(currentMove, findsOneWidget); expect( - tester.widgetList(currentMove).any((e) => e.isCurrentMove), + tester + .widget( + find.ancestor( + of: currentMove, + matching: find.byType(InlineMove), + ), + ) + .isCurrentMove, isTrue, ); }); @@ -92,13 +99,160 @@ void main() { await tester.tap(find.byKey(const Key('goto-previous'))); await tester.pumpAndSettle(); - final currentMove = find.widgetWithText(InlineMove, 'Kc1'); + final currentMove = find.textContaining('Kc1'); expect(currentMove, findsOneWidget); expect( - tester.widgetList(currentMove).any((e) => e.isCurrentMove), + tester + .widget( + find.ancestor( + of: currentMove, + matching: find.byType(InlineMove), + ), + ) + .isCurrentMove, isTrue, ); }); + + group('Analysis Tree View', () { + Future buildTree( + WidgetTester tester, + String pgn, + ) async { + final app = await makeTestProviderScopeApp( + tester, + home: AnalysisScreen( + pgnOrId: pgn, + options: const AnalysisOptions( + isLocalEvaluationAllowed: false, + variant: Variant.standard, + orientation: Side.white, + opening: opening, + id: standaloneAnalysisId, + ), + ), + ); + + await tester.pumpWidget(app); + } + + Text parentText(WidgetTester tester, String move) { + return tester.widget( + find.ancestor( + of: find.text(move), + matching: find.byType(Text), + ), + ); + } + + void expectSameLine(WidgetTester tester, Iterable moves) { + final line = parentText(tester, moves.first); + + for (final move in moves.skip(1)) { + final moveText = find.text(move); + expect(moveText, findsOneWidget); + expect( + parentText(tester, move), + line, + ); + } + } + + void expectDifferentLines( + WidgetTester tester, + List moves, + ) { + for (int i = 0; i < moves.length; i++) { + for (int j = i + 1; j < moves.length; j++) { + expect( + parentText(tester, moves[i]), + isNot(parentText(tester, moves[j])), + ); + } + } + } + + testWidgets('displays short sideline as inline', (tester) async { + await buildTree(tester, '1. e4 e5 (1... d5 2. exd5) 2. Nf3 *'); + + final mainline = find.ancestor( + of: find.text('1. e4'), + matching: find.byType(Text), + ); + expect(mainline, findsOneWidget); + + expectSameLine(tester, ['1. e4', 'e5', '1… d5', '2. exd5', '2. Nf3']); + }); + + testWidgets('displays long sideline on its own line', (tester) async { + await buildTree( + tester, + '1. e4 e5 (1... d5 2. exd5 Qxd5 3. Nc3 Qd8 4. d4 Nf6) 2. Nc3 *', + ); + + expectSameLine(tester, ['1. e4', 'e5']); + expectSameLine( + tester, + ['1… d5', '2. exd5', 'Qxd5', '3. Nc3', 'Qd8', '4. d4', 'Nf6'], + ); + expectSameLine(tester, ['2. Nc3']); + + expectDifferentLines(tester, ['1. e4', '1… d5', '2. Nc3']); + }); + + testWidgets('displays sideline with branching on its own line', + (tester) async { + await buildTree(tester, '1. e4 e5 (1... d5 2. exd5 (2. Nc3)) *'); + + expectSameLine(tester, ['1. e4', 'e5']); + + // 2nd branch is rendered inline again + expectSameLine(tester, ['1… d5', '2. exd5', '2. Nc3']); + + expectDifferentLines(tester, ['1. e4', '1… d5']); + }); + + testWidgets('multiple sidelines', (tester) async { + await buildTree( + tester, + '1. e4 e5 (1... d5 2. exd5) (1... Nf6 2. e5) 2. Nf3 Nc6 (2... a5) *', + ); + + expectSameLine(tester, ['1. e4', 'e5']); + expectSameLine(tester, ['1… d5', '2. exd5']); + expectSameLine(tester, ['1… Nf6', '2. e5']); + expectSameLine(tester, ['2. Nf3', 'Nc6', '2… a5']); + + expectDifferentLines(tester, ['1. e4', '1… d5', '1… Nf6', '2. Nf3']); + }); + + testWidgets('collapses lines with nesting > 2', (tester) async { + await buildTree( + tester, + '1. e4 e5 (1... d5 2. Nc3 (2. h4 h5 (2... Nc6 3. d3) (2... Qd7))) *', + ); + + expectSameLine(tester, ['1. e4', 'e5']); + expectSameLine(tester, ['1… d5']); + expectSameLine(tester, ['2. Nc3']); + expectSameLine(tester, ['2. h4']); + + expect(find.text('2… h5'), findsNothing); + expect(find.text('2… Nc6'), findsNothing); + expect(find.text('3. d3'), findsNothing); + expect(find.text('2… Qd7'), findsNothing); + + // sidelines with nesting > 2 are collapsed -> expand them + expect(find.byIcon(Icons.add_box), findsOneWidget); + + await tester.tap(find.byIcon(Icons.add_box)); + await tester.pumpAndSettle(); + + expectSameLine(tester, ['2… h5']); + expectSameLine(tester, ['2… Nc6', '3. d3']); + expectSameLine(tester, ['2… Qd7']); + }); + }); }); }