diff --git a/docs/theme-import.md b/docs/theme-import.md index 25d25e83..2097fcad 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -43,6 +43,20 @@ Hex strings as in VS Code: `#RRGGBB`, `#RRGGBBAA`, `#RGB`, `#RGBA` (see Comments and trailing commas are stripped before parse (`stripJsonc`). +## User overrides (#45) + +User customizations are stored as VS Code keys → hex strings in +`theme_overrides_json` (`AppSettings`). Merge order: + +``` +effectiveColors = merge(importedTheme.colors, userOverrides) +``` + +Built-in preset defaults apply for keys not present in the merged map. + +API: `ThemeController.setWorkbenchColor(key, color?)`, +`ThemeController.clearColorOverrides()` (user layer only). + ## Fixtures (tests) - `test/fixtures/themes/dark_subset.json` diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 8ff1c188..660ab4f3 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../theme/querya_theme_preset.dart'; @@ -50,6 +52,7 @@ abstract final class AppSettingsKeys { static const sqlHistoryMaxEntries = 'sql_history_max_entries'; static const themeMode = 'theme_mode'; static const themePreset = 'theme_preset'; + static const themeOverridesJson = 'theme_overrides_json'; } /// Bumps [listenable] when any preference is persisted so open screens can reload. @@ -211,9 +214,51 @@ class AppSettings { AppSettingsRevision.bump(); } + Future> getThemeColorOverrides() async { + final v = + await LocalDb.instance.getAppSetting(AppSettingsKeys.themeOverridesJson); + if (v == null || v.isEmpty) return {}; + try { + final decoded = jsonDecode(v); + if (decoded is! Map) return {}; + final out = {}; + for (final entry in decoded.entries) { + final key = entry.key?.toString(); + final value = entry.value?.toString(); + if (key != null && + key.isNotEmpty && + value != null && + value.isNotEmpty) { + out[key] = value; + } + } + return out; + } on FormatException { + return {}; + } + } + + Future setThemeColorOverrides(Map overrides) async { + if (overrides.isEmpty) { + await clearThemeColorOverrides(); + return; + } + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeOverridesJson, + jsonEncode(overrides), + ); + AppSettingsRevision.bump(); + } + + Future clearThemeColorOverrides() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson); + AppSettingsRevision.bump(); + } + Future clearThemeSettings() async { await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeMode); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson); AppSettingsRevision.bump(); } } diff --git a/lib/core/theme/parser/color_parser.dart b/lib/core/theme/parser/color_parser.dart index c4cae9f8..b1c7cdc4 100644 --- a/lib/core/theme/parser/color_parser.dart +++ b/lib/core/theme/parser/color_parser.dart @@ -33,6 +33,20 @@ Color parseVsCodeColor(String input) { throw FormatException('Unsupported color format: $input'); } +/// Encodes a [Color] as a VS Code hex string (`#RRGGBB` or `#RRGGBBAA`). +String formatVsCodeColor(Color color) { + String channel(double component) => + (component * 255.0).round().clamp(0, 255).toRadixString(16).padLeft(2, '0'); + final rr = channel(color.r); + final gg = channel(color.g); + final bb = channel(color.b); + if (color.a < 1.0) { + final aa = channel(color.a); + return '#$rr$gg$bb$aa'; + } + return '#$rr$gg$bb'; +} + Color _fromRgbaHex(String eight) { final rr = eight.substring(0, 2); final gg = eight.substring(2, 4); diff --git a/lib/core/theme/parser/querya_theme_from_vscode.dart b/lib/core/theme/parser/querya_theme_from_vscode.dart index 68dfca79..0b5c4c99 100644 --- a/lib/core/theme/parser/querya_theme_from_vscode.dart +++ b/lib/core/theme/parser/querya_theme_from_vscode.dart @@ -187,3 +187,21 @@ QueryaEditorTheme _applyEditorField( return e.copyWith(foreground: color); } } + +/// Builds [QueryaTheme] from merged VS Code `colors` on top of [fallback]. +QueryaTheme buildQueryaThemeFromVsCodeColors({ + required Brightness brightness, + required Map colors, + QueryaTheme? fallback, +}) { + final base = fallback ?? + (brightness == Brightness.light + ? QueryaTheme.lightDefault + : QueryaTheme.darkDefault); + if (colors.isEmpty) return base; + final manifest = VsCodeThemeManifest( + type: brightness == Brightness.light ? 'light' : 'dark', + colors: colors, + ); + return buildQueryaThemeFromVsCodeManifest(manifest, fallback: base); +} diff --git a/lib/core/theme/parser/vscode_colors_merge.dart b/lib/core/theme/parser/vscode_colors_merge.dart new file mode 100644 index 00000000..63269684 --- /dev/null +++ b/lib/core/theme/parser/vscode_colors_merge.dart @@ -0,0 +1,14 @@ +// Deep-merge VS Code `colors` maps (later layers override earlier keys). + +/// Merges VS Code color layers left-to-right; returns an unmodifiable map. +/// +/// Typical pipeline: `defaultColors` → `importedColors` → `userOverrides`. +Map mergeVsCodeColorLayers( + Iterable> layers, +) { + final merged = {}; + for (final layer in layers) { + merged.addAll(layer); + } + return Map.unmodifiable(merged); +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index fafd0eab..94bea9ad 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,10 +1,13 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'parser/color_parser.dart'; +import 'parser/querya_theme_from_vscode.dart'; +import 'parser/vscode_colors_merge.dart'; import 'querya_theme.dart'; import 'querya_theme_preset.dart'; -/// Active theme state: preset + [ThemeMode], persisted via [AppSettings]. +/// Active theme state: preset, optional imported colors, user overrides. class ThemeController extends ChangeNotifier { ThemeController._(); @@ -12,6 +15,8 @@ class ThemeController extends ChangeNotifier { ThemeMode _themeMode = ThemeMode.dark; QueryaThemePreset _preset = QueryaThemePreset.queryaDark; + Map _importedColors = const {}; + Map _userOverrides = const {}; bool _loaded = false; ThemeMode get themeMode => _themeMode; @@ -20,29 +25,48 @@ class ThemeController extends ChangeNotifier { bool get isLoaded => _loaded; - /// Workbench + editor tokens for the current preset/mode. - QueryaTheme get activeTheme { - if (_themeMode == ThemeMode.system) { - final b = WidgetsBinding.instance.platformDispatcher.platformBrightness; - return b == Brightness.dark - ? QueryaTheme.darkDefault - : QueryaTheme.lightDefault; + /// User `workbench.colorCustomizations` layer (VS Code keys → hex). + Map get userColorOverrides => + Map.unmodifiable(_userOverrides); + + /// Imported theme `colors` layer (from file import; empty until wired). + Map get importedColors => Map.unmodifiable(_importedColors); + + /// Merged VS Code color keys: imported → user overrides. + Map get effectiveVsCodeColors => mergeVsCodeColorLayers([ + _importedColors, + _userOverrides, + ]); + + /// Parsed effective colors for supported VS Code keys only. + Map get effectiveWorkbenchColors { + final out = {}; + for (final entry in effectiveVsCodeColors.entries) { + try { + out[entry.key] = parseVsCodeColor(entry.value); + } on FormatException { + continue; + } } - return _preset == QueryaThemePreset.queryaLight - ? QueryaTheme.lightDefault - : QueryaTheme.darkDefault; + return Map.unmodifiable(out); } + /// Workbench + editor tokens for the current preset/mode and overrides. + QueryaTheme get activeTheme => _themeForBrightness(_effectiveBrightness()); + ThemeData get lightShadcnTheme => - QueryaTheme.lightDefault.toShadcnThemeData(); + _themeForBrightness(Brightness.light).toShadcnThemeData(); - ThemeData get darkShadcnTheme => QueryaTheme.darkDefault.toShadcnThemeData(); + ThemeData get darkShadcnTheme => + _themeForBrightness(Brightness.dark).toShadcnThemeData(); Future load() async { final mode = await AppSettings.instance.getThemeMode(); final preset = await AppSettings.instance.getThemePreset(); + final overrides = await AppSettings.instance.getThemeColorOverrides(); _themeMode = mode; _preset = preset; + _userOverrides = Map.unmodifiable(overrides); _loaded = true; notifyListeners(); } @@ -67,10 +91,53 @@ class ThemeController extends ChangeNotifier { notifyListeners(); } + /// Sets or clears a user override for a VS Code `colors` key. + Future setWorkbenchColor(String vscodeKey, Color? value) async { + final next = Map.from(_userOverrides); + if (value == null) { + next.remove(vscodeKey); + } else { + next[vscodeKey] = formatVsCodeColor(value); + } + _userOverrides = Map.unmodifiable(next); + await AppSettings.instance.setThemeColorOverrides(next); + notifyListeners(); + } + + /// Removes only the user override layer (keeps preset/imported theme). + Future clearColorOverrides() async { + _userOverrides = const {}; + await AppSettings.instance.clearThemeColorOverrides(); + notifyListeners(); + } + Future resetToDefaults() async { await AppSettings.instance.clearThemeSettings(); _themeMode = ThemeMode.dark; _preset = QueryaThemePreset.queryaDark; + _importedColors = const {}; + _userOverrides = const {}; notifyListeners(); } + + Brightness _effectiveBrightness() { + if (_themeMode == ThemeMode.system) { + final b = WidgetsBinding.instance.platformDispatcher.platformBrightness; + return b; + } + return _themeMode == ThemeMode.light ? Brightness.light : Brightness.dark; + } + + QueryaTheme _themeForBrightness(Brightness brightness) { + final fallback = brightness == Brightness.light + ? QueryaTheme.lightDefault + : QueryaTheme.darkDefault; + final merged = effectiveVsCodeColors; + if (merged.isEmpty) return fallback; + return buildQueryaThemeFromVsCodeColors( + brightness: brightness, + colors: merged, + fallback: fallback, + ); + } } diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 5770cb7a..1b8d60a6 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -200,6 +200,19 @@ void main() { await AppSettings.instance.clearThemeSettings(); expect(await AppSettings.instance.getThemeMode(), ThemeMode.dark); }); + + test('theme color overrides json roundtrip', () async { + await AppSettings.instance.setThemeColorOverrides({ + 'sideBar.background': '#ff0000', + 'editor.background': '#1e1e1e', + }); + expect(await AppSettings.instance.getThemeColorOverrides(), { + 'sideBar.background': '#ff0000', + 'editor.background': '#1e1e1e', + }); + await AppSettings.instance.clearThemeColorOverrides(); + expect(await AppSettings.instance.getThemeColorOverrides(), isEmpty); + }); }); group('AppSettingsRevision', () { diff --git a/test/core/theme/parser/color_parser_test.dart b/test/core/theme/parser/color_parser_test.dart index 014598de..f928edbc 100644 --- a/test/core/theme/parser/color_parser_test.dart +++ b/test/core/theme/parser/color_parser_test.dart @@ -1,7 +1,8 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart' + show formatVsCodeColor, parseVsCodeColor; void main() { group('parseVsCodeColor', () { @@ -26,5 +27,11 @@ void main() { test('invalid throws', () { expect(() => parseVsCodeColor('nope'), throwsFormatException); }); + + test('formatVsCodeColor roundtrip', () { + const c = Color(0xFF1E1E1E); + expect(formatVsCodeColor(c), '#1e1e1e'); + expect(parseVsCodeColor(formatVsCodeColor(c)), c); + }); }); } diff --git a/test/core/theme/parser/querya_theme_merge_pipeline_test.dart b/test/core/theme/parser/querya_theme_merge_pipeline_test.dart new file mode 100644 index 00000000..df4b7177 --- /dev/null +++ b/test/core/theme/parser/querya_theme_merge_pipeline_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_from_vscode.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_colors_merge.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + group('theme merge pipeline', () { + test('default → imported → user overrides', () { + const defaultLayer = { + 'editor.background': '#1e1e1e', + 'sideBar.background': '#252526', + }; + const importedLayer = { + 'editor.background': '#2d2d30', + 'sideBar.background': '#333333', + }; + const userLayer = { + 'sideBar.background': '#ff0000', + }; + + final merged = mergeVsCodeColorLayers([ + defaultLayer, + importedLayer, + userLayer, + ]); + + final theme = buildQueryaThemeFromVsCodeColors( + brightness: Brightness.dark, + colors: merged, + fallback: QueryaTheme.darkDefault, + ); + + expect(theme.workbench.editorBackground, const Color(0xFF2D2D30)); + expect(theme.workbench.sidebarBackground, const Color(0xFFFF0000)); + }); + + test('manifest import then user override on same key', () { + const src = ''' +{ + "type": "dark", + "colors": { + "editor.background": "#1e1e1e" + } +} +'''; + final imported = VsCodeThemeManifest.fromJsonString(src).colors; + final merged = mergeVsCodeColorLayers([ + imported, + {'editor.background': '#abcdef'}, + ]); + final theme = buildQueryaThemeFromVsCodeColors( + brightness: Brightness.dark, + colors: merged, + fallback: QueryaTheme.darkDefault, + ); + expect(theme.workbench.editorBackground, const Color(0xFFABCDEF)); + }); + }); +} diff --git a/test/core/theme/parser/vscode_colors_merge_test.dart b/test/core/theme/parser/vscode_colors_merge_test.dart new file mode 100644 index 00000000..4bec6971 --- /dev/null +++ b/test/core/theme/parser/vscode_colors_merge_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_colors_merge.dart'; + +void main() { + group('mergeVsCodeColorLayers', () { + test('empty layers yields empty map', () { + expect(mergeVsCodeColorLayers([]), isEmpty); + }); + + test('later layers override earlier keys', () { + final merged = mergeVsCodeColorLayers([ + {'editor.background': '#111111', 'sideBar.background': '#222222'}, + {'editor.background': '#333333', 'panel.background': '#444444'}, + {'sideBar.background': '#ff0000'}, + ]); + expect(merged['editor.background'], '#333333'); + expect(merged['panel.background'], '#444444'); + expect(merged['sideBar.background'], '#ff0000'); + }); + + test('result is unmodifiable', () { + final merged = mergeVsCodeColorLayers([ + {'a': '#111111'}, + ]); + expect( + () => merged['b'] = '#222222', + throwsUnsupportedError, + ); + }); + }); +} diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 3da820c4..0d4b7e4c 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -75,4 +75,37 @@ void main() { expect(c.themeMode, ThemeMode.dark); expect(c.activeTheme, QueryaTheme.darkDefault); }); + + test('setWorkbenchColor overrides sidebar and persists', () async { + final c = ThemeController.instance; + await c.load(); + await c.setWorkbenchColor( + 'sideBar.background', + const Color(0xFFFF0000), + ); + expect( + c.activeTheme.workbench.sidebarBackground, + const Color(0xFFFF0000), + ); + expect( + (await AppSettings.instance.getThemeColorOverrides())['sideBar.background'], + '#ff0000', + ); + + await c.clearColorOverrides(); + expect(c.userColorOverrides, isEmpty); + expect( + c.activeTheme.workbench.sidebarBackground, + QueryaTheme.darkDefault.workbench.sidebarBackground, + ); + }); + + test('clearColorOverrides does not reset theme mode', () async { + final c = ThemeController.instance; + await c.setThemeMode(ThemeMode.light); + await c.setWorkbenchColor('editor.background', const Color(0xFF111111)); + await c.clearColorOverrides(); + expect(c.themeMode, ThemeMode.light); + expect(c.activeTheme, QueryaTheme.lightDefault); + }); }