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
19 changes: 17 additions & 2 deletions docs/theme-custom-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 38 additions & 5 deletions docs/theme-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
38 changes: 38 additions & 0 deletions lib/core/platform/open_directory.dart
Original file line number Diff line number Diff line change
@@ -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<bool> openDirectoryInFileManager(
String directoryPath, {
Future<bool> 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<bool> _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;
}
}
54 changes: 53 additions & 1 deletion lib/features/settings/preferences_appearance_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,7 +25,9 @@ class _PreferencesAppearanceSectionState
extends material.State<PreferencesAppearanceSection> {
final _controller = ThemeController.instance;
String? _importError;
String? _folderOpenError;
bool _importing = false;
bool _openingThemesFolder = false;

@override
void initState() {
Expand Down Expand Up @@ -98,6 +102,25 @@ class _PreferencesAppearanceSectionState
await _controller.loadAvailableThemes();
}

Future<void> _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<void> _setThemeAnimation(bool enabled) async {
await _controller.setThemeAnimationEnabled(enabled);
}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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'),
Expand All @@ -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.',
),
],
);
Expand Down
53 changes: 53 additions & 0 deletions test/core/platform/open_directory_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
13 changes: 13 additions & 0 deletions test/features/settings/preferences_appearance_section_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}
Loading