tidy the theme switcher popover (T-237)
Per demo feedback on the T-234 status-bar popover: collapse the -hc theme rows into a single 'High contrast' toggle at the top (applies the chosen base theme's -hc sibling live, falling back to the base when none exists); list base themes only, sorted by display name; widen 240->280 and ellipsize rows so 'Catppuccin Mocha' no longer wraps; swap the swatch dot for the Phosphor palette icon; lowercase the bar label to match the all-lowercase status bar (proper case kept in the a11y label). New pure theme_families helpers (base/sibling/resolve), unit-tested. Modal picker_view consistency + the status-bar right-alignment remain on T-237. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
/// Theme-family helpers for the picker surfaces (T-237).
|
||||
///
|
||||
/// Bundled themes ship as a base + a high-contrast (`-hc`) / colour-blind
|
||||
/// (`-cb`) sibling (D-69). The pickers don't list the siblings as separate
|
||||
/// rows — they show base themes only and expose the variant as a "High
|
||||
/// contrast" toggle. These pure helpers do the base/sibling bookkeeping so
|
||||
/// the popover and the modal share one source of truth.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart' show ThemeDefinition;
|
||||
|
||||
bool isHcName(String name) => name.endsWith('-hc');
|
||||
bool isCbName(String name) => name.endsWith('-cb');
|
||||
|
||||
/// The base theme name for any theme (strips a `-hc`/`-cb` suffix).
|
||||
String baseThemeName(String name) => (isHcName(name) || isCbName(name)) ? name.substring(0, name.length - 3) : name;
|
||||
|
||||
/// Base themes only (no `-hc`/`-cb`), sorted by display name so the list is
|
||||
/// stable and not "randomly" ordered.
|
||||
List<ThemeDefinition> baseThemes(List<ThemeDefinition> available) =>
|
||||
available.where((t) => !isHcName(t.name) && !isCbName(t.name)).toList()..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
|
||||
|
||||
/// Whether [base] has a high-contrast sibling among [available].
|
||||
bool hasHcSibling(List<ThemeDefinition> available, String base) => available.any((t) => t.name == '$base-hc');
|
||||
|
||||
/// The theme name to actually apply for a chosen [base] given the high-contrast
|
||||
/// toggle: the `-hc` sibling when requested and present, else the base itself.
|
||||
String resolveThemeName(List<ThemeDefinition> available, String base, {required bool highContrast}) =>
|
||||
(highContrast && hasHcSibling(available, base)) ? '$base-hc' : base;
|
||||
@@ -5,6 +5,7 @@
|
||||
/// (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';
|
||||
@@ -60,11 +61,7 @@ class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
|
||||
bottom: screen.height - anchor.dy + 4,
|
||||
child: _ThemePopover(
|
||||
controller: _controller,
|
||||
onPick: (name) {
|
||||
_controller.select(name);
|
||||
_close();
|
||||
},
|
||||
onDismiss: _close,
|
||||
onClose: _close,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -103,14 +100,11 @@ class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// A swatch dot in the theme's accent — the "what theme" cue.
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: tokens.buttonBackground, shape: BoxShape.circle),
|
||||
),
|
||||
ClideIcon(PhosphorIcons.palette, size: 13, color: tokens.statusBarForeground),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(label, fontSize: clideFontCaption, color: tokens.statusBarForeground),
|
||||
// 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -121,14 +115,15 @@ class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The popover body: a keyboard-navigable list of themes. Autofocuses so
|
||||
/// arrows/Enter/Esc work immediately; the current theme starts highlighted.
|
||||
/// The popover body: a "High contrast" toggle then the base themes (the `-hc`
|
||||
/// siblings collapse into the toggle, T-237). Keyboard-navigable as one list —
|
||||
/// index 0 is the toggle, 1..N the themes; arrows move, Enter activates, Esc
|
||||
/// dismisses. Autofocuses on open.
|
||||
class _ThemePopover extends StatefulWidget {
|
||||
const _ThemePopover({required this.controller, required this.onPick, required this.onDismiss});
|
||||
const _ThemePopover({required this.controller, required this.onClose});
|
||||
|
||||
final ThemeController controller;
|
||||
final void Function(String name) onPick;
|
||||
final VoidCallback onDismiss;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
State<_ThemePopover> createState() => _ThemePopoverState();
|
||||
@@ -136,14 +131,18 @@ class _ThemePopover extends StatefulWidget {
|
||||
|
||||
class _ThemePopoverState extends State<_ThemePopover> {
|
||||
final _focus = FocusNode(debugLabel: 'ThemeSwitcher.popover');
|
||||
late int _index;
|
||||
late bool _hc;
|
||||
int _index = 0;
|
||||
|
||||
List<ThemeDefinition> get _themes => baseThemes(widget.controller.available);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final themes = widget.controller.available;
|
||||
_index = themes.indexWhere((t) => t.name == widget.controller.currentName);
|
||||
if (_index < 0) _index = 0;
|
||||
_hc = isHcName(widget.controller.currentName);
|
||||
final currentBase = baseThemeName(widget.controller.currentName);
|
||||
final at = _themes.indexWhere((t) => t.name == currentBase);
|
||||
_index = at < 0 ? 0 : at + 1; // +1: row 0 is the toggle
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus());
|
||||
}
|
||||
|
||||
@@ -153,22 +152,38 @@ class _ThemePopoverState extends State<_ThemePopover> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||||
final themes = widget.controller.available;
|
||||
final count = _themes.length + 1; // +1 toggle
|
||||
switch (event.logicalKey) {
|
||||
case LogicalKeyboardKey.arrowDown:
|
||||
setState(() => _index = (_index + 1) % themes.length);
|
||||
setState(() => _index = (_index + 1) % count);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.arrowUp:
|
||||
setState(() => _index = (_index - 1 + themes.length) % themes.length);
|
||||
setState(() => _index = (_index - 1 + count) % count);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.enter:
|
||||
case LogicalKeyboardKey.numpadEnter:
|
||||
widget.onPick(themes[_index].name);
|
||||
if (_index == 0) {
|
||||
_toggleHc();
|
||||
} else {
|
||||
_pick(_themes[_index - 1]);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.escape:
|
||||
widget.onDismiss();
|
||||
widget.onClose();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
@@ -177,8 +192,8 @@ class _ThemePopoverState extends State<_ThemePopover> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final themes = widget.controller.available;
|
||||
final currentName = widget.controller.currentName;
|
||||
final themes = _themes;
|
||||
final currentBase = baseThemeName(widget.controller.currentName);
|
||||
return Focus(
|
||||
focusNode: _focus,
|
||||
onKeyEvent: _onKey,
|
||||
@@ -187,25 +202,32 @@ class _ThemePopoverState extends State<_ThemePopover> {
|
||||
label: 'Theme switcher',
|
||||
explicitChildNodes: true,
|
||||
child: ClideSurface(
|
||||
width: 240,
|
||||
width: 280,
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: tokens.modalSurfaceBorder,
|
||||
padding: const EdgeInsets.all(4),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
constraints: const BoxConstraints(maxHeight: 360),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_HighContrastToggle(
|
||||
checked: _hc,
|
||||
highlighted: _index == 0,
|
||||
onEnter: () => setState(() => _index = 0),
|
||||
onTap: _toggleHc,
|
||||
),
|
||||
ClideDivider(),
|
||||
for (var i = 0; i < themes.length; i++)
|
||||
_PopoverRow(
|
||||
displayName: themes[i].displayName,
|
||||
selected: themes[i].name == currentName,
|
||||
highlighted: i == _index,
|
||||
onEnter: () => setState(() => _index = i),
|
||||
onTap: () => widget.onPick(themes[i].name),
|
||||
selected: themes[i].name == currentBase,
|
||||
highlighted: _index == i + 1,
|
||||
onEnter: () => setState(() => _index = i + 1),
|
||||
onTap: () => _pick(themes[i]),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -217,6 +239,53 @@ class _ThemePopoverState extends State<_ThemePopover> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The "High contrast" checkbox row at the top of the popover.
|
||||
class _HighContrastToggle extends StatelessWidget {
|
||||
const _HighContrastToggle({required this.checked, required this.highlighted, required this.onEnter, required this.onTap});
|
||||
|
||||
final bool checked;
|
||||
final bool highlighted;
|
||||
final VoidCallback onEnter;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
checked: checked,
|
||||
label: 'High contrast',
|
||||
excludeSemantics: true,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => onEnter(),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
color: highlighted ? tokens.listItemHoverBackground : tokens.listItemBackground,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 13,
|
||||
height: 13,
|
||||
decoration: BoxDecoration(
|
||||
color: checked ? tokens.buttonBackground : null,
|
||||
border: Border.all(color: checked ? tokens.buttonBackground : tokens.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: checked ? ClideIcon(const CheckIcon(), size: 9, color: tokens.buttonForeground) : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: ClideText('High contrast', color: tokens.listItemForeground, fontSize: clideFontCaption)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PopoverRow extends StatelessWidget {
|
||||
const _PopoverRow({
|
||||
required this.displayName,
|
||||
@@ -259,7 +328,7 @@ class _PopoverRow extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 17),
|
||||
Expanded(child: ClideText(displayName, color: fg, fontSize: clideFontCaption)),
|
||||
Expanded(child: ClideText(displayName, color: fg, fontSize: clideFontCaption, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user