Conversation
|
Amazing! I was actually thinking about this recently, so this PR comes at a great time :) I'll review it soon, thanks in advance though 😀 |
|
@Norbert515 Oh good, glad to hear it's welcome! If you spot anything you want me to change, just let me know (or feel free to take matters into your own hand, too). 🤠 |
Norbert515
left a comment
There was a problem hiding this comment.
PR Review: Text Selection Feature
This is an impressive and well-thought-out implementation of text selection for TUI components. The contributor clearly put significant effort into understanding the rendering pipeline and handling the complexities of selection across lazy-built ListViews. Here's my detailed review:
✅ Strengths
Architecture
- Clean separation of concerns with
SelectionAreaas the controller,Selectablemixin for participants, andSelectionDragStatefor global coordination - The
Selectablemixin approach is extensible - any render object can become selectable by implementingselectableTextandselectableLayout - Proper use of render object lifecycle (
attach,markNeedsPaint, etc.) - Theme-aware selection color via
TuiThemeData.selectionColor
Lazy ListView Handling
- Clever solution to the fundamental problem: during selection drag, temporarily force-build items in the selection range via
_forceBuildSelectionRange() - Uses
SelectionDragState.isActiveto coordinate betweenSelectionAreaandRenderListViewport - Returns to lazy mode when selection ends
Code Quality
- Comprehensive test coverage (1614+ lines of tests covering many edge cases)
- Good documentation with dartdoc comments
- Extracted shared logic to
selection_utils.dartto reduce duplication betweenTextFieldandText - Stable selection IDs via
RenderObject.selectionIdto survive rebuilds
Edge Cases Handled
- Cross-widget selection with proper ordering
- Backward selection (focus before anchor)
- Selection from whitespace into text
- Scroll-while-selecting
- Viewport clipping
- Re-anchoring when anchored selectable is replaced
⚠️ Suggestions/Concerns
1. Performance (as noted by author)
The PR description acknowledges performance can improve. A few specific observations:
_collectSelectables()walks the entire subtree and sorts on every mouse move during drag- For very large lists,
_forceBuildSelectionRangewith variable extents iterates from index 0
Suggestion: Consider caching selectables and only refreshing when the tree actually changes, or use a dirty flag.
2. Global State in SelectionDragState
class SelectionDragState {
static int _activeCount = 0;
static final Map<Object, SelectionRange> _ranges = {};This uses static/global state to coordinate between SelectionArea and RenderListViewport. While pragmatic, this could cause issues with:
- Multiple
SelectionAreawidgets active simultaneously - Testing isolation (tests need to clean up state)
Suggestion: Consider an inherited model pattern or passing the state via the render tree. Low priority if multiple selection areas aren't a use case.
3. Missing clearSelection() on text change in RenderText
The test clears selection when text content changes suggests this behavior, but I don't see the text setter in RenderText calling clearSelection(). The test works by triggering a new selection pass. Should RenderText.text setter explicitly clear selection?
4. visitChildren change in RenderListViewport
void visitChildren(RenderObjectVisitor visitor) {
// Use _allChildren so that tree walks can discover force-built items
for (final child in _allChildren) {This changes visitChildren to iterate _allChildren instead of _visibleChildren. This is needed for selection, but could it affect other tree walks that expect only visible children? The comment says paint/hit-testing iterate _visibleChildren directly, so this should be safe, but worth double-checking.
5. Small nits
lib/src/components/selection_state.dart:14- Theend()method clears ranges when_activeCounthits 0, but ifbegin()is called twice andend()once, the ranges aren't cleared. This is probably intentional for nested selection scenarios but worth noting.
📋 Minor Style Suggestions
-
In
chat_demo.dart, theselectionColor: Colors.redseems like demo/debug code. Consider using the theme default or removing for the example. -
The
@visibleForTestingannotation ondebugAppendSelectedTextis good practice.
🧪 Test Coverage
Excellent test coverage! Tests cover:
- Basic selection completion with newlines
- Cross-boundary selection (gaps between widgets)
- Re-anchoring when selectables are replaced
- ListView lazy mode restoration
- Backward selection
- Wheel scroll during drag
- Viewport clipping
- And many more edge cases
Verdict
This is a solid, well-implemented feature that adds significant value. The implementation handles many complex edge cases correctly. The performance concerns are noted by the author and can be addressed incrementally.
Recommend: Approve with minor suggestions
Thanks for the excellent contribution, @jolexxa! 🎉
Follow-up: Improvement Suggestions Based on ResearchI did some deeper research into potential improvements for this PR. Here are concrete suggestions organized by priority: 🚀 Performance Improvements1. Cache Selectables & Invalidate on Layout (HIGH IMPACT, LOW RISK)Currently Suggested fix: class RenderSelectionArea extends RenderMouseRegion {
bool _selectablesCacheValid = false;
@override
void markNeedsLayout() {
super.markNeedsLayout();
_selectablesCacheValid = false; // Invalidate on layout
}
List<Selectable> _collectSelectables(RenderObject root) {
if (_selectablesCacheValid && _cachedSelectables.isNotEmpty) {
return _cachedSelectables; // Return cached
}
// Do the expensive tree walk & sort only when needed
_cachedSelectables = /* ... existing logic ... */;
_contextLists = _buildContextLists(_cachedSelectables);
_selectablesCacheValid = true;
return _cachedSelectables;
}
}Impact: Reduces tree walks from ~60/second (during drag) to ~1 per layout change. 2. Incremental Selection Updates (HIGH IMPACT, MEDIUM RISK)Currently void _updateSelectionRanges(...) {
int newStartIdx = math.min(anchorIdx, focusIdx);
int newEndIdx = math.max(anchorIdx, focusIdx);
// Only iterate the union of old and new ranges
for (int i = math.min(_lastStartIdx ?? newStartIdx, newStartIdx);
i <= math.max(_lastEndIdx ?? newEndIdx, newEndIdx); i++) {
final wasSelected = i >= (_lastStartIdx ?? newStartIdx) && i <= (_lastEndIdx ?? newEndIdx);
final isSelected = i >= newStartIdx && i <= newEndIdx;
if (wasSelected != isSelected) {
// Only update if state changed
if (isSelected) {
entries[i].selectable.setSelectionRange(...);
} else {
entries[i].selectable.clearSelection();
}
}
}
_lastStartIdx = newStartIdx;
_lastEndIdx = newEndIdx;
}Impact: For 1000 selectables with selection of 10 items, updates ~10 items instead of ~1000. 3. ListView: Use Parent Data for Position Estimation (HIGH IMPACT for variable-extent)Currently void _forceBuildSelectionRange({...}) {
// Skip the 0..minIndex walk for variable extent lists
double currentPosition = _estimatePositionForIndex(minIndex);
for (int index = minIndex; index <= maxIndex; index++) {
final renderObject = _buildAndLayoutChild(
index: index,
layoutOffset: currentPosition,
);
// ...
}
}
double _estimatePositionForIndex(int index) {
// Check existing parent data first
for (final child in _allChildren) {
final pd = child.renderObject.parentData as ListViewParentData?;
if (pd?.index == index && pd?.layoutOffset != null) {
return pd!.layoutOffset!;
}
}
// Fall back to average extent estimate
return index * (_averageItemExtent ?? 1.0);
}Impact: Selecting items 500-550 builds ~50 items instead of ~550. 🏗️ Architecture Improvement: Replace Global State with InheritedComponentThe Suggested approach: // New file: lib/src/components/selection_scope.dart
class SelectionScope extends InheritedComponent {
final SelectionController controller;
const SelectionScope({
super.key,
required this.controller,
required super.child,
});
static SelectionController? maybeOf(BuildContext context) {
return context
.dependOnInheritedComponentOfExactType<SelectionScope>()
?.controller;
}
@override
bool updateShouldNotify(SelectionScope old) => controller != old.controller;
}
class SelectionController {
bool _isActive = false;
final Map<Object, SelectionRange> _ranges = {};
bool get isActive => _isActive;
void begin() => _isActive = true;
void end() { _isActive = false; _ranges.clear(); }
void updateRange(Object context, int minIndex, int maxIndex) { ... }
SelectionRange? rangeFor(Object context) => _ranges[context];
}Migration path:
Benefits:
📊 Summary
I'd recommend implementing the caching first (biggest win, lowest risk), then the incremental updates. The InheritedComponent refactor can be a follow-up PR. These are just suggestions - the current implementation is already solid and functional! Happy to help implement any of these if you're interested. |
Additional Thought: ListView Lazy ModeOne more architectural consideration - the current approach of temporarily disabling lazy mode during selection drag ( Instead of force-building all items in the selection range, we could leverage the existing parent data infrastructure more directly:
This would mean:
The current implementation works correctly, but forcing non-laziness for potentially thousands of items during a drag operation could cause jank. Using parent data to track what would be selected, without actually building those items, would be more aligned with the lazy philosophy. What do you think @jolexxa? This could be a follow-up optimization if the current approach causes performance issues in practice. |
|
Hey! I've let me Claude do an extensive check of the PR, sounds very reasonable what it found. How shall we proceed, would you like to continue working on this or shall I take it over? I'm fine either way! :) |
|
Hey! I think your claude is picking up on a lot of good points. By all means, please take over (I don't think I'm giving enough money to Anthropic each month to steer an Opus on these changes in any reasonable amount of time). I'm still playing with this locally and trying to see how robust it is. Certain layouts don't always seem to "just work" but I've yet to figure out why. Additionally, this doesn't have support for MarkdownText. I would probably encourage you to do the "don't build all items in selection" as a follow-up PR. It's not been a huge problem and there are extraordinary correctness challenges involved. I tried several different approaches, and the only one which ended up working well in the end was the dumb "build everything" one. I'm sure it can be done, but it's probably not trivial. Oh, and the app I'm using nocterm for is now public. |
- Add Selectable mixin to RenderParagraph for RichText/MarkdownText selection - Replace global SelectionDragState with SelectionScope InheritedComponent - Add selectables caching to avoid tree walks on every mouse move - Add comprehensive tests for RenderParagraph selection (10 new tests) Performance improvements: - Cache selectables list, invalidate on layout changes - Cache context lists for selection range tracking Architecture improvements: - SelectionScope provides selection state via InheritedComponent pattern - Multiple SelectionAreas no longer interfere with each other - Maintains backwards compatibility with global state for existing code
|
i'm very happy to see all the activity on this 🤠 |
|
I think it's mostly good to go for now! Any subsequent changes should just be implementation details, I'll merge this in a minute! Also, cow looks really nice! I was thinking of setting up a 'Made with Nocterm' some time, do you mind if I showcase it there? |
|
@Norbert515 ooh, nice to see it merged! i'll try it out in Cow as soon as I get a chance. And thank you! feel free to showcase it! it's a silly project but it's been fun to poke at |
Text Selection
This introduces basic text selection support. I needed this for a project I was working on. While I was not previously familiar with lower level rendering pipelines in Flutter/Nocterm, Claude and Codex seemed quite comfortable to take on this challenge. It was also a good chance for me to brush up on those concepts. I now have even more appreciation for what you're doing in this library.
I realize this is a big change and regret that I had to tamper with
ListViewand other core systems a little bit.Lazy
ListViewbuilding complicates text selection, but this works around it by temporarily forcing theListViewto build everything in the selection range. When you let go, it should return to being lazy once again.There were a few spots that needed to reuse existing text layout and painting functions, so some of those have been moved to expose them for sharing (
TextFieldin particular).text_selection.mp4
About
SelectionAreacomponent which identifies text selection contexts below it.SelectionAreawraps.RenderObjectnow has an id which allows text selection to be stable across rebuilds, even as text changes. Theme data now has a selection color.ListViewandSingleChildScrollView.SelectionAreacaptures mouse events (enter/hover/exit). It discovers allSelectablerender objects on pointer down and drives their selection ranges as the drag moves.Selectableexposes selectableText + selectableLayout, hit‑tests local positions to character indices, and paints selection using a shared helper.RenderListViewporttemporarily builds the full selection range so hit‑testing and text extraction remain consistent while scrolling.TuiThemeData.selectionColor, with per‑selection overrides supported.Performance
Performance can improve. I did not feel confident further optimizing this.
_collectSelectableswalks the render subtree and sorts by global position.ListView: InRenderListViewport,_forceBuildSelectionRangecan walk from 0..maxIndex for variable‑extent lists.