Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ extension points or reach into a default block painter's fields. Ordinary
boxes**, not the full max-width layout of a paragraph. Empty horizontal
gutter beside a short line is no longer I-beam / click-to-open.
- **CHANGED**: Gesture *starts* (`hitsSelectableContent`) stay **surface-
bounds** (press inside the document/bubble arms text selection — tdesktop
`PointState::Inside` parity). Drag-extend may still clamp outside glyphs.
bounds**. A press inside the document or bubble arms text selection.
Drag-extend may still clamp outside glyphs.
- **CHANGED**: Mouse single-click that misses selectable content while a
non-collapsed range is active clears the selection (tdesktop empty
Selecting / dismiss parity), instead of leaving the old range.
non-collapsed range is active clears the selection, instead of leaving
the old range.
- **ADDED**: `SelectableBlockPainter.hitsRenderedTextAt`,
`MarkdownSelectionSurface.hitsSelectableGlyphs`, and
`MarkdownSelectionController.hitsSelectableGlyphs`.
Expand Down Expand Up @@ -85,6 +85,17 @@ extension points or reach into a default block painter's fields. Ordinary
that reference that scope’s own `LayerLink`s, so chat dual mounts keep
handles after non-collapsed range commits (including repeated handle-drag
settles).
- **FIXED**: A scope that loses `ownsSelectionChrome` for the live selection
document now removes its own context-menu overlay (and does the same when
handles are disabled for that reason). Previously only handles were cleared,
so a prior bubble’s adaptive toolbar could linger after selection moved to a
sibling scope. `toolbarWanted` is left alone so the owning scope can still
restore on settle.
- **FIXED**: Scroll / geometry toolbar restore is skipped while a selection
drag is active (`_dragGlobal`). Autoscroll `ScrollNotification`s must not
re-present the adaptive toolbar mid-gesture even when `toolbarWanted` was
re-armed (for example a host restoring a clamped range via the public
`selection` setter).
- **ADDED**: `MarkdownSelectionSurface.hasSelectionHandleLeaders`,
`clearSelectionHandleLayersIfLinked`, and
`MarkdownSelectionScopeState.selectionHandleLeadersAttached` for observing
Expand Down
4 changes: 2 additions & 2 deletions docs/selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@ Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ st
tap-down and shows chrome on tap-up, **triple** selects the block, `Shift`-click
extends, and a drag after a double/triple keeps word/block granularity. The
touch recognizer and long-press only join the arena on a selectable hit
inside a mounted surface’s bounds (`hitsSelectableContent` — tdesktop-style
bubble Inside, including padding / empty line gutter; I-beam / link hits stay
inside a mounted surface’s bounds (`hitsSelectableContent`: press inside the
document or bubble, including padding / empty line gutter; I-beam / link hits stay
glyph-tight via `hitsSelectableGlyphs`), or while a non-collapsed selection /
toolbar is up, and only when
`enableTouchGestures` is true. When `enableTouchConsecutiveTaps` is false,
Expand Down
23 changes: 21 additions & 2 deletions lib/src/selection_scope.dart
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,11 @@ class MarkdownSelectionScopeState extends State<MarkdownSelectionScope>
}

if (sel case final current? when !current.isCollapsed) {
if (_dragGlobal == null) {
if (!_ownsChromeForCurrentSelection) {
// Sibling / prior subject: drop this scope's overlay without clearing
// [toolbarWanted] (the owning scope may still need restore intent).
_contextMenuController.remove();
} else if (_dragGlobal == null) {
// Outside a drag: restore when [toolbarWanted] (scroll remount /
// geometry change). Skip while disabled — chrome comes back on
// the enabled flip in [didUpdateWidget].
Expand Down Expand Up @@ -498,6 +502,9 @@ class MarkdownSelectionScopeState extends State<MarkdownSelectionScope>
void _updateHandlesAndOverlay() {
if (!_handlesEnabled) {
_clearHandles();
// Losing chrome ownership (or disabled) must also drop a lingering
// context menu — handles alone were cleared before.
_contextMenuController.remove();
return;
}
final endpoints = controller.selectionHandleEndpoints();
Expand Down Expand Up @@ -1494,6 +1501,12 @@ class MarkdownSelectionScopeState extends State<MarkdownSelectionScope>
}
return;
}
if (_dragGlobal != null) {
// Absolute toolbar anchors are dormant mid-drag; handles still track
// via LeaderLayer. Drag-end [showToolbar] re-presents.
_syncOverlay();
return;
}
_refreshToolbarForVisibleGeometry();
// Scroll notifications can run before child layout finishes (common when
// the scope itself was outside the viewport cache). Retry next frame.
Expand All @@ -1511,7 +1524,9 @@ class MarkdownSelectionScopeState extends State<MarkdownSelectionScope>
}

void _scheduleToolbarGeometryRefresh() {
if (!controller.toolbarWanted || _toolbarGeometryRefreshScheduled) {
if (!controller.toolbarWanted ||
_dragGlobal != null ||
_toolbarGeometryRefreshScheduled) {
return;
}
_toolbarGeometryRefreshScheduled = true;
Expand All @@ -1528,6 +1543,10 @@ class MarkdownSelectionScopeState extends State<MarkdownSelectionScope>
/// Must not run during layout/build — geometry walks use [localToGlobal].
void _refreshToolbarForVisibleGeometry() {
if (!widget.enabled || !controller.toolbarWanted) return;
// Mid-drag: keep the menu hidden until drag-end [showToolbar]. Scroll /
// remount restore must not re-present while a pointer still owns the
// gesture (autoscroll delivers ScrollNotifications during edge hold).
if (_dragGlobal != null) return;
if (!_ownsChromeForCurrentSelection) {
_contextMenuController.remove();
return;
Expand Down
78 changes: 78 additions & 0 deletions test/selection/selection_host_gates_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,84 @@ void main() {
});
});

testWidgets(
'losing chrome ownership drops this scope toolbar without clearing '
'toolbarWanted', (tester) async {
await _withPlatform(TargetPlatform.android, () async {
final controller = _controllerWith({
'a': 'First message body text',
'b': 'Second message body text',
});
addTearDown(controller.dispose);

late MarkdownSelectionScopeState scopeA;

await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
MarkdownSelectionScope(
controller: controller,
ownsSelectionChrome: (id) => id == 'a',
child: Builder(builder: (context) {
scopeA = MarkdownSelectionScope.stateOf(context)!;
return const _Doc('a');
}),
),
MarkdownSelectionScope(
controller: controller,
ownsSelectionChrome: (id) => id == 'b',
child: const _Doc('b'),
),
],
),
),
),
),
));
await tester.pumpAndSettle();

