add ClidePane primitive + focus-driven status-bar slot
Replace the MessageBus-based pane-context slot with a focus-driven one. Panes keep their status widget locally; the FocusTracker holds the focused pane's widget (activeStatusWidget) and ClidePane conveys it to the shared slot only while its contribution is focused, re-conveying on change and clearing on blur. The status-bar item just renders focus.activeStatusWidget, height-clamped and marquee-scrolled when it overflows. Removes the publish/subscribe race the bus version had. T-150. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
/// Horizontal marquee (T-150). Shows [child] statically when it fits the
|
||||
/// available width; when it's wider, scrolls it leftward in a seamless
|
||||
/// loop (a second copy follows after [gap]). Clips to its box. Used by
|
||||
/// the status-bar slot so a long pane status doesn't get truncated.
|
||||
///
|
||||
/// Own-the-stack: a `Ticker`-driven `SingleChildScrollView`, no package.
|
||||
library;
|
||||
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClideMarquee extends StatefulWidget {
|
||||
const ClideMarquee({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.pxPerSecond = 28,
|
||||
this.gap = 48,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Scroll speed when overflowing.
|
||||
final double pxPerSecond;
|
||||
|
||||
/// Space between the looped copies.
|
||||
final double gap;
|
||||
|
||||
@override
|
||||
State<ClideMarquee> createState() => _ClideMarqueeState();
|
||||
}
|
||||
|
||||
class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderStateMixin {
|
||||
final GlobalKey _childKey = GlobalKey();
|
||||
final ScrollController _scroll = ScrollController();
|
||||
late final Ticker _ticker = createTicker(_tick);
|
||||
|
||||
double _contentWidth = 0;
|
||||
double _viewportWidth = 0;
|
||||
double _offset = 0;
|
||||
Duration _last = Duration.zero;
|
||||
|
||||
bool get _overflow => _contentWidth > _viewportWidth + 0.5;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ClideMarquee old) {
|
||||
super.didUpdateWidget(old);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
|
||||
}
|
||||
|
||||
void _measure() {
|
||||
if (!mounted) return;
|
||||
final w = _childKey.currentContext?.size?.width ?? 0;
|
||||
if ((w - _contentWidth).abs() > 0.5) {
|
||||
setState(() => _contentWidth = w);
|
||||
}
|
||||
if (_overflow && !_ticker.isActive) {
|
||||
_last = Duration.zero;
|
||||
_ticker.start();
|
||||
} else if (!_overflow && _ticker.isActive) {
|
||||
_ticker.stop();
|
||||
_offset = 0;
|
||||
if (_scroll.hasClients) _scroll.jumpTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
void _tick(Duration elapsed) {
|
||||
if (!_overflow || !_scroll.hasClients) return;
|
||||
final dt = _last == Duration.zero ? 0.0 : (elapsed - _last).inMicroseconds / 1e6;
|
||||
_last = elapsed;
|
||||
final span = _contentWidth + widget.gap;
|
||||
if (span <= 0) return;
|
||||
_offset = (_offset + widget.pxPerSecond * dt) % span;
|
||||
final max = _scroll.position.maxScrollExtent;
|
||||
_scroll.jumpTo(_offset.clamp(0, max));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker.dispose();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRect(
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
_viewportWidth = constraints.maxWidth;
|
||||
return SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
KeyedSubtree(key: _childKey, child: widget.child),
|
||||
if (_overflow) ...[SizedBox(width: widget.gap), widget.child],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/// Behaviour-only wrapper every pane uses (T-150). No chrome — that's
|
||||
/// [ClidePaneChrome]'s job. ClidePane handles cross-pane uniformity:
|
||||
/// surfacing the pane's [statusWidget] to the bottom status bar while the
|
||||
/// pane is focused, via [FocusTracker.setStatusWidget].
|
||||
///
|
||||
/// The widget lives with the pane; ClidePane only conveys it to the
|
||||
/// shared slot while this pane is the shown one (its contribution is
|
||||
/// focused and, for multi-pane contributions, it's the [active] sub-tab),
|
||||
/// and re-conveys whenever [statusWidget] changes. A backgrounded pane
|
||||
/// keeps its content locally and re-conveys on regaining focus.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/facade.dart';
|
||||
import 'package:clide/kernel/src/focus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClidePane extends StatefulWidget {
|
||||
const ClidePane({
|
||||
super.key,
|
||||
required this.contributionId,
|
||||
required this.child,
|
||||
this.statusWidget,
|
||||
this.active = true,
|
||||
});
|
||||
|
||||
/// The workspace contribution id this pane belongs to (what
|
||||
/// [FocusTracker.activeContributionId] reports when it's focused).
|
||||
final String contributionId;
|
||||
|
||||
/// Status-bar content to surface while focused, or null for none.
|
||||
final Widget? statusWidget;
|
||||
|
||||
/// For contributions hosting multiple panes (e.g. Claude's sub-tabs),
|
||||
/// whether this is the visible one. Only the active pane conveys.
|
||||
final bool active;
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<ClidePane> createState() => _ClidePaneState();
|
||||
}
|
||||
|
||||
class _ClidePaneState extends State<ClidePane> {
|
||||
FocusTracker? _focus;
|
||||
bool _wired = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_wired) {
|
||||
_wired = true;
|
||||
_focus = ClideKernel.of(context).focus..addListener(_sync);
|
||||
}
|
||||
_sync();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ClidePane old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.statusWidget != widget.statusWidget || old.active != widget.active || old.contributionId != widget.contributionId) {
|
||||
_sync();
|
||||
}
|
||||
}
|
||||
|
||||
bool get _shown => widget.active && _focus?.activeContributionId == widget.contributionId;
|
||||
|
||||
// Convey our status while shown. Re-entrancy is bounded: setStatusWidget
|
||||
// notifies (→ _sync again), but the identical-widget guard there makes
|
||||
// the second pass a no-op.
|
||||
void _sync() {
|
||||
if (_shown) _focus?.setStatusWidget(widget.contributionId, widget.statusWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_shown) _focus?.setStatusWidget(widget.contributionId, null);
|
||||
_focus?.removeListener(_sync);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
Reference in New Issue
Block a user