Files
clide/lib/builtin/theme_picker/src/theme_status_item.dart
T
jpmschweitzerandClaude Opus 4.8 18cd945380 refactor(theme): route consumer reads through ClideSettings.theme (T-473)
Migrate ClideTheme.of(context) reads across the widget, feature, and shell
layers to the unified ClideSettings.theme.of(context) facade (D-101), so
theme/i18n/fonts/settings share one widget-facing entry. The facade delegates
straight to ClideTheme, so behaviour is unchanged — goldens are unmoved.

The low-level theme provider keeps its direct ClideTheme.of: the facade is
built on it, and the two kernel sites (ClideTheme's own definition + the
panels drag-resize widget) stay direct to avoid a widgets→kernel import cycle.
Dead controller.dart/kernel.dart imports left by the sweep removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:47:15 +02:00

140 lines
5.1 KiB
Dart

/// Status-bar theme switcher (T-234). A compact right-aligned control
/// showing the current theme; activating it opens a popover (anchored
/// above the status bar, not a full-screen modal) listing every theme,
/// with live-apply on select. The modal `theme.pick` command is unchanged
/// (D-6 parity — both reach the same ThemeController).
library;
import 'package:clide/builtin/theme_picker/src/theme_families.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class ThemeSwitcherStatusItem extends StatefulWidget {
const ThemeSwitcherStatusItem({super.key});
static const ns = 'builtin.theme-picker';
@override
State<ThemeSwitcherStatusItem> createState() => _ThemeSwitcherStatusItemState();
}
class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
final ClideOverlayController _overlay = ClideOverlayController();
ThemeController get _controller => ClideKernel.of(context).theme;
@override
void dispose() {
_overlay.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
// Listen to both the theme (label changes) and the overlay (open state).
return ListenableBuilder(
listenable: Listenable.merge([_controller, _overlay]),
builder: (context, _) {
final label = _controller.current.displayName;
final open = _overlay.isOpen;
return ClideAnchoredOverlay(
controller: _overlay,
// The status bar lives at the window bottom, so the popover opens
// upward, right-aligned to the control; autoFlip handles the rare
// case where it sits high enough to grow down (e.g. in tests).
side: ClideAnchorSide.above,
align: ClideAnchorAlign.end,
offset: const Offset(0, -4),
anchor: Semantics(
button: true,
expanded: open,
label: 'Theme: $label',
hint: 'Open the theme switcher',
excludeSemantics: true,
child: ClideTappable(
onTap: _overlay.toggle,
builder: (context, hovered, focused) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
color: (hovered || focused || open) ? tokens.listItemHoverBackground : null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(PhosphorIcons.byName('palette'), size: 13, color: tokens.statusBarForeground),
const SizedBox(width: 6),
// The status bar is all-lowercase; the proper-case name stays
// in the Semantics label for screen readers.
ClideText(label.toLowerCase(), fontSize: clideFontCaption, color: tokens.statusBarForeground),
],
),
),
),
),
overlayBuilder: (ctx, ctrl) => _ThemePopover(controller: _controller, onClose: ctrl.close),
);
},
);
}
}
/// The popover body, rendered on the shared [ClideMenu] (D-88): a "High
/// contrast" toggle (a `keepOpenOnSelect` item that live-applies the `-hc`
/// sibling, T-237) then the base themes — the `-hc` variants collapse into the
/// toggle. ClideMenu owns the arrow / Enter / Esc nav + autofocus; this widget
/// just maps theme state to entries and re-applies on select.
class _ThemePopover extends StatefulWidget {
const _ThemePopover({required this.controller, required this.onClose});
final ThemeController controller;
final VoidCallback onClose;
@override
State<_ThemePopover> createState() => _ThemePopoverState();
}
class _ThemePopoverState extends State<_ThemePopover> {
late bool _hc;
List<ThemeDefinition> get _themes => baseThemes(widget.controller.available);
@override
void initState() {
super.initState();
_hc = isHcName(widget.controller.currentName);
}
void _toggleHc() {
setState(() => _hc = !_hc);
// Re-apply the current base with the new variant, live; keep the popover open.
final base = baseThemeName(widget.controller.currentName);
widget.controller.select(resolveThemeName(widget.controller.available, base, highContrast: _hc));
}
void _pick(ThemeDefinition base) {
widget.controller.select(resolveThemeName(widget.controller.available, base.name, highContrast: _hc));
widget.onClose();
}
@override
Widget build(BuildContext context) {
final currentBase = baseThemeName(widget.controller.currentName);
return Semantics(
container: true,
label: 'Theme switcher',
explicitChildNodes: true,
child: ClideMenu(
onClose: widget.onClose,
minWidth: 220,
maxWidth: 280,
maxHeight: 360,
entries: [
ClideMenuItem(label: 'High contrast', active: _hc, keepOpenOnSelect: true, onSelect: _toggleHc),
const ClideMenuSeparator(),
for (final t in _themes) ClideMenuItem(label: t.displayName, active: t.name == currentBase, onSelect: () => _pick(t)),
],
),
);
}
}