blob: c5b44bd8b3c66554bcbd470795a738560b89989c [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../auto_dispose_mixin.dart';
import '../common_widgets.dart';
import '../theme.dart';
import '../trees.dart';
import '../utils.dart';
/// Top 10 matches to display in auto-complete overlay.
const defaultTopMatchesLimit = 10;
int topMatchesLimit = defaultTopMatchesLimit;
mixin SearchControllerMixin<T extends DataSearchStateMixin> {
// Initial values for searching (richness primarily auto-complete).
TextEditingValue searchTextFieldValue = const TextEditingValue();
final _searchNotifier = ValueNotifier<String>('');
/// Notify that the search has changed.
ValueListenable get searchNotifier => _searchNotifier;
bool isField = false;
/// Last X position of caret in search field, used for pop-up position.
double xPosition = 0.0;
set search(String value) {
_searchNotifier.value = value;
refreshSearchMatches();
}
String get search => _searchNotifier.value;
final _searchMatches = ValueNotifier<List<T>>([]);
ValueListenable<List<T>> get searchMatches => _searchMatches;
void refreshSearchMatches() {
updateMatches(matchesForSearch(_searchNotifier.value));
}
void updateMatches(List<T> matches) {
_searchMatches.value = matches;
if (matches.isEmpty) {
matchIndex.value = 0;
}
if (matches.isNotEmpty && matchIndex.value == 0) {
matchIndex.value = 1;
}
_updateActiveSearchMatch();
}
final _activeSearchMatch = ValueNotifier<T>(null);
ValueListenable<T> get activeSearchMatch => _activeSearchMatch;
/// 1-based index used for displaying matches status text (e.g. "2 / 15")
final matchIndex = ValueNotifier<int>(0);
void previousMatch() {
var previousMatchIndex = matchIndex.value - 1;
if (previousMatchIndex < 1) {
previousMatchIndex = _searchMatches.value.length;
}
matchIndex.value = previousMatchIndex;
_updateActiveSearchMatch();
}
void nextMatch() {
var nextMatchIndex = matchIndex.value + 1;
if (nextMatchIndex > _searchMatches.value.length) {
nextMatchIndex = 1;
}
matchIndex.value = nextMatchIndex;
_updateActiveSearchMatch();
}
void _updateActiveSearchMatch() {
// [matchIndex] is 1-based. Subtract 1 for the 0-based list [searchMatches].
final activeMatchIndex = matchIndex.value - 1;
if (activeMatchIndex < 0) {
_activeSearchMatch.value = null;
return;
}
assert(activeMatchIndex < searchMatches.value.length);
_activeSearchMatch.value?.isActiveSearchMatch = false;
_activeSearchMatch.value = searchMatches.value[activeMatchIndex]
..isActiveSearchMatch = true;
}
List<T> matchesForSearch(String search) => [];
void resetSearch() {
_searchNotifier.value = '';
refreshSearchMatches();
}
}
class AutoComplete extends StatefulWidget {
/// [controller] AutoCompleteController to associate with this pop-up.
/// [searchFieldKey] global key of the TextField to associate with the
/// auto-complete.
/// [onTap] method to call when item in drop-down list is tapped.
/// [bottom] display drop-down below (true) the TextField or above (false)
/// the TextField.
const AutoComplete(
this.controller, {
@required this.searchFieldKey,
@required this.onTap,
bool bottom = true, // If false placed above.
bool maxWidth = true,
}) : isBottom = bottom,
isMaxWidth = maxWidth;
final AutoCompleteSearchControllerMixin controller;
final GlobalKey searchFieldKey;
final SelectAutoComplete onTap;
final bool isBottom;
final bool isMaxWidth;
@override
AutoCompleteState createState() => AutoCompleteState();
}
class AutoCompleteState extends State<AutoComplete> with AutoDisposeMixin {
@override
void didChangeDependencies() {
super.didChangeDependencies();
final autoComplete = context.widget as AutoComplete;
final controller = autoComplete.controller;
final searchFieldKey = autoComplete.searchFieldKey;
final onTap = autoComplete.onTap;
final bottom = autoComplete.isBottom;
final isMaxWidth = autoComplete.isMaxWidth;
addAutoDisposeListener(controller.searchAutoCompleteNotifier, () {
controller.handleAutoCompleteOverlay(
context: context,
searchFieldKey: searchFieldKey,
onTap: onTap,
bottom: bottom,
maxWidth: isMaxWidth,
);
});
}
@override
Widget build(BuildContext context) {
final autoComplete = context.widget as AutoComplete;
final controller = autoComplete.controller;
final searchFieldKey = autoComplete.searchFieldKey;
final bottom = autoComplete.isBottom;
final isMaxWidth = autoComplete.isMaxWidth;
final searchAutoComplete = controller.searchAutoComplete;
final ColorScheme colorScheme = Theme.of(context).colorScheme;
// Find the searchField and place overlay below bottom of TextField and
// make overlay width of TextField. This is also we decide the height of
// the ListTile height, position above (if bottom is false).
final RenderBox box = searchFieldKey.currentContext.findRenderObject();
// Approximation but it's pretty accurate. Could consider using a layout builder
// or maybe build in an overlay (that's isn't visible) to compute.
final tileEntryHeight = box.size.height;
// Compute to global coordinates.
final offset = box.localToGlobal(Offset.zero);
final areaHeight = offset.dy;
final maxAreaForPopup = areaHeight - tileEntryHeight;
// TODO(terry): Scrolling doesn't work so max popup height is also total
// matches to use.
topMatchesLimit = min(
defaultTopMatchesLimit,
(maxAreaForPopup / tileEntryHeight) - 1, // zero based.
).truncate();
// Total tiles visible.
final totalTiles = bottom
? searchAutoComplete.value.length
: (maxAreaForPopup / tileEntryHeight).truncateToDouble();
final autoCompleteTiles = <ListTile>[];
final count = min(searchAutoComplete.value.length, totalTiles);
for (var index = 0; index < count; index++) {
final matchedName = searchAutoComplete.value[index];
autoCompleteTiles.add(
ListTile(
dense: true,
title: Text(matchedName),
tileColor: controller.currentDefaultIndex == index
? colorScheme.autoCompleteHighlightColor
: colorScheme.defaultBackgroundColor,
onTap: () {
controller.selectTheSearch = true;
controller.search = matchedName;
autoComplete.onTap(matchedName);
},
),
);
}
// Compute the Y position of the popup (auto-complete list). Its bottom
// will be positioned at the top of the text field (tileEntryHeight is
// also the height of the TextField's render object height). Add 1 includes
// the TextField border.
// TODO(terry): Consider completely computed but a bunch more work this
// currently works for all cases where we use auto-complete.
final yCoord =
bottom ? 0.0 : -((count * tileEntryHeight) + tileEntryHeight + 1);
final xCoord = controller.xPosition;
return Positioned(
key: searchAutoCompleteKey,
width: isMaxWidth
? box.size.width
: AutoCompleteSearchControllerMixin.minPopupWidth,
height: bottom ? null : count * tileEntryHeight,
child: CompositedTransformFollower(
link: controller.autoCompleteLayerLink,
showWhenUnlinked: false,
targetAnchor: Alignment.bottomLeft,
offset: Offset(xCoord, yCoord),
child: Material(
elevation: defaultElevation,
child: ListView(
padding: EdgeInsets.zero,
shrinkWrap: true,
children: autoCompleteTiles,
),
),
),
);
}
}
const searchAutoCompleteKeyName = 'SearchAutoComplete';
@visibleForTesting
final searchAutoCompleteKey = GlobalKey(debugLabel: searchAutoCompleteKeyName);
/// Parts of active editing for auto-complete.
class EditingParts {
EditingParts({
this.activeWord,
this.leftSide,
this.rightSide,
});
final String activeWord;
final String leftSide;
final String rightSide;
bool get isField => leftSide.endsWith('.');
}
/// Parsing characters looking for valid names e.g.,
/// [ _ | a..z | A..Z ] [ _ | a..z | A..Z | 0..9 ]+
const asciiSpace = 32;
const ascii0 = 48;
const ascii9 = 57;
const asciiUnderscore = 95;
const asciiA = 65;
const asciiZ = 90;
const asciia = 97;
const asciiz = 122;
mixin AutoCompleteSearchControllerMixin on SearchControllerMixin {
final selectTheSearchNotifier = ValueNotifier<bool>(false);
bool get selectTheSearch => selectTheSearchNotifier.value;
/// Search is very dynamic, with auto-complete or programmatic searching,
/// setting the value to true will fire off searching.
set selectTheSearch(bool v) {
selectTheSearchNotifier.value = v;
}
final searchAutoComplete = ValueNotifier<List<String>>([]);
ValueListenable<List<String>> get searchAutoCompleteNotifier =>
searchAutoComplete;
void clearSearchAutoComplete() {
searchAutoComplete.value = [];
// Default index is 0.
currentDefaultIndex = 0;
}
/// Layer links autoComplete popup to the search TextField widget.
final LayerLink autoCompleteLayerLink = LayerLink();
OverlayEntry autoCompleteOverlay;
int currentDefaultIndex;
static const minPopupWidth = 300.0;
/// [bottom] if false placed above TextField (search field).
/// [maxWidth] if true drop-down is width of TextField otherwise minPopupWidth.
OverlayEntry createAutoCompleteOverlay({
@required BuildContext context,
@required GlobalKey searchFieldKey,
@required SelectAutoComplete onTap,
bool bottom = true,
bool maxWidth = true,
}) {
return OverlayEntry(builder: (context) {
return AutoComplete(
this,
searchFieldKey: searchFieldKey,
onTap: onTap,
bottom: bottom,
maxWidth: maxWidth,
);
});
}
void closeAutoCompleteOverlay() {
autoCompleteOverlay?.remove();
autoCompleteOverlay = null;
}
/// Helper setState callback when searchAutoCompleteNotifier changes, usage:
///
/// addAutoDisposeListener(controller.searchAutoCompleteNotifier, () {
/// setState(autoCompleteOverlaySetState(controller, context));
/// });
void handleAutoCompleteOverlay({
@required BuildContext context,
@required GlobalKey searchFieldKey,
@required SelectAutoComplete onTap,
bool bottom = true,
bool maxWidth = true,
}) {
if (autoCompleteOverlay != null) {
closeAutoCompleteOverlay();
}
autoCompleteOverlay = createAutoCompleteOverlay(
context: context,
searchFieldKey: searchFieldKey,
onTap: onTap,
bottom: bottom,
maxWidth: maxWidth,
);
Overlay.of(context).insert(autoCompleteOverlay);
}
/// Until an expression parser, poor man's way of finding the parts for
/// auto-complete.
///
/// Returns the parts of the editing area e.g.,
///
/// caret
/// ↓
/// addOne.yName + 1000 + myChart.tra┃
/// |_____________________________|_|
/// ↑ ↑
/// leftSide activeWord
///
/// activeWord is "tra"
/// leftSide is "addOne.yName + 1000 + myChart."
/// rightSide is "". RightSide isNotEmpty if caret is not
/// at the end the end TxtField value. If the
/// caret is within the text e.g.,
///
/// caret
/// ↓
/// controller.cl┃ + 1000 + myChart.tra
///
/// activeWord is "cl"
/// leftSide is "controller."
/// rightSide is " + 1000 + myChart.tra"
static EditingParts activeEdtingParts(
String editing,
TextSelection selection, {
bool handleFields = false,
}) {
String activeWord;
String leftSide;
String rightSide;
final startSelection = selection.start;
if (startSelection != -1 && startSelection == selection.end) {
final selectionValue = editing.substring(0, startSelection);
var lastSpaceIndex = selectionValue.lastIndexOf(handleFields ? '.' : ' ');
lastSpaceIndex = lastSpaceIndex >= 0 ? lastSpaceIndex + 1 : 0;
activeWord = selectionValue.substring(
lastSpaceIndex,
startSelection,
);
var variableStart = -1;
// Validate activeWord is really a word.
for (var index = activeWord.length - 1; index >= 0; index--) {
final char = activeWord.codeUnitAt(index);
if (char >= ascii0 && char <= ascii9) {
// Keep gobbling # assuming might be part of variable name.
continue;
} else if (char == asciiUnderscore ||
(char >= asciiA && char <= asciiZ) ||
(char >= asciia && char <= asciiz)) {
variableStart = index;
} else if (variableStart == -1) {
// Never had a variable start.
lastSpaceIndex += activeWord.length;
activeWord = selectionValue.substring(
lastSpaceIndex - 1,
startSelection - 1,
);
break;
} else {
lastSpaceIndex += variableStart;
activeWord = selectionValue.substring(
lastSpaceIndex,
startSelection,
);
break;
}
}
leftSide = selectionValue.substring(0, lastSpaceIndex);
rightSide = editing.substring(startSelection);
}
return EditingParts(
activeWord: activeWord,
leftSide: leftSide,
rightSide: rightSide,
);
}
}
mixin SearchableMixin<T> {
List<T> searchMatches = [];
T activeSearchMatch;
}
/// Callback when item in the drop-down list is selected.
typedef SelectAutoComplete = Function(String selection);
/// Callback to handle highlighting item in the drop-down list.
typedef HighlightAutoComplete = Function(
AutoCompleteSearchControllerMixin controller,
bool directionDown,
);
mixin SearchFieldMixin<T extends StatefulWidget> on State<T> {
FocusNode searchFieldFocusNode;
TextEditingController searchTextFieldController;
FocusNode rawKeyboardFocusNode;
SelectAutoComplete _onSelection;
void callOnSelection(String foundMatch) {
_onSelection(foundMatch);
}
/// Platform independent (Mac or Linux).
final arrowDown =
LogicalKeyboardKey.arrowDown.keyId & LogicalKeyboardKey.valueMask;
final arrowUp =
LogicalKeyboardKey.arrowUp.keyId & LogicalKeyboardKey.valueMask;
final enter = LogicalKeyboardKey.enter.keyId & LogicalKeyboardKey.valueMask;
final escape = LogicalKeyboardKey.escape.keyId & LogicalKeyboardKey.valueMask;
final tab = LogicalKeyboardKey.tab.keyId & LogicalKeyboardKey.valueMask;
/// Work around Mac Desktop bug returning physical keycode instead of logical
/// keyId for the RawKeyEvent's data.logical keyId keys ENTER and TAB.
final enterMac = PhysicalKeyboardKey.enter.usbHidUsage;
final tabMac = PhysicalKeyboardKey.tab.usbHidUsage;
/// Hookup up TextField (search field) to the auto-complete overlay
/// pop-up.
///
/// [controller]
/// [searchFieldKey]
/// [searchFieldEnabled]
/// [onSelection]
/// [onHilightDropdown] use to override default highlghter.
/// [decoration]
/// [tracking] if true displays pop-up to the right of the TextField's caret.
/// [supportClearField] if true clear TextField content if pop-up not visible. If
/// pop-up is visible close the pop-up on first ESCAPE.
Widget buildAutoCompleteSearchField({
@required AutoCompleteSearchControllerMixin controller,
@required GlobalKey searchFieldKey,
@required bool searchFieldEnabled,
@required bool shouldRequestFocus,
@required SelectAutoComplete onSelection,
HighlightAutoComplete onHighlightDropdown,
InputDecoration decoration,
bool tracking = false,
bool supportClearField = false,
}) {
_onSelection = onSelection;
onHighlightDropdown ??= _highlightDropdown;
rawKeyboardFocusNode = FocusNode();
rawKeyboardFocusNode.onKey = (FocusNode node, RawKeyEvent event) {
// Don't propagate the up/down arrow to the TextField it is handled
// by the drop-down.
final key = event.data.logicalKey.keyId & LogicalKeyboardKey.valueMask;
if (key == arrowDown || key == arrowUp) {
return KeyEventResult.handled;
}
// Continue propagating event.
return KeyEventResult.ignored;
};
return RawKeyboardListener(
focusNode: rawKeyboardFocusNode,
onKey: (RawKeyEvent event) {
if (event is RawKeyDownEvent) {
final key =
event.data.logicalKey.keyId & LogicalKeyboardKey.valueMask;
if (key == escape) {
// TODO(kenz): Enable this once we find a way around the navigation
// this causes. This triggers a "back" navigation.
// ESCAPE key pressed clear search TextField.c
if (controller.autoCompleteOverlay != null) {
controller.closeAutoCompleteOverlay();
} else if (supportClearField) {
// If pop-up closed ESCAPE will clean the TextField.
clearSearchField(controller, force: true);
}
} else if (key == enter ||
key == tab ||
key == enterMac ||
key == tabMac) {
// Enter / Tab pressed.
String foundExact;
// What the user has typed in so far.
final searchToMatch = controller.search.toLowerCase();
// Find exact match in autocomplete list - use that as our search value.
for (final autoEntry in controller.searchAutoComplete.value) {
if (searchToMatch == autoEntry.toLowerCase()) {
foundExact = autoEntry;
break;
}
}
// Nothing found, pick item selected in dropdown.
final autoCompleteList = controller.searchAutoComplete.value;
if (foundExact == null ||
autoCompleteList[controller.currentDefaultIndex] !=
foundExact) {
if (autoCompleteList.isNotEmpty) {
foundExact = autoCompleteList[controller.currentDefaultIndex];
}
}
if (foundExact != null) {
controller.selectTheSearch = true;
controller.search = foundExact;
onSelection(foundExact);
}
} else if (key == arrowDown || key == arrowUp) {
onHighlightDropdown(controller, key == arrowDown);
}
}
},
child: _buildSearchField(
controller: controller,
searchFieldKey: searchFieldKey,
searchFieldEnabled: searchFieldEnabled,
shouldRequestFocus: shouldRequestFocus,
autoCompleteLayerLink: controller.autoCompleteLayerLink,
decoration: decoration,
tracking: tracking,
),
);
}
void _highlightDropdown(
AutoCompleteSearchControllerMixin controller,
bool directionDown,
) {
final numItems = controller.searchAutoComplete.value.length - 1;
var indexToSelect = controller.currentDefaultIndex;
if (directionDown) {
// Select next item in auto-complete overlay.
++indexToSelect;
if (indexToSelect > numItems) {
// Greater than max go back to top list item.
indexToSelect = 0;
}
} else {
// Select previous item item in auto-complete overlay.
--indexToSelect;
if (indexToSelect < 0) {
// Less than first go back to bottom list item.
indexToSelect = numItems;
}
}
controller.currentDefaultIndex = indexToSelect;
// Cause the auto-complete list to update, list is small 10 items max.
controller.searchAutoComplete.value =
controller.searchAutoComplete.value.toList();
}
Widget buildSearchField({
@required SearchControllerMixin controller,
@required GlobalKey searchFieldKey,
@required bool searchFieldEnabled,
@required bool shouldRequestFocus,
bool supportsNavigation = false,
VoidCallback onClose,
}) {
return _buildSearchField(
controller: controller,
searchFieldKey: searchFieldKey,
searchFieldEnabled: searchFieldEnabled,
shouldRequestFocus: shouldRequestFocus,
autoCompleteLayerLink: null,
supportsNavigation: supportsNavigation,
onClose: onClose,
);
}
Widget _buildSearchField({
@required SearchControllerMixin controller,
@required GlobalKey searchFieldKey,
@required bool searchFieldEnabled,
@required bool shouldRequestFocus,
@required LayerLink autoCompleteLayerLink,
InputDecoration decoration,
bool supportsNavigation = false,
VoidCallback onClose,
bool tracking = false,
}) {
// Creating new TextEditingController.
searchFieldFocusNode = FocusNode();
if (controller is AutoCompleteSearchControllerMixin) {
searchFieldFocusNode.addListener(() {
if (!searchFieldFocusNode.hasFocus) {
controller.closeAutoCompleteOverlay();
}
});
}
searchTextFieldController = TextEditingController(
text: controller.searchTextFieldValue.text,
)..selection = controller.searchTextFieldValue.selection;
final searchField = TextField(
key: searchFieldKey,
autofocus: true,
enabled: searchFieldEnabled,
focusNode: searchFieldFocusNode,
controller: searchTextFieldController,
onChanged: (value) {
if (tracking) {
// Use a TextPainter to calculate the width of the newly entered text.
// TODO(terry): The TextPainter's TextStyle is default (same as this
// TextField) consider explicitly using a TextStyle of
// this TextField if the TextField needs styling.
final painter = TextPainter(
textDirection: TextDirection.ltr,
text: TextSpan(text: value),
);
painter.layout();
// X coordinate of the pop-up, immediately to the right of the insertion
// point (caret).
controller.xPosition = painter.width;
}
controller.search = value;
},
onEditingComplete: () {
searchFieldFocusNode.requestFocus();
},
// Guarantee that the TextField on all platforms renders in the same
// color for border, label text, and cursor. Primarly, so golden screen
// snapshots will compare with the exact color.
// Guarantee that the TextField on all platforms renders in the same
// color for border, label text, and cursor. Primarly, so golden screen
// snapshots will compare with the exact color.
decoration: InputDecoration(
contentPadding: const EdgeInsets.all(denseSpacing),
focusedBorder: OutlineInputBorder(borderSide: searchFocusBorderColor),
enabledBorder: OutlineInputBorder(borderSide: searchFocusBorderColor),
labelStyle: TextStyle(color: searchColor),
border: const OutlineInputBorder(),
labelText: 'Search',
suffix: (supportsNavigation || onClose != null)
? _buildSearchFieldSuffix(
controller,
supportsNavigation: supportsNavigation,
onClose: onClose,
)
: null,
),
cursorColor: searchColor,
);
if (shouldRequestFocus) {
searchFieldFocusNode.requestFocus();
}
if (controller is AutoCompleteSearchControllerMixin) {
return CompositedTransformTarget(
link: autoCompleteLayerLink,
child: searchField,
);
}
return searchField;
}
Widget _buildSearchFieldSuffix(
SearchControllerMixin controller, {
bool supportsNavigation = false,
VoidCallback onClose,
}) {
assert(supportsNavigation || onClose != null);
if (supportsNavigation) {
return SearchNavigationControls(controller, onClose: onClose);
} else {
return closeSearchDropdownButton(onClose);
}
}
void selectFromSearchField(
SearchControllerMixin controller, String selection) {
searchTextFieldController.clear();
controller.search = selection;
clearSearchField(controller, force: true);
if (controller is AutoCompleteSearchControllerMixin) {
controller.selectTheSearch = true;
controller.closeAutoCompleteOverlay();
}
}
void clearSearchField(SearchControllerMixin controller, {force = false}) {
if (force || controller.search.isNotEmpty) {
searchTextFieldController.clear();
controller.isField = false;
controller.resetSearch();
if (controller is AutoCompleteSearchControllerMixin) {
controller.closeAutoCompleteOverlay();
}
}
}
void updateSearchField(
SearchControllerMixin controller, String newValue, int caretPosition) {
searchTextFieldController.text = newValue;
searchTextFieldController.selection =
TextSelection.collapsed(offset: caretPosition);
}
}
class SearchNavigationControls extends StatelessWidget {
const SearchNavigationControls(this.controller, {@required this.onClose});
final SearchControllerMixin controller;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: controller.searchMatches,
builder: (context, matches, _) {
final numMatches = matches.length;
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
_matchesStatus(numMatches),
SizedBox(
height: 24.0,
width: defaultIconSize,
child: Transform.rotate(
angle: degToRad(90),
child: const PaddedDivider(
padding: EdgeInsets.symmetric(vertical: densePadding),
),
),
),
inputDecorationSuffixButton(Icons.keyboard_arrow_up,
numMatches > 1 ? controller.previousMatch : null),
inputDecorationSuffixButton(Icons.keyboard_arrow_down,
numMatches > 1 ? controller.nextMatch : null),
if (onClose != null) closeSearchDropdownButton(onClose)
],
);
},
);
}
Widget _matchesStatus(int numMatches) {
return ValueListenableBuilder(
valueListenable: controller.matchIndex,
builder: (context, index, _) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: densePadding),
child: Text(
'$index/$numMatches',
style: const TextStyle(fontSize: 12.0),
),
);
},
);
}
}
mixin DataSearchStateMixin {
bool isSearchMatch = false;
bool isActiveSearchMatch = false;
}
// This mixin is used to get around the type system where a type `T` needs to
// both extend `TreeNode<T>` and mixin `SearchableDataMixin`.
mixin TreeDataSearchStateMixin<T extends TreeNode<T>>
on TreeNode<T>, DataSearchStateMixin {}