final first = tester.getRect(find.byType(MarkdownWidget).first);
await _longPressDrag(
tester,
first.topLeft + const Offset(2, 4),
first.topLeft + const Offset(60, 4),
);
await tester.pumpAndSettle();

expect(controller.selection!.base.documentId, 'a');
expect(scopeA.toolbarIsVisible, isTrue);
expect(controller.toolbarWanted, isTrue);

// Retarget the live range onto B. Scope A no longer owns chrome for
// the selection document and must drop its overlay; toolbarWanted
// stays so B can restore on settle.
final second = tester.getRect(find.byType(MarkdownWidget).at(1));
controller.selectWordAtGlobal(second.center);
await tester.pumpAndSettle();

expect(controller.selection!.base.documentId, 'b');
expect(
scopeA.toolbarIsVisible,
isFalse,
reason:
'prior owner must remove its context menu when the selection '
'document moves to a sibling scope',
);
expect(
controller.toolbarWanted,
isTrue,
reason: 'toolbarWanted is shared restore intent, not this scope',
);
expect(scopeA.selectionHandleLeadersAttached, isFalse);
});
});

testWidgets('a disabled sibling does not strip the owner handles',
(tester) async {
await _withPlatform(TargetPlatform.android, () async {
Expand Down
78 changes: 78 additions & 0 deletions test/selection/selection_widget_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,84 @@ void main() {
expect(tester.takeException(), isNull);
});

testWidgets(
'expand drag keeps toolbar hidden across scroll even if toolbarWanted '
'is re-armed mid-drag',
(tester) async {
// Hosts may restore a clamped same-document range via paths that arm
// toolbarWanted on mobile. Autoscroll scroll notifications must not
// re-present the menu until drag end.
final scrollController = ScrollController();
addTearDown(scrollController.dispose);
final controller = MarkdownSelectionController()
..setDocuments(<MarkdownDocumentRef>[
MarkdownDocumentRef(
id: 'd',
model: Markdown.fromString('Hello selectable world again'),
),
]);

await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ListView(
controller: scrollController,
children: <Widget>[
const SizedBox(height: 80),
MarkdownSelectionScope(
controller: controller,
child: const SizedBox(width: 400, child: _Doc('d')),
),
const SizedBox(height: 1200),
],
),
),
),
);
await tester.pumpAndSettle();

final tl = tester.getTopLeft(find.byType(MarkdownWidget));
final pos = tl + const Offset(8, 8);
await tester.tapAt(pos);
await tester.pump(const Duration(milliseconds: 40));
await tester.tapAt(pos);
await tester.pumpAndSettle();
final state = tester.state<MarkdownSelectionScopeState>(
find.byType(MarkdownSelectionScope),
);
expect(state.toolbarIsVisible, isTrue);

final gesture = await tester.startGesture(pos);
await tester.pump(const Duration(seconds: 1));
await tester.pump();
expect(state.toolbarIsVisible, isFalse);

// Simulate a host restoring a clamped range via paths that arm
// toolbarWanted (public selection setter on a changed range).
controller.toolbarWanted = true;
expect(controller.toolbarWanted, isTrue);
await gesture.moveBy(const Offset(60, 0));
await tester.pump();

scrollController.jumpTo(40);
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));

expect(
state.toolbarIsVisible,
isFalse,
reason:
'scroll geometry refresh must not show the toolbar while an '
'expand drag is still active',
);

await gesture.up();
await tester.pumpAndSettle();
expect(state.toolbarIsVisible, isTrue);
expect(tester.takeException(), isNull);
},
);

testWidgets(
'Android long-press fires forLongPress haptic while handles deferred',
(tester) async {
Expand Down