From c260ff5df212c4cf5b7cbdad0074c6c0e3ccd355 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:37:29 +0300 Subject: [PATCH 1/2] feat(theme): document user themes folder and add open-folder control Explain app support themes paths in docs and surface a Preferences hint plus Open themes folder button so users know where to drop .json/.jsonc files. --- docs/theme-custom-json.md | 19 ++++++- docs/theme-import.md | 43 +++++++++++++-- lib/core/platform/open_directory.dart | 38 +++++++++++++ .../preferences_appearance_section.dart | 54 ++++++++++++++++++- 4 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 lib/core/platform/open_directory.dart diff --git a/docs/theme-custom-json.md b/docs/theme-custom-json.md index c2a09f49..b77f3991 100644 --- a/docs/theme-custom-json.md +++ b/docs/theme-custom-json.md @@ -4,7 +4,7 @@ Querya supports two theme file formats: | Format | Root marker | Import | Docs | |--------|-------------|--------|------| -| **Querya custom** | `"schema": "querya.theme.v1"` | Planned (theme registry) | this document | +| **Querya custom** | `"schema": "querya.theme.v1"` | **Supported** — registry / themes folder | this document | | **VS Code** | `"colors"` object (no `schema`) | **Supported today** | [theme-import.md](theme-import.md) | VS Code JSON/JSONC import remains fully supported. The custom format is a first-class @@ -261,13 +261,28 @@ Only required fields; all colors come from Querya Dark defaults: | Detection | No `schema`; has `colors` | `"schema": "querya.theme.v1"` | | UI colors | VS Code keys (`editor.background`, `sideBar.background`, …) | `shadcn_colors` + `editor_colors` Querya keys | | Stable id | File name only | Required `id` field | -| Import today | **Yes** — Preferences → Import theme | Planned via theme registry | +| Import today | **Yes** — themes folder or Preferences → Import theme | **Yes** — themes folder or Preferences → Import theme | | Syntax tokens | `tokenColors` | `tokenColors` (same) | To convert a VS Code theme manually, map keys using [theme-import.md](theme-import.md) and place workbench values into `editor_colors`; derive shadcn tokens from your palette or leave `{}` to use preset defaults. +## Installing custom themes + +1. Create a `.json` file with `"schema": "querya.theme.v1"` (see examples above). +2. Copy it into the app support **themes folder** (`{appSupport}/themes/`). See + [theme-import.md](theme-import.md) for platform-specific paths and the + **Open themes folder** button in Preferences. +3. In **Preferences → Appearance**, click **Refresh themes** and select your theme. + +The registry scans `.json` and `.jsonc` files on refresh; there is no live folder watcher. +Invalid files are skipped (logged in debug builds). Required fields: `schema`, `id`, +`name`, `type`, `shadcn_colors`, `editor_colors` (the color maps may be empty `{}`). + +Built-in bundled themes (under `assets/themes/`) ship with the app and do not require +manual installation. + ## Related docs - [Theme import (VS Code)](theme-import.md) diff --git a/docs/theme-import.md b/docs/theme-import.md index 6403ef83..675a25d0 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -52,16 +52,49 @@ Hex strings as in VS Code: `#RRGGBB`, `#RRGGBBAA`, `#RGB`, `#RGBA` (see Comments and trailing commas are stripped before parse (`stripJsonc`). -## Preferences UI (#43) +## Preferences UI In **Preferences → Appearance**: - **Theme mode** — Dark / Light / System -- **Color preset** — Querya Dark, Querya Light, or imported theme name -- **Import theme…** — pick `.json` / `.jsonc` (VS Code format) -- **Reset appearance** — clears import, overrides, returns to Querya Dark +- **Theme** — built-in presets, bundled themes, and themes from the user themes folder +- **Import theme…** — pick `.json` / `.jsonc` and copy into the themes folder +- **Refresh themes** — rescan the themes folder (no live file watcher) +- **Open themes folder** — reveal the app support `themes/` directory in the file manager +- **Reset appearance** — clears overrides and returns to Querya Dark -Imported files are copied to app data (`themes/imported.json`) and survive restarts. +## Where theme files live + +Querya loads themes from the **application support** directory (see +`lib/core/theme/theme_paths.dart`): + +| Location | Purpose | +|----------|---------| +| `{appSupport}/themes/` | User-installed themes (`.json`, `.jsonc`) | +| `{appSupport}/themes/imported/` | Legacy import subdirectory (still scanned) | +| `assets/themes/` (bundled) | Built-in themes shipped with the app (e.g. Cyberpunk Neon) | + +`{appSupport}` is the OS-specific support folder for Querya Desktop +(`com.example.querya_desktop`). Typical examples: + +| OS | Example path | +|----|----------------| +| Linux | `~/.local/share/com.example.querya_desktop/themes` | +| macOS | `~/Library/Application Support/com.example.querya_desktop/themes` | +| Windows | `%APPDATA%\com.example.querya_desktop\themes` | + +**Workflow:** copy or import a theme file into `themes/`, then click **Refresh themes** +in Preferences. The app does **not** watch the folder; a restart is not required after +refresh. + +**Accepted extensions:** `.json`, `.jsonc` (comments and trailing commas stripped before parse). + +**Import via file picker** copies the file into `themes/` (deduplicated by content hash; +duplicate ids get a numeric suffix). Themes picked up from disk use the file basename +(VS Code format) or the `id` field (Querya custom format) as the registry id. + +Legacy single-file import (`themes/imported.json` under older builds) is still migrated +into the registry on load when present. ## User overrides (#45) diff --git a/lib/core/platform/open_directory.dart b/lib/core/platform/open_directory.dart new file mode 100644 index 00000000..692b6fcb --- /dev/null +++ b/lib/core/platform/open_directory.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +/// Opens [directoryPath] in the platform file manager. +/// +/// Creates the directory first when missing. Returns `false` when the platform +/// opener is unavailable or reports failure. +Future openDirectoryInFileManager( + String directoryPath, { + Future Function(String path)? opener, +}) async { + final dir = Directory(directoryPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + final open = opener ?? _defaultOpener; + return open(directoryPath); +} + +Future _defaultOpener(String path) async { + try { + if (Platform.isLinux) { + final result = await Process.run('xdg-open', [path]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('open', [path]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + await Process.run('explorer', [path]); + return true; + } + return false; + } on Object { + return false; + } +} diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 5ee9b966..4f5e8159 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -2,9 +2,11 @@ import 'dart:async' show unawaited; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/platform/open_directory.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; import 'package:querya_desktop/core/theme/theme_load_result.dart'; +import 'package:querya_desktop/core/theme/theme_paths.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/features/settings/theme_picker_button.dart'; import 'package:querya_desktop/features/settings/theme_preview_card.dart'; @@ -23,7 +25,9 @@ class _PreferencesAppearanceSectionState extends material.State { final _controller = ThemeController.instance; String? _importError; + String? _folderOpenError; bool _importing = false; + bool _openingThemesFolder = false; @override void initState() { @@ -98,6 +102,25 @@ class _PreferencesAppearanceSectionState await _controller.loadAvailableThemes(); } + Future _openThemesFolder() async { + setState(() { + _openingThemesFolder = true; + _folderOpenError = null; + }); + try { + final dir = await ThemePaths.ensureUserThemesDirectory(); + final opened = await openDirectoryInFileManager(dir.path); + if (!mounted) return; + if (!opened) { + setState(() => _folderOpenError = 'Could not open themes folder.'); + } + } finally { + if (mounted) { + setState(() => _openingThemesFolder = false); + } + } + } + Future _setThemeAnimation(bool enabled) async { await _controller.setThemeAnimationEnabled(enabled); } @@ -163,6 +186,16 @@ class _PreferencesAppearanceSectionState ), ), ], + const material.SizedBox(height: 8), + const material.Padding( + padding: material.EdgeInsets.only(left: kPreferencesLabelWidth + 12), + child: PreferencesHint( + 'Themes are loaded from the app support themes folder. ' + 'Drop .json or .jsonc files there, then use Refresh themes. ' + 'The folder is not watched automatically.', + ), + ), + const material.SizedBox(height: 12), const PreferencesFieldRow( label: 'Interface scale', hint: @@ -210,6 +243,14 @@ class _PreferencesAppearanceSectionState refreshingThemes ? 'Refreshing…' : 'Refresh themes', ), ), + OutlineButton( + onPressed: (_importing || _openingThemesFolder) + ? null + : () => unawaited(_openThemesFolder()), + child: material.Text( + _openingThemesFolder ? 'Opening…' : 'Open themes folder', + ), + ), OutlineButton( onPressed: () => unawaited(_resetAppearance()), child: const Text('Reset appearance'), @@ -226,9 +267,20 @@ class _PreferencesAppearanceSectionState ), ), ], + if (_folderOpenError != null) ...[ + const material.SizedBox(height: 8), + material.Text( + _folderOpenError!, + style: material.TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.destructive, + ), + ), + ], const material.SizedBox(height: 4), const PreferencesHint( - 'Import VS Code theme JSON/JSONC (.colors subset). Changes apply immediately.', + 'Import copies a theme into the themes folder. ' + 'VS Code JSON/JSONC (.colors subset) and Querya custom JSON are supported.', ), ], ); From 63a1d702c269b21f7b53319061b42e45e7ccee4f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:37:30 +0300 Subject: [PATCH 2/2] test(theme): cover themes folder hint and open-directory helper Closes #120 --- test/core/platform/open_directory_test.dart | 53 +++++++++++++++++++ .../preferences_appearance_section_test.dart | 13 +++++ 2 files changed, 66 insertions(+) create mode 100644 test/core/platform/open_directory_test.dart diff --git a/test/core/platform/open_directory_test.dart b/test/core/platform/open_directory_test.dart new file mode 100644 index 00000000..975add89 --- /dev/null +++ b/test/core/platform/open_directory_test.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/platform/open_directory.dart'; + +void main() { + group('openDirectoryInFileManager', () { + test('creates missing directory before delegating to opener', () async { + final root = await Directory.systemTemp + .createTemp('querya_open_directory_test_'); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + final target = p.join(root.path, 'themes'); + String? openedPath; + + final opened = await openDirectoryInFileManager( + target, + opener: (path) async { + openedPath = path; + return true; + }, + ); + + expect(opened, isTrue); + expect(openedPath, target); + expect(await Directory(target).exists(), isTrue); + }); + + test('returns false when opener reports failure', () async { + final root = await Directory.systemTemp + .createTemp('querya_open_directory_fail_test_'); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + final target = p.join(root.path, 'themes'); + final opened = await openDirectoryInFileManager( + target, + opener: (_) async => false, + ); + + expect(opened, isFalse); + expect(await Directory(target).exists(), isTrue); + }); + }); +} diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart index 547c938e..936bd2f1 100644 --- a/test/features/settings/preferences_appearance_section_test.dart +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -120,7 +120,20 @@ void main() { expect(find.text('Import theme…'), findsOneWidget); expect(find.text('Refresh themes'), findsOneWidget); + expect(find.text('Open themes folder'), findsOneWidget); expect(find.text('Reset appearance'), findsOneWidget); }); + + testWidgets('shows themes folder hint without live reload promise', + (tester) async { + await pumpSection(tester); + + expect( + find.textContaining('Themes are loaded from the app support themes folder'), + findsOneWidget, + ); + expect(find.textContaining('not watched automatically'), findsOneWidget); + expect(find.textContaining('live reload'), findsNothing); + }); }); }