Compare commits

...
Author SHA1 Message Date
Levi 539ed2bba7 fix(ui): keep narrow file trees readable and scrollable 2026-11-03 22:18:32 +08:00
5 changed files with 474 additions and 81 deletions

No files matched your search

@@ -0,0 +1,66 @@
import 'dart:math' as math;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../theme/app_design_tokens.dart';
/// Scrolls the header and virtualized tree together without freezing columns.
class FileTreeHorizontalViewport extends StatelessWidget {
const FileTreeHorizontalViewport({
super.key,
required this.keyPrefix,
required this.controller,
required this.contentWidth,
required this.child,
});
final String keyPrefix;
final ScrollController controller;
final double contentWidth;
final Widget child;
static double? widthOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<_FileTreeHorizontalScope>()?.width;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = math.max(constraints.maxWidth, contentWidth);
final canScroll = width > constraints.maxWidth + 0.5;
return _FileTreeHorizontalScope(
width: constraints.maxWidth,
child: Scrollbar(
key: ValueKey('$keyPrefix-horizontal-scrollbar'),
controller: controller,
thumbVisibility: canScroll,
trackVisibility: canScroll,
interactive: true,
thickness: 6,
scrollbarOrientation: ScrollbarOrientation.bottom,
notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal,
child: Padding(
padding: EdgeInsets.only(bottom: canScroll ? AppDesignTokens.space12 : 0),
child: SingleChildScrollView(
key: ValueKey('$keyPrefix-horizontal-scroll'),
controller: controller,
scrollDirection: Axis.horizontal,
child: SizedBox(width: width, child: child),
),
),
),
);
},
);
}
}
class _FileTreeHorizontalScope extends InheritedWidget {
const _FileTreeHorizontalScope({required this.width, required super.child});
final double width;
@override
bool updateShouldNotify(_FileTreeHorizontalScope oldWidget) => width != oldWidget.width;
}
@@ -0,0 +1,69 @@
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'app_tooltip.dart';
import 'file_tree_horizontal_viewport.dart';
/// Keeps the full name scrollable, with an ellipsis at its visible trailing edge.
class FileTreeName extends StatelessWidget {
const FileTreeName({
super.key,
required this.name,
required this.style,
required this.controller,
required this.contentWidth,
this.treeContentOffset,
});
final String name;
final TextStyle style;
final ScrollController controller;
final double contentWidth;
/// The name's leading position on the canvas when the whole tree scrolls.
/// Null means that only the name scrolls, with the trailing columns frozen.
final double? treeContentOffset;
@override
Widget build(BuildContext context) {
final treeViewportWidth = FileTreeHorizontalViewport.widthOf(context);
final direction = Directionality.of(context);
return AppTooltip(
message: name,
child: LayoutBuilder(
builder: (context, constraints) => AnimatedBuilder(
animation: controller,
builder: (context, child) {
final offset = controller.hasClients ? controller.offset : 0.0;
final available = treeContentOffset == null
? constraints.maxWidth + offset
: (treeViewportWidth ?? constraints.maxWidth) + offset - treeContentOffset!;
final width = math.max(
0.0,
math.min(treeContentOffset == null ? contentWidth : constraints.maxWidth, available),
);
final text = SizedBox(
width: width,
child: Text(name, style: style, maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis),
);
if (treeContentOffset != null) {
return Align(alignment: AlignmentDirectional.centerStart, child: text);
}
return ClipRect(
child: OverflowBox(
alignment: AlignmentDirectional.centerStart,
minWidth: 0,
maxWidth: double.infinity,
child: Transform.translate(
offset: Offset(direction == TextDirection.rtl ? offset : -offset, 0),
child: text,
),
),
);
},
),
),
);
}
}
+100 -76
View File
@@ -4,9 +4,12 @@ import 'package:flutter/gestures.dart' show Drag;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row; import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
import '../../core/utils/breakpoints.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
import '../../util/util.dart'; import '../../util/util.dart';
import 'file_tree_horizontal_viewport.dart';
import 'file_tree_name.dart';
import 'virtual_tree_view.dart'; import 'virtual_tree_view.dart';
class FileTreeItem<T> { class FileTreeItem<T> {
@@ -110,6 +113,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
late final Animation<double> _reveal; late final Animation<double> _reveal;
final _scrollController = ScrollController(); final _scrollController = ScrollController();
final _horizontalScrollController = ScrollController(); final _horizontalScrollController = ScrollController();
final _wholeTreeScrollController = ScrollController();
Drag? _horizontalDrag; Drag? _horizontalDrag;
final Map<String, double> _labelWidthCache = {}; final Map<String, double> _labelWidthCache = {};
TextStyle? _labelMeasurementStyle; TextStyle? _labelMeasurementStyle;
@@ -163,6 +167,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
_transitionController.dispose(); _transitionController.dispose();
_scrollController.dispose(); _scrollController.dispose();
_horizontalScrollController.dispose(); _horizontalScrollController.dispose();
_wholeTreeScrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -196,6 +201,8 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final treeWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth; final treeWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth;
// Folder expansion must not switch between frozen and scrolling columns.
final scrollWholeTree = treeWidth < Breakpoints.mobile;
final horizontalOverflow = _horizontalOverflow( final horizontalOverflow = _horizontalOverflow(
_nodes, _nodes,
baseNameViewport: baseNameViewport:
@@ -204,57 +211,70 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
measureLabel: measureLabel, measureLabel: measureLabel,
); );
return OutlinedContainer( final content = Column(
child: Column( children: [
children: [ _FileTreeToolbar(
_FileTreeToolbar( keyPrefix: widget.keyPrefix,
keyPrefix: widget.keyPrefix, headerLeading: widget.headerLeading,
headerLeading: widget.headerLeading, allExpanded: _allFoldersExpanded,
allExpanded: _allFoldersExpanded, nameSortDirection: _sortColumn == _FileTreeSortColumn.name ? _sortDirection : _FileTreeSortDirection.none,
nameSortDirection: _sortColumn == _FileTreeSortColumn.name sizeSortDirection: _sortColumn == _FileTreeSortColumn.size ? _sortDirection : _FileTreeSortDirection.none,
? _sortDirection showSizeColumn: widget.showSizeColumn,
: _FileTreeSortDirection.none, trailingHeader: widget.trailingHeader,
sizeSortDirection: _sortColumn == _FileTreeSortColumn.size trailingColumnWidth: trailingColumnWidth,
? _sortDirection onNameSort: () => _toggleSort(_FileTreeSortColumn.name),
: _FileTreeSortDirection.none, onSizeSort: () => _toggleSort(_FileTreeSortColumn.size),
showSizeColumn: widget.showSizeColumn, onToggleExpanded: _allFoldersExpanded ? _collapseAll : _expandAll,
trailingHeader: widget.trailingHeader, ),
trailingColumnWidth: trailingColumnWidth, Expanded(
onNameSort: () => _toggleSort(_FileTreeSortColumn.name), child: _nodes.isEmpty
onSizeSort: () => _toggleSort(_FileTreeSortColumn.size), ? _FileTreeEmptyState(label: widget.emptyLabel ?? context.l10n.noFiles)
onToggleExpanded: _allFoldersExpanded ? _collapseAll : _expandAll, : VirtualTreeView<FileTreeNode<T>>(
), nodes: _nodes,
Expanded( controller: _scrollController,
child: _nodes.isEmpty branchLine: BranchLine.path,
? _FileTreeEmptyState(label: widget.emptyLabel ?? context.l10n.noFiles) rowHeight: widget.rowHeight,
: VirtualTreeView<FileTreeNode<T>>( padding: const EdgeInsets.fromLTRB(_treeContentOffset, 4, 8, 4),
nodes: _nodes, itemPadding: EdgeInsets.symmetric(vertical: treeItemInset * 0.5),
controller: _scrollController, itemBuilder: (context, entry) => _buildTreeItem(
branchLine: BranchLine.path, context,
rowHeight: widget.rowHeight, entry,
padding: const EdgeInsets.fromLTRB(_treeContentOffset, 4, 8, 4), nameContentWidth: nameContentWidth,
itemPadding: EdgeInsets.symmetric(vertical: treeItemInset * 0.5), trailingColumnWidth: trailingColumnWidth,
itemBuilder: (context, entry) => _buildTreeItem( hasTrailingColumn: hasTrailingColumn,
context, horizontalScrollEnabled: horizontalOverflow > 0.5,
entry, scrollWholeTree: scrollWholeTree,
nameContentWidth: nameContentWidth, nameContentOffset:
trailingColumnWidth: trailingColumnWidth, _treeContentOffset + (entry.depth.length - 1) * treeItemInset * 3 + fixedNameChrome,
hasTrailingColumn: hasTrailingColumn, contentTextStyle: labelStyle,
horizontalScrollEnabled: horizontalOverflow > 0.5,
contentTextStyle: labelStyle,
),
transitionBuilder: _buildTreeTransition,
), ),
transitionBuilder: _buildTreeTransition,
),
),
if (!scrollWholeTree && _nodes.isNotEmpty && horizontalOverflow > 0.5)
_FileNameHorizontalScrollbar(
keyPrefix: widget.keyPrefix,
controller: _horizontalScrollController,
overflow: horizontalOverflow,
trailingColumnWidth: trailingColumnWidth,
), ),
if (_nodes.isNotEmpty && horizontalOverflow > 0.5) ],
_FileNameHorizontalScrollbar( );
return OutlinedContainer(
child: scrollWholeTree
? FileTreeHorizontalViewport(
keyPrefix: widget.keyPrefix, keyPrefix: widget.keyPrefix,
controller: _horizontalScrollController, controller: _wholeTreeScrollController,
overflow: horizontalOverflow, contentWidth:
trailingColumnWidth: trailingColumnWidth, _treeHorizontalPadding +
), fixedNameChrome +
], _treeTrailingGap +
), trailingColumnWidth +
_widestIndentedLabelWidth(_nodes, measureLabel, treeItemInset * 3),
child: content,
)
: content,
); );
}, },
); );
@@ -267,6 +287,8 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
required double trailingColumnWidth, required double trailingColumnWidth,
required bool hasTrailingColumn, required bool hasTrailingColumn,
required bool horizontalScrollEnabled, required bool horizontalScrollEnabled,
required bool scrollWholeTree,
required double nameContentOffset,
required TextStyle contentTextStyle, required TextStyle contentTextStyle,
}) { }) {
final palette = AppPalette.of(context); final palette = AppPalette.of(context);
@@ -315,36 +337,22 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
child: GestureDetector( child: GestureDetector(
key: ValueKey('${widget.keyPrefix}-name-scroll-${data.key}'), key: ValueKey('${widget.keyPrefix}-name-scroll-${data.key}'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onHorizontalDragStart: horizontalScrollEnabled ? _startNameDrag : null, onHorizontalDragStart: !scrollWholeTree && horizontalScrollEnabled ? _startNameDrag : null,
onHorizontalDragUpdate: horizontalScrollEnabled onHorizontalDragUpdate: !scrollWholeTree && horizontalScrollEnabled
? (details) => _horizontalDrag?.update(details) ? (details) => _horizontalDrag?.update(details)
: null, : null,
onHorizontalDragEnd: horizontalScrollEnabled ? (details) => _horizontalDrag?.end(details) : null, onHorizontalDragEnd: !scrollWholeTree && horizontalScrollEnabled
onHorizontalDragCancel: horizontalScrollEnabled ? () => _horizontalDrag?.cancel() : null, ? (details) => _horizontalDrag?.end(details)
child: ClipRect( : null,
child: AnimatedBuilder( onHorizontalDragCancel: !scrollWholeTree && horizontalScrollEnabled
animation: _horizontalScrollController, ? () => _horizontalDrag?.cancel()
builder: (context, child) { : null,
final offset = horizontalScrollEnabled && _horizontalScrollController.hasClients child: FileTreeName(
? _horizontalScrollController.offset name: data.label,
: 0.0; style: contentTextStyle.copyWith(color: palette.textPrimary),
return Transform.translate(offset: Offset(-offset, 0), child: child); controller: scrollWholeTree ? _wholeTreeScrollController : _horizontalScrollController,
}, contentWidth: nameContentWidth,
child: OverflowBox( treeContentOffset: scrollWholeTree ? nameContentOffset : null,
alignment: Alignment.centerLeft,
minWidth: 0,
maxWidth: double.infinity,
child: SizedBox(
width: nameContentWidth,
child: Text(
data.label,
maxLines: 1,
softWrap: false,
style: contentTextStyle.copyWith(color: palette.textPrimary),
),
),
),
),
), ),
), ),
), ),
@@ -1076,6 +1084,22 @@ double _widestLabelWidth<T>(List<TreeNode<FileTreeNode<T>>> nodes, double Functi
return widest; return widest;
} }
double _widestIndentedLabelWidth<T>(
List<TreeNode<FileTreeNode<T>>> nodes,
double Function(String label) measureLabel,
double depthIndent, [
int depth = 0,
]) {
var widest = _treeMinimumNameContentWidth;
for (final node in nodes) {
final item = node as TreeItemNode<FileTreeNode<T>>;
widest = math.max(widest, depth * depthIndent + measureLabel(item.data.label));
// Include collapsed descendants so expanding folders keeps the canvas stable.
widest = math.max(widest, _widestIndentedLabelWidth(item.children, measureLabel, depthIndent, depth + 1));
}
return widest;
}
double _measureLabelWidth( double _measureLabelWidth(
String label, { String label, {
required TextStyle style, required TextStyle style,
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/create_task_batch.dart'; import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/options.dart'; import 'package:gopeed/api/model/options.dart';
@@ -16,6 +17,7 @@ import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dar
import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_loading_button.dart'; import 'package:gopeed/shared/widgets/app_loading_button.dart';
import 'package:gopeed/shared/widgets/app_tooltip.dart'; import 'package:gopeed/shared/widgets/app_tooltip.dart';
import 'package:gopeed/shared/widgets/file_tree_horizontal_viewport.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
PendingDownload pending(String id) => PendingDownload( PendingDownload pending(String id) => PendingDownload(
@@ -76,6 +78,50 @@ void main() {
}); });
} }
testWidgets('long pending links scroll with the tree before and after resolving on narrow screens', (tester) async {
final response = Completer<ResolveResult>();
final url = 'https://pan.quark.cn/s/${List.filled(8, 'long-resource-link').join('-')}';
final source = PendingDownload(
id: 'long',
request: Request(url: url),
options: Options(),
);
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) => response.future);
await open(tester, [source], registry, width: 320);
expect(find.byType(FileTreeHorizontalViewport), findsOneWidget);
final paragraph = tester.renderObject<RenderParagraph>(find.text(url));
expect(paragraph.didExceedMaxLines, isTrue);
expect(paragraph.overflow, TextOverflow.ellipsis);
expect(paragraph.text.toPlainText(), url);
expect(find.byWidgetPredicate((widget) => widget is AppTooltip && widget.message == url), findsOneWidget);
final label = find.byKey(const ValueKey('resolve-tree-name-scroll-folder:long'));
final before = tester.getTopLeft(find.text(url));
final expand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:long'));
final expandBefore = tester.getTopLeft(expand);
await tester.dragFrom(tester.getTopLeft(label) + const Offset(20, 8), const Offset(-100, 0));
// The pending indicator animates continuously, so do not wait for settling.
await tester.pump(const Duration(seconds: 1));
final delta = tester.getTopLeft(find.text(url)).dx - before.dx;
expect(delta, lessThan(0));
expect(tester.getTopLeft(expand).dx - expandBefore.dx, closeTo(delta, 0.01));
final horizontal = tester.widget<SingleChildScrollView>(
find.byKey(const ValueKey('resolve-tree-horizontal-scroll')),
);
horizontal.controller!.jumpTo(horizontal.controller!.position.maxScrollExtent);
await tester.pump();
expect(
paragraph.getBoxesForSelection(TextSelection(baseOffset: url.length - 4, extentOffset: url.length)),
isNotEmpty,
);
response.complete(resolved('resolved-file.txt'));
await tester.pumpAndSettle();
expect(find.text('resolved-file.txt'), findsOneWidget);
expect(find.byType(FileTreeHorizontalViewport), findsOneWidget);
expect(source.selected, {0});
expect(tester.takeException(), isNull);
});
testWidgets('shows all roots immediately and resolves sequentially while preserving choices', (tester) async { testWidgets('shows all roots immediately and resolves sequentially while preserving choices', (tester) async {
final calls = <String>[]; final calls = <String>[];
final responses = [Completer<ResolveResult>(), Completer<ResolveResult>()]; final responses = [Completer<ResolveResult>(), Completer<ResolveResult>()];
@@ -1,7 +1,10 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/core/utils/breakpoints.dart';
import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_tooltip.dart';
import 'package:gopeed/shared/widgets/file_tree_horizontal_viewport.dart';
import 'package:gopeed/shared/widgets/file_tree_view.dart'; import 'package:gopeed/shared/widgets/file_tree_view.dart';
import 'package:gopeed/shared/widgets/virtual_tree_view.dart'; import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
@@ -41,7 +44,7 @@ void main() {
expect(labels(), ['z.txt', 'folder', 'a.txt']); expect(labels(), ['z.txt', 'folder', 'a.txt']);
}); });
for (final width in [390.0, 700.0, 1024.0]) { for (final width in [320.0, 390.0, 700.0, 1024.0]) {
testWidgets('long directory display labels remain readable at $width', (tester) async { testWidgets('long directory display labels remain readable at $width', (tester) async {
tester.view.physicalSize = Size(width, 800); tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@@ -62,20 +65,195 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
for (final label in [name, nestedName]) { for (final label in [name, nestedName]) {
final paragraph = tester.renderObject<RenderParagraph>(find.text(label)); final paragraph = tester.renderObject<RenderParagraph>(find.text(label));
expect(paragraph.size.width, greaterThanOrEqualTo(paragraph.getMaxIntrinsicWidth(double.infinity))); expect(paragraph.text.toPlainText(), label);
expect(paragraph.overflow, TextOverflow.ellipsis);
expect(find.byWidgetPredicate((widget) => widget is AppTooltip && widget.message == label), findsOneWidget);
} }
expect(find.byKey(const ValueKey('file-tree-horizontal-scrollbar')), findsOneWidget); expect(find.byKey(const ValueKey('file-tree-horizontal-scrollbar')), findsOneWidget);
final root = find.byKey(const ValueKey('file-tree-name-scroll-folder:source-0')); final root = find.byKey(const ValueKey('file-tree-name-scroll-folder:source-0'));
final before = tester.getTopLeft(find.text(name)); final before = tester.getTopLeft(find.text(name));
final trailing = tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0'))); final trailing = tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0')));
await tester.drag(root, const Offset(-120, 0)); final expand = find.byKey(const ValueKey('file-tree-node-expand-folder:source-0'));
final expandBefore = tester.getTopLeft(expand);
final header = find.byKey(const ValueKey('file-tree-sort-size-asc'));
final headerBefore = tester.getTopLeft(header);
await tester.dragFrom(tester.getTopLeft(root) + const Offset(40, 10), const Offset(-120, 0));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text(name)).dx, lessThan(before.dx)); final delta = tester.getTopLeft(find.text(name)).dx - before.dx;
expect(tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0'))), trailing); expect(delta, lessThan(0));
final trailingAfter = tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0')));
if (width < Breakpoints.mobile) {
expect(trailingAfter.left - trailing.left, closeTo(delta, 0.01));
expect(tester.getTopLeft(expand).dx - expandBefore.dx, closeTo(delta, 0.01));
expect(tester.getTopLeft(header).dx - headerBefore.dx, closeTo(delta, 0.01));
} else {
expect(trailingAfter, trailing);
expect(tester.getTopLeft(expand), expandBefore);
expect(tester.getTopLeft(header), headerBefore);
}
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
} }
testWidgets('deep trees scroll all controls and keep their canvas stable when folders toggle', (tester) async {
tester.view.physicalSize = const Size(1024, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const name = '课程视频与配套练习_最终修订版.mp4';
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Center(
child: SizedBox(
width: 320,
height: 500,
child: FileTreeView<int>(
items: const [FileTreeItem(key: 'deep', path: 'a/b/c/d/e/f', name: name, size: 1024, data: 0)],
leadingBuilder: (_, _) => const SizedBox(width: 16, height: 16),
),
),
),
),
);
await tester.pumpAndSettle();
// The list's width, rather than the desktop window's width, chooses the mode.
expect(find.byType(FileTreeHorizontalViewport), findsOneWidget);
final horizontal = tester.widget<SingleChildScrollView>(find.byKey(const ValueKey('file-tree-horizontal-scroll')));
final controller = horizontal.controller!;
final extent = controller.position.maxScrollExtent;
expect(extent, greaterThan(0));
final paragraph = tester.renderObject<RenderParagraph>(find.text(name));
expect(paragraph.overflow, TextOverflow.ellipsis);
expect(paragraph.text.toPlainText(), name);
final root = find.byKey(const ValueKey('file-tree-node-expand-folder:a'));
final trailing = find.byKey(const ValueKey('file-tree-trailing-file:deep'));
final before = tester.getTopLeft(trailing);
final label = find.byKey(const ValueKey('file-tree-name-scroll-folder:a'));
await tester.dragFrom(tester.getTopLeft(label) + const Offset(20, 8), const Offset(-100, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(trailing).dx, lessThan(before.dx));
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pump();
final viewport = tester.getRect(find.byType(FileTreeHorizontalViewport));
expect(tester.getRect(trailing).right, lessThanOrEqualTo(viewport.right));
expectNameTailVisible(tester, name, viewport);
controller.jumpTo(0);
await tester.pump();
await tester.tap(root);
await tester.pumpAndSettle();
expect(find.text(name), findsNothing);
expect(find.byType(FileTreeHorizontalViewport), findsOneWidget);
expect(controller.position.maxScrollExtent, extent);
await tester.tap(root);
await tester.pumpAndSettle();
expect(find.text(name), findsOneWidget);
expect(controller.position.maxScrollExtent, extent);
expect(tester.takeException(), isNull);
});
for (final width in [320.0, 1024.0]) {
testWidgets('long names show ellipsis, reveal their tail on scroll and expose full text at $width', (tester) async {
tester.view.physicalSize = Size(width, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final name = '${List.filled(8, '课程视频与完整配套资料').join('_')}.mp4';
var presses = 0;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: FileTreeView<int>(
items: [FileTreeItem(key: 'long', path: '', name: name, size: 1024, data: 0)],
onNodePressed: (_) => presses++,
),
),
);
await tester.pumpAndSettle();
final label = find.text(name);
final paragraph = tester.renderObject<RenderParagraph>(label);
expect(paragraph.didExceedMaxLines, isTrue);
expect(paragraph.overflow, TextOverflow.ellipsis);
expect(paragraph.text.toPlainText(), name);
expect(
paragraph.getBoxesForSelection(TextSelection(baseOffset: name.length - 4, extentOffset: name.length)),
isEmpty,
);
final horizontal = tester.widget<SingleChildScrollView>(
find.byKey(const ValueKey('file-tree-horizontal-scroll')),
);
horizontal.controller!.jumpTo(horizontal.controller!.position.maxScrollExtent);
await tester.pumpAndSettle();
expectNameTailVisible(tester, name, Rect.fromLTWH(0, 0, width, 600));
horizontal.controller!.jumpTo(0);
await tester.pump();
await tester.longPressAt(tester.getTopLeft(label) + const Offset(20, 8));
await tester.pumpAndSettle();
expect(find.text(name), findsNWidgets(2));
expect(presses, 0);
expect(tester.takeException(), isNull);
});
}
testWidgets('resizing changes scrolling mode without losing folder state or tap handling', (tester) async {
tester.view.physicalSize = const Size(1024, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
late StateSetter update;
var width = 390.0;
final pressed = <String>[];
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: StatefulBuilder(
builder: (context, setState) {
update = setState;
return Center(
child: SizedBox(
width: width,
height: 500,
child: FileTreeView<int>(
items: const [FileTreeItem(key: 'file', path: 'bundle', name: 'file.txt', size: 10, data: 0)],
directoryLabels: const {
'bundle': 'Long resource title that needs horizontal scrolling in both desktop and mobile views',
},
onNodePressed: (node) => pressed.add(node.key),
),
),
);
},
),
),
);
await tester.pumpAndSettle();
expect(find.byType(FileTreeHorizontalViewport), findsOneWidget);
await tester.tapAt(tester.getTopLeft(find.text('file.txt')) + const Offset(20, 8));
await tester.pump();
expect(pressed, ['file:file']);
final expand = find.byKey(const ValueKey('file-tree-node-expand-folder:bundle'));
await tester.tap(expand);
await tester.pumpAndSettle();
update(() => width = 900);
await tester.pumpAndSettle();
expect(find.byType(FileTreeHorizontalViewport), findsNothing);
expect(find.text('file.txt'), findsNothing);
update(() => width = 390);
await tester.pumpAndSettle();
expect(find.byType(FileTreeHorizontalViewport), findsOneWidget);
expect(find.text('file.txt'), findsNothing);
await tester.tap(expand);
await tester.pumpAndSettle();
await tester.tapAt(tester.getTopLeft(find.text('file.txt')) + const Offset(20, 8));
await tester.pump();
expect(pressed, ['file:file', 'file:file']);
expect(tester.takeException(), isNull);
});
testWidgets('empty directory root keeps its identity and expansion when files arrive', (tester) async { testWidgets('empty directory root keeps its identity and expansion when files arrive', (tester) async {
late StateSetter update; late StateSetter update;
var items = [ var items = [
@@ -159,3 +337,13 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
} }
void expectNameTailVisible(WidgetTester tester, String name, Rect viewport) {
final paragraph = tester.renderObject<RenderParagraph>(find.text(name));
final boxes = paragraph.getBoxesForSelection(TextSelection(baseOffset: name.length - 4, extentOffset: name.length));
expect(boxes, isNotEmpty);
for (final box in boxes) {
expect(paragraph.localToGlobal(Offset(box.left, 0)).dx, greaterThanOrEqualTo(viewport.left));
expect(paragraph.localToGlobal(Offset(box.right, 0)).dx, lessThanOrEqualTo(viewport.right));
}
}