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
7 changes: 7 additions & 0 deletions docs/theme-custom-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
38 changes: 38 additions & 0 deletions lib/core/theme/parser/querya_theme_manifest.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,44 @@ class QueryaThemeManifest {
bool get isDark => type == QueryaThemeType.dark;
bool get isLight => type == QueryaThemeType.light;

Map<String, dynamic> toJson() {
final json = <String, dynamic>{
'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<String, dynamic> _tokenColorRuleToJson(TokenColorRule rule) {
final settings = <String, dynamic>{};
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;
Expand Down
36 changes: 36 additions & 0 deletions lib/core/theme/theme_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> 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<void> 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<void> startThemeFolderWatcher() async {
_themeFolderWatcher ??= ThemeFolderWatcher(
Expand Down
252 changes: 252 additions & 0 deletions lib/core/theme/theme_editor_draft.dart
Original file line number Diff line number Diff line change
@@ -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<String, String> shadcnColors,
required Map<String, String> editorColors,
List<TokenColorRule> tokenColors = const [],
this.description,
this.author,
this.version,
this.homepage,
this.license,
this.preview,
List<String> tags = const [],
this.readOnlySource = false,
}) : shadcnColors = Map<String, String>.from(shadcnColors),
editorColors = Map<String, String>.from(editorColors),
tokenColors = List<TokenColorRule>.from(tokenColors),
tags = List<String>.from(tags);

String id;
String name;
QueryaThemeType type;
final Map<String, String> shadcnColors;
final Map<String, String> editorColors;
final List<TokenColorRule> tokenColors;
String? description;
String? author;
String? version;
String? homepage;
String? license;
String? preview;
final List<String> 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());
}
}
Loading
Loading