Files
clide/lib/widgets/src/clide_anchored.dart
T
jpmschweitzerandClaude Opus 4.8 c08f249b00 docs: fix 41 unresolved dartdoc references
Convert non-resolving [refs] in doc comments to backtick code-spans across 24
lib/ files (param/field names out of doc scope, method refs on other classes,
non-API strings like regex char-classes and command ids). Verified 0
"unresolved doc reference" warnings via `dart doc --validate-links`. The
dart-doc CI gate (test.yml) never ran before — Gitea Actions was inactive — so
this debt had accumulated unchecked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:09:55 +02:00

301 lines
10 KiB
Dart

/// Anchored-overlay positioning + lifecycle primitive (D-88).
///
/// Every clide popover surface — menu-bar dropdowns, the theme picker, the
/// permission-mode picker, the slash / @ typeaheads, quick-open — used to
/// re-derive the same four things: a [LayerLink] + [CompositedTransformFollower]
/// (or a hand-rolled `Positioned`), a full-screen tap-away barrier, the
/// `Overlay.insert` / `OverlayEntry` bookkeeping, and post-frame focus capture.
/// This widget owns all of it; callers supply the trigger (`anchor`) and the
/// floating content (`overlayBuilder`). Modal, centred dialogs stay on the
/// kernel `DialogRouter` — this is for anchored, non-modal popovers.
library;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// Primary placement of the floating panel relative to the `anchor`.
enum ClideAnchorSide { below, above, left, right }
/// Cross-axis alignment of the panel's edge to the anchor's edge.
enum ClideAnchorAlign { start, center, end }
/// Open/close state for a [ClideAnchoredOverlay]. A plain [ChangeNotifier] so
/// it composes with `ListenableBuilder` and outside controllers (e.g. the menu
/// bar's single-open coordinator drives one of these per top-level button).
class ClideOverlayController extends ChangeNotifier {
bool _open = false;
bool get isOpen => _open;
void open() {
if (_open) return;
_open = true;
notifyListeners();
}
void close() {
if (!_open) return;
_open = false;
notifyListeners();
}
void toggle() => _open ? close() : open();
}
/// Wraps `anchor` with a [CompositedTransformTarget] and, while [controller] is
/// open, inserts an [OverlayEntry] built from `overlayBuilder`, positioned
/// relative to the anchor (or centred when [centered]).
class ClideAnchoredOverlay extends StatefulWidget {
const ClideAnchoredOverlay({
super.key,
required this.controller,
required this.anchor,
required this.overlayBuilder,
this.side = ClideAnchorSide.below,
this.align = ClideAnchorAlign.start,
this.offset = const Offset(0, 2),
this.autoFlip = true,
this.barrier = true,
this.dismissOnEscape = true,
this.captureFocus = true,
this.rootOverlay = false,
this.centered = false,
this.onOpened,
this.onClosed,
});
final ClideOverlayController controller;
/// The trigger widget. Wrapped in a [CompositedTransformTarget].
final Widget anchor;
/// Builds the floating content. Receives the [controller] so rows can close
/// the overlay on activation.
final Widget Function(BuildContext context, ClideOverlayController controller) overlayBuilder;
final ClideAnchorSide side;
final ClideAnchorAlign align;
/// Follower offset for the requested [side]. On an [autoFlip] vertical flip
/// the dy is negated so the gap stays on the correct edge.
final Offset offset;
/// Flip [side] to its opposite when the anchor sits in the far edge of the
/// viewport (e.g. a status-bar control at the window bottom opens upward).
final bool autoFlip;
/// Insert a full-screen tap-away barrier behind the panel.
final bool barrier;
/// Close on Escape (as a fallback; menu content may handle Esc first).
final bool dismissOnEscape;
/// Request focus into the panel on open.
final bool captureFocus;
/// Insert into the root overlay (menu bar needs this to clear pane chrome).
final bool rootOverlay;
/// Ignore the anchor; render the panel centred horizontally at [offset].dy
/// from the top (the command-palette / quick-open shape).
final bool centered;
final VoidCallback? onOpened;
final VoidCallback? onClosed;
@override
State<ClideAnchoredOverlay> createState() => _ClideAnchoredOverlayState();
}
class _ClideAnchoredOverlayState extends State<ClideAnchoredOverlay> {
final LayerLink _link = LayerLink();
final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'clide-anchored');
OverlayEntry? _entry;
ClideAnchorSide _resolvedSide = ClideAnchorSide.below;
@override
void initState() {
super.initState();
_resolvedSide = widget.side;
widget.controller.addListener(_sync);
}
@override
void didUpdateWidget(ClideAnchoredOverlay old) {
super.didUpdateWidget(old);
if (!identical(old.controller, widget.controller)) {
old.controller.removeListener(_sync);
widget.controller.addListener(_sync);
}
}
@override
void dispose() {
widget.controller.removeListener(_sync);
_entry?.remove();
_entry = null;
_scope.dispose();
super.dispose();
}
void _sync() {
if (widget.controller.isOpen && _entry == null) {
_resolvedSide = _computeSide();
_entry = OverlayEntry(builder: _buildEntry);
Overlay.of(context, rootOverlay: widget.rootOverlay).insert(_entry!);
if (widget.captureFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _entry != null) _scope.requestFocus();
});
}
widget.onOpened?.call();
} else if (!widget.controller.isOpen && _entry != null) {
_entry!.remove();
_entry = null;
widget.onClosed?.call();
}
}
/// Resolve [ClideAnchorSide] honouring [autoFlip]: a vertical side flips when
/// the anchor is past 60% (below) / before 40% (above) of the viewport.
ClideAnchorSide _computeSide() {
var side = widget.side;
if (!widget.autoFlip || widget.centered) return side;
final box = context.findRenderObject();
// Use the real view size, not MediaQuery — the latter can be overridden to
// zero (e.g. the shared test harness), which would defeat the flip.
final view = View.maybeOf(context);
if (box is RenderBox && box.hasSize && view != null) {
final rect = box.localToGlobal(Offset.zero) & box.size;
final h = view.physicalSize.height / view.devicePixelRatio;
if (h > 0) {
if (side == ClideAnchorSide.below && rect.bottom > h * 0.6) {
side = ClideAnchorSide.above;
} else if (side == ClideAnchorSide.above && rect.top < h * 0.4) {
side = ClideAnchorSide.below;
}
}
}
return side;
}
(Alignment target, Alignment follower) _alignments(ClideAnchorSide side) {
final a = widget.align;
switch (side) {
case ClideAnchorSide.below:
return (
a == ClideAnchorAlign.start
? Alignment.bottomLeft
: a == ClideAnchorAlign.end
? Alignment.bottomRight
: Alignment.bottomCenter,
a == ClideAnchorAlign.start
? Alignment.topLeft
: a == ClideAnchorAlign.end
? Alignment.topRight
: Alignment.topCenter,
);
case ClideAnchorSide.above:
return (
a == ClideAnchorAlign.start
? Alignment.topLeft
: a == ClideAnchorAlign.end
? Alignment.topRight
: Alignment.topCenter,
a == ClideAnchorAlign.start
? Alignment.bottomLeft
: a == ClideAnchorAlign.end
? Alignment.bottomRight
: Alignment.bottomCenter,
);
case ClideAnchorSide.right:
return (
a == ClideAnchorAlign.start
? Alignment.topRight
: a == ClideAnchorAlign.end
? Alignment.bottomRight
: Alignment.centerRight,
a == ClideAnchorAlign.start
? Alignment.topLeft
: a == ClideAnchorAlign.end
? Alignment.bottomLeft
: Alignment.centerLeft,
);
case ClideAnchorSide.left:
return (
a == ClideAnchorAlign.start
? Alignment.topLeft
: a == ClideAnchorAlign.end
? Alignment.bottomLeft
: Alignment.centerLeft,
a == ClideAnchorAlign.start
? Alignment.topRight
: a == ClideAnchorAlign.end
? Alignment.bottomRight
: Alignment.centerRight,
);
}
}
Widget _buildEntry(BuildContext context) {
// Content owns its own focus (ClideMenu autofocuses + handles keys). The
// FocusScope isolates Tab traversal from the page behind; the Escape Focus
// sits ABOVE the scope so it catches Esc the content left unhandled, without
// stealing focus from the content.
Widget content = widget.overlayBuilder(context, widget.controller);
if (widget.captureFocus) content = FocusScope(node: _scope, child: content);
if (widget.dismissOnEscape) {
content = Focus(
onKeyEvent: (node, event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
widget.controller.close();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: content,
);
}
final Widget positioned;
if (widget.centered) {
positioned = Positioned(
top: widget.offset.dy,
left: 0,
right: 0,
child: Center(child: content),
);
} else {
final flipped = _resolvedSide != widget.side;
final off = flipped ? Offset(widget.offset.dx, -widget.offset.dy) : widget.offset;
final (target, follower) = _alignments(_resolvedSide);
// The Stack lays the follower out loosely, so the (shrink-wrapping) panel
// sizes to its content; the follower layer's transform alone positions it.
// An Align here would peg the panel to a corner of the full-screen follower
// box and break hit-testing for non-top-left anchors.
positioned = CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: target,
followerAnchor: follower,
offset: off,
child: content,
);
}
return Stack(
children: [
if (widget.barrier)
Positioned.fill(
child: GestureDetector(behavior: HitTestBehavior.opaque, onTap: widget.controller.close),
),
positioned,
],
);
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(link: _link, child: widget.anchor);
}
}