Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/theme-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
45 changes: 45 additions & 0 deletions lib/core/storage/app_settings.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

import 'package:shadcn_flutter/shadcn_flutter.dart';

import '../theme/querya_theme_preset.dart';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -211,9 +214,51 @@ class AppSettings {
AppSettingsRevision.bump();
}

Future<Map<String, String>> 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 = <String, String>{};
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<void> setThemeColorOverrides(Map<String, String> overrides) async {
if (overrides.isEmpty) {
await clearThemeColorOverrides();
return;
}
await LocalDb.instance.setAppSetting(
AppSettingsKeys.themeOverridesJson,
jsonEncode(overrides),
);
AppSettingsRevision.bump();
}

Future<void> clearThemeColorOverrides() async {
await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson);
AppSettingsRevision.bump();
}

Future<void> clearThemeSettings() async {
await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeMode);
await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset);
await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson);
AppSettingsRevision.bump();
}
}
14 changes: 14 additions & 0 deletions lib/core/theme/parser/color_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions lib/core/theme/parser/querya_theme_from_vscode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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);
}
14 changes: 14 additions & 0 deletions lib/core/theme/parser/vscode_colors_merge.dart
Original file line number Diff line number Diff line change
@@ -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<String, String> mergeVsCodeColorLayers(
Iterable<Map<String, String>> layers,
) {
final merged = <String, String>{};
for (final layer in layers) {
merged.addAll(layer);
}
return Map.unmodifiable(merged);
}
93 changes: 80 additions & 13 deletions lib/core/theme/theme_controller.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
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._();

static final ThemeController instance = ThemeController._();

ThemeMode _themeMode = ThemeMode.dark;
QueryaThemePreset _preset = QueryaThemePreset.queryaDark;
Map<String, String> _importedColors = const {};
Map<String, String> _userOverrides = const {};
bool _loaded = false;

ThemeMode get themeMode => _themeMode;
Expand All @@ -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<String, String> get userColorOverrides =>
Map.unmodifiable(_userOverrides);

/// Imported theme `colors` layer (from file import; empty until wired).
Map<String, String> get importedColors => Map.unmodifiable(_importedColors);

/// Merged VS Code color keys: imported → user overrides.
Map<String, String> get effectiveVsCodeColors => mergeVsCodeColorLayers([
_importedColors,
_userOverrides,
]);

/// Parsed effective colors for supported VS Code keys only.
Map<String, Color> get effectiveWorkbenchColors {
final out = <String, Color>{};
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<void> 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();
}
Expand All @@ -67,10 +91,53 @@ class ThemeController extends ChangeNotifier {
notifyListeners();
}

/// Sets or clears a user override for a VS Code `colors` key.
Future<void> setWorkbenchColor(String vscodeKey, Color? value) async {
final next = Map<String, String>.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<void> clearColorOverrides() async {
_userOverrides = const {};
await AppSettings.instance.clearThemeColorOverrides();
notifyListeners();
}

Future<void> 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,
);
}
}
13 changes: 13 additions & 0 deletions test/core/storage/app_settings_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
9 changes: 8 additions & 1 deletion test/core/theme/parser/color_parser_test.dart
Original file line number Diff line number Diff line change
@@ -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', () {
Expand All @@ -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);
});
});
}
61 changes: 61 additions & 0 deletions test/core/theme/parser/querya_theme_merge_pipeline_test.dart
Original file line number Diff line number Diff line change
@@ -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));
});
});
}
Loading
Loading