diff --git a/docs/theme-custom-json.md b/docs/theme-custom-json.md index e744ff13..8c44df8b 100644 --- a/docs/theme-custom-json.md +++ b/docs/theme-custom-json.md @@ -287,6 +287,13 @@ Invalid files are skipped (logged in debug builds). Required fields: `schema`, ` Built-in bundled themes (under `assets/themes/`) ship with the app and do not require manual installation. +## Visual editor (Preferences) + +Use **Edit theme colors…** in **Preferences → Appearance** to tweak MVP color tokens +with live preview, then **Export theme…** to save a `querya.theme.v1` JSON file. +Built-in themes are exported as copies with a new `id`; import the file via +**Import theme…** or copy into the themes folder. + ## Troubleshooting | Symptom | Likely cause | What to do | diff --git a/lib/core/theme/parser/querya_theme_manifest.dart b/lib/core/theme/parser/querya_theme_manifest.dart index 2fa4b778..074272dd 100644 --- a/lib/core/theme/parser/querya_theme_manifest.dart +++ b/lib/core/theme/parser/querya_theme_manifest.dart @@ -47,6 +47,44 @@ class QueryaThemeManifest { bool get isDark => type == QueryaThemeType.dark; bool get isLight => type == QueryaThemeType.light; + Map toJson() { + final json = { + 'schema': schema, + 'id': id, + 'name': name, + 'type': type.name, + 'shadcn_colors': shadcnColors, + 'editor_colors': editorColors, + }; + + if (tokenColors.isNotEmpty) { + json['tokenColors'] = tokenColors.map(_tokenColorRuleToJson).toList(); + } + if (description != null) json['description'] = description; + if (author != null) json['author'] = author; + if (version != null) json['version'] = version; + if (homepage != null) json['homepage'] = homepage; + if (license != null) json['license'] = license; + if (preview != null) json['preview'] = preview; + if (tags.isNotEmpty) json['tags'] = tags; + + return json; + } + + String toJsonString() => const JsonEncoder.withIndent(' ').convert(toJson()); + + static Map _tokenColorRuleToJson(TokenColorRule rule) { + final settings = {}; + if (rule.foreground != null) settings['foreground'] = rule.foreground; + if (rule.background != null) settings['background'] = rule.background; + if (rule.fontStyle != null) settings['fontStyle'] = rule.fontStyle; + + return { + 'scope': rule.scopes.length == 1 ? rule.scopes.first : rule.scopes, + if (settings.isNotEmpty) 'settings': settings, + }; + } + factory QueryaThemeManifest.fromJsonString(String source) { final cleaned = stripJsonc(source); final dynamic decoded; diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 2128597b..cf4d3c6d 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -5,7 +5,9 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'parser/apply_token_colors_to_editor.dart'; import 'parser/color_parser.dart'; +import 'parser/querya_theme_from_manifest.dart'; import 'parser/querya_theme_from_vscode.dart'; +import 'parser/querya_theme_manifest.dart'; import 'parser/vscode_colors_merge.dart'; import 'parser/vscode_theme_manifest.dart'; import 'querya_theme.dart'; @@ -71,6 +73,8 @@ class ThemeController extends ChangeNotifier { bool _registrySelectionFailed = false; bool _isLoadingAvailableThemes = false; ThemeFolderWatcher? _themeFolderWatcher; + bool _editorPreviewActive = false; + String? _editorPreviewRestoreThemeId; QueryaTheme? _cachedLightTheme; QueryaTheme? _cachedDarkTheme; @@ -177,6 +181,38 @@ class ThemeController extends ChangeNotifier { bool get isThemeFolderWatcherStarted => _themeFolderWatcher?.isStarted ?? false; + @visibleForTesting + bool get isEditorPreviewActive => _editorPreviewActive; + + /// Applies [manifest] for live editor preview without persisting selection. + Future previewEditorManifest(QueryaThemeManifest manifest) async { + if (!_editorPreviewActive) { + _editorPreviewRestoreThemeId = effectiveSelectedThemeId; + _editorPreviewActive = true; + } + + _registryTheme = queryaThemeFromManifest(manifest); + _registrySelectionFailed = false; + _selectedThemeLoadError = null; + _themeMode = manifest.isLight ? ThemeMode.light : ThemeMode.dark; + _notifyThemeChanged(); + } + + /// Restores the theme that was active before editor preview. + Future endEditorPreview() async { + if (!_editorPreviewActive) return; + + final restoreId = _editorPreviewRestoreThemeId; + _editorPreviewActive = false; + _editorPreviewRestoreThemeId = null; + + if (restoreId != null) { + await setThemeById(restoreId); + } else { + _notifyThemeChanged(); + } + } + /// Watches `{appSupport}/themes/` and debounces [loadAvailableThemes]. Future startThemeFolderWatcher() async { _themeFolderWatcher ??= ThemeFolderWatcher( diff --git a/lib/core/theme/theme_editor_draft.dart b/lib/core/theme/theme_editor_draft.dart new file mode 100644 index 00000000..63442848 --- /dev/null +++ b/lib/core/theme/theme_editor_draft.dart @@ -0,0 +1,252 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:shadcn_flutter/shadcn_flutter.dart' show ColorScheme; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/theme_metadata.dart'; + +/// One editable color in the theme editor MVP. +class ThemeEditorColorField { + const ThemeEditorColorField({ + required this.section, + required this.key, + required this.label, + }); + + final String section; + final String key; + final String label; +} + +/// MVP color fields for Preferences theme editor (TP-F3). +const themeEditorMvpColorFields = [ + ThemeEditorColorField( + section: 'shadcn_colors', + key: 'primary', + label: 'Primary', + ), + ThemeEditorColorField( + section: 'shadcn_colors', + key: 'background', + label: 'Background', + ), + ThemeEditorColorField( + section: 'shadcn_colors', + key: 'foreground', + label: 'Foreground', + ), + ThemeEditorColorField( + section: 'shadcn_colors', + key: 'card', + label: 'Card', + ), + ThemeEditorColorField( + section: 'shadcn_colors', + key: 'border', + label: 'Border', + ), + ThemeEditorColorField( + section: 'editor_colors', + key: 'background', + label: 'Editor background', + ), + ThemeEditorColorField( + section: 'editor_colors', + key: 'foreground', + label: 'Editor foreground', + ), + ThemeEditorColorField( + section: 'editor_colors', + key: 'selection', + label: 'Editor selection', + ), + ThemeEditorColorField( + section: 'editor_colors', + key: 'canvas', + label: 'Workbench canvas', + ), + ThemeEditorColorField( + section: 'editor_colors', + key: 'sidebarBackground', + label: 'Sidebar background', + ), +]; + +/// Mutable draft for editing and exporting `querya.theme.v1`. +class ThemeEditorDraft { + ThemeEditorDraft({ + required this.id, + required this.name, + required this.type, + required Map shadcnColors, + required Map editorColors, + List tokenColors = const [], + this.description, + this.author, + this.version, + this.homepage, + this.license, + this.preview, + List tags = const [], + this.readOnlySource = false, + }) : shadcnColors = Map.from(shadcnColors), + editorColors = Map.from(editorColors), + tokenColors = List.from(tokenColors), + tags = List.from(tags); + + String id; + String name; + QueryaThemeType type; + final Map shadcnColors; + final Map editorColors; + final List tokenColors; + String? description; + String? author; + String? version; + String? homepage; + String? license; + String? preview; + final List tags; + + /// Built-in / asset themes are exported as copies only. + final bool readOnlySource; + + factory ThemeEditorDraft.fromManifest( + QueryaThemeManifest manifest, { + bool readOnlySource = false, + }) { + return ThemeEditorDraft( + id: manifest.id, + name: manifest.name, + type: manifest.type, + shadcnColors: manifest.shadcnColors, + editorColors: manifest.editorColors, + tokenColors: manifest.tokenColors, + description: manifest.description, + author: manifest.author, + version: manifest.version, + homepage: manifest.homepage, + license: manifest.license, + preview: manifest.preview, + tags: manifest.tags, + readOnlySource: readOnlySource, + ); + } + + factory ThemeEditorDraft.fromQueryaTheme({ + required String id, + required String name, + required bool isDark, + required QueryaTheme theme, + ThemeMetadata? metadata, + bool readOnlySource = false, + }) { + final scheme = theme.colorScheme; + final editor = theme.editor; + final workbench = theme.workbench; + + return ThemeEditorDraft( + id: id, + name: name, + type: isDark ? QueryaThemeType.dark : QueryaThemeType.light, + shadcnColors: { + for (final field in themeEditorMvpColorFields) + if (field.section == 'shadcn_colors') + field.key: _colorForShadcnField(field.key, scheme), + }, + editorColors: { + 'background': formatVsCodeColor(editor.background), + 'foreground': formatVsCodeColor(editor.foreground), + 'selection': formatVsCodeColor(editor.selection), + 'canvas': formatVsCodeColor(workbench.canvas), + 'sidebarBackground': formatVsCodeColor(workbench.sidebarBackground), + }, + tokenColors: theme.tokenColors, + description: metadata?.description, + author: metadata?.author, + version: metadata?.version, + homepage: metadata?.homepage, + license: metadata?.license, + preview: metadata?.preview, + tags: metadata?.tags ?? const [], + readOnlySource: readOnlySource, + ); + } + + static String _colorForShadcnField(String key, ColorScheme scheme) { + final color = switch (key) { + 'primary' => scheme.primary, + 'background' => scheme.background, + 'foreground' => scheme.foreground, + 'card' => scheme.card, + 'border' => scheme.border, + _ => scheme.primary, + }; + return formatVsCodeColor(color); + } + + String? colorHex(ThemeEditorColorField field) { + final map = field.section == 'shadcn_colors' ? shadcnColors : editorColors; + return map[field.key]; + } + + void setColorHex(ThemeEditorColorField field, String hex) { + final normalized = hex.trim(); + parseQueryaThemeColor(normalized); + final map = field.section == 'shadcn_colors' ? shadcnColors : editorColors; + map[field.key] = formatVsCodeColor(parseQueryaThemeColor(normalized)); + } + + void setColor(ThemeEditorColorField field, Color color) { + final map = field.section == 'shadcn_colors' ? shadcnColors : editorColors; + map[field.key] = formatVsCodeColor(color); + } + + QueryaThemeManifest toManifest() { + return QueryaThemeManifest( + schema: queryaThemeSchemaV1, + id: id, + name: name, + type: type, + shadcnColors: Map.unmodifiable(shadcnColors), + editorColors: Map.unmodifiable(editorColors), + tokenColors: List.unmodifiable(tokenColors), + description: description, + author: author, + version: version, + homepage: homepage, + license: license, + preview: preview, + tags: List.unmodifiable(tags), + ); + } + + /// JSON export with a unique id when saving a built-in/read-only source. + ThemeEditorDraft forExport({String? exportId}) { + if (!readOnlySource && exportId == null) return this; + final nextId = exportId ?? '$id-edited'; + return ThemeEditorDraft( + id: nextId, + name: '$name (edited)', + type: type, + shadcnColors: shadcnColors, + editorColors: editorColors, + tokenColors: tokenColors, + description: description, + author: author, + version: version ?? '1.0.0', + homepage: homepage, + license: license, + preview: preview, + tags: tags, + readOnlySource: false, + ); + } + + String toExportJsonString() { + return const JsonEncoder.withIndent(' ').convert(toManifest().toJson()); + } +} diff --git a/lib/core/theme/theme_editor_loader.dart b/lib/core/theme/theme_editor_loader.dart new file mode 100644 index 00000000..bfbf843e --- /dev/null +++ b/lib/core/theme/theme_editor_loader.dart @@ -0,0 +1,62 @@ +import 'dart:io'; +import 'dart:ui' show Brightness; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_definition.dart'; +import 'package:querya_desktop/core/theme/theme_editor_draft.dart'; + +/// Builds a [ThemeEditorDraft] from the active theme selection. +abstract final class ThemeEditorLoader { + static Future fromController(ThemeController controller) async { + final selectedId = controller.effectiveSelectedThemeId; + final definition = _definitionForId(controller, selectedId); + final readOnlySource = definition?.source == ThemeSource.builtin; + + final manifest = await _loadSourceManifest(definition); + if (manifest != null) { + return ThemeEditorDraft.fromManifest( + manifest, + readOnlySource: readOnlySource, + ); + } + + return ThemeEditorDraft.fromQueryaTheme( + id: selectedId, + name: definition?.name ?? selectedId, + isDark: controller.activeTheme.brightness == Brightness.dark, + theme: controller.activeTheme, + metadata: definition?.metadata, + readOnlySource: readOnlySource, + ); + } + + static ThemeDefinition? _definitionForId( + ThemeController controller, + String id, + ) { + for (final definition in controller.availableThemes) { + if (definition.id == id) return definition; + } + return null; + } + + static Future _loadSourceManifest( + ThemeDefinition? definition, + ) async { + if (definition == null || + definition.format != ThemeFormat.queryaCustom || + definition.path == null || + definition.source == ThemeSource.builtin) { + return null; + } + + final file = File(definition.path!); + if (!await file.exists()) return null; + + try { + return QueryaThemeManifest.fromJsonString(await file.readAsString()); + } on Object { + return null; + } + } +} diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 4f5e8159..60c1ec36 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -8,6 +8,7 @@ import 'package:querya_desktop/core/theme/theme_import_service.dart'; import 'package:querya_desktop/core/theme/theme_load_result.dart'; import 'package:querya_desktop/core/theme/theme_paths.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/features/settings/theme_editor_section.dart'; import 'package:querya_desktop/features/settings/theme_picker_button.dart'; import 'package:querya_desktop/features/settings/theme_preview_card.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -191,10 +192,11 @@ class _PreferencesAppearanceSectionState padding: material.EdgeInsets.only(left: kPreferencesLabelWidth + 12), child: PreferencesHint( 'Themes are loaded from the app support themes folder. ' - 'Drop .json or .jsonc files there, then use Refresh themes. ' - 'The folder is not watched automatically.', + 'Drop .json or .jsonc files there; the folder is watched automatically ' + 'or use Refresh themes.', ), ), + const ThemeEditorSection(), const material.SizedBox(height: 12), const PreferencesFieldRow( label: 'Interface scale', diff --git a/lib/features/settings/theme_color_picker_dialog.dart b/lib/features/settings/theme_color_picker_dialog.dart new file mode 100644 index 00000000..fb6187f9 --- /dev/null +++ b/lib/features/settings/theme_color_picker_dialog.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Simple color picker dialog for the theme editor. +Future showThemeColorPickerDialog({ + required material.BuildContext context, + required Color initial, +}) async { + var picked = ColorDerivative.fromColor(initial); + + return material.showDialog( + context: context, + builder: (dialogContext) { + return material.AlertDialog( + title: const material.Text('Pick color'), + content: material.SizedBox( + width: 320, + height: 360, + child: ColorPicker( + value: picked, + onChanged: (value) => picked = value, + ), + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.pop(dialogContext), + child: const material.Text('Cancel'), + ), + material.TextButton( + onPressed: () => material.Navigator.pop(dialogContext, picked.toColor()), + child: const material.Text('Apply'), + ), + ], + ); + }, + ); +} diff --git a/lib/features/settings/theme_editor_section.dart b/lib/features/settings/theme_editor_section.dart new file mode 100644 index 00000000..c87f8cac --- /dev/null +++ b/lib/features/settings/theme_editor_section.dart @@ -0,0 +1,280 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_editor_draft.dart'; +import 'package:querya_desktop/core/theme/theme_editor_loader.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/features/settings/theme_color_picker_dialog.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// MVP visual theme editor in Preferences → Appearance. +class ThemeEditorSection extends material.StatefulWidget { + const ThemeEditorSection({super.key}); + + @override + material.State createState() => _ThemeEditorSectionState(); +} + +class _ThemeEditorSectionState extends material.State { + final _controller = ThemeController.instance; + ThemeEditorDraft? _draft; + bool _expanded = false; + bool _loading = false; + bool _exporting = false; + String? _error; + Timer? _previewDebounce; + + @override + void dispose() { + _previewDebounce?.cancel(); + unawaited(_controller.endEditorPreview()); + super.dispose(); + } + + Future _loadDraft() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final draft = await ThemeEditorLoader.fromController(_controller); + if (!mounted) return; + setState(() { + _draft = draft; + _loading = false; + }); + if (_expanded) { + unawaited(_controller.previewEditorManifest(draft.toManifest())); + } + } on Object catch (error) { + if (!mounted) return; + setState(() { + _loading = false; + _error = error.toString(); + }); + } + } + + Future _toggleExpanded() async { + final next = !_expanded; + setState(() => _expanded = next); + + if (next) { + if (_draft == null) { + await _loadDraft(); + } else { + unawaited(_controller.previewEditorManifest(_draft!.toManifest())); + } + return; + } + + await _controller.endEditorPreview(); + } + + void _schedulePreview() { + final draft = _draft; + if (draft == null) return; + + _previewDebounce?.cancel(); + _previewDebounce = Timer(const Duration(milliseconds: 150), () { + if (!mounted || _draft == null) return; + unawaited(_controller.previewEditorManifest(_draft!.toManifest())); + }); + } + + Future _pickColor(ThemeEditorColorField field) async { + final draft = _draft; + if (draft == null) return; + + final currentHex = draft.colorHex(field); + Color initial; + try { + initial = currentHex != null + ? parseQueryaThemeColor(currentHex) + : Theme.of(context).colorScheme.primary; + } on FormatException { + initial = Theme.of(context).colorScheme.primary; + } + + final picked = await showThemeColorPickerDialog( + context: context, + initial: initial, + ); + if (picked == null || !mounted) return; + + setState(() { + draft.setColor(field, picked); + }); + _schedulePreview(); + } + + Future _exportDraft() async { + final draft = _draft; + if (draft == null) return; + + setState(() { + _exporting = true; + _error = null; + }); + + try { + final exportDraft = draft.forExport(); + final location = await getSaveLocation( + suggestedName: '${exportDraft.id}.json', + acceptedTypeGroups: const [ + XTypeGroup( + label: 'Querya theme', + extensions: ['json'], + ), + ], + ); + if (location == null) return; + + await File(location.path).writeAsString(exportDraft.toExportJsonString()); + QueryaThemeManifest.fromJsonString(exportDraft.toExportJsonString()); + } on Object catch (error) { + if (!mounted) return; + setState(() => _error = error.toString()); + } finally { + if (mounted) { + setState(() => _exporting = false); + } + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const material.SizedBox(height: 12), + material.Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlineButton( + onPressed: _loading ? null : () => unawaited(_toggleExpanded()), + child: material.Text( + _loading + ? 'Loading editor…' + : _expanded + ? 'Hide theme editor' + : 'Edit theme colors…', + ), + ), + if (_expanded && _draft != null) + OutlineButton( + onPressed: _exporting ? null : () => unawaited(_exportDraft()), + child: material.Text( + _exporting ? 'Exporting…' : 'Export theme…', + ), + ), + if (_expanded) + OutlineButton( + onPressed: _loading ? null : () => unawaited(_loadDraft()), + child: const material.Text('Reset from current'), + ), + ], + ), + if (_expanded && _draft?.readOnlySource == true) ...[ + const material.SizedBox(height: 8), + const PreferencesHint( + 'Built-in themes are not modified in place. Export saves a copy ' + 'with a new id that you can import into the themes folder.', + ), + ], + if (_error != null) ...[ + const material.SizedBox(height: 8), + material.Text( + _error!, + style: material.TextStyle(fontSize: 12, color: cs.destructive), + ), + ], + if (_expanded && _draft != null) ...[ + const material.SizedBox(height: 12), + for (final field in themeEditorMvpColorFields) + _ThemeEditorColorRow( + field: field, + hex: _draft!.colorHex(field), + onPick: () => unawaited(_pickColor(field)), + ), + ], + ], + ); + } +} + +class _ThemeEditorColorRow extends material.StatelessWidget { + const _ThemeEditorColorRow({ + required this.field, + required this.hex, + required this.onPick, + }); + + final ThemeEditorColorField field; + final String? hex; + final material.VoidCallback onPick; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + Color? swatchColor; + if (hex != null) { + try { + swatchColor = parseQueryaThemeColor(hex!); + } on FormatException { + swatchColor = null; + } + } + + return material.Padding( + padding: const material.EdgeInsets.only(bottom: 8), + child: material.Row( + children: [ + material.SizedBox( + width: kPreferencesLabelWidth, + child: material.Text( + field.label, + style: material.TextStyle( + fontSize: 13, + color: cs.popoverForeground, + ), + ), + ), + const material.SizedBox(width: 12), + material.InkWell( + onTap: onPick, + borderRadius: material.BorderRadius.circular(6), + child: material.Container( + width: 28, + height: 28, + decoration: material.BoxDecoration( + color: swatchColor ?? cs.muted, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all(color: cs.border), + ), + ), + ), + const material.SizedBox(width: 10), + material.Expanded( + child: material.Text( + hex ?? '—', + style: material.TextStyle( + fontSize: 12, + color: cs.mutedForeground, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ); + } +} diff --git a/test/core/theme/parser/querya_theme_manifest_test.dart b/test/core/theme/parser/querya_theme_manifest_test.dart index e073dbaf..65d5cc56 100644 --- a/test/core/theme/parser/querya_theme_manifest_test.dart +++ b/test/core/theme/parser/querya_theme_manifest_test.dart @@ -187,6 +187,19 @@ void main() { ); }); + test('serializes to valid JSON for export', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + final exported = manifest.toJsonString(); + final reparsed = QueryaThemeManifest.fromJsonString(exported); + + expect(reparsed.id, manifest.id); + expect(reparsed.shadcnColors['primary'], manifest.shadcnColors['primary']); + expect(reparsed.tokenColors.length, manifest.tokenColors.length); + }); + test('reuses TokenColorRule parsing from VS Code themes', () { const src = ''' { diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 8f3012e9..6992e273 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -91,6 +91,7 @@ void main() { tearDown(() async { await ThemeController.instance.stopThemeFolderWatcher(); + await ThemeController.instance.endEditorPreview(); await AppSettings.instance.clearThemeSettings(); await ThemeImportService.deletePersistedImport(); if (await themesDir.exists()) { diff --git a/test/core/theme/theme_editor_draft_test.dart b/test/core/theme/theme_editor_draft_test.dart new file mode 100644 index 00000000..38539a11 --- /dev/null +++ b/test/core/theme/theme_editor_draft_test.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_from_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/theme_editor_draft.dart'; + +void main() { + group('ThemeEditorDraft', () { + test('fromQueryaTheme captures MVP colors', () { + const theme = QueryaTheme.darkDefault; + final draft = ThemeEditorDraft.fromQueryaTheme( + id: 'querya-dark', + name: 'Querya Dark', + isDark: true, + theme: theme, + ); + + expect(draft.shadcnColors['primary'], formatVsCodeColor(theme.colorScheme.primary)); + expect(draft.editorColors['background'], formatVsCodeColor(theme.editor.background)); + expect(draft.editorColors['canvas'], formatVsCodeColor(theme.workbench.canvas)); + }); + + test('setColor updates manifest and round-trips export', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final source = QueryaThemeManifest.fromJsonString(raw); + final draft = ThemeEditorDraft.fromManifest(source); + + draft.setColorHex( + themeEditorMvpColorFields.first, + '#FF00AA', + ); + + final exported = draft.toExportJsonString(); + final reparsed = QueryaThemeManifest.fromJsonString(exported); + expect(reparsed.shadcnColors['primary'], '#ff00aa'); + + final theme = queryaThemeFromManifest(reparsed); + expect(theme.colorScheme.primary, parseQueryaThemeColor('#FF00AA')); + }); + + test('forExport assigns new id for read-only built-in source', () { + final draft = ThemeEditorDraft.fromQueryaTheme( + id: 'querya-dark', + name: 'Querya Dark', + isDark: true, + theme: QueryaTheme.darkDefault, + readOnlySource: true, + ); + + final exported = draft.forExport(); + expect(exported.id, 'querya-dark-edited'); + expect(exported.name, 'Querya Dark (edited)'); + + final json = jsonDecode(exported.toExportJsonString()) as Map; + expect(json['schema'], queryaThemeSchemaV1); + expect(json['id'], 'querya-dark-edited'); + }); + }); +} diff --git a/test/core/theme/theme_editor_loader_test.dart b/test/core/theme/theme_editor_loader_test.dart new file mode 100644 index 00000000..8d8834f5 --- /dev/null +++ b/test/core/theme/theme_editor_loader_test.dart @@ -0,0 +1,115 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_editor_loader.dart'; +import 'package:querya_desktop/core/theme/theme_registry_service.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; +} + +Future _fixtureAssetLoader(String assetPath) async { + final fileName = p.basename(assetPath); + return File(p.join('test/fixtures/themes', fileName)).readAsString(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late Directory themesDir; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_theme_editor_loader_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + setUp(() async { + themesDir = Directory(p.join(tempDir.path, 'themes')); + await Directory(p.join(themesDir.path, 'imported')).create(recursive: true); + ThemeController.instance.setRegistryServiceForTest( + ThemeRegistryService( + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => Directory( + p.join(themesDir.path, 'imported'), + ), + assetLoader: _fixtureAssetLoader, + ), + ); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + await ThemeController.instance.stopThemeFolderWatcher(); + await ThemeController.instance.endEditorPreview(); + await AppSettings.instance.clearThemeSettings(); + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + ThemeController.instance.setRegistryServiceForTest(ThemeRegistryService()); + await ThemeController.instance.load(); + }); + + group('ThemeEditorLoader', () { + test('loads custom theme file manifest when available', () async { + final source = File(p.join('test/fixtures/themes', 'querya_custom_dark.json')); + await File(p.join(themesDir.path, 'querya_custom_dark.json')) + .writeAsString(await source.readAsString()); + + final controller = ThemeController.instance; + await controller.load(); + await controller.setThemeById('fixture-custom-dark'); + + final draft = await ThemeEditorLoader.fromController(controller); + expect(draft.id, 'fixture-custom-dark'); + expect(draft.shadcnColors['primary'], '#38BDF8'); + expect(draft.editorColors['background'], '#0F1117'); + }); + }); + + group('ThemeController editor preview', () { + test('previewEditorManifest updates active theme and restores', () async { + final c = ThemeController.instance; + await c.load(); + final beforePrimary = c.activeTheme.colorScheme.primary; + + final manifest = QueryaThemeManifest.fromJsonString(''' +{ + "schema": "querya.theme.v1", + "id": "preview-test", + "name": "Preview Test", + "type": "dark", + "shadcn_colors": { "primary": "#FF00AA" }, + "editor_colors": { "background": "#010203" } +} +'''); + + await c.previewEditorManifest(manifest); + expect(c.isEditorPreviewActive, isTrue); + expect(c.activeTheme.colorScheme.primary, parseQueryaThemeColor('#FF00AA')); + + await c.endEditorPreview(); + expect(c.isEditorPreviewActive, isFalse); + expect(c.activeTheme.colorScheme.primary, beforePrimary); + }); + }); +} diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart index 936bd2f1..15ad192e 100644 --- a/test/features/settings/preferences_appearance_section_test.dart +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -124,16 +124,15 @@ void main() { expect(find.text('Reset appearance'), findsOneWidget); }); - testWidgets('shows themes folder hint without live reload promise', - (tester) async { + testWidgets('shows themes folder hint and theme editor entry', (tester) async { await pumpSection(tester); expect( find.textContaining('Themes are loaded from the app support themes folder'), findsOneWidget, ); - expect(find.textContaining('not watched automatically'), findsOneWidget); - expect(find.textContaining('live reload'), findsNothing); + expect(find.textContaining('watched automatically'), findsOneWidget); + expect(find.text('Edit theme colors…'), findsOneWidget); }); }); }