From 54e7bb2194d484ed64aa2ee48c34f170a40b7b2f Mon Sep 17 00:00:00 2001 From: Tan Hung Date: Sat, 3 Oct 2026 01:37:24 +0700 Subject: [PATCH 1/3] feat: Enhance tag management with child tag support and UI improvements - Implemented child tag entry in the Properties editor and file tag dialogs, allowing users to type a parent tag and enter child tags seamlessly. - Updated UI components to ensure the caret and text selection remain within the parent tag chip for better user experience. - Improved tag suggestion logic to exclude completed and assigned children while typing. - Added localization support for parent and child tag labels. - Enhanced test coverage for tag input behavior, including keyboard navigation and suggestion handling. - Updated documentation to reflect new child tag functionality and usage instructions. --- .../config/languages/app_localizations.dart | 3 + .../languages/english_localizations.dart | 10 +- .../languages/vietnamese_localizations.dart | 9 +- .../tab_manager/components/tag_dialogs.dart | 32 +- .../lib/ui/widgets/chips_input.dart | 619 ++++++++++-------- .../lib/ui/widgets/selection_tag_editor.dart | 1 + .../lib/ui/widgets/tag_chips_field.dart | 2 +- .../lib/ui/widgets/tag_input_helpers.dart | 8 +- cb_file_manager/test/01_chips_input_test.dart | 63 +- .../test/ui/widgets/child_tag_input_test.dart | 389 +++++++++++ .../ui/widgets/tag_input_helpers_test.dart | 38 ++ docs/features/06-tag-management.md | 20 + 12 files changed, 882 insertions(+), 312 deletions(-) create mode 100644 cb_file_manager/test/ui/widgets/child_tag_input_test.dart create mode 100644 cb_file_manager/test/ui/widgets/tag_input_helpers_test.dart diff --git a/cb_file_manager/lib/config/languages/app_localizations.dart b/cb_file_manager/lib/config/languages/app_localizations.dart index aebf105..d953b21 100644 --- a/cb_file_manager/lib/config/languages/app_localizations.dart +++ b/cb_file_manager/lib/config/languages/app_localizations.dart @@ -393,6 +393,9 @@ abstract class AppLocalizations { String exitTagScope(String parent); String childTagHint(String parent); String addingUnderTag(String parent); + String get tagInputHelp; + String get tagSuggestionsLabel; + String childTagCount(int count); String deleteTagConfirmation(String tag); String get tagDeleteConfirmationText; String tagDeleted(String tag); diff --git a/cb_file_manager/lib/config/languages/english_localizations.dart b/cb_file_manager/lib/config/languages/english_localizations.dart index 14c8212..1a7e467 100644 --- a/cb_file_manager/lib/config/languages/english_localizations.dart +++ b/cb_file_manager/lib/config/languages/english_localizations.dart @@ -822,7 +822,15 @@ class EnglishLocalizations implements AppLocalizations { String childTagHint(String parent) => 'New tag inside "$parent"...'; @override String addingUnderTag(String parent) => - 'Adding inside "$parent" — Enter adds another child, Backspace leaves.'; + 'Adding children to "$parent". Esc leaves this parent.'; + @override + String get tagInputHelp => + 'Type a parent tag, then press ":" or → to enter a child inside its chip.'; + @override + String get tagSuggestionsLabel => 'Suggested tags'; + @override + String childTagCount(int count) => + '$count ${count == 1 ? 'child' : 'children'}'; @override String deleteTagConfirmation(String tag) => 'Delete tag "$tag"?'; @override diff --git a/cb_file_manager/lib/config/languages/vietnamese_localizations.dart b/cb_file_manager/lib/config/languages/vietnamese_localizations.dart index fb3f442..678c37f 100644 --- a/cb_file_manager/lib/config/languages/vietnamese_localizations.dart +++ b/cb_file_manager/lib/config/languages/vietnamese_localizations.dart @@ -817,7 +817,14 @@ class VietnameseLocalizations implements AppLocalizations { String childTagHint(String parent) => 'Thẻ con mới trong "$parent"...'; @override String addingUnderTag(String parent) => - 'Đang thêm trong "$parent" — Enter để thêm thẻ con tiếp theo, Backspace để thoát.'; + 'Thêm thẻ con vào "$parent". Esc để thoát thẻ cha.'; + @override + String get tagInputHelp => + 'Gõ thẻ cha, rồi nhấn ":" hoặc → để nhập thẻ con ngay trong chip.'; + @override + String get tagSuggestionsLabel => 'Thẻ gợi ý'; + @override + String childTagCount(int count) => '$count thẻ con'; @override String deleteTagConfirmation(String tag) => 'Xóa thẻ "$tag"?'; @override diff --git a/cb_file_manager/lib/ui/tab_manager/components/tag_dialogs.dart b/cb_file_manager/lib/ui/tab_manager/components/tag_dialogs.dart index 04828aa..f369b97 100644 --- a/cb_file_manager/lib/ui/tab_manager/components/tag_dialogs.dart +++ b/cb_file_manager/lib/ui/tab_manager/components/tag_dialogs.dart @@ -18,6 +18,7 @@ import 'package:cb_file_manager/ui/components/common/app_toast.dart'; import 'package:cb_file_manager/ui/widgets/resizable_dialog.dart'; import 'package:cb_file_manager/ui/widgets/tag_browse_section.dart'; import 'package:cb_file_manager/ui/widgets/tag_chips_field.dart'; +import 'package:cb_file_manager/ui/widgets/chips_input.dart'; import 'package:cb_file_manager/ui/widgets/tag_management_section.dart'; import 'package:cb_file_manager/utils/app_logger.dart'; import '../../utils/route.dart'; @@ -68,12 +69,13 @@ class _SingleFileTagDialog extends StatefulWidget { } class _SingleFileTagDialogState extends State<_SingleFileTagDialog> { + final _inputKey = GlobalKey>(); List _originalTags = []; List _selectedTags = []; List _tagSuggestions = []; String _draftTagText = ''; - /// Parent tag the input is scoped to, shown as a pill inside the field. + /// Parent tag whose inline chip contains the child draft. /// While set, the draft is only the child name and every submit composes /// "parent:child" — so a run of children goes in without retyping the /// parent once. @@ -81,6 +83,7 @@ class _SingleFileTagDialogState extends State<_SingleFileTagDialog> { bool _isLoading = true; bool _isSaving = false; Timer? _debounceTimer; + int _suggestionGeneration = 0; final _thumbnailManager = TagThumbnailManager.instance; final _hierarchyManager = TagHierarchyManager.instance; @@ -101,6 +104,7 @@ class _SingleFileTagDialogState extends State<_SingleFileTagDialog> { @override void dispose() { + _suggestionGeneration++; _debounceTimer?.cancel(); super.dispose(); } @@ -161,7 +165,9 @@ class _SingleFileTagDialogState extends State<_SingleFileTagDialog> { Future _updateTagSuggestions(String text) async { _debounceTimer?.cancel(); + final generation = ++_suggestionGeneration; final query = _scopedQuery(text); + if (mounted) setState(() => _tagSuggestions = []); if (query.isEmpty) { if (!mounted) return; @@ -178,7 +184,7 @@ class _SingleFileTagDialogState extends State<_SingleFileTagDialog> { hierarchyManager: _hierarchyManager, isSelected: _containsTag, ); - if (!mounted) return; + if (!mounted || generation != _suggestionGeneration) return; setState(() { _tagSuggestions = suggestions; }); @@ -209,6 +215,7 @@ class _SingleFileTagDialogState extends State<_SingleFileTagDialog> { void _addTag(String rawTag) { final tag = rawTag.trim(); + _inputKey.currentState?.clearDraft(); if (tag.isEmpty) { _draftTagText = ''; return; @@ -371,19 +378,18 @@ class _SingleFileTagDialogState extends State<_SingleFileTagDialog> { return _buildSectionCard( icon: PhosphorIconsLight.pencilSimpleLine, title: l10n.addTag, - subtitle: scope != null - ? l10n.addingUnderTag(scope) - : 'Type a tag. Press ":" or → on a suggestion to add inside it.', + subtitle: scope != null ? l10n.addingUnderTag(scope) : l10n.tagInputHelp, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TagChipsField( + fieldKey: _inputKey, tags: _selectedTags, suggestions: _tagSuggestions, scopeParent: scope, onScopeChanged: _onScopeChanged, onSuggestionSelected: _onSuggestionSelected, - hintText: '${l10n.enterTagName} (e.g. Actress:Hung)', + hintText: l10n.enterTagName, onTextChanged: (value) { _draftTagText = value; _updateTagSuggestions(value); @@ -714,10 +720,11 @@ void showDeleteTagDialog( /// Dialog for batch adding tags void showBatchAddTagDialog(BuildContext context, List selectedFiles) { final focusNode = FocusNode(); - final TextEditingController textController = TextEditingController(); + final inputKey = GlobalKey>(); List tagSuggestions = []; List selectedTags = []; String draftTagText = ''; + int suggestionGeneration = 0; /// Parent tag the input is scoped to — see _SingleFileTagDialogState. String? scopeParent; @@ -741,11 +748,13 @@ void showBatchAddTagDialog(BuildContext context, List selectedFiles) { } Future updateTagSuggestions(String text) async { - tagSuggestions = await computeTagSuggestions( + final generation = ++suggestionGeneration; + final suggestions = await computeTagSuggestions( scopedQuery(text), hierarchyManager: hierarchyManager, isSelected: selectedTags.contains, ); + if (generation == suggestionGeneration) tagSuggestions = suggestions; } /// Handles "parent:child1,child2" input by adding the parent + children to @@ -762,7 +771,7 @@ void showBatchAddTagDialog(BuildContext context, List selectedFiles) { .toList(growable: false); selectedTags.addAll(tagsToAdd); - textController.clear(); + inputKey.currentState?.clearDraft(); draftTagText = ''; tagSuggestions = []; @@ -790,7 +799,7 @@ void showBatchAddTagDialog(BuildContext context, List selectedFiles) { if (!selectedTags.contains(trimmed)) { selectedTags.add(trimmed); - textController.clear(); + inputKey.currentState?.clearDraft(); draftTagText = ''; } } @@ -846,7 +855,7 @@ void showBatchAddTagDialog(BuildContext context, List selectedFiles) { void handleTextChange(String value) { draftTagText = value; - setState(() {}); + setState(() => tagSuggestions = []); refreshSuggestions(value); } @@ -918,6 +927,7 @@ void showBatchAddTagDialog(BuildContext context, List selectedFiles) { Focus( focusNode: focusNode, child: TagChipsField( + fieldKey: inputKey, tags: selectedTags, suggestions: tagSuggestions, scopeParent: scopeParent, diff --git a/cb_file_manager/lib/ui/widgets/chips_input.dart b/cb_file_manager/lib/ui/widgets/chips_input.dart index a27c8d3..8165d35 100644 --- a/cb_file_manager/lib/ui/widgets/chips_input.dart +++ b/cb_file_manager/lib/ui/widgets/chips_input.dart @@ -36,10 +36,10 @@ class ChipsInput extends StatefulWidget { final ValueChanged? onTextChanged; /// Autocomplete suggestions shown below the input. - /// Press Tab or click to pick a suggestion. + /// Press Tab, or Enter after arrow navigation, or click to pick a suggestion. final List suggestions; - /// Called when a suggestion is picked (via Tab or click). + /// Called when a suggestion is picked (via keyboard or click). final ValueChanged? onSuggestionSelected; /// Custom builder for suggestion items. If null, uses default rendering. @@ -59,14 +59,14 @@ class ChipsInput extends StatefulWidget { final bool enableColonAutocomplete; /// The parent tag the field is scoped to, or null when typing at the top - /// level. While scoped, a pill is shown after the selected tag chips and - /// immediately before the child draft — the caller composes + /// level. While scoped, the draft is edited inside the parent's inline chip. + /// The caller composes /// "parent:child" itself and decides when the scope is dropped. final String? scopeParent; - /// Called when the scope changes: a parent is entered (":" or "->" on a - /// highlighted suggestion) or left (Backspace on an empty draft, or the - /// pill's "x"). Leaving this null keeps the older inline "parent:" prefix + /// Called when a parent is entered through its child action or ":" / "→", + /// or left through Esc, Backspace on an empty draft, or the context's "x". + /// Leaving this null keeps the older inline "parent:" prefix /// behavior of [enableColonAutocomplete]. final ValueChanged? onScopeChanged; @@ -91,9 +91,14 @@ class ChipsInputState extends State> { TextSelection? _previousSelection; /// Index of the currently highlighted suggestion (-1 = none). - int _highlightedIndex = 0; + int _highlightedIndex = -1; + bool _suggestionNavigated = false; + bool _suggestionsDismissed = false; final LayerLink _layerLink = LayerLink(); + final GlobalKey _targetKey = GlobalKey(); + final GlobalKey _editableKey = GlobalKey(); + final ScrollController _suggestionScroll = ScrollController(); OverlayEntry? _overlayEntry; @override @@ -103,8 +108,7 @@ class ChipsInputState extends State> { controller = ChipsInputEditingController( [...widget.values], widget.chipBuilder, - _exitScope, - scopeParent: widget.scopeParent, + editingChild: widget.scopeParent != null, ); controller.addListener(_textListener); _focusNode = FocusNode(); @@ -114,9 +118,11 @@ class ChipsInputState extends State> { @override void didUpdateWidget(covariant ChipsInput oldWidget) { super.didUpdateWidget(oldWidget); + controller.updateChildMode(widget.scopeParent != null); // Reset highlight when suggestions change if (widget.suggestions != oldWidget.suggestions) { - _highlightedIndex = 0; + _highlightedIndex = -1; + _suggestionNavigated = false; _updateOverlay(); } } @@ -124,6 +130,7 @@ class ChipsInputState extends State> { @override void dispose() { _removeOverlay(); + _suggestionScroll.dispose(); controller.removeListener(_textListener); controller.dispose(); _focusNode.removeListener(_onFocusChanged); @@ -132,9 +139,11 @@ class ChipsInputState extends State> { } void _onFocusChanged() { + if (mounted) setState(() {}); if (!_focusNode.hasFocus) { _removeOverlay(); } else { + _suggestionsDismissed = false; _updateOverlay(); } } @@ -146,7 +155,9 @@ class ChipsInputState extends State> { // during a build phase (e.g. when called from didUpdateWidget). WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - if (widget.suggestions.isNotEmpty && _focusNode.hasFocus) { + if (widget.suggestions.isNotEmpty && + _focusNode.hasFocus && + !_suggestionsDismissed) { if (_overlayEntry != null) { _overlayEntry!.markNeedsBuild(); } else { @@ -165,13 +176,27 @@ class ChipsInputState extends State> { } OverlayEntry _buildOverlayEntry() { - final renderBox = context.findRenderObject() as RenderBox; - final size = renderBox.size; - return OverlayEntry( builder: (context) { final suggestions = widget.suggestions; if (suggestions.isEmpty) return const SizedBox.shrink(); + final renderBox = _targetKey.currentContext?.findRenderObject(); + if (renderBox is! RenderBox || !renderBox.hasSize) { + return const SizedBox.shrink(); + } + final size = renderBox.size; + final position = renderBox.localToGlobal(Offset.zero); + final media = MediaQuery.of(context); + final below = + media.size.height - + media.viewInsets.bottom - + media.padding.bottom - + position.dy - + size.height - + 8; + final above = position.dy - media.padding.top - 8; + final showAbove = below < 180 && above > below; + final available = (showAbove ? above : below).clamp(80.0, 280.0); final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; @@ -181,7 +206,11 @@ class ChipsInputState extends State> { child: CompositedTransformFollower( link: _layerLink, showWhenUnlinked: false, - offset: Offset(0, size.height + 4), + targetAnchor: Alignment.topLeft, + followerAnchor: showAbove + ? Alignment.bottomLeft + : Alignment.topLeft, + offset: Offset(0, showAbove ? -4 : size.height + 4), child: TextFieldTapRegion( child: Material( elevation: 4, @@ -209,29 +238,35 @@ class ChipsInputState extends State> { .withValues(alpha: 0.6), ), const SizedBox(width: 6), - Text( - 'Suggestions', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurfaceVariant - .withValues(alpha: 0.6), + Expanded( + child: Text( + AppLocalizations.of( + context, + )?.tagSuggestionsLabel ?? + 'Suggested tags', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.6), + ), ), ), - const Spacer(), if (widget.onScopeChanged != null && widget.scopeParent == null) ...[ _keyHintBadge(theme, '→'), const SizedBox(width: 4), ], - _keyHintBadge(theme, 'Tab ↹'), + _keyHintBadge(theme, '↑↓ Enter'), ], ), ), const Divider(height: 1), ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 220), + constraints: BoxConstraints(maxHeight: available - 40), child: ListView.builder( + controller: _suggestionScroll, + itemExtent: _suggestionRowHeight, padding: const EdgeInsets.symmetric(vertical: 4), shrinkWrap: true, itemCount: suggestions.length, @@ -338,9 +373,27 @@ class ChipsInputState extends State> { void _pickSuggestion(String suggestion) { widget.onSuggestionSelected?.call(suggestion); + _clearDraft(); + _suggestionsDismissed = true; + _removeOverlay(); _focusNode.requestFocus(); } + double get _suggestionRowHeight => widget.suggestionBuilder == null ? 48 : 72; + + void _revealHighlightedSuggestion() { + if (!_suggestionScroll.hasClients) return; + final position = _suggestionScroll.position; + final start = _highlightedIndex * _suggestionRowHeight; + final end = start + _suggestionRowHeight; + final offset = start < position.pixels + ? start + : end > position.pixels + position.viewportDimension + ? end - position.viewportDimension + : position.pixels; + _suggestionScroll.jumpTo(offset.clamp(0.0, position.maxScrollExtent)); + } + /// Replaces the currently-typed text with `":"` so the user can keep /// typing/autocompleting the child tag. void _promoteToParent(String parent) { @@ -365,20 +418,20 @@ class ChipsInputState extends State> { /// "parent:child" on every submit until the scope is left. void _enterScope(String parent) { _clearDraft(); + _suggestionsDismissed = false; + _removeOverlay(); widget.onScopeChanged!(parent); _restoreDraftFocusAfterScopeChange(); } void _exitScope() { + _clearDraft(); + _removeOverlay(); widget.onScopeChanged?.call(null); _restoreDraftFocusAfterScopeChange(); } - /// A scope change rebuilds the multiline field to insert/remove the parent - /// pill. With many wrapped chips Flutter can restore the click-derived text - /// selection after that rebuild, which puts the caret near the first chip. - /// Re-assert the logical draft end after layout so child typing always - /// resumes after every selected tag. + /// Restore the editable draft's caret after a parent action changes layout. void _restoreDraftFocusAfterScopeChange() { _focusNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -398,7 +451,6 @@ class ChipsInputState extends State> { void _clearDraft() { final String chipChars = controller.prefixFor( valueCount: widget.values.length, - scopeParent: widget.scopeParent, ); controller.value = TextEditingValue( text: chipChars, @@ -436,9 +488,34 @@ class ChipsInputState extends State> { final bool scopeEnabled = widget.onScopeChanged != null; final String typed = controller.textWithoutReplacements; - // Backspace on an empty draft leaves the parent scope. The pill sits - // between the chips and the caret, so it is what Backspace reaches first; - // deleting a chip stays one Backspace further back. + // Let the IME finish Vietnamese and other composed text first. + if (controller.value.composing.isValid && + !controller.value.composing.isCollapsed) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.escape) { + if (widget.scopeParent != null && scopeEnabled) { + _exitScope(); + return KeyEventResult.handled; + } + if (_overlayEntry != null) { + _suggestionsDismissed = true; + _removeOverlay(); + return KeyEventResult.handled; + } + } + + if (HardwareKeyboard.instance.isControlPressed || + HardwareKeyboard.instance.isMetaPressed || + HardwareKeyboard.instance.isAltPressed || + HardwareKeyboard.instance.isShiftPressed) { + // Preserve text-selection keys and Shift+Tab focus traversal. Colon is + // the sole exception because keyboards commonly produce it with Shift. + if (event.character != ':') return KeyEventResult.ignored; + } + + // Leave the parent before allowing another Backspace to remove a chip. if (scopeEnabled && widget.scopeParent != null && event.logicalKey == LogicalKeyboardKey.backspace && @@ -456,12 +533,14 @@ class ChipsInputState extends State> { if (scopeEnabled && widget.scopeParent != null) { return KeyEventResult.handled; } - if (suggestions.isNotEmpty && !typed.contains(':')) { - final index = _highlightedIndex.clamp(0, suggestions.length - 1); + if (typed.trim().isNotEmpty && !typed.contains(':')) { + final parent = suggestions.isNotEmpty && !_suggestionsDismissed + ? suggestions[_highlightedIndex.clamp(0, suggestions.length - 1)] + : typed.trim(); if (scopeEnabled) { - _enterScope(suggestions[index]); + _enterScope(parent); } else { - _promoteToParent(suggestions[index]); + _promoteToParent(parent); } return KeyEventResult.handled; } @@ -478,7 +557,7 @@ class ChipsInputState extends State> { controller.selection.isCollapsed && controller.selection.baseOffset >= controller.text.length) { final typedParent = typed.trim(); - if (suggestions.isNotEmpty) { + if (suggestions.isNotEmpty && !_suggestionsDismissed) { final index = _highlightedIndex.clamp(0, suggestions.length - 1); _enterScope(suggestions[index]); return KeyEventResult.handled; @@ -489,7 +568,9 @@ class ChipsInputState extends State> { } } - if (suggestions.isEmpty) return KeyEventResult.ignored; + if (suggestions.isEmpty || _suggestionsDismissed) { + return KeyEventResult.ignored; + } if (event.logicalKey == LogicalKeyboardKey.tab) { // Pick the highlighted suggestion @@ -500,18 +581,25 @@ class ChipsInputState extends State> { if (event.logicalKey == LogicalKeyboardKey.arrowDown) { setState(() { - _highlightedIndex = (_highlightedIndex + 1) % suggestions.length; + _highlightedIndex = _suggestionNavigated + ? (_highlightedIndex + 1) % suggestions.length + : 0; + _suggestionNavigated = true; }); _overlayEntry?.markNeedsBuild(); + _revealHighlightedSuggestion(); return KeyEventResult.handled; } if (event.logicalKey == LogicalKeyboardKey.arrowUp) { setState(() { - _highlightedIndex = - (_highlightedIndex - 1 + suggestions.length) % suggestions.length; + _highlightedIndex = _suggestionNavigated + ? (_highlightedIndex - 1 + suggestions.length) % suggestions.length + : suggestions.length - 1; + _suggestionNavigated = true; }); _overlayEntry?.markNeedsBuild(); + _revealHighlightedSuggestion(); return KeyEventResult.handled; } @@ -523,7 +611,7 @@ class ChipsInputState extends State> { void _textListener() { final String currentText = controller.text; - if (_previousSelection != null) { + if (_previousSelection != null && !controller.editingChild) { final int currentNumber = countReplacements(currentText); final int previousNumber = countReplacements(_previousText); @@ -563,61 +651,122 @@ class ChipsInputState extends State> { } static int countPrefixReplacements(String text) { - return text.codeUnits.where((int unit) { - return unit == ChipsInputEditingController.kObjectReplacementChar || - unit == ChipsInputEditingController.kScopeReplacementChar; - }).length; + return countReplacements(text); } @override Widget build(BuildContext context) { controller.chipBuilder = widget.chipBuilder; controller.updateValues([...widget.values]); - controller.updateScope(widget.scopeParent, _exitScope); - - // Create a decoration that ensures proper padding for chips - final InputDecoration adjustedDecoration = widget.decoration.copyWith( + final parent = widget.scopeParent; + final decoration = widget.decoration.copyWith( contentPadding: const EdgeInsets.fromLTRB(12, 12, 12, 10), isDense: false, ); + Widget draftField({Color? childColor}) => TextField( + // Keep the same editor and text-input connection when moving into/out + // of the parent chip. Only the editing mode and decoration change. + key: _editableKey, + minLines: 1, + maxLines: parent == null ? 8 : 1, + textInputAction: TextInputAction.done, + style: parent == null + ? widget.style + : Theme.of(context).textTheme.bodyMedium! + .merge(widget.style) + .copyWith(fontSize: 13, color: childColor), + strutStyle: parent == null + ? widget.strutStyle ?? + const StrutStyle( + fontSize: _kStrutFontSize, + height: _kRowHeight / _kStrutFontSize, + forceStrutHeight: true, + leadingDistribution: TextLeadingDistribution.even, + ) + : null, + cursorHeight: parent == null + ? (widget.style?.fontSize ?? _kStrutFontSize) * 1.25 + : 18, + cursorColor: childColor, + textAlignVertical: parent == null ? null : TextAlignVertical.center, + controller: controller, + focusNode: _focusNode, + inputFormatters: const [_ChipPrefixTextInputFormatter()], + decoration: parent == null + ? decoration + : InputDecoration( + hintText: AppLocalizations.of(context)!.childTagInputHint, + hintStyle: TextStyle( + color: childColor?.withValues(alpha: .65), + fontSize: 13, + ), + isDense: true, + // Let the chip's Row center the editor at its natural text height. + // Neutral density avoids the compact theme's baseline offsets. + isCollapsed: true, + visualDensity: VisualDensity.standard, + filled: false, + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + ), + onChanged: (_) { + _suggestionsDismissed = false; + _suggestionNavigated = false; + _highlightedIndex = -1; + widget.onTextChanged?.call(controller.textWithoutReplacements); + setState(() {}); + _updateOverlay(); + }, + onSubmitted: (_) { + if (_suggestionNavigated && + widget.suggestions.isNotEmpty && + !_suggestionsDismissed) { + _pickSuggestion(widget.suggestions[_highlightedIndex]); + } else { + widget.onSubmitted?.call(controller.textWithoutReplacements); + _clearDraft(); + } + _focusNode.requestFocus(); + }, + ); + return CompositedTransformTarget( + key: _targetKey, link: _layerLink, child: FocusScope( onKeyEvent: _handleKeyEvent, child: Container( margin: const EdgeInsets.symmetric(vertical: 2), - child: TextField( - minLines: 1, - maxLines: 8, - textInputAction: TextInputAction.done, - style: widget.style, - strutStyle: - widget.strutStyle ?? - const StrutStyle( - fontSize: _kStrutFontSize, - height: _kRowHeight / _kStrutFontSize, - forceStrutHeight: true, - leadingDistribution: TextLeadingDistribution.even, + child: parent == null + ? draftField() + : InputDecorator( + decoration: decoration.copyWith(hintText: null), + isFocused: _focusNode.hasFocus, + isEmpty: false, + child: LayoutBuilder( + builder: (context, constraints) => Wrap( + spacing: 4, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final tag in widget.values) + widget.chipBuilder(context, tag), + TagScopeChip( + parent: parent, + onExit: _exitScope, + maxWidth: constraints.maxWidth, + draft: controller.textWithoutReplacements, + inputBuilder: (color) => + draftField(childColor: color), + ), + ], + ), + ), ), - // Keep the caret text-sized; it would otherwise span the whole row. - cursorHeight: widget.strutStyle == null - ? (widget.style?.fontSize ?? _kStrutFontSize) * 1.25 - : null, - controller: controller, - focusNode: _focusNode, - inputFormatters: const [ - _ChipPrefixTextInputFormatter(), - ], - decoration: adjustedDecoration, - onChanged: (String value) => - widget.onTextChanged?.call(controller.textWithoutReplacements), - onSubmitted: (String value) { - widget.onSubmitted?.call(controller.textWithoutReplacements); - // Re-focus the input so the user can continue typing tags - _focusNode.requestFocus(); - }, - ), ), ), ); @@ -627,18 +776,13 @@ class ChipsInputState extends State> { class ChipsInputEditingController extends TextEditingController { ChipsInputEditingController( this.values, - this.chipBuilder, - this._onScopeExit, { - this.scopeParent, + this.chipBuilder, { + this.editingChild = false, }) : super() { - final prefix = prefixFor( - valueCount: values.length, - scopeParent: scopeParent, - ); - final text = _emptyDraftText(prefix, scopeParent); + final text = prefixFor(valueCount: values.length); value = TextEditingValue( text: text, - selection: TextSelection.collapsed(offset: prefix.length), + selection: TextSelection.collapsed(offset: text.length), ); } @@ -646,17 +790,19 @@ class ChipsInputEditingController extends TextEditingController { // There will be one character for each of the InputChip displayed. static const int kObjectReplacementChar = 0xFFFE; - /// A separate placeholder keeps the parent scope pill in the editable text - /// flow without making it look like one of the selected value chips. - static const int kScopeReplacementChar = 0xFFFC; - - /// One replacement character for the visible inline child-input hint. - /// The caret sits immediately before it; typing replaces it with the draft. - static const int kInputHintReplacementChar = 0xFFFB; - List values; - String? scopeParent; - VoidCallback _onScopeExit; + bool editingChild; + + void updateChildMode(bool enabled) { + if (editingChild == enabled) return; + final draft = textWithoutReplacements; + editingChild = enabled; + final prefix = prefixFor(valueCount: values.length); + value = TextEditingValue( + text: '$prefix$draft', + selection: TextSelection.collapsed(offset: prefix.length + draft.length), + ); + } /// Replaced on every build so chips never render through a stale closure. Widget Function(BuildContext context, T data) chipBuilder; @@ -665,14 +811,13 @@ class ChipsInputEditingController extends TextEditingController { /// from the outside the context of the text field. void updateValues(List values) { if (values.length != this.values.length) { - final prefix = prefixFor( - valueCount: values.length, - scopeParent: scopeParent, - ); - final text = _emptyDraftText(prefix, scopeParent); + final prefix = prefixFor(valueCount: values.length); + final draft = textWithoutReplacements; value = TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: prefix.length), + text: '$prefix$draft', + selection: TextSelection.collapsed( + offset: prefix.length + draft.length, + ), ); } // Always take the new list: a same-length change (another file's tags, @@ -680,51 +825,20 @@ class ChipsInputEditingController extends TextEditingController { this.values = values; } - void updateScope(String? scopeParent, VoidCallback onScopeExit) { - _onScopeExit = onScopeExit; - if (scopeParent == this.scopeParent) return; - - final draft = textWithoutReplacements; - this.scopeParent = scopeParent; - final prefix = prefixFor( - valueCount: values.length, - scopeParent: scopeParent, - ); - final text = draft.isEmpty - ? _emptyDraftText(prefix, scopeParent) - : '$prefix$draft'; - value = TextEditingValue( - text: text, - selection: TextSelection.collapsed( - offset: draft.isEmpty ? prefix.length : text.length, - ), - ); - } - - String prefixFor({required int valueCount, required String? scopeParent}) { + String prefixFor({required int valueCount}) { + if (editingChild) return ''; final chip = String.fromCharCode(kObjectReplacementChar); - final scope = String.fromCharCode(kScopeReplacementChar); - return '${chip * valueCount}${scopeParent == null ? '' : scope}'; - } - - String _emptyDraftText(String prefix, String? scopeParent) { - if (scopeParent == null) return prefix; - return '$prefix${String.fromCharCode(kInputHintReplacementChar)}'; + return chip * valueCount; } String get textWithoutReplacements { final chip = String.fromCharCode(kObjectReplacementChar); - final scope = String.fromCharCode(kScopeReplacementChar); - final hint = String.fromCharCode(kInputHintReplacementChar); - return text.replaceAll(chip, '').replaceAll(scope, '').replaceAll(hint, ''); + return text.replaceAll(chip, ''); } String get textWithReplacements => text; - int get draftEndOffset { - final hintIndex = text.codeUnits.indexOf(kInputHintReplacementChar); - return hintIndex < 0 ? text.length : hintIndex; - } + int get draftEndOffset => text.length; @override TextSpan buildTextSpan({ @@ -732,6 +846,13 @@ class ChipsInputEditingController extends TextEditingController { TextStyle? style, required bool withComposing, }) { + if (editingChild) { + return super.buildTextSpan( + context: context, + style: style, + withComposing: withComposing, + ); + } // Create a list to hold all spans final List spans = []; @@ -749,27 +870,6 @@ class ChipsInputEditingController extends TextEditingController { ); } - final parent = scopeParent; - if (parent != null) { - spans.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: TagScopeChip(parent: parent, onExit: _onScopeExit), - ), - ); - } - - if (text.codeUnits.contains(kInputHintReplacementChar)) { - spans.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: ChildTagInputHint( - label: AppLocalizations.of(context)!.childTagInputHint, - ), - ), - ); - } - // Add text input after chips if (textWithoutReplacements.isNotEmpty) { spans.add(TextSpan(text: textWithoutReplacements)); @@ -780,7 +880,7 @@ class ChipsInputEditingController extends TextEditingController { } /// Keeps the editable draft after the replacement characters that represent -/// selected chips and the optional parent-scope pill. +/// selected chips. /// /// Flutter can place the raw text selection before (or between) replacement /// characters when a user clicks a wrapped chip field. The controller renders @@ -799,27 +899,10 @@ class _ChipPrefixTextInputFormatter extends TextInputFormatter { final chipReplacement = String.fromCharCode( ChipsInputEditingController.kObjectReplacementChar, ); - final scopeReplacement = String.fromCharCode( - ChipsInputEditingController.kScopeReplacementChar, - ); - final hintReplacement = String.fromCharCode( - ChipsInputEditingController.kInputHintReplacementChar, - ); final chipCount = ChipsInputState.countReplacements(newValue.text); - final scopeCount = newValue.text.codeUnits - .where( - (unit) => unit == ChipsInputEditingController.kScopeReplacementChar, - ) - .length; - final prefixLength = chipCount + scopeCount; - final draft = newValue.text - .replaceAll(chipReplacement, '') - .replaceAll(scopeReplacement, '') - .replaceAll(hintReplacement, ''); - final normalizedText = - chipReplacement * chipCount + - scopeReplacement * scopeCount + - (scopeCount > 0 && draft.isEmpty ? hintReplacement : draft); + final prefixLength = chipCount; + final draft = newValue.text.replaceAll(chipReplacement, ''); + final normalizedText = chipReplacement * chipCount + draft; int mapOffset(int offset) { if (offset < 0) return offset; @@ -829,9 +912,7 @@ class _ChipPrefixTextInputFormatter extends TextInputFormatter { .codeUnits .where( (unit) => - unit != ChipsInputEditingController.kObjectReplacementChar && - unit != ChipsInputEditingController.kScopeReplacementChar && - unit != ChipsInputEditingController.kInputHintReplacementChar, + unit != ChipsInputEditingController.kObjectReplacementChar, ) .length; return prefixLength + draftUnitsBeforeOffset; @@ -859,14 +940,22 @@ class _ChipPrefixTextInputFormatter extends TextInputFormatter { } } -/// The parent pill that rides after selected chips in a scoped [ChipsInput]: -/// while it is showing, everything typed after it is added as a child of -/// [parent]. It deliberately reads as a breadcrumb rather than a tag chip. +/// The inline parent chip contains the real child editor, including its caret. class TagScopeChip extends StatelessWidget { - const TagScopeChip({super.key, required this.parent, required this.onExit}); + const TagScopeChip({ + super.key, + required this.parent, + required this.onExit, + required this.inputBuilder, + required this.maxWidth, + required this.draft, + }); final String parent; final VoidCallback onExit; + final Widget Function(Color color) inputBuilder; + final double maxWidth; + final String draft; @override Widget build(BuildContext context) { @@ -874,95 +963,91 @@ class TagScopeChip extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final isDark = theme.brightness == Brightness.dark; final tagColor = TagColorManager.instance.getTagColor(parent); - final foregroundColor = TagChipStyle.readableOn( + final foreground = TagChipStyle.readableOn( Color.alphaBlend( TagChipStyle.tint(tagColor, isDark: isDark), theme.colorScheme.surface, ), ); - final contentColor = foregroundColor == Colors.white - ? Colors.white - : tagColor; + final color = foreground == Colors.white ? Colors.white : tagColor; + final textStyle = theme.textTheme.bodyMedium!.copyWith(fontSize: 13); + double measure(String text, TextStyle style) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final width = painter.width; + painter.dispose(); + return width; + } + + final showLabel = maxWidth >= 240; + final labelStyle = textStyle.copyWith( + fontSize: 10, + fontWeight: FontWeight.w600, + ); + final parentStyle = textStyle.copyWith( + fontWeight: FontWeight.w700, + color: color, + ); + final parentWidth = measure(parent, parentStyle).clamp(0.0, maxWidth * .32); + final labelWidth = showLabel + ? measure('${l10n.parentTagLabel}:', labelStyle) + 4 + : 0; + final inputWidth = + measure(draft.isEmpty ? l10n.childTagInputHint : draft, textStyle) + 16; + final width = + (labelWidth + parentWidth + inputWidth.clamp(80.0, maxWidth) + 52) + .clamp(0.0, maxWidth); - return Padding( - padding: const EdgeInsets.only(right: 6), - child: Container( - padding: const EdgeInsets.fromLTRB(8, 5, 5, 5), - decoration: TagChipStyle.decoration(tagColor, isDark: isDark), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ + return Container( + width: width, + constraints: const BoxConstraints(minHeight: 36), + padding: const EdgeInsets.only(left: 8, right: 4), + decoration: TagChipStyle.decoration(tagColor, isDark: isDark), + child: Row( + children: [ + if (showLabel) ...[ Text( '${l10n.parentTagLabel}:', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: contentColor.withValues(alpha: 0.72), - ), + style: labelStyle.copyWith(color: color.withValues(alpha: .72)), ), const SizedBox(width: 4), - Flexible( - fit: FlexFit.loose, + ], + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth * .32), + child: Tooltip( + message: parent, child: Text( parent, - overflow: TextOverflow.ellipsis, maxLines: 1, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: contentColor, - ), + overflow: TextOverflow.ellipsis, + style: parentStyle, ), ), - Icon( - PhosphorIconsLight.caretRight, - size: 12, - color: contentColor.withValues(alpha: 0.7), - ), - const SizedBox(width: 5), - Tooltip( - message: l10n.exitTagScope(parent), - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: onExit, - child: Padding( - padding: const EdgeInsets.all(2), - child: Icon( - PhosphorIconsLight.x, - size: 12, - color: contentColor, - ), - ), - ), + ), + Icon( + PhosphorIconsLight.caretRight, + size: 12, + color: color.withValues(alpha: .7), + ), + const SizedBox(width: 4), + Expanded(child: inputBuilder(color)), + IconButton( + tooltip: l10n.exitTagScope(parent), + onPressed: onExit, + constraints: const BoxConstraints.tightFor(width: 24, height: 32), + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + minimumSize: const Size(24, 32), + maximumSize: const Size(24, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, ), - ], - ), - ), - ); - } -} - -/// Ghost text rendered at the actual child-draft caret position. It occupies -/// one replacement character and disappears as soon as the user types. -class ChildTagInputHint extends StatelessWidget { - const ChildTagInputHint({super.key, required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - final color = Theme.of( - context, - ).colorScheme.onSurfaceVariant.withValues(alpha: 0.62); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - label, - style: TextStyle( - fontSize: 13, - fontStyle: FontStyle.italic, - color: color, - ), + icon: Icon(PhosphorIconsLight.x, size: 12, color: color), + ), + ], ), ); } diff --git a/cb_file_manager/lib/ui/widgets/selection_tag_editor.dart b/cb_file_manager/lib/ui/widgets/selection_tag_editor.dart index 1dabd48..8bfb295 100644 --- a/cb_file_manager/lib/ui/widgets/selection_tag_editor.dart +++ b/cb_file_manager/lib/ui/widgets/selection_tag_editor.dart @@ -95,6 +95,7 @@ class _SelectionTagEditorState extends State { _debounce?.cancel(); final generation = ++_generation; final query = _query(value); + setState(() => _suggestions = []); if (query.trim().isEmpty) { setState(() => _suggestions = []); return; diff --git a/cb_file_manager/lib/ui/widgets/tag_chips_field.dart b/cb_file_manager/lib/ui/widgets/tag_chips_field.dart index ac84a35..cac54c0 100644 --- a/cb_file_manager/lib/ui/widgets/tag_chips_field.dart +++ b/cb_file_manager/lib/ui/widgets/tag_chips_field.dart @@ -9,7 +9,7 @@ import 'package:cb_file_manager/ui/widgets/tag_input_helpers.dart'; /// The tag field shared by the Manage Tags dialogs and the properties pane: /// assigned tags sit as chips inside the input, suggestions show thumbnails -/// and hierarchy, and ":" / "→" scope the draft under a parent tag. +/// and hierarchy, and ":" / "→" place the child editor inside the parent chip. /// /// Callers own the tag list and the draft/scope state; removing a chip (its /// "x" or Backspace) is reported per tag through [onRemoved]. diff --git a/cb_file_manager/lib/ui/widgets/tag_input_helpers.dart b/cb_file_manager/lib/ui/widgets/tag_input_helpers.dart index f4ed881..847b48e 100644 --- a/cb_file_manager/lib/ui/widgets/tag_input_helpers.dart +++ b/cb_file_manager/lib/ui/widgets/tag_input_helpers.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:cb_file_manager/config/languages/app_localizations.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:cb_file_manager/helpers/tags/tag_manager.dart'; import 'package:cb_file_manager/helpers/tags/tag_hierarchy_manager.dart'; @@ -77,6 +78,7 @@ Future> computeTagSuggestions( // Comma-separated: only match the partial entry being typed now. final existingChildren = childPart .split(',') + .take(childPart.split(',').length - 1) .map((c) => c.trim().toLowerCase()) .where((c) => c.isNotEmpty) .toSet(); @@ -221,6 +223,8 @@ Widget buildTagSuggestionItem( children: [ Text( suggestion, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14, fontWeight: isHighlighted ? FontWeight.w600 : FontWeight.w400, @@ -229,7 +233,7 @@ Widget buildTagSuggestionItem( ), if (parents.isNotEmpty) Text( - 'Parent: ${parents.join(", ")}', + '${AppLocalizations.of(context)!.parentTagLabel}: ${parents.join(", ")}', style: TextStyle( fontSize: 11, color: theme.colorScheme.onSurfaceVariant.withValues( @@ -242,7 +246,7 @@ Widget buildTagSuggestionItem( ), if (children.isNotEmpty) Text( - '${children.length} child${children.length > 1 ? "ren" : ""}: ${children.take(3).join(", ")}${children.length > 3 ? "..." : ""}', + '${AppLocalizations.of(context)!.childTagCount(children.length)}: ${children.take(3).join(", ")}${children.length > 3 ? "..." : ""}', style: TextStyle( fontSize: 11, color: theme.colorScheme.onSurfaceVariant.withValues( diff --git a/cb_file_manager/test/01_chips_input_test.dart b/cb_file_manager/test/01_chips_input_test.dart index 5796096..fffe56f 100644 --- a/cb_file_manager/test/01_chips_input_test.dart +++ b/cb_file_manager/test/01_chips_input_test.dart @@ -213,12 +213,18 @@ void main() { expect(find.widgetWithText(TagScopeChip, 'Actress'), findsOneWidget); expect(find.text('Parent:'), findsOneWidget); - expect(find.text('Type child tag…'), findsOneWidget); + expect( + find.descendant( + of: find.byType(TagScopeChip), + matching: find.byType(EditableText), + ), + findsOneWidget, + ); await tester.tap( find.descendant( of: find.byType(TagScopeChip), - matching: find.byType(InkWell), + matching: find.byType(IconButton), ), ); await tester.pumpAndSettle(); @@ -253,36 +259,36 @@ void main() { await tester.pumpAndSettle(); final controller = key.currentState!.controller; - expect(controller.selection, const TextSelection.collapsed(offset: 3)); - expect(find.text('Type child tag…'), findsOneWidget); + expect(controller.selection, const TextSelection.collapsed(offset: 0)); + expect(find.text('People'), findsOneWidget); await tester.tap(find.byType(TextField)); tester.testTextInput.updateEditingValue( const TextEditingValue( // Reproduces a click-derived insertion before the chip placeholders. - text: 'n\uFFFE\uFFFE\uFFFC', + text: 'n', selection: TextSelection.collapsed(offset: 1), ), ); await tester.pump(); - expect(controller.textWithReplacements, '\uFFFE\uFFFE\uFFFCn'); + expect(controller.textWithReplacements, 'n'); expect(controller.textWithoutReplacements, 'n'); - expect(controller.selection, const TextSelection.collapsed(offset: 4)); + expect(controller.selection, const TextSelection.collapsed(offset: 1)); expect(draftText, 'n'); - expect(find.text('Type child tag…'), findsNothing); + expect(find.text('People'), findsOneWidget); tester.testTextInput.updateEditingValue( const TextEditingValue( - text: '\uFFFE\uFFFE\uFFFC', - selection: TextSelection.collapsed(offset: 3), + text: '', + selection: TextSelection.collapsed(offset: 0), ), ); await tester.pump(); - expect(controller.textWithReplacements, '\uFFFE\uFFFE\uFFFC\uFFFB'); - expect(controller.selection, const TextSelection.collapsed(offset: 3)); - expect(find.text('Type child tag…'), findsOneWidget); + expect(controller.textWithReplacements, ''); + expect(controller.selection, const TextSelection.collapsed(offset: 0)); + expect(find.text('People'), findsOneWidget); }, ); @@ -415,26 +421,25 @@ void main() { expect(key.currentState!.controller.textWithoutReplacements, isEmpty); expect( key.currentState!.controller.selection, - TextSelection.collapsed(offset: selectedTags.length + 1), - ); - expect( - key.currentState!.controller.textWithReplacements, - '${'\uFFFE' * selectedTags.length}\uFFFC\uFFFB', + const TextSelection.collapsed(offset: 0), ); + expect(key.currentState!.controller.textWithReplacements, ''); - final span = key.currentState!.controller.buildTextSpan( - context: key.currentContext!, - style: const TextStyle(), - withComposing: false, - ); - final inlineWidgets = span.children!.whereType().toList(); - expect(inlineWidgets, hasLength(selectedTags.length + 2)); + expect(find.widgetWithText(TagScopeChip, 'People'), findsOneWidget); expect( - (inlineWidgets[selectedTags.length].child as TagScopeChip).parent, - 'People', - reason: 'the parent scope must render after a,b,c rather than first', + find.descendant( + of: find.byType(TagScopeChip), + matching: find.byType(EditableText), + ), + findsOneWidget, ); - expect(inlineWidgets.last.child, isA()); + for (final tag in selectedTags) { + expect(find.text(tag), findsOneWidget); + } + final parentBounds = tester.getRect(find.byType(TagScopeChip)); + final inputBounds = tester.getRect(find.byType(InputDecorator).first); + expect(inputBounds.contains(parentBounds.center), isTrue); + expect(tester.takeException(), isNull); }, ); } diff --git a/cb_file_manager/test/ui/widgets/child_tag_input_test.dart b/cb_file_manager/test/ui/widgets/child_tag_input_test.dart new file mode 100644 index 0000000..ade3a15 --- /dev/null +++ b/cb_file_manager/test/ui/widgets/child_tag_input_test.dart @@ -0,0 +1,389 @@ +import 'package:cb_file_manager/config/languages/app_localizations_delegate.dart'; +import 'package:cb_file_manager/config/theme_config.dart'; +import 'package:cb_file_manager/ui/widgets/chips_input.dart'; +import 'package:cb_file_manager/ui/widgets/tag_chips_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _TagEditor extends StatefulWidget { + const _TagEditor({super.key}); + + @override + State<_TagEditor> createState() => _TagEditorState(); +} + +class _TagEditorState extends State<_TagEditor> { + final inputKey = GlobalKey>(); + final tags = ['existing']; + final added = []; + String? parent; + String draft = ''; + List children = ['Alice', 'Bob']; + + void setChildren(List values) => setState(() => children = values); + + void add(String tag) => setState(() { + added.add(parent == null ? tag : '$parent:$tag'); + tags.add(tag); + }); + + @override + Widget build(BuildContext context) => SizedBox( + width: 320, + child: TagChipsField( + fieldKey: inputKey, + tags: tags, + suggestions: parent == null + ? (draft.isEmpty + ? const [] + : ['People', 'Places'] + .where( + (tag) => + tag.toLowerCase().contains(draft.toLowerCase()), + ) + .toList()) + : children.where((tag) => !tags.contains(tag)).toList(), + scopeParent: parent, + onScopeChanged: (value) => setState(() { + parent = value; + draft = ''; + }), + onSuggestionSelected: add, + onTextChanged: (value) => setState(() => draft = value), + onSubmitted: add, + onRemoved: (tag) => setState(() => tags.remove(tag)), + ), + ); +} + +void main() { + Future<_TagEditorState> mount( + WidgetTester tester, { + String language = 'en', + Alignment alignment = Alignment.topCenter, + ThemeData? theme, + double textScale = 1, + }) async { + final key = GlobalKey<_TagEditorState>(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + locale: Locale(language), + supportedLocales: const [Locale('en'), Locale('vi')], + localizationsDelegates: const [ + AppLocalizationsDelegate(), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Scaffold( + body: Align( + alignment: alignment, + child: _TagEditor(key: key), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.showKeyboard(find.byType(TextField)); + await tester.pumpAndSettle(); + return key.currentState!; + } + + Future type( + WidgetTester tester, + _TagEditorState editor, + String text, { + TextRange composing = TextRange.empty, + }) async { + final prefix = editor.parent == null ? '\uFFFE' * editor.tags.length : ''; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: '$prefix$text', + selection: TextSelection.collapsed(offset: prefix.length + text.length), + composing: composing, + ), + ); + await tester.pumpAndSettle(); + expect(editor.draft, text); + } + + for (final brightness in Brightness.values) { + for (final textScale in [1.0, 1.5]) { + testWidgets( + 'child text aligns with parent in $brightness at scale $textScale', + (tester) async { + final editor = await mount( + tester, + theme: brightness == Brightness.dark + ? ThemeConfig.getDarkTheme() + : ThemeConfig.getLightTheme(), + textScale: textScale, + ); + await type(tester, editor, 'Peo'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + void expectAligned() { + final scope = find.byType(TagScopeChip); + final parent = tester.renderObject( + find.descendant(of: scope, matching: find.text('People')), + ); + final editable = tester + .state( + find.descendant( + of: scope, + matching: find.byType(EditableText), + ), + ) + .renderEditable; + final parentBaseline = parent + .localToGlobal( + Offset( + 0, + parent.getDryBaseline( + parent.constraints, + TextBaseline.alphabetic, + )!, + ), + ) + .dy; + final childBaseline = editable + .localToGlobal( + Offset( + 0, + editable.getDryBaseline( + editable.constraints, + TextBaseline.alphabetic, + )!, + ), + ) + .dy; + expect(childBaseline, closeTo(parentBaseline, .5)); + } + + expectAligned(); + await type(tester, editor, 'Alice'); + expectAligned(); + await type( + tester, + editor, + 'người', + composing: const TextRange(start: 0, end: 5), + ); + expectAligned(); + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets( + 'entering a parent places the real child caret inside its inline chip', + (tester) async { + final editor = await mount(tester); + await type(tester, editor, 'Peo'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(editor.parent, 'People'); + expect(editor.added, isEmpty); + expect( + editor.inputKey.currentState!.controller.textWithoutReplacements, + isEmpty, + ); + expect(find.text('Alice'), findsOneWidget); + final childEditor = find.descendant( + of: find.byType(TagScopeChip), + matching: find.byType(EditableText), + ); + expect(childEditor, findsOneWidget); + final editable = tester + .state(childEditor) + .renderEditable; + final caret = editable.getLocalRectForCaret( + const TextPosition(offset: 0), + ); + final chipRect = tester.getRect(find.byType(TagScopeChip)); + expect(chipRect.contains(editable.localToGlobal(caret.topLeft)), isTrue); + expect( + chipRect.contains(editable.localToGlobal(caret.bottomRight)), + isTrue, + ); + await type(tester, editor, 'Alice'); + final typedCaret = editable.getLocalRectForCaret( + const TextPosition(offset: 5), + ); + final typedChipRect = tester.getRect(find.byType(TagScopeChip)); + expect( + typedChipRect.contains(editable.localToGlobal(typedCaret.bottomRight)), + isTrue, + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect( + editor.inputKey.currentState!.controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 5), + ); + expect(editor.tags, ['existing']); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('a typed parent supports repeated children and a visible exit', ( + tester, + ) async { + final editor = await mount(tester, language: 'vi'); + await type(tester, editor, 'Gia đình'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(editor.parent, 'Gia đình'); + for (final child in ['Ảnh', 'Du lịch']) { + await type(tester, editor, child); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(editor.parent, 'Gia đình'); + } + expect(editor.added, ['Gia đình:Ảnh', 'Gia đình:Du lịch']); + await tester.tap( + find.descendant( + of: find.byType(TagScopeChip), + matching: find.byType(IconButton), + ), + ); + await tester.pumpAndSettle(); + expect(editor.parent, isNull); + expect(editor.tags, containsAll(['existing', 'Ảnh', 'Du lịch'])); + }); + + testWidgets( + 'arrow navigation then Enter picks the selected child suggestion', + (tester) async { + final editor = await mount(tester); + await type(tester, editor, 'Peo'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + await type(tester, editor, 'B'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(editor.added, ['People:Bob']); + expect(editor.parent, 'People'); + }, + ); + + testWidgets('Shift+Right selects text instead of entering a parent', ( + tester, + ) async { + final editor = await mount(tester); + await type(tester, editor, 'Peo'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + expect(editor.parent, isNull); + expect(editor.draft, 'Peo'); + }); + + testWidgets('composition is not interrupted by parent shortcuts', ( + tester, + ) async { + final editor = await mount(tester); + await type( + tester, + editor, + 'người', + composing: const TextRange(start: 1, end: 6), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + expect(editor.parent, isNull); + }); + + testWidgets('Escape leaves a parent and preserves assigned tags', ( + tester, + ) async { + final editor = await mount(tester); + await type(tester, editor, 'Peo'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(editor.parent, isNull); + expect(editor.tags, ['existing']); + }); + + testWidgets('suggestions open upward near the bottom of a narrow viewport', ( + tester, + ) async { + final editor = await mount(tester, alignment: Alignment.bottomCenter); + await type(tester, editor, 'Peo'); + final suggestion = find.text('People'); + expect( + tester.getBottomLeft(suggestion).dy, + lessThan(tester.getTopLeft(find.byType(TextField)).dy), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('keyboard navigation scrolls the highlighted child into view', ( + tester, + ) async { + final editor = await mount(tester); + await type(tester, editor, 'Peo'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + editor.setChildren(List.generate(9, (index) => 'Child $index')); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(find.text('Child 8').hitTestable(), findsOneWidget); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(editor.added, ['People:Child 8']); + }); + + testWidgets('Escape dismisses suggestions until the draft changes', ( + tester, + ) async { + final editor = await mount(tester); + await type(tester, editor, 'Peo'); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('People'), findsNothing); + expect(editor.draft, 'Peo'); + await type(tester, editor, 'Peop'); + expect(find.text('People'), findsOneWidget); + }); + + testWidgets('removing an assigned chip keeps the pending child draft', ( + tester, + ) async { + final editor = await mount(tester); + await type(tester, editor, 'Peo'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + await type(tester, editor, 'Carol'); + final chip = tester.widget(find.byType(TagInputChip)); + chip.onDeleted('existing'); + await tester.pumpAndSettle(); + expect(editor.parent, 'People'); + expect(editor.draft, 'Carol'); + expect( + editor.inputKey.currentState!.controller.textWithoutReplacements, + 'Carol', + ); + expect( + editor.inputKey.currentState!.controller.selection, + const TextSelection.collapsed(offset: 5), + ); + }); +} diff --git a/cb_file_manager/test/ui/widgets/tag_input_helpers_test.dart b/cb_file_manager/test/ui/widgets/tag_input_helpers_test.dart new file mode 100644 index 0000000..6344247 --- /dev/null +++ b/cb_file_manager/test/ui/widgets/tag_input_helpers_test.dart @@ -0,0 +1,38 @@ +import 'package:cb_file_manager/helpers/tags/tag_hierarchy_manager.dart'; +import 'package:cb_file_manager/ui/widgets/tag_input_helpers.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Hierarchy extends Fake implements TagHierarchyManager { + @override + List getChildren(String parentTag) => ['Alice', 'Bob', 'Carol']; +} + +void main() { + test( + 'an exact child name stays available as an autocomplete suggestion', + () async { + final result = await computeTagSuggestions( + 'People:Bob', + hierarchyManager: _Hierarchy(), + isSelected: (_) => false, + ); + expect(result, ['Bob']); + }, + ); + + test( + 'completed and assigned children are excluded while the current entry matches', + () async { + final result = await computeTagSuggestions( + 'People:Alice, Bob', + hierarchyManager: _Hierarchy(), + isSelected: (tag) => tag == 'Carol', + ); + expect(result, ['Bob']); + expect( + resolvePickedSuggestion('People:Alice, Bo', 'Bob'), + 'People:Alice,Bob', + ); + }, + ); +} diff --git a/docs/features/06-tag-management.md b/docs/features/06-tag-management.md index e8134cb..2145077 100644 --- a/docs/features/06-tag-management.md +++ b/docs/features/06-tag-management.md @@ -36,6 +36,26 @@ discarded when selecting different files. Failed writes expose a retry action for only the failed files, even after the selection changes. Renaming, coloring, and deleting tags globally remain in the tag management screen. +### Entering child tags + +The Properties editor and the single/batch file tag dialogs share the same +input. Type a parent name and press `:` or Right Arrow to enter its child +tags. Entering this mode does not assign the parent to the selected files. + +The parent stays inline with assigned tags. Its chip contains a real text +editor after the parent name and arrow, so the caret, text selection, and +child draft all stay inside the chip. Type a child name and press Enter to +add it. The parent stays active for the next child. +Use Up/Down then Enter, Tab, or a click to choose an existing suggestion; +keyboard navigation scrolls the selected row into view. Enter without +suggestion navigation submits the text as typed, allowing new child names. + +The chip's close button or Esc exits the parent and clears the draft; +Backspace also exits when the draft is empty. Existing assigned tags remain. +The `:` and Right Arrow shortcuts still enter a parent, while modified arrow +keys retain normal text-selection behavior. Suggestion popups open above the +editor when there is insufficient space below it. + ## Database Location - **Windows**: `C:\Users\\Documents\CBFileHub_v2\cb_file_hub.sqlite` From d0d1d8321983fafda507b06f4ec362704d634723 Mon Sep 17 00:00:00 2001 From: Tan Hung Date: Sat, 3 Oct 2026 01:54:17 +0700 Subject: [PATCH 2/3] fix: Prevent orphaned player creation and ensure mounted state during media operations --- .../media_kit_playback_e2e_test.dart | 4 +++- .../video/video_player/video_player.dart | 17 +++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/cb_file_manager/integration_test/media_kit_playback_e2e_test.dart b/cb_file_manager/integration_test/media_kit_playback_e2e_test.dart index 3608fe7..4368533 100644 --- a/cb_file_manager/integration_test/media_kit_playback_e2e_test.dart +++ b/cb_file_manager/integration_test/media_kit_playback_e2e_test.dart @@ -267,7 +267,9 @@ void main() { .widget(find.byType(mk.Video)) .controller .player; - expect(second, isNot(same(first))); + // A healthy player is reused across source changes; only a failed + // source gets a fresh one (see VideoPlayer.didUpdateWidget). + expect(second, same(first)); await waitFor(tester, () => second.state.position.inMilliseconds > 500); expect(errors, isEmpty); await tester.pumpWidget(const SizedBox.shrink()); diff --git a/cb_file_manager/lib/ui/components/video/video_player/video_player.dart b/cb_file_manager/lib/ui/components/video/video_player/video_player.dart index 68172d0..ad02e3c 100644 --- a/cb_file_manager/lib/ui/components/video/video_player/video_player.dart +++ b/cb_file_manager/lib/ui/components/video/video_player/video_player.dart @@ -742,6 +742,8 @@ class _VideoPlayerState extends _VideoPlayerSettingsHost userPreferences.getVideoPlayerVolume(), userPreferences.getVideoPlayerMute(), ).wait; + // Disposed while loading preferences: don't create an orphaned player. + if (!mounted) return; _lastVolume = savedVolume > 0 ? savedVolume : _lastVolume; setState(() { @@ -775,6 +777,7 @@ class _VideoPlayerState extends _VideoPlayerSettingsHost // Open media based on source type await _openMediaSource(); + if (!mounted) return; // Apply saved volume preferences with multiple attempts await _applyVolumeSettings(); @@ -932,23 +935,25 @@ class _VideoPlayerState extends _VideoPlayerSettingsHost } Future _openMediaSource() async { + // Held locally: dispose() can null _player while open() is in flight. + final player = _player!; if (widget.file != null) { // Local file playback - await _player!.open( + await player.open( PlaybackMedia(widget.file!.path), play: widget.autoPlay, ); - if (widget.autoPlay) { - await _player!.play(); + if (widget.autoPlay && mounted) { + await player.play(); } } else if (widget.streamingUrl != null) { // Streaming URL playback - await _player!.open( + await player.open( PlaybackMedia(widget.streamingUrl!), play: widget.autoPlay, ); - if (widget.autoPlay) { - await _player!.play(); + if (widget.autoPlay && mounted) { + await player.play(); } } else if (widget.smbMrl != null) { // Direct SMB playback uses the same media_kit backend as local files. From 8410f15a599e087627bc14e5d0696a128cb8836e Mon Sep 17 00:00:00 2001 From: Tan Hung Date: Sat, 3 Oct 2026 02:57:26 +0700 Subject: [PATCH 3/3] feat: Enhance file properties pane with compact view and new tag section headings --- .../ui/widgets/file_properties_details.dart | 75 ++++++-- .../lib/ui/widgets/file_properties_pane.dart | 118 +++++++----- .../lib/ui/widgets/selection_tag_editor.dart | 175 +++++++++++------- .../lib/ui/widgets/tag_input_helpers.dart | 58 +++++- .../ui/widgets/tag_management_section.dart | 132 +++++++++---- .../ui/widgets/tag_input_helpers_test.dart | 19 ++ 6 files changed, 399 insertions(+), 178 deletions(-) diff --git a/cb_file_manager/lib/ui/widgets/file_properties_details.dart b/cb_file_manager/lib/ui/widgets/file_properties_details.dart index c8c5b40..e25b13b 100644 --- a/cb_file_manager/lib/ui/widgets/file_properties_details.dart +++ b/cb_file_manager/lib/ui/widgets/file_properties_details.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as path; import 'package:cb_file_manager/config/languages/app_localizations.dart'; +import 'package:cb_file_manager/design_system/cb_design_system.dart'; import 'package:cb_file_manager/ui/utils/file_type_utils.dart'; import 'package:cb_file_manager/ui/utils/format_utils.dart'; @@ -11,11 +12,15 @@ class FilePropertiesDetails extends StatelessWidget { super.key, required this.filePath, required this.statFuture, + this.dense = false, }); final String filePath; final Future statFuture; + /// Label beside value, one row each, for the compact properties pane. + final bool dense; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -51,25 +56,65 @@ class FilePropertiesDetails extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final entry in values.entries) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - entry.key, - style: Theme.of(context).textTheme.labelSmall, - ), - SelectableText( - entry.value, - style: Theme.of(context).textTheme.bodyMedium, - ), - ], + if (dense) + FilePropertyRow(label: entry.key, value: entry.value) + else + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.key, + style: Theme.of(context).textTheme.labelSmall, + ), + SelectableText( + entry.value, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), ), - ), ], ); }, ); } } + +/// One label/value line of the compact properties pane. +class FilePropertyRow extends StatelessWidget { + const FilePropertyRow({super.key, required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final c = context.cbColors; + return Padding( + padding: const EdgeInsets.symmetric(vertical: CbSpacing.xxs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 88, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: CbTypography.bodySm.copyWith(color: c.textSecondary), + ), + ), + const SizedBox(width: CbSpacing.sm), + Expanded( + child: SelectableText( + value, + style: CbTypography.bodySm.copyWith(color: c.textPrimary), + ), + ), + ], + ), + ); + } +} diff --git a/cb_file_manager/lib/ui/widgets/file_properties_pane.dart b/cb_file_manager/lib/ui/widgets/file_properties_pane.dart index 80bdac5..ada52b4 100644 --- a/cb_file_manager/lib/ui/widgets/file_properties_pane.dart +++ b/cb_file_manager/lib/ui/widgets/file_properties_pane.dart @@ -3,7 +3,9 @@ import 'dart:io'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; import 'package:cb_file_manager/config/languages/app_localizations.dart'; +import 'package:cb_file_manager/design_system/cb_design_system.dart'; import 'package:cb_file_manager/helpers/files/lazy_path_size_calculator.dart'; import 'package:cb_file_manager/ui/controllers/selection_tags_controller.dart'; import 'package:cb_file_manager/ui/utils/format_utils.dart'; @@ -54,6 +56,7 @@ class _FilePropertiesPaneState extends State { Future? _stat; int? _size; int _generation = 0; + bool _propertiesExpanded = false; @override void initState() { @@ -108,35 +111,58 @@ class _FilePropertiesPaneState extends State { super.dispose(); } + /// Collapsed by default so the tag editor gets the room; the choice sticks + /// across selections while the pane stays mounted. Widget _properties(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + final showSize = _paths.length > 1 || widget.folderPaths.isNotEmpty; + return CbExpander( + expanded: _propertiesExpanded, + onExpansionChanged: (value) => + setState(() => _propertiesExpanded = value), + headerPadding: const EdgeInsets.symmetric( + horizontal: CbSpacing.sm, + vertical: CbSpacing.xs + CbSpacing.xxs, + ), + contentPadding: const EdgeInsets.all(CbSpacing.sm), + title: Text(l10n.properties), + // What the selection is, readable without expanding. + subtitle: Text( + _paths.length == 1 + ? p.basename(_paths.single) + : '${widget.filePaths.length} ${l10n.files}, ${widget.folderPaths.length} ${l10n.folders}', + ), children: [ - Text(l10n.properties, style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: 8), if (_paths.length == 1) - FilePropertiesDetails(filePath: _paths.single, statFuture: _stat!), - if (_paths.length > 1) - Text( - '${widget.filePaths.length} ${l10n.files}, ${widget.folderPaths.length} ${l10n.folders}', + FilePropertiesDetails( + filePath: _paths.single, + statFuture: _stat!, + dense: true, ), - if (_paths.length > 1 || widget.folderPaths.isNotEmpty) - Text( - '${l10n.fileSize}: ${_size == null ? l10n.loading : FormatUtils.formatFileSizeExact(_size!)}', + if (showSize) + FilePropertyRow( + label: l10n.fileSize, + value: _size == null + ? l10n.loading + : FormatUtils.formatFileSizeExact(_size!), ), ], ); } - Widget _editor(BuildContext context) { + Widget _editor(BuildContext context, double browseMaxHeight) { final l10n = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final failure in _tags.failures) Padding( - padding: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.fromLTRB( + CbSpacing.sm, + 0, + CbSpacing.sm, + CbSpacing.sm, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -161,11 +187,25 @@ class _FilePropertiesPaneState extends State { ), if (widget.folderPaths.isNotEmpty) Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text(l10n.propertiesFilesOnly), + padding: const EdgeInsets.fromLTRB( + CbSpacing.sm, + 0, + CbSpacing.sm, + CbSpacing.sm, + ), + child: Text( + l10n.propertiesFilesOnly, + style: CbTypography.bodySm.copyWith( + color: context.cbColors.textSecondary, + ), + ), ), if (widget.filePaths.isNotEmpty) - SelectionTagEditor(key: _editorKey, controller: _tags), + SelectionTagEditor( + key: _editorKey, + controller: _tags, + browseMaxHeight: browseMaxHeight, + ), ], ); } @@ -177,39 +217,25 @@ class _FilePropertiesPaneState extends State { ); } return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 680) { - return SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _properties(context), - const SizedBox(height: 16), - _editor(context), - ], - ), - ); - } - return Row( + builder: (context, constraints) => SingleChildScrollView( + // A narrow gutter so expander headers can show their hover fill; + // everything else is inset to line up with their titles. + padding: const EdgeInsets.fromLTRB( + CbSpacing.xs, + 0, + CbSpacing.xs, + CbSpacing.md, + ), + child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox( - width: math.min(340, constraints.maxWidth * .34), - child: SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: _properties(context), - ), - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: _editor(context), - ), - ), + if (_paths.isNotEmpty) _properties(context), + const SizedBox(height: CbSpacing.xs), + // Once scrolled to, the browser fills most of the pane. + _editor(context, math.max(320, constraints.maxHeight - 48)), ], - ); - }, + ), + ), ); } diff --git a/cb_file_manager/lib/ui/widgets/selection_tag_editor.dart b/cb_file_manager/lib/ui/widgets/selection_tag_editor.dart index 8bfb295..e1046d3 100644 --- a/cb_file_manager/lib/ui/widgets/selection_tag_editor.dart +++ b/cb_file_manager/lib/ui/widgets/selection_tag_editor.dart @@ -2,7 +2,9 @@ import 'package:cb_file_manager/helpers/tags/tag_manager.dart'; import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:cb_file_manager/config/languages/app_localizations.dart'; +import 'package:cb_file_manager/design_system/cb_design_system.dart'; import 'package:cb_file_manager/helpers/tags/tag_color_manager.dart'; import 'package:cb_file_manager/helpers/tags/tag_hierarchy_manager.dart'; import 'package:cb_file_manager/helpers/tags/tag_thumbnail_manager.dart'; @@ -14,9 +16,16 @@ import 'package:cb_file_manager/ui/widgets/tag_input_helpers.dart'; import 'package:cb_file_manager/ui/widgets/tag_management_section.dart'; class SelectionTagEditor extends StatefulWidget { - const SelectionTagEditor({super.key, required this.controller}); + const SelectionTagEditor({ + super.key, + required this.controller, + this.browseMaxHeight = 320, + }); final SelectionTagsController controller; + /// Height cap for the expanded tag browser. + final double browseMaxHeight; + @override State createState() => _SelectionTagEditorState(); } @@ -133,7 +142,6 @@ class _SelectionTagEditorState extends State { Widget build(BuildContext context) { final c = widget.controller; final l10n = AppLocalizations.of(context)!; - final theme = Theme.of(context); if (c.loading && c.tagsByPath.isEmpty) return Text(l10n.loadingTags); if (c.loadError != null) { return Column( @@ -150,80 +158,109 @@ class _SelectionTagEditorState extends State { // until the new selection's tags arrive. final stale = c.loading && !setEquals(c.tagsByPath.keys.toSet(), c.paths.toSet()); + // The host leaves only a narrow gutter so the browse expander can show its + // hover fill; everything else is inset to line up with its title. + const inset = EdgeInsets.symmetric(horizontal: CbSpacing.sm); return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(l10n.tags, style: theme.textTheme.titleSmall), + Padding( + padding: const EdgeInsets.fromLTRB( + CbSpacing.sm, + CbSpacing.xs + CbSpacing.xxs, + CbSpacing.sm, + CbSpacing.sm, + ), + child: Text( + l10n.tags, + style: CbTypography.label.copyWith( + color: context.cbColors.textPrimary, + ), + ), + ), if (c.saving) const LinearProgressIndicator(minHeight: 2), - const SizedBox(height: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - IgnorePointer( - ignoring: stale, - child: AnimatedOpacity( - opacity: stale ? .5 : 1, - duration: const Duration(milliseconds: 150), - child: AnimatedSize( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.topLeft, - child: TagChipsField( - fieldKey: _inputKey, - tags: counts.keys.toList(), - suggestions: _suggestions, - scopeParent: _scope, - onScopeChanged: (value) { - setState(() => _scope = value); - _suggest(''); - }, - hintText: l10n.propertiesTagHint, - onTextChanged: _suggest, - onSubmitted: (value) => _add(_query(value)), - onSuggestionSelected: (value) => - _add(resolvePickedSuggestion(_query(_draft), value)), - onRemoved: c.remove, - // A tag only some selected files carry reads "tag n/total"; - // tapping it applies the tag to the rest. - chipLabel: total > 1 - ? (tag) => '$tag ${counts[tag]}/$total' - : null, - chipTooltip: (tag) => - (counts[tag] ?? 0) < total ? l10n.addTag : null, - onChipTapped: (tag) { - if ((counts[tag] ?? 0) < total) _add(tag); - }, + Padding( + padding: inset, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IgnorePointer( + ignoring: stale, + child: AnimatedOpacity( + opacity: stale ? .5 : 1, + duration: const Duration(milliseconds: 150), + child: AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.topLeft, + child: TagChipsField( + fieldKey: _inputKey, + tags: counts.keys.toList(), + suggestions: _suggestions, + scopeParent: _scope, + onScopeChanged: (value) { + setState(() => _scope = value); + _suggest(''); + }, + hintText: l10n.propertiesTagHint, + onTextChanged: _suggest, + onSubmitted: (value) => _add(_query(value)), + onSuggestionSelected: (value) => + _add(resolvePickedSuggestion(_query(_draft), value)), + onRemoved: c.remove, + // A tag only some selected files carry reads "tag n/total"; + // tapping it applies the tag to the rest. + chipLabel: total > 1 + ? (tag) => '$tag ${counts[tag]}/$total' + : null, + chipTooltip: (tag) => + (counts[tag] ?? 0) < total ? l10n.addTag : null, + onChipTapped: (tag) { + if ((counts[tag] ?? 0) < total) _add(tag); + }, + ), ), ), ), - ), - const SizedBox(height: 12), - RecentTagsWidget( - limit: 6, - onTagSelected: _add, - loadRecentTags: (_) => _recentTags, - ), - const SizedBox(height: 12), - PopularTagsWidget( - limit: 6, + const SizedBox(height: CbSpacing.md), + RecentTagsWidget( + limit: 6, + compact: true, + onTagSelected: _add, + loadRecentTags: (_) => _recentTags, + ), + const SizedBox(height: CbSpacing.md), + PopularTagsWidget( + limit: 6, + compact: true, + onTagSelected: _add, + loadPopularTags: (_) => _popularTags, + ), + ], + ), + ), + const SizedBox(height: CbSpacing.xs), + CbExpander( + flush: true, + expanded: _browseExpanded, + onExpansionChanged: (value) => + setState(() => _browseExpanded = value), + headerPadding: const EdgeInsets.symmetric( + horizontal: CbSpacing.sm, + vertical: CbSpacing.xs + CbSpacing.xxs, + ), + contentPadding: inset, + title: TagSectionHeading( + icon: PhosphorIconsLight.treeView, + label: l10n.browseTab, + ), + children: [ + TagBrowseSection( + refreshVersion: widget.controller.revision, + selectedTags: counts.keys.where(c.isCommon).toList(), onTagSelected: _add, - loadPopularTags: (_) => _popularTags, - ), - ExpansionTile( - onExpansionChanged: (value) => - setState(() => _browseExpanded = value), - tilePadding: EdgeInsets.zero, - title: Text(l10n.browseTab), - children: [ - if (_browseExpanded) - TagBrowseSection( - refreshVersion: widget.controller.revision, - selectedTags: counts.keys.where(c.isCommon).toList(), - onTagSelected: _add, - onTagDeselected: (tag) => c.remove(tag), - maxHeight: 220, - ), - ], + onTagDeselected: (tag) => c.remove(tag), + maxHeight: widget.browseMaxHeight, ), ], ), diff --git a/cb_file_manager/lib/ui/widgets/tag_input_helpers.dart b/cb_file_manager/lib/ui/widgets/tag_input_helpers.dart index 847b48e..33b7704 100644 --- a/cb_file_manager/lib/ui/widgets/tag_input_helpers.dart +++ b/cb_file_manager/lib/ui/widgets/tag_input_helpers.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:cb_file_manager/config/languages/app_localizations.dart'; +import 'package:cb_file_manager/helpers/core/text_utils.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:cb_file_manager/helpers/tags/tag_manager.dart'; import 'package:cb_file_manager/helpers/tags/tag_hierarchy_manager.dart'; @@ -48,7 +49,7 @@ ParsedHierarchyInput? parseHierarchyInput(String input) { /// /// - When the query contains ":", suggests existing children of the typed /// parent (filtered by the partial child being typed, comma-aware). -/// - Otherwise runs a regular tag search with parents ordered first. +/// - Otherwise runs a regular tag search ordered by [rankTagSuggestions]. /// /// [isSelected] excludes tags already chosen. Results are capped at 10. Future> computeTagSuggestions( @@ -96,17 +97,54 @@ Future> computeTagSuggestions( } } - // Regular search with hierarchy-aware ordering (parents first). final suggestions = await TagManager.instance.searchTags(trimmed); - final sorted = suggestions.where((tag) => !isSelected(tag)).toList(); - sorted.sort((a, b) { - final aIsParent = hierarchyManager.isParent(a); - final bIsParent = hierarchyManager.isParent(b); - if (aIsParent && !bIsParent) return -1; - if (!aIsParent && bIsParent) return 1; - return a.toLowerCase().compareTo(b.toLowerCase()); + return rankTagSuggestions( + suggestions.where((tag) => !isSelected(tag)), + trimmed, + isParent: hierarchyManager.isParent, + ).take(10).toList(growable: false); +} + +final _wordSeparators = RegExp(r'[\s_\-.:/()\[\]{}]+'); + +/// Orders matches by how well they fit [query]: exact, exact ignoring accents, +/// prefix, prefix ignoring accents, word start, then anywhere. Within a tier, +/// parents come first, then shorter names, then A-Z. +/// +/// Search ignores accents, so "ro" matches "rõ" — but a plain A-Z sort puts +/// "rõ" after "robot" and even "bro"; ranking keeps the closest tag on top. +List rankTagSuggestions( + Iterable tags, + String query, { + bool Function(String tag)? isParent, +}) { + final lower = query.trim().toLowerCase(); + final folded = TextUtils.normalizeForSearch(lower); + int tier(String tag) { + final tagLower = tag.toLowerCase(); + final tagFolded = TextUtils.normalizeForSearch(tagLower); + if (tagLower == lower) return 0; + if (tagFolded == folded) return 1; + if (tagLower.startsWith(lower)) return 2; + if (tagFolded.startsWith(folded)) return 3; + if (tagFolded.split(_wordSeparators).any((w) => w.startsWith(folded))) { + return 4; + } + return 5; + } + + final tiers = {for (final tag in tags) tag: tier(tag)}; + final parents = { + for (final tag in tiers.keys) tag: isParent?.call(tag) ?? false, + }; + return tiers.keys.toList()..sort((a, b) { + final byTier = tiers[a]!.compareTo(tiers[b]!); + if (byTier != 0) return byTier; + if (parents[a] != parents[b]) return parents[a]! ? -1 : 1; + final byLength = a.length.compareTo(b.length); + if (byLength != 0) return byLength; + return TextUtils.compareAlphabetically(a, b); }); - return sorted.take(10).toList(growable: false); } /// Given the current draft text and a picked suggestion, reconstructs the full diff --git a/cb_file_manager/lib/ui/widgets/tag_management_section.dart b/cb_file_manager/lib/ui/widgets/tag_management_section.dart index 17da3c6..2c5fc0b 100644 --- a/cb_file_manager/lib/ui/widgets/tag_management_section.dart +++ b/cb_file_manager/lib/ui/widgets/tag_management_section.dart @@ -555,6 +555,39 @@ class _TagManagementSectionState extends State { } } +/// The compact icon + label heading of a tag group (recent, popular, browse). +class TagSectionHeading extends StatelessWidget { + const TagSectionHeading({ + super.key, + required this.icon, + required this.label, + this.iconColor, + }); + + final IconData icon; + final String label; + final Color? iconColor; + + @override + Widget build(BuildContext context) { + final c = context.cbColors; + return Row( + children: [ + Icon(icon, size: CbSizes.iconSm, color: iconColor ?? c.iconSubtle), + const SizedBox(width: CbSpacing.xs + CbSpacing.xxs), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: CbTypography.labelSm.copyWith(color: c.textSecondary), + ), + ), + ], + ); + } +} + /// Widget to display a list of popular tags with animation and hover effects class PopularTagsWidget extends StatelessWidget { final Function(String) onTagSelected; @@ -566,8 +599,13 @@ class PopularTagsWidget extends StatelessWidget { required this.onTagSelected, this.limit = 20, this.loadPopularTags, + this.compact = false, }); + /// Small muted heading, for a host whose own section titles are smaller + /// than the dialog-sized default. + final bool compact; + @override Widget build(BuildContext context) { return FutureBuilder>( @@ -584,26 +622,33 @@ class PopularTagsWidget extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Icon( - PhosphorIconsLight.star, - size: 18, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.amber[300] - : Colors.amber, - ), - const SizedBox(width: 8), - Text( - AppLocalizations.of(context)!.popularTags, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + if (compact) + TagSectionHeading( + icon: PhosphorIconsLight.star, + iconColor: Colors.amber, + label: AppLocalizations.of(context)!.popularTags, + ) + else + Row( + children: [ + Icon( + PhosphorIconsLight.star, + size: 18, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.amber[300] + : Colors.amber, ), - ), - ], - ), - const SizedBox(height: 12), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context)!.popularTags, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + SizedBox(height: compact ? CbSpacing.xs + CbSpacing.xxs : 12), AnimatedTagList( tags: popularTags.keys.toList(), counts: popularTags, @@ -627,8 +672,13 @@ class RecentTagsWidget extends StatelessWidget { required this.onTagSelected, this.limit = 20, this.loadRecentTags, + this.compact = false, }); + /// Small muted heading, for a host whose own section titles are smaller + /// than the dialog-sized default. + final bool compact; + @override Widget build(BuildContext context) { return FutureBuilder>( @@ -644,26 +694,32 @@ class RecentTagsWidget extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Icon( - PhosphorIconsLight.clockCounterClockwise, - size: 18, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[400] - : Colors.grey[700], - ), - const SizedBox(width: 8), - Text( - AppLocalizations.of(context)!.recentTags, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + if (compact) + TagSectionHeading( + icon: PhosphorIconsLight.clockCounterClockwise, + label: AppLocalizations.of(context)!.recentTags, + ) + else + Row( + children: [ + Icon( + PhosphorIconsLight.clockCounterClockwise, + size: 18, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[400] + : Colors.grey[700], ), - ), - ], - ), - const SizedBox(height: 12), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context)!.recentTags, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + SizedBox(height: compact ? CbSpacing.xs + CbSpacing.xxs : 12), AnimatedTagList(tags: recentTags, onTagSelected: onTagSelected), ], ); diff --git a/cb_file_manager/test/ui/widgets/tag_input_helpers_test.dart b/cb_file_manager/test/ui/widgets/tag_input_helpers_test.dart index 6344247..efb4ea7 100644 --- a/cb_file_manager/test/ui/widgets/tag_input_helpers_test.dart +++ b/cb_file_manager/test/ui/widgets/tag_input_helpers_test.dart @@ -35,4 +35,23 @@ void main() { ); }, ); + + test('closest matches rank first, accents only breaking ties', () { + final ranked = rankTagSuggestions( + ['bro', 'robot', 'Rõ ràng', 'rõ', 'parent:ro', 'macro', 'Rock'], + 'ro', + isParent: (tag) => tag == 'Rock', + ); + expect(ranked, [ + 'rõ', + 'Rock', + 'robot', + 'Rõ ràng', + 'parent:ro', + 'bro', + 'macro', + ]); + expect(rankTagSuggestions(['ro', 'rõ'], 'rõ'), ['rõ', 'ro']); + expect(rankTagSuggestions(['rõ', 'ro'], 'ro'), ['ro', 'rõ']); + }); }