Framework strings outside any extension — widget primitives (collapser, toast, lightbox, multitab, ex-line, spine, pane chrome), the shared reader chrome, the markdown 'Open in editor' tooltip, and the drag-resize handle a11y labels — now resolve under a new 'core' namespace (preloaded at boot). Settles the T-469 namespace question: framework chrome gets one 'core' catalog. Makes ClideSettings.i18n.string/.interpolated null-safe (ClideKernel.maybeOf): primitives render kernel-less in isolated tests, returning the placeholder — matching the D-101 fallback contract for fonts. The markdown tooltip threads via the ClideMarkdownHooks carrier like mono/ui; drag_resize reads the kernel i18n directly to avoid a kernel→widgets layering inversion. No en_US change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
241 lines
11 KiB
Dart
241 lines
11 KiB
Dart
/// The conversation stream's collapsible card (T-305) — the category-3 card.
|
|
///
|
|
/// Holds a list of `1..N` inner item cards as one unit. A single item is just a
|
|
/// list of one: there is no separate single-card path, which keeps the model
|
|
/// uniform and reliable.
|
|
///
|
|
/// - **Collapsed** (default): a one-line ticker — the `label`, the echoed
|
|
/// `collapsedSummary` (the run's latest content line), a fixed-width `counter`
|
|
/// ("3 steps"), and the aggregate `status` (spinner / check / cross). The
|
|
/// whole row is the toggle.
|
|
/// - **Expanded**: a framed inner canvas wrapping the item cards; clicking the
|
|
/// frame BACKGROUND (padding, the gaps between items, the gutter — anywhere an
|
|
/// item doesn't cover) collapses it. An explicit focusable header caret keeps
|
|
/// the control keyboard/AT reachable while a run streams (the tail-follow race
|
|
/// — a top-only control would be unreachable as the view auto-scrolls, D-78).
|
|
///
|
|
/// Chrome is consistent across every collapser: the chevron is hard against the
|
|
/// LEFT edge, the status icon hard against the RIGHT edge, the counter sits in a
|
|
/// fixed-width slot just inboard of it, and `color` drives the border + the
|
|
/// chevron/label tint so each instance keeps its visual identity through one
|
|
/// widget. The inner item cards are content (they keep their OWN per-item status
|
|
/// + stripe); the aggregate status/count/title shown here are computed by the
|
|
/// caller and passed in — this widget stays free of conversation semantics.
|
|
library;
|
|
|
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
|
import 'package:clide/widgets/src/clide_card_metrics.dart';
|
|
import 'package:clide/widgets/src/clide_icon.dart';
|
|
import 'package:clide/widgets/src/clide_settings.dart';
|
|
import 'package:clide/widgets/src/clide_status_indicator.dart';
|
|
import 'package:clide/widgets/src/clide_tappable.dart';
|
|
import 'package:clide/widgets/src/clide_text.dart';
|
|
import 'package:clide/widgets/src/icons/chevron.dart';
|
|
import 'package:clide/widgets/src/spacing.dart';
|
|
import 'package:clide/widgets/src/typography.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
class ClideCollapserCard extends StatefulWidget {
|
|
const ClideCollapserCard({
|
|
super.key,
|
|
required this.label,
|
|
required this.children,
|
|
this.color,
|
|
this.collapsedSummary,
|
|
this.counter,
|
|
this.status,
|
|
this.initiallyExpanded = false,
|
|
});
|
|
|
|
/// Header label, shown collapsed AND expanded (e.g. `Edits`, `Bash`).
|
|
final String label;
|
|
|
|
/// The inner item cards, wrapped in the frame when expanded. A single item is
|
|
/// a list of one.
|
|
final List<Widget> children;
|
|
|
|
/// Border + chevron/label tint. Null → panel border with a muted label.
|
|
final Color? color;
|
|
|
|
/// The run's latest content line, echoed beside the label while collapsed.
|
|
final String? collapsedSummary;
|
|
|
|
/// Fixed-width right-aligned count, e.g. `3 steps`. Null → no counter slot.
|
|
final String? counter;
|
|
|
|
/// Aggregate run status (spinner / check / cross) at the right edge. The inner
|
|
/// cards still carry their own per-item marks; this is the roll-up.
|
|
final ClideRunStatus? status;
|
|
|
|
final bool initiallyExpanded;
|
|
|
|
@override
|
|
State<ClideCollapserCard> createState() => _ClideCollapserCardState();
|
|
}
|
|
|
|
class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
|
late bool _expanded = widget.initiallyExpanded;
|
|
final FocusNode _controlFocus = FocusNode(debugLabel: 'collapser-control');
|
|
|
|
@override
|
|
void dispose() {
|
|
_controlFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _toggle() => setState(() => _expanded = !_expanded);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = ClideSettings.theme.of(context).surface;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: kClideCardGap),
|
|
child: _expanded ? _expandedFrame(context, tokens) : _tickerRow(context, tokens),
|
|
);
|
|
}
|
|
|
|
/// Summarized button semantics for the toggle. Scoped to the HEADER only —
|
|
/// wrapping the whole card excluded every expanded child from the a11y
|
|
/// tree, so a screen-reader user could expand a run and hear nothing
|
|
/// inside it (T-370). Collapsed, the header summary IS the whole card.
|
|
Widget _headerSemantics(BuildContext context, {required Widget child}) {
|
|
final semanticCount = widget.counter == null ? '' : ', ${widget.counter}';
|
|
final stateWord = _expanded
|
|
? ClideSettings.i18n.string(context, 'collapser.expanded', namespace: 'core', placeholder: 'expanded')
|
|
: ClideSettings.i18n.string(context, 'collapser.collapsed', namespace: 'core', placeholder: 'collapsed');
|
|
return Semantics(button: true, expanded: _expanded, label: '${widget.label}$semanticCount, $stateWord', excludeSemantics: true, child: child);
|
|
}
|
|
|
|
/// The header row content, shared by the collapsed ticker and the expanded
|
|
/// header so the chrome is pixel-identical between states.
|
|
Widget _headerContent(SurfaceTokens tokens, {required bool expanded}) {
|
|
final accent = widget.color ?? tokens.globalTextMuted;
|
|
final showSummary = !expanded && widget.collapsedSummary != null;
|
|
return Row(
|
|
children: [
|
|
// Chevron — hard against the left edge; the toggle.
|
|
ClideIcon(expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: accent),
|
|
const SizedBox(width: 8),
|
|
ClideText(widget.label, fontSize: clideFontCaption, fontFamily: ClideSettings.fonts.monoOf(context), color: accent),
|
|
if (showSummary) ...[
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: ClideText(
|
|
widget.collapsedSummary!,
|
|
// Summary one step below the label, matching ConversationCard so
|
|
// collapser + tool cards read consistently in the stream (T-344).
|
|
fontSize: clideFontMeta,
|
|
fontFamily: ClideSettings.fonts.monoOf(context),
|
|
color: tokens.globalTextMuted,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
] else
|
|
const Spacer(),
|
|
// Counter — fixed-width right-aligned slot so the status icon never shifts.
|
|
if (widget.counter != null) ...[
|
|
const SizedBox(width: 8),
|
|
SizedBox(
|
|
width: kClideCardCounterSlotWidth,
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: ClideText(widget.counter!, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1),
|
|
),
|
|
),
|
|
],
|
|
// Status — the icon hard against the right edge.
|
|
if (widget.status != null) ...[const SizedBox(width: 8), ClideStatusIndicator(status: widget.status!, size: clideIconHero)],
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Collapsed: the ticker row IS the toggle, focusable for keyboard/AT.
|
|
Widget _tickerRow(BuildContext context, SurfaceTokens tokens) => _headerSemantics(
|
|
context,
|
|
child: ClideTappable(
|
|
focusNode: _controlFocus,
|
|
onTap: _toggle,
|
|
tooltip: ClideSettings.i18n.string(context, 'collapser.expand', namespace: 'core', placeholder: 'Expand'),
|
|
builder: (context, hovered, focused) => Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
|
|
decoration: BoxDecoration(
|
|
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
|
|
border: Border.all(color: widget.color ?? tokens.panelBorder),
|
|
borderRadius: BorderRadius.circular(kClideCardRadius),
|
|
),
|
|
child: _headerContent(tokens, expanded: false),
|
|
),
|
|
),
|
|
);
|
|
|
|
/// Expanded: a framed inner canvas wrapping the item cards. The frame
|
|
/// BACKGROUND is a gesture target behind the items that only fires for hits
|
|
/// the items don't consume.
|
|
Widget _expandedFrame(BuildContext context, SurfaceTokens tokens) => DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: widget.color ?? tokens.panelBorder),
|
|
borderRadius: BorderRadius.circular(kClideCardRadius),
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
// Background toggle: behind the items, not a whole-card overlay, so
|
|
// item taps are never intercepted. Excluded from focus traversal AND
|
|
// semantics — the header caret is the single keyboard/AT stop.
|
|
Positioned.fill(
|
|
child: ExcludeFocus(
|
|
child: ExcludeSemantics(
|
|
child: ClideTappable(
|
|
onTap: _toggle,
|
|
tooltip: ClideSettings.i18n.string(context, 'collapser.collapse', namespace: 'core', placeholder: 'Collapse'),
|
|
builder: (_, _, _) => const SizedBox.expand(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_headerSemantics(context, child: _headerRow(context, tokens)),
|
|
// Even padding around the inner item canvas (T-305): the sides +
|
|
// top match, and each inner item carries a matching bottom margin
|
|
// (so the last item's margin is the bottom inset and items in a
|
|
// multi-item run are evenly separated) — hence bottom 0 here.
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(kClideCardHeaderPadH, kClideCardHeaderPadH, kClideCardHeaderPadH, 0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (final child in widget.children)
|
|
// Each item opaquely consumes its full bounds so a body
|
|
// tap interacts with that card (or does nothing), never
|
|
// the collapser background. Deeper controls still win;
|
|
// only taps are absorbed, so selection drags pass through.
|
|
GestureDetector(behavior: HitTestBehavior.opaque, onTap: () {}, child: child),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
/// The explicit, focusable collapse control in the expanded header. A
|
|
/// background tap alone is not keyboard/AT reachable, so this keeps the
|
|
/// control on the Tab path and Enter/Space-activatable (D-78).
|
|
Widget _headerRow(BuildContext context, SurfaceTokens tokens) => ClideTappable(
|
|
focusNode: _controlFocus,
|
|
onTap: _toggle,
|
|
tooltip: ClideSettings.i18n.string(context, 'collapser.collapse', namespace: 'core', placeholder: 'Collapse'),
|
|
builder: (context, hovered, focused) => Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
|
|
decoration: BoxDecoration(
|
|
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
|
|
border: Border(bottom: BorderSide(color: widget.color ?? tokens.panelBorder)),
|
|
),
|
|
child: _headerContent(tokens, expanded: true),
|
|
),
|
|
);
|
|
}
|