Files
jpmschweitzerandClaude Opus 4.8 b8e6629df5 feat(settings): monospace font picker + bundle Fira Mono (T-471)
Settings → Appearance gains a Monospace font select (JetBrains Mono / Fira
Mono) on the schema engine, writing app.mono.font. It applies live: root_shell
resolves the family into the ClideSettingsScope (D-101) and rebuilds on the
settings change, so the ~93 migrated mono sites — terminal, diffs, code, IDs —
re-read the new family at once.

Vendor Fira Mono (Regular + Bold, OFL) under assets/fonts/fira_mono/ with the
pubspec font family + licenses.yaml entry per D-42; JetBrains Mono stays the
default.

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

220 lines
10 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:clide/builtin/theme_picker/src/theme_status_item.dart';
import 'package:clide/builtin/theme_picker/theme_picker.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart' show kMonoFontSettingKey, kUiFontSettingKey;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
ThemeDefinition _def(String name) => ThemeDefinition(
name: name,
displayName: name,
dark: true,
palette: Palette(const {
'primary': Color(0xFF00A3D2),
'accent': Color(0xFFFA5F8B),
'background': Color(0xFF21262F),
'surface': Color(0xFF393E48),
'panel': Color(0xFF292E38),
'foreground': Color(0xFFE2E8F5),
'success': Color(0xFF00AB9A),
'warning': Color(0xFFD08447),
'error': Color(0xFFF06C6F),
}),
);
void main() {
group('ThemePickerExtension', () {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create(
bundledThemes: [_def('summer-night'), _def('forest')],
i18nCatalogs: {
'builtin.theme-picker': {
const Locale('en', 'US'): const {
'modal.title': {'translation': 'Settings'},
'section.appearance': {'translation': 'Appearance'},
'toggle.highContrast': {'translation': 'High contrast'},
'modal.cancel': {'translation': 'Cancel'},
'modal.cancel.hint': {'translation': 'Dismiss'},
'row.select.hint': {'translation': 'Activate this theme'},
},
},
},
);
});
tearDown(() async => f.dispose());
test('contributes a theme.pick command', () async {
f.services.extensions.register(ThemePickerExtension());
await f.services.extensions.activateAll();
expect(f.services.commands.get('theme.pick'), isNotNull);
});
test('default binding ctrl+k is registered', () async {
f.services.extensions.register(ThemePickerExtension());
await f.services.extensions.activateAll();
expect(f.services.keybindings.commandFor(Keybinding.parse('ctrl+k')), 'theme.pick');
});
testWidgets('settings modal lists base themes + a High contrast toggle', (tester) async {
await tester.pumpWidget(harness(f, SettingsView(controller: f.services.theme, onDismiss: ([_]) {})));
// Each row renders both displayName and name; displayName==name in
// test fixtures so the label appears twice per row.
expect(find.text('summer-night'), findsNWidgets(2));
expect(find.text('forest'), findsNWidgets(2));
// Promoted to a Settings modal with an Appearance section (T-238).
expect(find.text('Settings'), findsOneWidget);
expect(find.text('Appearance'), findsOneWidget);
expect(find.text('High contrast'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
});
testWidgets('tapping a row calls controller.select + onDismiss', (tester) async {
String? dismissed;
await tester.pumpWidget(harness(f, SettingsView(controller: f.services.theme, onDismiss: ([v]) => dismissed = v)));
await tester.tap(find.bySemanticsLabel('forest'));
await tester.pumpAndSettle();
expect(f.services.theme.currentName, 'forest');
expect(dismissed, 'forest');
});
testWidgets('Cancel button dismisses without selecting', (tester) async {
String? dismissed = 'not-called';
await tester.pumpWidget(harness(f, SettingsView(controller: f.services.theme, onDismiss: ([v]) => dismissed = v)));
await tester.tap(find.bySemanticsLabel('Cancel'));
await tester.pumpAndSettle();
expect(dismissed, isNull);
});
testWidgets('settings modal hides -hc siblings; High contrast applies them (T-238)', (tester) async {
// KernelFixture.create does real file I/O (temp dir + boot); awaiting it
// inside the testWidgets fake-async body would hang (the T-122 trap), so
// build it on the real event loop via runAsync.
late KernelFixture hf;
await tester.runAsync(() async => hf = await KernelFixture.create(bundledThemes: [_def('paper'), _def('paper-hc')]));
addTearDown(hf.dispose);
await tester.pumpWidget(harness(hf, SettingsView(controller: hf.services.theme, onDismiss: ([_]) {})));
// Base theme listed once (displayName + muted name); the -hc sibling is
// folded into the toggle, not shown as a row.
expect(find.text('paper'), findsNWidgets(2));
expect(find.text('paper-hc'), findsNothing);
// Toggling High contrast applies the -hc sibling live.
expect(hf.services.theme.currentName, 'paper');
await tester.tap(find.bySemanticsLabel('High contrast'));
await tester.pump();
expect(hf.services.theme.currentName, 'paper-hc');
});
testWidgets('status switcher shows the active theme and opens a popover (T-234)', (tester) async {
// anchoredHarness gives a real 800×600 overlay with the control pinned at
// its real corner (bottom-right) so the above-anchored popover renders and
// hit-tests on-screen — the shared canSizeOverlay harness mispositions it.
await tester.pumpWidget(anchoredHarness(f, const ThemeSwitcherStatusItem(), alignment: Alignment.bottomRight));
await tester.pump();
// Controller starts on the first bundled theme.
expect(f.services.theme.currentName, 'summer-night');
expect(find.text('summer-night'), findsOneWidget);
// Open the popover; it lists every theme (the other one appears).
await tester.tap(find.bySemanticsLabel('Theme: summer-night'));
await tester.pumpAndSettle();
expect(find.text('forest'), findsOneWidget);
});
testWidgets('selecting in the popover applies live and closes (T-234)', (tester) async {
// anchoredHarness gives a real 800×600 overlay with the control pinned at
// its real corner (bottom-right) so the above-anchored popover renders and
// hit-tests on-screen — the shared canSizeOverlay harness mispositions it.
await tester.pumpWidget(anchoredHarness(f, const ThemeSwitcherStatusItem(), alignment: Alignment.bottomRight));
await tester.pump();
await tester.tap(find.bySemanticsLabel('Theme: summer-night'));
await tester.pumpAndSettle();
await tester.tap(find.bySemanticsLabel('forest'));
await tester.pumpAndSettle();
expect(f.services.theme.currentName, 'forest'); // applied live
// Popover closed: the only 'forest' left is the trigger label.
expect(find.bySemanticsLabel('forest'), findsNothing);
});
testWidgets('Esc dismisses the popover without changing the theme (T-234)', (tester) async {
// anchoredHarness gives a real 800×600 overlay with the control pinned at
// its real corner (bottom-right) so the above-anchored popover renders and
// hit-tests on-screen — the shared canSizeOverlay harness mispositions it.
await tester.pumpWidget(anchoredHarness(f, const ThemeSwitcherStatusItem(), alignment: Alignment.bottomRight));
await tester.pump();
await tester.tap(find.bySemanticsLabel('Theme: summer-night'));
await tester.pumpAndSettle();
expect(find.text('forest'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text('forest'), findsNothing); // popover gone
expect(f.services.theme.currentName, 'summer-night'); // unchanged
});
test('_pick before activate returns a not-activated error', () async {
// Reach the command's run callback without going through activate —
// _ctx is still null, so _pick hits the defensive error branch.
final ext = ThemePickerExtension();
final cmd = ext.contributions.whereType<CommandContribution>().single;
final resp = await cmd.run(const []);
expect(resp.ok, isFalse);
expect(resp.error?.message, contains('not activated'));
});
testWidgets('_pick (after activate) opens a dialog and resolves the user selection', (tester) async {
f.services.extensions.register(ThemePickerExtension());
await f.services.extensions.activateAll();
// Pump a tree so dialog has a parent BuildContext to render under.
await tester.pumpWidget(harness(f, const SizedBox()));
await tester.pump();
// Kick off the command. _pick awaits ctx.dialog.show; the future
// resolves once dialog is dismissed.
final responseFuture = f.services.commands.execute('theme.pick');
await tester.pump();
expect(f.services.dialog.isOpen, isTrue);
// Simulate user picking 'forest' and closing.
f.services.dialog.dismiss('forest');
final resp = await responseFuture;
expect(resp.ok, isTrue);
expect(resp.data['selected'], 'forest');
});
test('contributes an Appearance category + theme.picker control (T-452)', () {
final ext = ThemePickerExtension();
final cat = ext.contributions.whereType<SettingsCategoryContribution>().firstWhere((c) => c.id == 'appearance').category;
expect(cat.title, 'Appearance');
expect(ext.contributions.whereType<SettingsControlContribution>().any((c) => c.customId == 'theme.picker'), isTrue);
});
test('Appearance includes UI + monospace font pickers (T-460/T-471)', () {
final cat = ThemePickerExtension().contributions.whereType<SettingsCategoryContribution>().firstWhere((c) => c.id == 'appearance').category;
final fields = cat.sections.expand((s) => s.fields).toList();
final ui = fields.firstWhere((f) => f.key == kUiFontSettingKey);
expect(ui.kind, SettingsFieldKind.select);
expect(ui.options.map((o) => o.value), containsAll(['Inter', 'JosefinSans']));
final mono = fields.firstWhere((f) => f.key == kMonoFontSettingKey);
expect(mono.kind, SettingsFieldKind.select);
expect(mono.options.map((o) => o.value), containsAll(['JetBrainsMono', 'FiraMono']));
});
testWidgets('AppearanceThemeControl lists base themes and applies a pick (T-452)', (tester) async {
await tester.pumpWidget(harness(f, const SizedBox(width: 420, child: AppearanceThemeControl())));
expect(find.text('summer-night'), findsOneWidget);
expect(find.text('forest'), findsOneWidget);
await tester.tap(find.text('forest'));
await tester.pump();
expect(f.services.theme.currentName, 'forest');
});
});
}