Skip to content

Commit 269c337

Browse files
Merge pull request #166 from QueryaHub/issue/162-visual-theme-editor
TP-F3 — Visual theme editor in Preferences (#162)
2 parents 26e0abb + 2df4fe7 commit 269c337

13 files changed

Lines changed: 912 additions & 6 deletions

docs/theme-custom-json.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,13 @@ Invalid files are skipped (logged in debug builds). Required fields: `schema`, `
287287
Built-in bundled themes (under `assets/themes/`) ship with the app and do not require
288288
manual installation.
289289

290+
## Visual editor (Preferences)
291+
292+
Use **Edit theme colors…** in **Preferences → Appearance** to tweak MVP color tokens
293+
with live preview, then **Export theme…** to save a `querya.theme.v1` JSON file.
294+
Built-in themes are exported as copies with a new `id`; import the file via
295+
**Import theme…** or copy into the themes folder.
296+
290297
## Troubleshooting
291298

292299
| Symptom | Likely cause | What to do |

lib/core/theme/parser/querya_theme_manifest.dart

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,44 @@ class QueryaThemeManifest {
4747
bool get isDark => type == QueryaThemeType.dark;
4848
bool get isLight => type == QueryaThemeType.light;
4949

50+
Map<String, dynamic> toJson() {
51+
final json = <String, dynamic>{
52+
'schema': schema,
53+
'id': id,
54+
'name': name,
55+
'type': type.name,
56+
'shadcn_colors': shadcnColors,
57+
'editor_colors': editorColors,
58+
};
59+
60+
if (tokenColors.isNotEmpty) {
61+
json['tokenColors'] = tokenColors.map(_tokenColorRuleToJson).toList();
62+
}
63+
if (description != null) json['description'] = description;
64+
if (author != null) json['author'] = author;
65+
if (version != null) json['version'] = version;
66+
if (homepage != null) json['homepage'] = homepage;
67+
if (license != null) json['license'] = license;
68+
if (preview != null) json['preview'] = preview;
69+
if (tags.isNotEmpty) json['tags'] = tags;
70+
71+
return json;
72+
}
73+
74+
String toJsonString() => const JsonEncoder.withIndent(' ').convert(toJson());
75+
76+
static Map<String, dynamic> _tokenColorRuleToJson(TokenColorRule rule) {
77+
final settings = <String, dynamic>{};
78+
if (rule.foreground != null) settings['foreground'] = rule.foreground;
79+
if (rule.background != null) settings['background'] = rule.background;
80+
if (rule.fontStyle != null) settings['fontStyle'] = rule.fontStyle;
81+
82+
return {
83+
'scope': rule.scopes.length == 1 ? rule.scopes.first : rule.scopes,
84+
if (settings.isNotEmpty) 'settings': settings,
85+
};
86+
}
87+
5088
factory QueryaThemeManifest.fromJsonString(String source) {
5189
final cleaned = stripJsonc(source);
5290
final dynamic decoded;

lib/core/theme/theme_controller.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
55

66
import 'parser/apply_token_colors_to_editor.dart';
77
import 'parser/color_parser.dart';
8+
import 'parser/querya_theme_from_manifest.dart';
89
import 'parser/querya_theme_from_vscode.dart';
10+
import 'parser/querya_theme_manifest.dart';
911
import 'parser/vscode_colors_merge.dart';
1012
import 'parser/vscode_theme_manifest.dart';
1113
import 'querya_theme.dart';
@@ -71,6 +73,8 @@ class ThemeController extends ChangeNotifier {
7173
bool _registrySelectionFailed = false;
7274
bool _isLoadingAvailableThemes = false;
7375
ThemeFolderWatcher? _themeFolderWatcher;
76+
bool _editorPreviewActive = false;
77+
String? _editorPreviewRestoreThemeId;
7478

7579
QueryaTheme? _cachedLightTheme;
7680
QueryaTheme? _cachedDarkTheme;
@@ -177,6 +181,38 @@ class ThemeController extends ChangeNotifier {
177181
bool get isThemeFolderWatcherStarted =>
178182
_themeFolderWatcher?.isStarted ?? false;
179183

184+
@visibleForTesting
185+
bool get isEditorPreviewActive => _editorPreviewActive;
186+
187+
/// Applies [manifest] for live editor preview without persisting selection.
188+
Future<void> previewEditorManifest(QueryaThemeManifest manifest) async {
189+
if (!_editorPreviewActive) {
190+
_editorPreviewRestoreThemeId = effectiveSelectedThemeId;
191+
_editorPreviewActive = true;
192+
}
193+
194+
_registryTheme = queryaThemeFromManifest(manifest);
195+
_registrySelectionFailed = false;
196+
_selectedThemeLoadError = null;
197+
_themeMode = manifest.isLight ? ThemeMode.light : ThemeMode.dark;
198+
_notifyThemeChanged();
199+
}
200+
201+
/// Restores the theme that was active before editor preview.
202+
Future<void> endEditorPreview() async {
203+
if (!_editorPreviewActive) return;
204+
205+
final restoreId = _editorPreviewRestoreThemeId;
206+
_editorPreviewActive = false;
207+
_editorPreviewRestoreThemeId = null;
208+
209+
if (restoreId != null) {
210+
await setThemeById(restoreId);
211+
} else {
212+
_notifyThemeChanged();
213+
}
214+
}
215+
180216
/// Watches `{appSupport}/themes/` and debounces [loadAvailableThemes].
181217
Future<void> startThemeFolderWatcher() async {
182218
_themeFolderWatcher ??= ThemeFolderWatcher(
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import 'dart:convert';
2+
import 'dart:ui';
3+
4+
import 'package:shadcn_flutter/shadcn_flutter.dart' show ColorScheme;
5+
import 'package:querya_desktop/core/theme/parser/color_parser.dart';
6+
import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart';
7+
import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart';
8+
import 'package:querya_desktop/core/theme/querya_theme.dart';
9+
import 'package:querya_desktop/core/theme/theme_metadata.dart';
10+
11+
/// One editable color in the theme editor MVP.
12+
class ThemeEditorColorField {
13+
const ThemeEditorColorField({
14+
required this.section,
15+
required this.key,
16+
required this.label,
17+
});
18+
19+
final String section;
20+
final String key;
21+
final String label;
22+
}
23+
24+
/// MVP color fields for Preferences theme editor (TP-F3).
25+
const themeEditorMvpColorFields = [
26+
ThemeEditorColorField(
27+
section: 'shadcn_colors',
28+
key: 'primary',
29+
label: 'Primary',
30+
),
31+
ThemeEditorColorField(
32+
section: 'shadcn_colors',
33+
key: 'background',
34+
label: 'Background',
35+
),
36+
ThemeEditorColorField(
37+
section: 'shadcn_colors',
38+
key: 'foreground',
39+
label: 'Foreground',
40+
),
41+
ThemeEditorColorField(
42+
section: 'shadcn_colors',
43+
key: 'card',
44+
label: 'Card',
45+
),
46+
ThemeEditorColorField(
47+
section: 'shadcn_colors',
48+
key: 'border',
49+
label: 'Border',
50+
),
51+
ThemeEditorColorField(
52+
section: 'editor_colors',
53+
key: 'background',
54+
label: 'Editor background',
55+
),
56+
ThemeEditorColorField(
57+
section: 'editor_colors',
58+
key: 'foreground',
59+
label: 'Editor foreground',
60+
),
61+
ThemeEditorColorField(
62+
section: 'editor_colors',
63+
key: 'selection',
64+
label: 'Editor selection',
65+
),
66+
ThemeEditorColorField(
67+
section: 'editor_colors',
68+
key: 'canvas',
69+
label: 'Workbench canvas',
70+
),
71+
ThemeEditorColorField(
72+
section: 'editor_colors',
73+
key: 'sidebarBackground',
74+
label: 'Sidebar background',
75+
),
76+
];
77+
78+
/// Mutable draft for editing and exporting `querya.theme.v1`.
79+
class ThemeEditorDraft {
80+
ThemeEditorDraft({
81+
required this.id,
82+
required this.name,
83+
required this.type,
84+
required Map<String, String> shadcnColors,
85+
required Map<String, String> editorColors,
86+
List<TokenColorRule> tokenColors = const [],
87+
this.description,
88+
this.author,
89+
this.version,
90+
this.homepage,
91+
this.license,
92+
this.preview,
93+
List<String> tags = const [],
94+
this.readOnlySource = false,
95+
}) : shadcnColors = Map<String, String>.from(shadcnColors),
96+
editorColors = Map<String, String>.from(editorColors),
97+
tokenColors = List<TokenColorRule>.from(tokenColors),
98+
tags = List<String>.from(tags);
99+
100+
String id;
101+
String name;
102+
QueryaThemeType type;
103+
final Map<String, String> shadcnColors;
104+
final Map<String, String> editorColors;
105+
final List<TokenColorRule> tokenColors;
106+
String? description;
107+
String? author;
108+
String? version;
109+
String? homepage;
110+
String? license;
111+
String? preview;
112+
final List<String> tags;
113+
114+
/// Built-in / asset themes are exported as copies only.
115+
final bool readOnlySource;
116+
117+
factory ThemeEditorDraft.fromManifest(
118+
QueryaThemeManifest manifest, {
119+
bool readOnlySource = false,
120+
}) {
121+
return ThemeEditorDraft(
122+
id: manifest.id,
123+
name: manifest.name,
124+
type: manifest.type,
125+
shadcnColors: manifest.shadcnColors,
126+
editorColors: manifest.editorColors,
127+
tokenColors: manifest.tokenColors,
128+
description: manifest.description,
129+
author: manifest.author,
130+
version: manifest.version,
131+
homepage: manifest.homepage,
132+
license: manifest.license,
133+
preview: manifest.preview,
134+
tags: manifest.tags,
135+
readOnlySource: readOnlySource,
136+
);
137+
}
138+
139+
factory ThemeEditorDraft.fromQueryaTheme({
140+
required String id,
141+
required String name,
142+
required bool isDark,
143+
required QueryaTheme theme,
144+
ThemeMetadata? metadata,
145+
bool readOnlySource = false,
146+
}) {
147+
final scheme = theme.colorScheme;
148+
final editor = theme.editor;
149+
final workbench = theme.workbench;
150+
151+
return ThemeEditorDraft(
152+
id: id,
153+
name: name,
154+
type: isDark ? QueryaThemeType.dark : QueryaThemeType.light,
155+
shadcnColors: {
156+
for (final field in themeEditorMvpColorFields)
157+
if (field.section == 'shadcn_colors')
158+
field.key: _colorForShadcnField(field.key, scheme),
159+
},
160+
editorColors: {
161+
'background': formatVsCodeColor(editor.background),
162+
'foreground': formatVsCodeColor(editor.foreground),
163+
'selection': formatVsCodeColor(editor.selection),
164+
'canvas': formatVsCodeColor(workbench.canvas),
165+
'sidebarBackground': formatVsCodeColor(workbench.sidebarBackground),
166+
},
167+
tokenColors: theme.tokenColors,
168+
description: metadata?.description,
169+
author: metadata?.author,
170+
version: metadata?.version,
171+
homepage: metadata?.homepage,
172+
license: metadata?.license,
173+
preview: metadata?.preview,
174+
tags: metadata?.tags ?? const [],
175+
readOnlySource: readOnlySource,
176+
);
177+
}
178+
179+
static String _colorForShadcnField(String key, ColorScheme scheme) {
180+
final color = switch (key) {
181+
'primary' => scheme.primary,
182+
'background' => scheme.background,
183+
'foreground' => scheme.foreground,
184+
'card' => scheme.card,
185+
'border' => scheme.border,
186+
_ => scheme.primary,
187+
};
188+
return formatVsCodeColor(color);
189+
}
190+
191+
String? colorHex(ThemeEditorColorField field) {
192+
final map = field.section == 'shadcn_colors' ? shadcnColors : editorColors;
193+
return map[field.key];
194+
}
195+
196+
void setColorHex(ThemeEditorColorField field, String hex) {
197+
final normalized = hex.trim();
198+
parseQueryaThemeColor(normalized);
199+
final map = field.section == 'shadcn_colors' ? shadcnColors : editorColors;
200+
map[field.key] = formatVsCodeColor(parseQueryaThemeColor(normalized));
201+
}
202+
203+
void setColor(ThemeEditorColorField field, Color color) {
204+
final map = field.section == 'shadcn_colors' ? shadcnColors : editorColors;
205+
map[field.key] = formatVsCodeColor(color);
206+
}
207+
208+
QueryaThemeManifest toManifest() {
209+
return QueryaThemeManifest(
210+
schema: queryaThemeSchemaV1,
211+
id: id,
212+
name: name,
213+
type: type,
214+
shadcnColors: Map.unmodifiable(shadcnColors),
215+
editorColors: Map.unmodifiable(editorColors),
216+
tokenColors: List.unmodifiable(tokenColors),
217+
description: description,
218+
author: author,
219+
version: version,
220+
homepage: homepage,
221+
license: license,
222+
preview: preview,
223+
tags: List.unmodifiable(tags),
224+
);
225+
}
226+
227+
/// JSON export with a unique id when saving a built-in/read-only source.
228+
ThemeEditorDraft forExport({String? exportId}) {
229+
if (!readOnlySource && exportId == null) return this;
230+
final nextId = exportId ?? '$id-edited';
231+
return ThemeEditorDraft(
232+
id: nextId,
233+
name: '$name (edited)',
234+
type: type,
235+
shadcnColors: shadcnColors,
236+
editorColors: editorColors,
237+
tokenColors: tokenColors,
238+
description: description,
239+
author: author,
240+
version: version ?? '1.0.0',
241+
homepage: homepage,
242+
license: license,
243+
preview: preview,
244+
tags: tags,
245+
readOnlySource: false,
246+
);
247+
}
248+
249+
String toExportJsonString() {
250+
return const JsonEncoder.withIndent(' ').convert(toManifest().toJson());
251+
}
252+
}

0 commit comments

Comments
 (0)