mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 15:49:49 +00:00
1147 lines
38 KiB
Dart
1147 lines
38 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/gestures.dart' show Drag;
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
|
|
|
|
import '../theme/app_palette.dart';
|
|
import '../../l10n/l10n.dart';
|
|
import '../../util/util.dart';
|
|
import 'virtual_tree_view.dart';
|
|
|
|
class FileTreeItem<T> {
|
|
const FileTreeItem({
|
|
required this.key,
|
|
required this.path,
|
|
required this.name,
|
|
required this.size,
|
|
required this.data,
|
|
this.directoryOnly = false,
|
|
this.namespace = '',
|
|
});
|
|
|
|
final String key;
|
|
final String path;
|
|
final String name;
|
|
final int size;
|
|
final T data;
|
|
final bool directoryOnly;
|
|
|
|
/// Keeps directories from independent resources distinct without adding a visible root.
|
|
final String namespace;
|
|
}
|
|
|
|
class FileTreeNode<T> {
|
|
const FileTreeNode({
|
|
required this.key,
|
|
required this.label,
|
|
required this.leafItems,
|
|
required this.size,
|
|
required this.originalIndex,
|
|
this.item,
|
|
});
|
|
|
|
final String key;
|
|
final String label;
|
|
final List<FileTreeItem<T>> leafItems;
|
|
final int size;
|
|
final int originalIndex;
|
|
final FileTreeItem<T>? item;
|
|
|
|
bool get isFolder => item == null;
|
|
|
|
@override
|
|
bool operator ==(Object other) => other is FileTreeNode<T> && other.key == key;
|
|
|
|
@override
|
|
int get hashCode => key.hashCode;
|
|
}
|
|
|
|
typedef FileTreeNodeWidgetBuilder<T> = Widget Function(BuildContext context, FileTreeNode<T> node);
|
|
typedef FileTreeNodeIconBuilder<T> = IconData Function(FileTreeNode<T> node);
|
|
|
|
class FileTreeView<T> extends StatefulWidget {
|
|
const FileTreeView({
|
|
super.key,
|
|
required this.items,
|
|
this.headerLeading,
|
|
this.leadingBuilder,
|
|
this.iconBuilder,
|
|
this.iconWidgetBuilder,
|
|
this.trailingBuilder,
|
|
this.onNodePressed,
|
|
this.keyPrefix = 'file-tree',
|
|
this.emptyLabel,
|
|
this.showSizeColumn = true,
|
|
this.trailingHeader,
|
|
this.rowHeight = _treeRowHeight,
|
|
this.contentTextStyle,
|
|
this.iconSize = 18,
|
|
this.initialExpandedDepth,
|
|
this.directoryLabels = const {},
|
|
});
|
|
|
|
final List<FileTreeItem<T>> items;
|
|
final Map<String, String> directoryLabels;
|
|
final Widget? headerLeading;
|
|
final FileTreeNodeWidgetBuilder<T>? leadingBuilder;
|
|
final FileTreeNodeIconBuilder<T>? iconBuilder;
|
|
final FileTreeNodeWidgetBuilder<T>? iconWidgetBuilder;
|
|
final FileTreeNodeWidgetBuilder<T>? trailingBuilder;
|
|
final ValueChanged<FileTreeNode<T>>? onNodePressed;
|
|
final String keyPrefix;
|
|
final String? emptyLabel;
|
|
final bool showSizeColumn;
|
|
final Widget? trailingHeader;
|
|
final double rowHeight;
|
|
final TextStyle? contentTextStyle;
|
|
final double iconSize;
|
|
|
|
/// Number of directory levels initially expanded; null expands all levels.
|
|
final int? initialExpandedDepth;
|
|
|
|
@override
|
|
State<FileTreeView<T>> createState() => _FileTreeViewState<T>();
|
|
}
|
|
|
|
class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProviderStateMixin {
|
|
late List<TreeNode<FileTreeNode<T>>> _nodes;
|
|
late final AnimationController _transitionController;
|
|
late final Animation<double> _reveal;
|
|
final _scrollController = ScrollController();
|
|
final _horizontalScrollController = ScrollController();
|
|
Drag? _horizontalDrag;
|
|
final Map<String, double> _labelWidthCache = {};
|
|
TextStyle? _labelMeasurementStyle;
|
|
TextDirection? _labelMeasurementDirection;
|
|
TextScaler? _labelMeasurementScaler;
|
|
List<TreeNode<FileTreeNode<T>>>? _pendingCollapsedNodes;
|
|
Set<String> _transitioningNodeKeys = const {};
|
|
var _transitioning = false;
|
|
var _allFoldersExpanded = false;
|
|
_FileTreeSortColumn? _sortColumn;
|
|
_FileTreeSortDirection _sortDirection = _FileTreeSortDirection.none;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_nodes = _buildTree(
|
|
widget.items,
|
|
expandedDepth: widget.initialExpandedDepth,
|
|
directoryLabels: widget.directoryLabels,
|
|
);
|
|
_allFoldersExpanded = _foldersAreExpanded(_nodes);
|
|
_transitionController = AnimationController(vsync: this, duration: _treeTransitionDuration, value: 1)
|
|
..addStatusListener(_onTransitionStatus);
|
|
_reveal = CurvedAnimation(parent: _transitionController, curve: Curves.easeOutCubic);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant FileTreeView<T> oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (identical(widget.items, oldWidget.items)) return;
|
|
|
|
// During a collapse animation, the pending tree holds the user's target state.
|
|
final expandedKeys = _expandedFolderKeys(_pendingCollapsedNodes ?? _nodes);
|
|
var nodes = _restoreExpandedFolders(
|
|
_buildTree(widget.items, directoryLabels: widget.directoryLabels),
|
|
expandedKeys,
|
|
);
|
|
nodes = _sortNodes(nodes, _sortColumn, _sortDirection);
|
|
_nodes = nodes;
|
|
_allFoldersExpanded = _foldersAreExpanded(nodes);
|
|
_pendingCollapsedNodes = null;
|
|
_transitioningNodeKeys = const {};
|
|
_transitioning = false;
|
|
_transitionController.value = 1;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_horizontalDrag?.cancel();
|
|
_transitionController.removeStatusListener(_onTransitionStatus);
|
|
_transitionController.dispose();
|
|
_scrollController.dispose();
|
|
_horizontalScrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final treeItemInset = theme.density.baseGap * theme.scaling;
|
|
final hasTrailingColumn = widget.trailingBuilder != null || widget.trailingHeader != null || widget.showSizeColumn;
|
|
final trailingColumnWidth = hasTrailingColumn
|
|
? (widget.trailingBuilder == null ? _treeSizeColumnWidth : _treeCustomTrailingColumnWidth)
|
|
: 0.0;
|
|
final labelStyle = DefaultTextStyle.of(context).style.merge(widget.contentTextStyle);
|
|
final textDirection = Directionality.of(context);
|
|
final textScaler = MediaQuery.textScalerOf(context);
|
|
if (_labelMeasurementStyle != labelStyle ||
|
|
_labelMeasurementDirection != textDirection ||
|
|
_labelMeasurementScaler != textScaler) {
|
|
_labelWidthCache.clear();
|
|
_labelMeasurementStyle = labelStyle;
|
|
_labelMeasurementDirection = textDirection;
|
|
_labelMeasurementScaler = textScaler;
|
|
}
|
|
double measureLabel(String label) => _labelWidthCache.putIfAbsent(
|
|
label,
|
|
() => _measureLabelWidth(label, style: labelStyle, textDirection: textDirection, textScaler: textScaler),
|
|
);
|
|
final nameContentWidth = _widestLabelWidth(_nodes, measureLabel);
|
|
final fixedNameChrome =
|
|
_treeNodeExpandChrome + widget.iconSize + 4 + (widget.leadingBuilder == null ? 0 : _treeLeadingChrome);
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final treeWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth;
|
|
final horizontalOverflow = _horizontalOverflow(
|
|
_nodes,
|
|
baseNameViewport:
|
|
treeWidth - _treeHorizontalPadding - trailingColumnWidth - _treeTrailingGap - fixedNameChrome,
|
|
depthIndent: treeItemInset * 3,
|
|
measureLabel: measureLabel,
|
|
);
|
|
|
|
return OutlinedContainer(
|
|
child: Column(
|
|
children: [
|
|
_FileTreeToolbar(
|
|
keyPrefix: widget.keyPrefix,
|
|
headerLeading: widget.headerLeading,
|
|
allExpanded: _allFoldersExpanded,
|
|
nameSortDirection: _sortColumn == _FileTreeSortColumn.name
|
|
? _sortDirection
|
|
: _FileTreeSortDirection.none,
|
|
sizeSortDirection: _sortColumn == _FileTreeSortColumn.size
|
|
? _sortDirection
|
|
: _FileTreeSortDirection.none,
|
|
showSizeColumn: widget.showSizeColumn,
|
|
trailingHeader: widget.trailingHeader,
|
|
trailingColumnWidth: trailingColumnWidth,
|
|
onNameSort: () => _toggleSort(_FileTreeSortColumn.name),
|
|
onSizeSort: () => _toggleSort(_FileTreeSortColumn.size),
|
|
onToggleExpanded: _allFoldersExpanded ? _collapseAll : _expandAll,
|
|
),
|
|
Expanded(
|
|
child: _nodes.isEmpty
|
|
? _FileTreeEmptyState(label: widget.emptyLabel ?? context.l10n.noFiles)
|
|
: VirtualTreeView<FileTreeNode<T>>(
|
|
nodes: _nodes,
|
|
controller: _scrollController,
|
|
branchLine: BranchLine.path,
|
|
rowHeight: widget.rowHeight,
|
|
padding: const EdgeInsets.fromLTRB(_treeContentOffset, 4, 8, 4),
|
|
itemPadding: EdgeInsets.symmetric(vertical: treeItemInset * 0.5),
|
|
itemBuilder: (context, entry) => _buildTreeItem(
|
|
context,
|
|
entry,
|
|
nameContentWidth: nameContentWidth,
|
|
trailingColumnWidth: trailingColumnWidth,
|
|
hasTrailingColumn: hasTrailingColumn,
|
|
horizontalScrollEnabled: horizontalOverflow > 0.5,
|
|
contentTextStyle: labelStyle,
|
|
),
|
|
transitionBuilder: _buildTreeTransition,
|
|
),
|
|
),
|
|
if (_nodes.isNotEmpty && horizontalOverflow > 0.5)
|
|
_FileNameHorizontalScrollbar(
|
|
keyPrefix: widget.keyPrefix,
|
|
controller: _horizontalScrollController,
|
|
overflow: horizontalOverflow,
|
|
trailingColumnWidth: trailingColumnWidth,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildTreeItem(
|
|
BuildContext context,
|
|
VirtualTreeEntry<FileTreeNode<T>> entry, {
|
|
required double nameContentWidth,
|
|
required double trailingColumnWidth,
|
|
required bool hasTrailingColumn,
|
|
required bool horizontalScrollEnabled,
|
|
required TextStyle contentTextStyle,
|
|
}) {
|
|
final palette = AppPalette.of(context);
|
|
final node = entry.node;
|
|
final data = node.data;
|
|
final pendingNode = _pendingCollapsedNodes == null ? null : _findTreeNode(_pendingCollapsedNodes!, data.key);
|
|
final visuallyExpanded = pendingNode?.expanded ?? node.expanded;
|
|
final leading = widget.leadingBuilder?.call(context, data);
|
|
final trailing = widget.trailingBuilder?.call(context, data);
|
|
final trailingContent =
|
|
trailing ??
|
|
(data.size > 0
|
|
? Text(Util.fmtByte(data.size), style: contentTextStyle.copyWith(color: palette.textSecondary))
|
|
: const SizedBox.shrink());
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
_FileTreeNodeExpandButton(
|
|
key: ValueKey('${widget.keyPrefix}-node-expand-${data.key}'),
|
|
expandable: node.children.isNotEmpty,
|
|
expanded: visuallyExpanded,
|
|
rowHeight: widget.rowHeight,
|
|
onPressed: () => _toggleFolder(data.key),
|
|
),
|
|
const SizedBox(width: _treeExpandLeadingGap),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: widget.onNodePressed == null ? null : () => widget.onNodePressed!(data),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (leading != null) ...[leading, const SizedBox(width: 8)],
|
|
widget.iconWidgetBuilder?.call(context, data) ??
|
|
Icon(
|
|
widget.iconBuilder?.call(data) ??
|
|
(data.isFolder
|
|
? (visuallyExpanded ? BootstrapIcons.folder2Open : BootstrapIcons.folder2)
|
|
: _fileIcon(data.label)),
|
|
size: widget.iconSize,
|
|
color: palette.textSecondary,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
key: ValueKey('${widget.keyPrefix}-name-scroll-${data.key}'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onHorizontalDragStart: horizontalScrollEnabled ? _startNameDrag : null,
|
|
onHorizontalDragUpdate: horizontalScrollEnabled
|
|
? (details) => _horizontalDrag?.update(details)
|
|
: null,
|
|
onHorizontalDragEnd: horizontalScrollEnabled ? (details) => _horizontalDrag?.end(details) : null,
|
|
onHorizontalDragCancel: horizontalScrollEnabled ? () => _horizontalDrag?.cancel() : null,
|
|
child: ClipRect(
|
|
child: AnimatedBuilder(
|
|
animation: _horizontalScrollController,
|
|
builder: (context, child) {
|
|
final offset = horizontalScrollEnabled && _horizontalScrollController.hasClients
|
|
? _horizontalScrollController.offset
|
|
: 0.0;
|
|
return Transform.translate(offset: Offset(-offset, 0), child: child);
|
|
},
|
|
child: OverflowBox(
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
if (hasTrailingColumn)
|
|
SizedBox(
|
|
key: ValueKey('${widget.keyPrefix}-trailing-${data.key}'),
|
|
width: trailingColumnWidth,
|
|
child: Align(alignment: Alignment.centerRight, child: trailingContent),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _startNameDrag(DragStartDetails details) {
|
|
_horizontalDrag?.cancel();
|
|
if (!_horizontalScrollController.hasClients) return;
|
|
_horizontalDrag = _horizontalScrollController.position.drag(details, () => _horizontalDrag = null);
|
|
}
|
|
|
|
Widget _buildTreeTransition(BuildContext context, VirtualTreeEntry<FileTreeNode<T>> entry, Widget row) {
|
|
if (_transitioningNodeKeys.contains(entry.node.data.key)) {
|
|
return SizeTransition(
|
|
sizeFactor: _reveal,
|
|
axisAlignment: -1,
|
|
child: FadeTransition(opacity: _reveal, child: row),
|
|
);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
void _onTransitionStatus(AnimationStatus status) {
|
|
if (!mounted) return;
|
|
if (status == AnimationStatus.completed && _pendingCollapsedNodes == null && _transitioning) {
|
|
setState(() {
|
|
_transitioningNodeKeys = const {};
|
|
_transitioning = false;
|
|
});
|
|
return;
|
|
}
|
|
if (status == AnimationStatus.dismissed && _pendingCollapsedNodes != null) {
|
|
final nodes = _pendingCollapsedNodes!;
|
|
setState(() {
|
|
_nodes = nodes;
|
|
_allFoldersExpanded = _foldersAreExpanded(nodes);
|
|
_pendingCollapsedNodes = null;
|
|
_transitioningNodeKeys = const {};
|
|
_transitioning = false;
|
|
});
|
|
_transitionController.value = 1;
|
|
}
|
|
}
|
|
|
|
void _toggleFolder(String key) {
|
|
final node = _findTreeNode(_nodes, key);
|
|
if (node == null) return;
|
|
_transitionToTree(node.expanded ? _nodes.collapseNode(node) : _nodes.expandNode(node));
|
|
}
|
|
|
|
void _expandAll() => _transitionToTree(_nodes.expandAll());
|
|
|
|
void _collapseAll() => _transitionToTree(_nodes.collapseAll());
|
|
|
|
void _transitionToTree(List<TreeNode<FileTreeNode<T>>> nodes) {
|
|
if (_transitioning) return;
|
|
final currentKeyOrder = _visibleNodeKeysInOrder(_nodes);
|
|
final nextKeyOrder = _visibleNodeKeysInOrder(nodes);
|
|
final currentKeys = currentKeyOrder.toSet();
|
|
final nextKeys = nextKeyOrder.toSet();
|
|
final enteringKeys = nextKeys.difference(currentKeys);
|
|
final leavingKeys = currentKeys.difference(nextKeys);
|
|
|
|
if (enteringKeys.isNotEmpty) {
|
|
setState(() {
|
|
_nodes = nodes;
|
|
_allFoldersExpanded = _foldersAreExpanded(nodes);
|
|
_transitioningNodeKeys = _viewportTransitionKeys(nextKeyOrder, enteringKeys);
|
|
_transitioning = true;
|
|
});
|
|
_transitionController.forward(from: 0);
|
|
return;
|
|
}
|
|
if (leavingKeys.isNotEmpty) {
|
|
setState(() {
|
|
_pendingCollapsedNodes = nodes;
|
|
_transitioningNodeKeys = _viewportTransitionKeys(currentKeyOrder, leavingKeys);
|
|
_transitioning = true;
|
|
});
|
|
_transitionController.reverse(from: 1);
|
|
return;
|
|
}
|
|
setState(() {
|
|
_nodes = nodes;
|
|
_allFoldersExpanded = _foldersAreExpanded(nodes);
|
|
});
|
|
}
|
|
|
|
Set<String> _viewportTransitionKeys(List<String> keyOrder, Set<String> changedKeys) {
|
|
if (changedKeys.isEmpty) return const {};
|
|
var firstIndex = 0;
|
|
var rowCount = _fallbackAnimatedRows;
|
|
if (_scrollController.hasClients) {
|
|
final position = _scrollController.position;
|
|
firstIndex = (position.pixels / widget.rowHeight).floor() - _treeAnimationOverscanRows;
|
|
rowCount = (position.viewportDimension / widget.rowHeight).ceil() + (_treeAnimationOverscanRows * 2);
|
|
}
|
|
firstIndex = firstIndex.clamp(0, keyOrder.length);
|
|
final endIndex = (firstIndex + rowCount).clamp(firstIndex, keyOrder.length);
|
|
return keyOrder.sublist(firstIndex, endIndex).where(changedKeys.contains).toSet();
|
|
}
|
|
|
|
void _toggleSort(_FileTreeSortColumn column) {
|
|
setState(() {
|
|
if (_sortColumn != column) {
|
|
_sortColumn = column;
|
|
_sortDirection = _FileTreeSortDirection.asc;
|
|
} else {
|
|
_sortDirection = switch (_sortDirection) {
|
|
_FileTreeSortDirection.none => _FileTreeSortDirection.asc,
|
|
_FileTreeSortDirection.asc => _FileTreeSortDirection.desc,
|
|
_FileTreeSortDirection.desc => _FileTreeSortDirection.none,
|
|
};
|
|
if (_sortDirection == _FileTreeSortDirection.none) _sortColumn = null;
|
|
}
|
|
_nodes = _sortNodes(_nodes, _sortColumn, _sortDirection);
|
|
});
|
|
}
|
|
}
|
|
|
|
class _FileTreeToolbar extends StatelessWidget {
|
|
const _FileTreeToolbar({
|
|
required this.keyPrefix,
|
|
required this.headerLeading,
|
|
required this.allExpanded,
|
|
required this.nameSortDirection,
|
|
required this.sizeSortDirection,
|
|
required this.showSizeColumn,
|
|
required this.trailingHeader,
|
|
required this.trailingColumnWidth,
|
|
required this.onNameSort,
|
|
required this.onSizeSort,
|
|
required this.onToggleExpanded,
|
|
});
|
|
|
|
final String keyPrefix;
|
|
final Widget? headerLeading;
|
|
final bool allExpanded;
|
|
final _FileTreeSortDirection nameSortDirection;
|
|
final _FileTreeSortDirection sizeSortDirection;
|
|
final bool showSizeColumn;
|
|
final Widget? trailingHeader;
|
|
final double trailingColumnWidth;
|
|
final VoidCallback onNameSort;
|
|
final VoidCallback onSizeSort;
|
|
final VoidCallback onToggleExpanded;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 2),
|
|
child: SizedBox(
|
|
height: _treeHeaderControlHeight,
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
_FileTreeExpandToggle(keyPrefix: keyPrefix, expanded: allExpanded, onPressed: onToggleExpanded),
|
|
const SizedBox(width: _treeExpandLeadingGap),
|
|
if (headerLeading != null) ...[headerLeading!, const SizedBox(width: 8)],
|
|
_FileTreeSortButton(
|
|
keyPrefix: keyPrefix,
|
|
sortKey: 'name',
|
|
label: context.l10n.name,
|
|
direction: nameSortDirection,
|
|
onPressed: onNameSort,
|
|
),
|
|
const Spacer(),
|
|
if (trailingColumnWidth > 0)
|
|
SizedBox(
|
|
width: trailingColumnWidth,
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: trailingHeader != null
|
|
? trailingHeader!
|
|
: showSizeColumn
|
|
? _FileTreeSortButton(
|
|
keyPrefix: keyPrefix,
|
|
sortKey: 'size',
|
|
label: context.l10n.size,
|
|
direction: sizeSortDirection,
|
|
onPressed: onSizeSort,
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FileNameHorizontalScrollbar extends StatelessWidget {
|
|
const _FileNameHorizontalScrollbar({
|
|
required this.keyPrefix,
|
|
required this.controller,
|
|
required this.overflow,
|
|
required this.trailingColumnWidth,
|
|
});
|
|
|
|
final String keyPrefix;
|
|
final ScrollController controller;
|
|
final double overflow;
|
|
final double trailingColumnWidth;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: SizedBox(
|
|
height: _treeHorizontalScrollbarHeight,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final viewportWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth;
|
|
return Scrollbar(
|
|
key: ValueKey('$keyPrefix-horizontal-scrollbar'),
|
|
controller: controller,
|
|
thumbVisibility: true,
|
|
trackVisibility: true,
|
|
interactive: true,
|
|
thickness: 6,
|
|
scrollbarOrientation: ScrollbarOrientation.bottom,
|
|
child: SingleChildScrollView(
|
|
key: ValueKey('$keyPrefix-horizontal-scroll'),
|
|
controller: controller,
|
|
scrollDirection: Axis.horizontal,
|
|
child: SizedBox(width: viewportWidth + overflow, height: 1),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
if (trailingColumnWidth > 0) ...[
|
|
const SizedBox(width: _treeTrailingGap),
|
|
SizedBox(width: trailingColumnWidth),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FileTreeExpandToggle extends StatelessWidget {
|
|
const _FileTreeExpandToggle({required this.keyPrefix, required this.expanded, required this.onPressed});
|
|
|
|
final String keyPrefix;
|
|
final bool expanded;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = AppPalette.of(context);
|
|
return SizedBox(
|
|
key: ValueKey('$keyPrefix-expand-toggle'),
|
|
width: _treeExpandButtonWidth,
|
|
height: _treeHeaderControlHeight,
|
|
child: GhostButton(
|
|
density: ButtonDensity.compact,
|
|
onPressed: onPressed,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_DirectionChevron(key: ValueKey('$keyPrefix-expand-top'), up: !expanded, color: palette.textPrimary),
|
|
_DirectionChevron(key: ValueKey('$keyPrefix-expand-bottom'), up: expanded, color: palette.textPrimary),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FileTreeNodeExpandButton extends StatelessWidget {
|
|
const _FileTreeNodeExpandButton({
|
|
super.key,
|
|
required this.expandable,
|
|
required this.expanded,
|
|
required this.rowHeight,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final bool expandable;
|
|
final bool expanded;
|
|
final double rowHeight;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!expandable) {
|
|
return SizedBox(width: _treeExpandButtonWidth, height: rowHeight);
|
|
}
|
|
final palette = AppPalette.of(context);
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onPressed,
|
|
child: SizedBox(
|
|
width: _treeExpandButtonWidth,
|
|
height: rowHeight,
|
|
child: Center(
|
|
child: AnimatedRotation(
|
|
turns: expanded ? 0.25 : 0,
|
|
duration: _treeTransitionDuration,
|
|
curve: Curves.easeOutCubic,
|
|
child: Icon(BootstrapIcons.caretRightFill, size: 10, color: palette.textSecondary),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DirectionChevron extends StatelessWidget {
|
|
const _DirectionChevron({super.key, required this.up, required this.color});
|
|
|
|
final bool up;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: 14,
|
|
height: 8,
|
|
child: CustomPaint(
|
|
painter: _DirectionChevronPainter(up: up, color: color),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DirectionChevronPainter extends CustomPainter {
|
|
const _DirectionChevronPainter({required this.up, required this.color});
|
|
|
|
final bool up;
|
|
final Color color;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final path = Path();
|
|
if (up) {
|
|
path
|
|
..moveTo(2.5, 5.75)
|
|
..lineTo(size.width / 2, 1.75)
|
|
..lineTo(size.width - 2.5, 5.75);
|
|
} else {
|
|
path
|
|
..moveTo(2.5, 2.25)
|
|
..lineTo(size.width / 2, 6.25)
|
|
..lineTo(size.width - 2.5, 2.25);
|
|
}
|
|
canvas.drawPath(
|
|
path,
|
|
Paint()
|
|
..color = color
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1.5
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.round,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _DirectionChevronPainter oldDelegate) {
|
|
return oldDelegate.up != up || oldDelegate.color != color;
|
|
}
|
|
}
|
|
|
|
class _FileTreeSortButton extends StatelessWidget {
|
|
const _FileTreeSortButton({
|
|
required this.keyPrefix,
|
|
required this.sortKey,
|
|
required this.label,
|
|
required this.direction,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final String keyPrefix;
|
|
final String sortKey;
|
|
final String label;
|
|
final _FileTreeSortDirection direction;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = AppPalette.of(context);
|
|
return GhostButton(
|
|
density: ButtonDensity.compact,
|
|
onPressed: onPressed,
|
|
child: SizedBox(
|
|
height: _treeHeaderControlHeight,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Text(label, style: const TextStyle(fontSize: 12)),
|
|
const SizedBox(width: 2),
|
|
SizedBox(
|
|
width: 14,
|
|
height: 16,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_SortTriangle(
|
|
key: ValueKey('$keyPrefix-sort-$sortKey-asc'),
|
|
up: true,
|
|
active: direction == _FileTreeSortDirection.asc,
|
|
palette: palette,
|
|
),
|
|
_SortTriangle(
|
|
key: ValueKey('$keyPrefix-sort-$sortKey-desc'),
|
|
up: false,
|
|
active: direction == _FileTreeSortDirection.desc,
|
|
palette: palette,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SortTriangle extends StatelessWidget {
|
|
const _SortTriangle({super.key, required this.up, required this.active, required this.palette});
|
|
|
|
final bool up;
|
|
final bool active;
|
|
final AppPalette palette;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(color: active ? palette.itemActiveBg : null, borderRadius: BorderRadius.circular(2)),
|
|
child: SizedBox(
|
|
width: 14,
|
|
height: 8,
|
|
child: CustomPaint(
|
|
painter: _SortTrianglePainter(up: up, color: active ? palette.textPrimary : palette.textMuted),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SortTrianglePainter extends CustomPainter {
|
|
const _SortTrianglePainter({required this.up, required this.color});
|
|
|
|
final bool up;
|
|
final Color color;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final centerX = size.width / 2;
|
|
const horizontalInset = 2.5;
|
|
const verticalInset = 1.75;
|
|
final left = horizontalInset;
|
|
final right = size.width - horizontalInset;
|
|
final top = verticalInset;
|
|
final bottom = size.height - verticalInset;
|
|
final path = Path();
|
|
if (up) {
|
|
path
|
|
..moveTo(centerX, top)
|
|
..lineTo(right, bottom)
|
|
..lineTo(left, bottom);
|
|
} else {
|
|
path
|
|
..moveTo(left, top)
|
|
..lineTo(right, top)
|
|
..lineTo(centerX, bottom);
|
|
}
|
|
canvas.drawPath(path..close(), Paint()..color = color);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _SortTrianglePainter oldDelegate) {
|
|
return oldDelegate.up != up || oldDelegate.color != color;
|
|
}
|
|
}
|
|
|
|
class _FileTreeEmptyState extends StatelessWidget {
|
|
const _FileTreeEmptyState({required this.label});
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = AppPalette.of(context);
|
|
return Center(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(color: palette.textMuted, fontSize: 13, fontWeight: FontWeight.w600),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BuildNode<T> {
|
|
_BuildNode({required this.key, required this.label, required this.originalIndex, this.item});
|
|
|
|
final String key;
|
|
final String label;
|
|
final int originalIndex;
|
|
final FileTreeItem<T>? item;
|
|
final List<_BuildNode<T>> children = [];
|
|
|
|
int get size => item?.size ?? children.fold<int>(0, (sum, child) => sum + child.size);
|
|
|
|
TreeItemNode<FileTreeNode<T>> toTreeItem({int? expandedDepth, int depth = 0}) {
|
|
final childItems = children
|
|
.map((child) => child.toTreeItem(expandedDepth: expandedDepth, depth: depth + 1))
|
|
.toList(growable: false);
|
|
final leaves = item == null ? [for (final child in childItems) ...child.data.leafItems] : <FileTreeItem<T>>[item!];
|
|
return TreeItemNode(
|
|
data: FileTreeNode(
|
|
key: key,
|
|
label: label,
|
|
leafItems: List.unmodifiable(leaves),
|
|
size: size,
|
|
originalIndex: originalIndex,
|
|
item: item,
|
|
),
|
|
expanded: expandedDepth == null || depth < expandedDepth,
|
|
children: childItems,
|
|
);
|
|
}
|
|
}
|
|
|
|
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(
|
|
List<FileTreeItem<T>> items, {
|
|
int? expandedDepth,
|
|
Map<String, String> directoryLabels = const {},
|
|
}) {
|
|
final root = <_BuildNode<T>>[];
|
|
final folders = <String, _BuildNode<T>>{};
|
|
var originalIndex = 0;
|
|
|
|
for (final item in items) {
|
|
final parts = item.path.split(RegExp(r'[/\\]+')).where((part) => part.trim().isNotEmpty).toList();
|
|
var currentPath = '';
|
|
_BuildNode<T>? parent;
|
|
|
|
for (final part in parts) {
|
|
currentPath = currentPath.isEmpty ? part : '$currentPath/$part';
|
|
final directoryKey = item.namespace.isEmpty ? currentPath : '${item.namespace}:$currentPath';
|
|
final folder = folders.putIfAbsent(directoryKey, () {
|
|
final node = _BuildNode<T>(
|
|
key: 'folder:$directoryKey',
|
|
label: directoryLabels[directoryKey] ?? part,
|
|
originalIndex: originalIndex++,
|
|
);
|
|
if (parent == null) {
|
|
root.add(node);
|
|
} else {
|
|
parent.children.add(node);
|
|
}
|
|
return node;
|
|
});
|
|
parent = folder;
|
|
}
|
|
|
|
if (item.directoryOnly) continue;
|
|
|
|
final fileNode = _BuildNode<T>(
|
|
key: 'file:${item.key}',
|
|
label: item.name,
|
|
originalIndex: originalIndex++,
|
|
item: item,
|
|
);
|
|
if (parent == null) {
|
|
root.add(fileNode);
|
|
} else {
|
|
parent.children.add(fileNode);
|
|
}
|
|
}
|
|
|
|
return root.map((node) => node.toTreeItem(expandedDepth: expandedDepth)).toList(growable: false);
|
|
}
|
|
|
|
List<String> _visibleNodeKeysInOrder<T>(List<TreeNode<FileTreeNode<T>>> nodes) {
|
|
final keys = <String>[];
|
|
|
|
void collect(TreeNode<FileTreeNode<T>> node) {
|
|
final item = node as TreeItemNode<FileTreeNode<T>>;
|
|
keys.add(item.data.key);
|
|
if (!item.expanded) return;
|
|
for (final child in item.children) {
|
|
collect(child);
|
|
}
|
|
}
|
|
|
|
for (final node in nodes) {
|
|
collect(node);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
List<TreeNode<FileTreeNode<T>>> _sortNodes<T>(
|
|
List<TreeNode<FileTreeNode<T>>> nodes,
|
|
_FileTreeSortColumn? column,
|
|
_FileTreeSortDirection direction,
|
|
) {
|
|
final sorted = nodes
|
|
.map((node) {
|
|
final item = node as TreeItemNode<FileTreeNode<T>>;
|
|
return item.updateChildren(_sortNodes(item.children, column, direction));
|
|
})
|
|
.toList(growable: true);
|
|
sorted.sort((a, b) {
|
|
final left = a.data;
|
|
final right = b.data;
|
|
if (column == null || direction == _FileTreeSortDirection.none) {
|
|
return left.originalIndex.compareTo(right.originalIndex);
|
|
}
|
|
if (left.isFolder != right.isFolder) return left.isFolder ? -1 : 1;
|
|
final comparison = switch (column) {
|
|
_FileTreeSortColumn.name => left.label.toLowerCase().compareTo(right.label.toLowerCase()),
|
|
_FileTreeSortColumn.size => left.size.compareTo(right.size),
|
|
};
|
|
if (comparison == 0) return left.originalIndex.compareTo(right.originalIndex);
|
|
return direction == _FileTreeSortDirection.asc ? comparison : -comparison;
|
|
});
|
|
return sorted;
|
|
}
|
|
|
|
TreeItemNode<FileTreeNode<T>>? _findTreeNode<T>(Iterable<TreeNode<FileTreeNode<T>>> nodes, String key) {
|
|
for (final node in nodes) {
|
|
final item = node as TreeItemNode<FileTreeNode<T>>;
|
|
if (item.data.key == key) return item;
|
|
final child = _findTreeNode(item.children, key);
|
|
if (child != null) return child;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool _foldersAreExpanded<T>(Iterable<TreeNode<FileTreeNode<T>>> nodes) {
|
|
var hasFolder = false;
|
|
var allExpanded = true;
|
|
|
|
void inspect(TreeNode<FileTreeNode<T>> node) {
|
|
final item = node as TreeItemNode<FileTreeNode<T>>;
|
|
if (item.children.isNotEmpty) {
|
|
hasFolder = true;
|
|
allExpanded = allExpanded && item.expanded;
|
|
}
|
|
for (final child in item.children) {
|
|
inspect(child);
|
|
}
|
|
}
|
|
|
|
for (final node in nodes) {
|
|
inspect(node);
|
|
}
|
|
return hasFolder && allExpanded;
|
|
}
|
|
|
|
Set<String> _expandedFolderKeys<T>(Iterable<TreeNode<FileTreeNode<T>>> nodes) {
|
|
final keys = <String>{};
|
|
void collect(TreeNode<FileTreeNode<T>> node) {
|
|
final item = node as TreeItemNode<FileTreeNode<T>>;
|
|
if (item.data.isFolder && item.expanded) keys.add(item.data.key);
|
|
for (final child in item.children) {
|
|
collect(child);
|
|
}
|
|
}
|
|
|
|
for (final node in nodes) {
|
|
collect(node);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
List<TreeNode<FileTreeNode<T>>> _restoreExpandedFolders<T>(
|
|
List<TreeNode<FileTreeNode<T>>> nodes,
|
|
Set<String> expandedKeys,
|
|
) {
|
|
return nodes
|
|
.map((node) {
|
|
final item = node as TreeItemNode<FileTreeNode<T>>;
|
|
final children = _restoreExpandedFolders(item.children, expandedKeys);
|
|
return item
|
|
.updateChildren(children)
|
|
.updateState(expanded: item.data.isFolder && expandedKeys.contains(item.data.key));
|
|
})
|
|
.toList(growable: false);
|
|
}
|
|
|
|
IconData _fileIcon(String name) {
|
|
final ext = _extensionOf(name);
|
|
if (_videoExts.contains(ext)) return BootstrapIcons.fileEarmarkPlay;
|
|
if (_audioExts.contains(ext)) return BootstrapIcons.fileEarmarkMusic;
|
|
if (_imageExts.contains(ext)) return BootstrapIcons.fileEarmarkImage;
|
|
if (_archiveExts.contains(ext)) return BootstrapIcons.fileEarmarkZip;
|
|
return BootstrapIcons.fileEarmark;
|
|
}
|
|
|
|
String _extensionOf(String name) {
|
|
final index = name.lastIndexOf('.');
|
|
if (index < 0 || index == name.length - 1) return '';
|
|
return name.substring(index + 1).toLowerCase();
|
|
}
|
|
|
|
double _widestLabelWidth<T>(List<TreeNode<FileTreeNode<T>>> nodes, double Function(String label) measureLabel) {
|
|
var widest = _treeMinimumNameContentWidth;
|
|
for (final node in nodes) {
|
|
final item = node as TreeItemNode<FileTreeNode<T>>;
|
|
widest = math.max(widest, measureLabel(item.data.label));
|
|
widest = math.max(widest, _widestLabelWidth(item.children, measureLabel));
|
|
}
|
|
return widest;
|
|
}
|
|
|
|
double _measureLabelWidth(
|
|
String label, {
|
|
required TextStyle style,
|
|
required TextDirection textDirection,
|
|
required TextScaler textScaler,
|
|
}) {
|
|
final painter = TextPainter(
|
|
text: TextSpan(text: label, style: style),
|
|
maxLines: 1,
|
|
textDirection: textDirection,
|
|
textScaler: textScaler,
|
|
)..layout();
|
|
return painter.width + _treeNameTextPadding;
|
|
}
|
|
|
|
double _horizontalOverflow<T>(
|
|
List<TreeNode<FileTreeNode<T>>> nodes, {
|
|
required double baseNameViewport,
|
|
required double depthIndent,
|
|
required double Function(String label) measureLabel,
|
|
int depth = 0,
|
|
}) {
|
|
var overflow = 0.0;
|
|
final viewport = math.max(_treeMinimumNameViewport, baseNameViewport - depth * depthIndent);
|
|
for (final node in nodes) {
|
|
final item = node as TreeItemNode<FileTreeNode<T>>;
|
|
overflow = math.max(overflow, measureLabel(item.data.label) - viewport);
|
|
overflow = math.max(
|
|
overflow,
|
|
_horizontalOverflow(
|
|
item.children,
|
|
baseNameViewport: baseNameViewport,
|
|
depthIndent: depthIndent,
|
|
measureLabel: measureLabel,
|
|
depth: depth + 1,
|
|
),
|
|
);
|
|
}
|
|
return math.max(0, overflow);
|
|
}
|
|
|
|
enum _FileTreeSortColumn { name, size }
|
|
|
|
enum _FileTreeSortDirection { none, asc, desc }
|
|
|
|
const _videoExts = <String>{'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'};
|
|
const _audioExts = <String>{'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'};
|
|
const _imageExts = <String>{'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'};
|
|
const _archiveExts = <String>{'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'};
|
|
const _treeExpandButtonWidth = 18.0;
|
|
const _treeExpandLeadingGap = 4.0;
|
|
const _treeContentOffset = 8.0;
|
|
const _treeHeaderControlHeight = 24.0;
|
|
const _treeRowHeight = 28.0;
|
|
const _treeSizeColumnWidth = 72.0;
|
|
const _treeCustomTrailingColumnWidth = 116.0;
|
|
const _treeTrailingGap = 4.0;
|
|
const _treeHorizontalPadding = 16.0;
|
|
const _treeNodeExpandChrome = _treeExpandButtonWidth + _treeExpandLeadingGap;
|
|
const _treeLeadingChrome = 24.0;
|
|
const _treeMinimumNameViewport = 48.0;
|
|
const _treeMinimumNameContentWidth = 48.0;
|
|
const _treeNameTextPadding = 8.0;
|
|
const _treeFallbackWidth = 600.0;
|
|
const _treeHorizontalScrollbarHeight = 12.0;
|
|
const _treeTransitionDuration = Duration(milliseconds: 150);
|
|
const _treeAnimationOverscanRows = 3;
|
|
const _fallbackAnimatedRows = 32;
|