diff --git a/docs/theme-import.md b/docs/theme-import.md new file mode 100644 index 00000000..25d25e83 --- /dev/null +++ b/docs/theme-import.md @@ -0,0 +1,50 @@ +# VS Code theme import (workbench colors) + +Querya can apply a **subset** of VS Code theme JSON / JSONC `colors` to +`QueryaWorkbenchTheme`, `QueryaEditorTheme`, and the shadcn `ColorScheme`. + +Syntax highlighting (`tokenColors`) is tracked separately (issue #46). + +## Supported `colors` keys + +| VS Code key | Querya target | +|-------------|---------------| +| `editor.background` | `workbench.editorBackground`, `editor.background` | +| `editor.foreground` | `editor.foreground`, `ColorScheme.foreground` | +| `sideBar.background` | `workbench.sidebarBackground` | +| `sideBar.foreground` | `workbench.mutedForeground` | +| `activityBar.background` | `workbench.canvas` | +| `tab.activeBackground` | `workbench.surface` | +| `statusBar.background` | `workbench.canvas` | +| `panel.background` | `workbench.surface` | +| `focusBorder` | `workbench.accent`, `ColorScheme.ring` | +| `input.background` | `workbench.surface` | +| `list.hoverBackground` | `ColorScheme.accent` | +| `gitDecoration.modifiedResourceForeground` | `workbench.gitModified` | +| `gitDecoration.untrackedResourceForeground` | `workbench.gitUntracked` | + +Implementation: `lib/core/theme/parser/vscode_color_map.dart`, +`lib/core/theme/parser/querya_theme_from_vscode.dart`. + +## Behavior + +- **`type`**: `"dark"` or `"light"` in the manifest selects brightness and + default fallback (`QueryaTheme.darkDefault` / `lightDefault`). +- **Missing keys**: unchanged from the fallback theme. +- **Unknown keys**: ignored; in debug builds a line is printed to the console. +- **Invalid color values**: skipped for that key only. + +## Color formats + +Hex strings as in VS Code: `#RRGGBB`, `#RRGGBBAA`, `#RGB`, `#RGBA` (see +`parseVsCodeColor`). + +## JSONC + +Comments and trailing commas are stripped before parse (`stripJsonc`). + +## Fixtures (tests) + +- `test/fixtures/themes/dark_subset.json` +- `test/fixtures/themes/light_subset.json` +- `test/fixtures/themes/with_unknown_keys.json` diff --git a/lib/core/theme/parser/color_parser.dart b/lib/core/theme/parser/color_parser.dart index 065a1bc8..c4cae9f8 100644 --- a/lib/core/theme/parser/color_parser.dart +++ b/lib/core/theme/parser/color_parser.dart @@ -4,7 +4,7 @@ import 'dart:ui'; Color parseVsCodeColor(String input) { var s = input.trim(); if (s.isEmpty) { - throw FormatException('Empty color string'); + throw const FormatException('Empty color string'); } if (s.startsWith('#')) { s = s.substring(1); diff --git a/lib/core/theme/parser/querya_theme_from_vscode.dart b/lib/core/theme/parser/querya_theme_from_vscode.dart new file mode 100644 index 00000000..68dfca79 --- /dev/null +++ b/lib/core/theme/parser/querya_theme_from_vscode.dart @@ -0,0 +1,189 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +import '../querya_editor_theme.dart'; +import '../querya_theme.dart'; +import '../querya_workbench_theme.dart'; +import 'color_parser.dart'; +import 'vscode_color_map.dart'; +import 'vscode_theme_manifest.dart'; + +/// Builds a [QueryaTheme] from a parsed VS Code manifest. +/// +/// Missing keys keep values from [fallback] (defaults by manifest `type`). +QueryaTheme buildQueryaThemeFromVsCodeManifest( + VsCodeThemeManifest manifest, { + QueryaTheme? fallback, + void Function(String unknownVsCodeKey)? onUnknownColorKey, +}) { + final base = fallback ?? _defaultFallbackFor(manifest); + final brightness = _brightnessFrom(manifest, base); + + var workbench = base.workbench; + var editor = base.editor; + Color? schemeForeground; + Color? schemeBackground; + Color? schemeCard; + Color? schemeBorder; + Color? schemeInput; + Color? schemeRing; + Color? schemeMutedForeground; + Color? schemeAccent; + + for (final entry in manifest.colors.entries) { + final target = kVsCodeColorMap[entry.key]; + if (target == null) { + onUnknownColorKey?.call(entry.key); + if (kDebugMode) { + debugPrint('VsCode theme: ignored color key "${entry.key}"'); + } + continue; + } + + final Color color; + try { + color = parseVsCodeColor(entry.value); + } on FormatException { + if (kDebugMode) { + debugPrint( + 'VsCode theme: invalid color for "${entry.key}": ${entry.value}', + ); + } + continue; + } + + if (target.workbench != null) { + workbench = _applyWorkbenchField(workbench, target.workbench!, color); + if (target.workbench == VsCodeWorkbenchField.editorBackground) { + editor = editor.copyWith(background: color); + } + } else if (target.editor != null) { + editor = _applyEditorField(editor, target.editor!, color); + } else if (target.colorScheme != null) { + switch (target.colorScheme!) { + case VsCodeColorSchemeField.foreground: + schemeForeground = color; + case VsCodeColorSchemeField.background: + schemeBackground = color; + case VsCodeColorSchemeField.card: + schemeCard = color; + case VsCodeColorSchemeField.border: + schemeBorder = color; + case VsCodeColorSchemeField.input: + schemeInput = color; + case VsCodeColorSchemeField.ring: + schemeRing = color; + case VsCodeColorSchemeField.mutedForeground: + schemeMutedForeground = color; + case VsCodeColorSchemeField.accent: + schemeAccent = color; + } + } + } + + if (editor.background != workbench.editorBackground) { + editor = editor.copyWith(background: workbench.editorBackground); + } + + var colorScheme = QueryaTheme.colorSchemeFromWorkbench( + workbench, + brightness: brightness, + ); + + final editorForegroundChanged = + editor.foreground != base.editor.foreground; + if (schemeForeground != null || editorForegroundChanged) { + final fg = schemeForeground ?? editor.foreground; + colorScheme = colorScheme.copyWith( + foreground: () => fg, + cardForeground: () => fg, + popoverForeground: () => fg, + ); + } + if (schemeBackground != null) { + colorScheme = colorScheme.copyWith(background: () => schemeBackground!); + } + if (schemeCard != null) { + colorScheme = colorScheme.copyWith( + card: () => schemeCard!, + popover: () => schemeCard!, + ); + } + if (schemeBorder != null) { + colorScheme = colorScheme.copyWith(border: () => schemeBorder!); + } + if (schemeInput != null) { + colorScheme = colorScheme.copyWith(input: () => schemeInput!); + } + if (schemeRing != null) { + colorScheme = colorScheme.copyWith(ring: () => schemeRing!); + } + if (schemeMutedForeground != null) { + colorScheme = colorScheme.copyWith( + mutedForeground: () => schemeMutedForeground!, + ); + } + if (schemeAccent != null) { + colorScheme = colorScheme.copyWith(accent: () => schemeAccent!); + } + + return QueryaTheme( + workbench: workbench, + editor: editor, + brightness: brightness, + colorScheme: colorScheme, + ); +} + +QueryaTheme _defaultFallbackFor(VsCodeThemeManifest manifest) { + if (manifest.isLight) return QueryaTheme.lightDefault; + if (manifest.isDark) return QueryaTheme.darkDefault; + return QueryaTheme.darkDefault; +} + +Brightness _brightnessFrom(VsCodeThemeManifest manifest, QueryaTheme base) { + if (manifest.isLight) return Brightness.light; + if (manifest.isDark) return Brightness.dark; + return base.brightness; +} + +QueryaWorkbenchTheme _applyWorkbenchField( + QueryaWorkbenchTheme w, + VsCodeWorkbenchField field, + Color color, +) { + switch (field) { + case VsCodeWorkbenchField.canvas: + return w.copyWith(canvas: color); + case VsCodeWorkbenchField.surface: + return w.copyWith(surface: color); + case VsCodeWorkbenchField.sidebarBackground: + return w.copyWith(sidebarBackground: color); + case VsCodeWorkbenchField.editorBackground: + return w.copyWith(editorBackground: color); + case VsCodeWorkbenchField.borderSubtle: + return w.copyWith(borderSubtle: color); + case VsCodeWorkbenchField.accent: + return w.copyWith(accent: color); + case VsCodeWorkbenchField.mutedForeground: + return w.copyWith(mutedForeground: color); + case VsCodeWorkbenchField.gitModified: + return w.copyWith(gitModified: color); + case VsCodeWorkbenchField.gitUntracked: + return w.copyWith(gitUntracked: color); + } +} + +QueryaEditorTheme _applyEditorField( + QueryaEditorTheme e, + VsCodeEditorField field, + Color color, +) { + switch (field) { + case VsCodeEditorField.background: + return e.copyWith(background: color); + case VsCodeEditorField.foreground: + return e.copyWith(foreground: color); + } +} diff --git a/lib/core/theme/parser/vscode_color_map.dart b/lib/core/theme/parser/vscode_color_map.dart new file mode 100644 index 00000000..bc910627 --- /dev/null +++ b/lib/core/theme/parser/vscode_color_map.dart @@ -0,0 +1,106 @@ +// Supported VS Code `colors` keys → Querya workbench / editor tokens. +// Unknown keys are ignored; see kSupportedVsCodeColorKeys and docs/theme-import.md. + +/// Workbench token updated from a VS Code color key. +enum VsCodeWorkbenchField { + canvas, + surface, + sidebarBackground, + editorBackground, + borderSubtle, + accent, + mutedForeground, + gitModified, + gitUntracked, +} + +/// Editor token updated from a VS Code color key. +enum VsCodeEditorField { + background, + foreground, +} + +/// Optional direct [ColorScheme] fields (shadcn) beyond workbench derivation. +enum VsCodeColorSchemeField { + foreground, + background, + card, + border, + input, + ring, + mutedForeground, + accent, +} + +/// Maps one VS Code `colors` entry to Querya tokens. +class VsCodeColorTarget { + const VsCodeColorTarget.workbench(this.workbench) + : editor = null, + colorScheme = null; + + const VsCodeColorTarget.editor(this.editor) + : workbench = null, + colorScheme = null; + + const VsCodeColorTarget.scheme(this.colorScheme) + : workbench = null, + editor = null; + + final VsCodeWorkbenchField? workbench; + final VsCodeEditorField? editor; + final VsCodeColorSchemeField? colorScheme; +} + +/// VS Code key → Querya target. Keys not listed are ignored. +const Map kVsCodeColorMap = { + 'editor.background': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.editorBackground, + ), + 'editor.foreground': VsCodeColorTarget.editor(VsCodeEditorField.foreground), + 'sideBar.background': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.sidebarBackground, + ), + 'sideBar.foreground': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.mutedForeground, + ), + 'activityBar.background': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.canvas, + ), + 'tab.activeBackground': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.surface, + ), + 'statusBar.background': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.canvas, + ), + 'panel.background': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.surface, + ), + 'focusBorder': VsCodeColorTarget.workbench(VsCodeWorkbenchField.accent), + 'input.background': VsCodeColorTarget.workbench(VsCodeWorkbenchField.surface), + 'list.hoverBackground': VsCodeColorTarget.scheme( + VsCodeColorSchemeField.accent, + ), + 'gitDecoration.modifiedResourceForeground': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.gitModified, + ), + 'gitDecoration.untrackedResourceForeground': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.gitUntracked, + ), +}; + +/// Documented subset of supported VS Code keys (stable API). +const List kSupportedVsCodeColorKeys = [ + 'editor.background', + 'editor.foreground', + 'sideBar.background', + 'sideBar.foreground', + 'activityBar.background', + 'tab.activeBackground', + 'statusBar.background', + 'panel.background', + 'focusBorder', + 'input.background', + 'list.hoverBackground', + 'gitDecoration.modifiedResourceForeground', + 'gitDecoration.untrackedResourceForeground', +]; diff --git a/test/core/theme/parser/color_parser_test.dart b/test/core/theme/parser/color_parser_test.dart index 78c4fdd9..014598de 100644 --- a/test/core/theme/parser/color_parser_test.dart +++ b/test/core/theme/parser/color_parser_test.dart @@ -10,7 +10,8 @@ void main() { }); test('8-digit RRGGBBAA', () { - expect(parseVsCodeColor('#11223344').alpha, 0x44); + final c = parseVsCodeColor('#11223344'); + expect((c.a * 255).round(), 0x44); }); test('3-digit shorthand', () { diff --git a/test/core/theme/parser/vscode_color_map_test.dart b/test/core/theme/parser/vscode_color_map_test.dart new file mode 100644 index 00000000..9f556af2 --- /dev/null +++ b/test/core/theme/parser/vscode_color_map_test.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +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_color_map.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('kVsCodeColorMap', () { + test('documents every mapped key in supported list', () { + for (final key in kVsCodeColorMap.keys) { + expect(kSupportedVsCodeColorKeys, contains(key)); + } + }); + }); + + group('buildQueryaThemeFromVsCodeManifest', () { + Future fixture(String name) async { + final path = 'test/fixtures/themes/$name'; + return File(path).readAsString(); + } + + test('dark_subset fixture maps workbench and editor', () async { + final src = await fixture('dark_subset.json'); + final manifest = VsCodeThemeManifest.fromJsonString(src); + final theme = buildQueryaThemeFromVsCodeManifest(manifest); + + expect(manifest.isDark, isTrue); + expect(theme.brightness, Brightness.dark); + expect(theme.workbench.editorBackground, const Color(0xFF1E1E1E)); + expect(theme.workbench.sidebarBackground, const Color(0xFF252526)); + expect(theme.editor.foreground, const Color(0xFFD4D4D4)); + expect(theme.workbench.canvas, const Color(0xFF007ACC)); + expect(theme.workbench.accent, const Color(0xFF007FD4)); + expect(theme.workbench.gitModified, const Color(0xFFE2C08D)); + expect(theme.workbench.gitUntracked, const Color(0xFF73C991)); + expect(theme.colorScheme.foreground, const Color(0xFFD4D4D4)); + }); + + test('light_subset fixture uses light brightness', () async { + final src = await fixture('light_subset.json'); + final manifest = VsCodeThemeManifest.fromJsonString(src); + final theme = buildQueryaThemeFromVsCodeManifest(manifest); + + expect(manifest.isLight, isTrue); + expect(theme.brightness, Brightness.light); + expect(theme.workbench.editorBackground, const Color(0xFFFFFFFF)); + expect(theme.workbench.sidebarBackground, const Color(0xFFF3F3F3)); + // `input.background` and `panel.background` both map to surface; last wins. + expect(theme.workbench.surface, const Color(0xFFFFFFFF)); + }); + + test('unknown keys are reported and defaults kept for unmapped tokens', () async { + final src = await fixture('with_unknown_keys.json'); + final manifest = VsCodeThemeManifest.fromJsonString(src); + final unknown = []; + final theme = buildQueryaThemeFromVsCodeManifest( + manifest, + onUnknownColorKey: unknown.add, + ); + + expect(unknown, contains('titleBar.activeBackground')); + expect(unknown, contains('workbench.colorCustomizations.unsupported')); + expect(theme.workbench.editorBackground, const Color(0xFF2D2D30)); + expect( + theme.workbench.destructive, + QueryaTheme.darkDefault.workbench.destructive, + ); + }); + + test('missing keys fall back to dark default', () async { + const src = ''' +{ + "type": "dark", + "colors": { + "editor.background": "#111111" + } +} +'''; + final manifest = VsCodeThemeManifest.fromJsonString(src); + final theme = buildQueryaThemeFromVsCodeManifest(manifest); + + expect(theme.workbench.editorBackground, const Color(0xFF111111)); + expect( + theme.workbench.accent, + QueryaTheme.darkDefault.workbench.accent, + ); + }); + }); +} diff --git a/test/fixtures/themes/dark_subset.json b/test/fixtures/themes/dark_subset.json new file mode 100644 index 00000000..ea32e77b --- /dev/null +++ b/test/fixtures/themes/dark_subset.json @@ -0,0 +1,14 @@ +{ + "name": "Fixture Dark Subset", + "type": "dark", + "colors": { + "editor.background": "#1e1e1e", + "editor.foreground": "#d4d4d4", + "sideBar.background": "#252526", + "sideBar.foreground": "#cccccc", + "statusBar.background": "#007acc", + "focusBorder": "#007fd4", + "gitDecoration.modifiedResourceForeground": "#e2c08d", + "gitDecoration.untrackedResourceForeground": "#73c991" + } +} diff --git a/test/fixtures/themes/light_subset.json b/test/fixtures/themes/light_subset.json new file mode 100644 index 00000000..38ea2f3a --- /dev/null +++ b/test/fixtures/themes/light_subset.json @@ -0,0 +1,10 @@ +{ + "name": "Fixture Light Subset", + "type": "light", + "colors": { + "editor.background": "#ffffff", + "sideBar.background": "#f3f3f3", + "panel.background": "#f8f8f8", + "input.background": "#ffffff" + } +} diff --git a/test/fixtures/themes/with_unknown_keys.json b/test/fixtures/themes/with_unknown_keys.json new file mode 100644 index 00000000..f5fc356e --- /dev/null +++ b/test/fixtures/themes/with_unknown_keys.json @@ -0,0 +1,9 @@ +{ + "name": "Fixture Unknown Keys", + "type": "dark", + "colors": { + "editor.background": "#2d2d30", + "workbench.colorCustomizations.unsupported": "#ff00ff", + "titleBar.activeBackground": "#3c3c3c" + } +}