diff --git a/.cursor/rules/gitflow.md b/.cursor/rules/gitflow.md index 1cbdce4d..28f9a773 100644 --- a/.cursor/rules/gitflow.md +++ b/.cursor/rules/gitflow.md @@ -72,16 +72,47 @@ git checkout -b issue/38-querya-workbench-theme-models ## GitHub labels & milestones -На **issues** и **PRs** через `gh`: +### Milestone: **Theme system** + +Epic [#37](https://github.com/QueryaHub/Querya-Desktop/issues/37) и дочерние issues **#38–#60** (кроме закрытого #56) — milestone [Theme system](https://github.com/QueryaHub/Querya-Desktop/milestone/1). + +Новые theme-issues: label `theme` → workflow [issue-theme-milestone.yml](.github/workflows/issue-theme-milestone.yml) проставит milestone автоматически. Шаблон: [.github/ISSUE_TEMPLATE/theme_task.yml](.github/ISSUE_TEMPLATE/theme_task.yml). + +### PR: labels + milestone (автоматика) + +1. Ветка **`issue/-`** (рекомендуется), например `issue/38-workbench-theme-models`. +2. В PR body: **`Closes #38`** (или Fixes/Resolves). +3. В title опционально: `feat(theme): … (#38)`. + +Workflow [pr-linked-issue-metadata.yml](.github/workflows/pr-linked-issue-metadata.yml) копирует **все labels** и **milestone** с linked issue на PR при open/edit/sync. + +Шаблон PR: [.github/pull_request_template.md](.github/pull_request_template.md). + +### Ручное создание PR (если автоматика не сработала) + +```bash +gh pr create --base dev \ + --milestone "Theme system" \ + --label "theme,enhancement" \ + --title "feat(theme): QueryaWorkbenchTheme models (#38)" \ + --body "$(cat <<'EOF' +## Summary +… + +Closes #38 +EOF +)" +``` + +Для editor-задач добавь label `editor`: `--label "theme,editor,enhancement"`. + +### Issues ```bash -gh issue edit 38 --add-label "theme,enhancement" -gh pr create --base dev --label "theme,enhancement" \ - --body "Closes #38" --title "feat(theme): QueryaWorkbenchTheme models (#38)" +gh issue edit 38 --milestone "Theme system" --add-label "theme,enhancement" ``` -Актуальные labels в репо: `bug`, `enhancement`, `documentation`, `theme`, `editor`, `epic`, … -Для темизации: `theme`, `editor`, `epic` (см. epic [#37](https://github.com/QueryaHub/Querya-Desktop/issues/37)). +Labels: `bug`, `enhancement`, `documentation`, `theme`, `editor`, `epic`. После merge: issue `CLOSED`; `git fetch` + `git pull --ff-only` на `dev`. diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 1d9188ed..e58e7712 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"file_selector_ios","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_ios-0.5.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"file_selector_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_android-0.5.2+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[{"name":"file_selector_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_web-0.9.4+2/","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"file_selector","dependencies":["file_selector_android","file_selector_ios","file_selector_linux","file_selector_macos","file_selector_web","file_selector_windows"]},{"name":"file_selector_android","dependencies":[]},{"name":"file_selector_ios","dependencies":[]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_web","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-04-24 15:57:28.697051","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_ios","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_ios-0.5.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"android":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_android-0.5.2+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"web":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","dependencies":[],"dev_dependency":false},{"name":"file_selector_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_web-0.9.4+2/","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","dependencies":["device_info_plus"],"dev_dependency":false}]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"device_info_plus","dependencies":[]},{"name":"file_selector","dependencies":["file_selector_android","file_selector_ios","file_selector_linux","file_selector_macos","file_selector_web","file_selector_windows"]},{"name":"file_selector_android","dependencies":[]},{"name":"file_selector_ios","dependencies":[]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_web","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"irondash_engine_context","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"super_native_extensions","dependencies":["irondash_engine_context","device_info_plus"]}],"date_created":"2026-05-28 10:58:45.738032","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/theme_task.yml b/.github/ISSUE_TEMPLATE/theme_task.yml new file mode 100644 index 00000000..0d90ac3a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/theme_task.yml @@ -0,0 +1,47 @@ +name: Theme system task +description: Task under epic #37 (theming / editor) +title: "Theme: " +labels: + - theme + - enhancement +projects: [] +body: + - type: markdown + attributes: + value: | + Epic: [#37 — Theme system](https://github.com/QueryaHub/Querya-Desktop/issues/37) + Milestone: **Theme system** + - type: textarea + id: summary + attributes: + label: Summary + description: One paragraph — what and why + validations: + required: true + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: Checkbox list + value: | + - [ ] + validations: + required: true + - type: dropdown + id: phase + attributes: + label: Phase + options: + - "0 — Foundation" + - "1 — App & UI" + - "2 — Editor MVP" + - "3 — Advanced" + validations: + required: true + - type: checkboxes + id: labels_extra + attributes: + label: Extra labels + options: + - label: editor + - label: documentation diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..f57e7412 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,37 @@ +## Summary + + + +## Linked issue + + + +Closes # + +## Checklist + +- [ ] `flutter analyze` and `flutter test` pass locally +- [ ] Scope matches the linked issue only (no drive-by refactors) +- [ ] PR targets **`dev`** (not `main`, unless hotfix) + +## Metadata (automation) + +If the branch is `issue/-…` or the body contains `Closes #n`, workflow **[PR linked issue metadata](.github/workflows/pr-linked-issue-metadata.yml)** copies **labels** and **milestone** from the linked issue onto this PR. + +For **Theme system** work, ensure the issue has milestone **Theme system** and labels such as `theme`, `editor`, `enhancement`. + +Manual override when creating the PR: + +```bash +gh pr create --base dev \ + --milestone "Theme system" \ + --label "theme,enhancement" \ + --title "feat(theme): short description (#NN)" \ + --body "$(cat <<'EOF' +## Summary +... + +Closes #NN +EOF +)" +``` diff --git a/.github/workflows/issue-theme-milestone.yml b/.github/workflows/issue-theme-milestone.yml new file mode 100644 index 00000000..fad99d42 --- /dev/null +++ b/.github/workflows/issue-theme-milestone.yml @@ -0,0 +1,50 @@ +# New issues with label `theme` → milestone Theme system +name: Issue theme milestone + +on: + issues: + types: [opened, labeled] + +permissions: + issues: write + +jobs: + assign-milestone: + runs-on: ubuntu-latest + steps: + - name: Set Theme system milestone on theme issues + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + if (!issue?.labels) return; + + const hasTheme = issue.labels.some( + (l) => (typeof l === 'string' ? l : l.name) === 'theme' + ); + if (!hasTheme) return; + + if (issue.milestone?.title === 'Theme system') { + core.info('Milestone already set.'); + return; + } + + const owner = context.repo.owner; + const repo = context.repo.repo; + const milestones = await github.paginate( + github.rest.issues.listMilestones, + { owner, repo, state: 'open', per_page: 100 } + ); + const milestone = milestones.find((m) => m.title === 'Theme system'); + if (!milestone) { + core.setFailed('Milestone "Theme system" not found'); + return; + } + + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + milestone: milestone.number, + }); + core.info(`Milestone Theme system set on #${issue.number}`); diff --git a/.github/workflows/pr-linked-issue-metadata.yml b/.github/workflows/pr-linked-issue-metadata.yml new file mode 100644 index 00000000..c977d3cc --- /dev/null +++ b/.github/workflows/pr-linked-issue-metadata.yml @@ -0,0 +1,103 @@ +# Copy milestone + labels from linked issues onto the PR. +# Triggers: branch issue/-*, PR title/body "Closes #n", etc. +name: PR linked issue metadata + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: write + issues: read + +jobs: + apply-from-linked-issues: + runs-on: ubuntu-latest + steps: + - name: Apply milestone and labels from linked issues + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const owner = context.repo.owner; + const repo = context.repo.repo; + + const issueIds = new Set(); + + // issue/38-short-slug + const branchMatch = pr.head.ref.match(/^issue\/(\d+)(?:-|$)/i); + if (branchMatch) issueIds.add(Number(branchMatch[1])); + + const body = pr.body || ''; + const title = pr.title || ''; + + for (const m of body.matchAll( + /(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)/gi + )) { + issueIds.add(Number(m[1])); + } + + // feat(theme): short description (#38) + const titleTail = title.match(/\(#(\d+)\)\s*$/); + if (titleTail) issueIds.add(Number(titleTail[1])); + + if (issueIds.size === 0) { + core.info('No linked issue numbers found; skipping.'); + return; + } + + const labelNames = new Set(); + let milestoneTitle = null; + + for (const num of issueIds) { + let issue; + try { + const res = await github.rest.issues.get({ + owner, + repo, + issue_number: num, + }); + issue = res.data; + } catch (e) { + core.warning(`Issue #${num} not found: ${e.message}`); + continue; + } + + for (const l of issue.labels) { + if (typeof l === 'string') labelNames.add(l); + else if (l.name) labelNames.add(l.name); + } + + if (issue.milestone?.title) { + milestoneTitle = issue.milestone.title; + } + } + + if (labelNames.size > 0) { + await github.rest.issues.setLabels({ + owner, + repo, + issue_number: pr.number, + labels: [...labelNames], + }); + core.info(`Labels set: ${[...labelNames].join(', ')}`); + } + + if (milestoneTitle) { + const milestones = await github.paginate( + github.rest.issues.listMilestones, + { owner, repo, state: 'open', per_page: 100 } + ); + const milestone = milestones.find((m) => m.title === milestoneTitle); + if (milestone) { + await github.rest.issues.update({ + owner, + repo, + issue_number: pr.number, + milestone: milestone.number, + }); + core.info(`Milestone set: ${milestoneTitle}`); + } else { + core.warning(`Milestone not found: ${milestoneTitle}`); + } + } diff --git a/CHANGELOG.md b/CHANGELOG.md index 356684e0..1b4efe13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-05-28 + +### Added + +- **Theme system** — runtime dark/light/system modes, **Querya Light** preset, optional **Animate theme changes** in Preferences. +- **VS Code themes** — import `.json` / `.jsonc` (`colors` + `tokenColors`); user color overrides; persisted imported theme. +- **Syntax highlighting** — SQL and JSON in `QueryaCodeEditor` via `syntax_highlight`; `tokenColors` mapped to TextMate scopes; isolate highlight for large buffers. +- **Editor** — `QueryaCodeEditor` abstraction, `SqlEditorChrome` from theme tokens, `QueryaThemeScope` for workbench/editor tokens. +- **Samples** — `themes/samples/cyberpunk-neon.json` (+ JSONC) for manual import testing. +- **Docs** — [docs/theme.md](docs/theme.md), [docs/theme-import.md](docs/theme-import.md), [docs/editor-spike-report.md](docs/editor-spike-report.md), [docs/code-forge-evaluation.md](docs/code-forge-evaluation.md). + +### Changed + +- **Workbench UI** — P0 surfaces (sidebar, SQL chrome, empty hero, main shell) use design tokens instead of hardcoded `QueryaColors`. +- **Preferences** — Appearance section (theme mode, preset, import, animation); Material `DropdownMenu` for stable menus in scrollable dialogs. +- **Imported themes** — `mutedForeground` clamped for readable helper text when VS Code sidebar colors are low-contrast. + +### Fixed + +- **Preferences (dark / imported themes)** — readable labels and dropdown text via `materialTheme` + `popoverForeground` hints. +- **Preferences dropdown** — Color preset menu no longer stretches full screen (`expandedInsets` instead of `width: infinity`). +- **Dialogs / SQL editor** — `QueryaThemeScope` on `ShadcnApp.builder` so overlays (Preferences, SQL editor) resolve theme tokens. + ## [0.2.0] - 2026-04-24 ### Added diff --git a/README.md b/README.md index f807d0a3..e8d34c75 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ flutter build macos ## More documentation - [Security / local data](docs/security.md) +- [Theme system](docs/theme.md) - [User guide](docs/user-guide.md) - [Releases](docs/tags-and-releases.md) - [Release checklist](docs/release-checklist.md) diff --git a/docs/code-forge-evaluation.md b/docs/code-forge-evaluation.md new file mode 100644 index 00000000..5824fe27 --- /dev/null +++ b/docs/code-forge-evaluation.md @@ -0,0 +1,188 @@ +# code_forge & LSP evaluation (#52) + +**Date:** 2026-05-28 +**Status:** Spike complete — **NO-GO** as primary editor for Querya 0.3.x +**Conditional:** separate **Editor LSP** epic only if product triggers below are met +**Prerequisite docs:** [editor-spike-report.md](editor-spike-report.md) (#48), theme milestone (#37–#57) + +## Executive summary + +| Question | Answer | +|----------|--------| +| Replace `syntax_highlight` + `QueryaCodeEditor` now? | **No** — MVP stack meets theme milestone goals | +| Adopt `code_forge` before `re_editor`? | **No** — higher cost, weaker VS Code theme fidelity | +| Start an LSP epic via `code_forge`? | **Only if** inline diagnostics / semantic tokens / 10k+ line editing become P0 | +| Recommended next editor step | **`re_editor` spike** when benchmarks fail (see #58 / paste scenarios) | + +**Decision:** **NO-GO** for integrating `code_forge` in the current release line. Document a deferred epic breakdown for a future “professional SQL IDE” phase. + +--- + +## Context (post–theme milestone) + +Querya Desktop today: + +``` +ThemeController → QueryaThemeScope +SqlEditorChrome → QueryaCodeEditor + ├─ TextField (shadcn/material) + └─ QueryaHighlightController + syntax_highlight (SQL/JSON) + ├─ HighlighterTheme from QueryaEditorTheme + tokenColors (#46) + └─ isolate highlight for buffers ≥ 8 KB +``` + +Delivered: #47 abstraction, #49/#50 highlighting, #46 tokenColors, #58 tests, theme docs. + +`code_forge` (^10.x) is a **full editor widget** with a **Rust FFI backend** (rope, folding, LSP client, optional AI completion). It is not a highlighter-only layer. + +--- + +## Spike questions (from #52) + +### 1. Does it fit `QueryaCodeEditor` (#47)? + +**Partially — not as a drop-in backend.** + +| Aspect | `syntax_highlight` (current) | `code_forge` | +|--------|------------------------------|--------------| +| Integration | `QueryaHighlightController` extends `TextEditingController`; same chrome (`SqlEditorChrome`) | `CodeForge` + `CodeForgeController`; own `RenderBox`, gutter, popups | +| External controller | Works with workspace-owned `TextEditingController` | `CodeForgeController` — sync layer needed | +| Widget tree | Stays inside shadcn `TextField` | Parallel editor subtree; risk of double chrome | + +**Verdict:** A new backend would **replace** the editor body, not extend `HighlightingCodeEditorBackend` from #48. Estimate **2–3 weeks** for adapter + theme bridge + workspace wiring, not a small PR. + +### 2. Gutter / line numbers vs `QueryaEditorTheme` + +`code_forge` themes target **re_highlight** class names (`atomOneDarkTheme`, etc.), not TextMate scopes or imported VS Code JSON. + +| Token source | Maps cleanly to Querya import? | +|--------------|-------------------------------| +| `QueryaEditorTheme` flat fields | Manual map → `EditorTheme` / gutter config | +| VS Code `tokenColors` | **No** 1:1 — same gap as `re_editor` | +| VS Code `colors` (workbench) | N/A for editor chrome inside CodeForge | + +**Verdict:** Acceptable for a **fixed Querya palette**, poor for **user-imported VS Code themes** unless we build a second adapter (high effort). + +### 3. SQL LSP — available vs lex-only highlight + +| Engine | Role | Fit for Querya | +|--------|------|----------------| +| `syntax_highlight` | TextMate lex highlight | **Current** — no server, works offline | +| [Postgres Language Server](https://pg-language-server.com/) | LSP: complete, diagnostics, lint | **PG workspaces** — external binary (`postgres-language-server` / `@postgrestools/postgrestools`) | +| MySQL | No mainstream SQL LSP with MySQL parser parity | Lex-only or custom later | +| Mongo (JSON) | `json` LSP exists; not SQL | Keep JSON grammar in editor | + +`code_forge` provides an **LSP client** (stdio / WebSocket); Querya would still own **process management** (spawn per connection, env, cwd, shutdown, stderr logs). + +**Verdict:** LSP is **orthogonal** to choosing `code_forge`. We could run PLS with a lighter editor (`re_editor`) + `flutter_lsp` / custom client later. **Do not adopt code_forge solely for LSP.** + +### 4. Bundle size and native dependencies + +| Dependency | Impact | +|------------|--------| +| `await RustLib.init()` in `main()` | Required; FFI to Rust rope/sum-tree | +| Rust toolchain | **All devs + CI** need `rustup`; release builds use AOT (debug noted 60% slower on large files) | +| Platform artifacts | Per-target native libs in Flutter build | +| External LSP binaries | Additional download/bundle (PG LS ~tens of MB per platform) | + +Querya already ships **FFI** (`sqflite_common_ffi`, DB drivers). Adding **editor Rust** + **LSP servers** is a step-change in release engineering. + +**Verdict:** Acceptable for a dedicated IDE phase; **too heavy** for theme/editor MVP closure. + +### 5. AI completion in the package — do we need it? + +`code_forge` advertises multi-model AI completion. Querya is a **database client**, not an AI IDE. + +**Verdict:** **Out of scope** — disable in config; do not factor into buy decision. + +--- + +## Comparison matrix (2026-05) + +Scores 1–5 (higher = better for Querya today). + +| Criterion | syntax_highlight (now) | re_editor | code_forge | +|-----------|------------------------|-----------|------------| +| VS Code import fidelity | **5** | 2 | 2 | +| SQL + JSON editing | **4** | **4** | **4** | +| 10k+ lines | 3 (isolate) | **5** | **5** | +| shadcn / SqlEditorChrome fit | **5** | 3 | 2 | +| Integration cost | **5** | 3 | 1 | +| LSP / diagnostics | 1 | 1 | **5** | +| Build / ops complexity | **5** | **4** | 2 | + +**Weighted for Querya 0.3:** keep **syntax_highlight**; plan **re_editor** before **code_forge**. + +--- + +## Triggers to reopen (go/no-go gates) + +Re-evaluate `code_forge` or a full LSP stack when **all** are true: + +1. **Perf:** p95 keystroke-to-paint > 100 ms on 5k-line SQL with isolate + debounce (#58 benchmark). +2. **Product:** inline SQL diagnostics or semantic tokens are **P0** on the roadmap. +3. **Engineering:** team commits to Rust in CI + bundling PG Language Server (or equivalent) per OS. + +If only (1) is true → spike **re_editor** first (lower risk). + +--- + +## Recommendation + +### NO-GO (now) + +- Do **not** add `code_forge` to `pubspec.yaml` for 0.3.x. +- Do **not** block theme epic closure on LSP. + +### YES (keep) + +- `syntax_highlight` + `QueryaCodeEditor` + `tokenColors` bridge. +- Isolate threshold `kSyntaxHighlightIsolateThreshold = 8192`. +- Optional **re_editor** benchmark branch when users report large-script lag. + +### CONDITIONAL epic (later) + +If triggers above fire, prefer epic **“Editor platform (LSP)”** rather than a theme sub-task: + +| Slice | Scope | Estimate | +|-------|--------|----------| +| E1 | Editor backend decision (`re_editor` vs `code_forge`) from benchmark | 3–5 d | +| E2 | `QueryaEditorTheme` → editor package theme adapter | 5–8 d | +| E3 | `LspProcessManager` (stdio spawn, lifecycle, logging) | 5–8 d | +| E4 | Postgres: PLS config UI + connection-scoped server | 8–13 d | +| E5 | Diagnostics gutter + quick fixes in SQL tabs | 8–13 d | +| E6 | MySQL/Mongo: lex-only or separate spikes | TBD | +| E7 | Packaging: CI Rust + LSP binaries for Linux/macOS/Windows | 5–8 d | + +**Only if E1 selects `code_forge`:** add E0 `RustLib.init()`, FFI crash telemetry, profile-only perf tests. + +Total rough order: **~6–10 weeks** engineering (not 3–5 day spike). + +--- + +## Manual smoke (not run in CI) + +Spike did **not** add `code_forge` to the repo (avoids Rust CI). If revisiting: + +1. `flutter create` sample + `code_forge: ^10.0.1` + `RustLib.init()`. +2. Load 50k-line SQL paste — measure frame time profile vs release. +3. Map `QueryaEditorTheme.darkDefault` colors to `editorTheme` — screenshot diff vs VS Code import. +4. Spawn `postgres-language-server lsp-proxy` — verify completions on `SELECT `. +5. Toggle theme during edit — check gutter/selection colors. + +Record results in a comment on #52 when executed. + +--- + +## References + +- [code_forge on pub.dev](https://pub.dev/packages/code_forge) — Rust backend, LSP, no Flutter web +- [editor-spike-report.md](editor-spike-report.md) — MVP choice `syntax_highlight` +- [Postgres Language Server](https://github.com/supabase-community/postgres-language-server) +- Querya: `lib/core/editor/querya_code_editor.dart`, `syntax_highlight_isolate.dart` + +## Decision log + +| Date | Decision | +|------|----------| +| 2026-05-28 | **NO-GO** primary adoption; defer LSP epic; **re_editor** before `code_forge` | diff --git a/docs/editor-spike-report.md b/docs/editor-spike-report.md new file mode 100644 index 00000000..33248a21 --- /dev/null +++ b/docs/editor-spike-report.md @@ -0,0 +1,172 @@ +# Editor package spike report (#48) + +**Date:** 2026-05-28 +**Context:** Querya Desktop — Flutter desktop SQL/NoSQL client with `QueryaCodeEditor` (#47), `QueryaEditorTheme`, VS Code theme import (#43–#44). + +## Goal + +Choose an MVP highlighting/editor backend for **#49** (SQL) and **#50** (JSON), and a Phase 2 candidate if we outgrow MVP. + +## Candidates evaluated + +| Package | Pub | License | Maintainer signal | +|---------|-----|---------|-------------------| +| [syntax_highlight](https://pub.dev/packages/syntax_highlight) | ^0.5.0 | BSD-3 | Serverpod, active | +| [re_editor](https://pub.dev/packages/re_editor) + [re_highlight](https://pub.dev/packages/re_highlight) | ^0.8.0 | MIT | Reqable, active (2026) | +| [flutter_code_editor](https://pub.dev/packages/flutter_code_editor) | ^0.3.5 | Apache-2 / MIT | Akvelon, moderate | +| [code_forge](https://pub.dev/packages/code_forge) | ^9.9.0 | (see pub) | Active, large scope | + +`flutter_syntax_highlighter` was excluded: display-only widget, not an editable editor. + +## Scorecard (1–5, higher is better for Querya) + +| Criterion | syntax_highlight | re_editor | flutter_code_editor | code_forge | +|-----------|------------------|-----------|---------------------|------------| +| VS Code theme fidelity | **5** — TextMate scopes + `HighlighterTheme` | 2 — highlight.js themes | 2 — highlight.dart themes | 3 — VS themes via re_highlight, not imported JSON | +| SQL quality | **4** — built-in `sql` grammar | **4** — sql in re_highlight | **4** — highlight languages | **4** | +| JSON quality | **4** — built-in `json` | **4** | **4** | **4** | +| 10k lines perf (editing) | 2 — highlighter only; editor = ours | **5** — custom layout, large text | 3 — CodeField on controller | **5** — rope, 100k+ claimed | +| Desktop focus / shortcuts | 3 — we own input | **5** — shortcuts, IME, folding | 4 | **5** | +| shadcn / Querya chrome fit | **5** — returns `TextSpan`, wraps in `QueryaCodeEditor` | 3 — own `CodeEditor` chrome | 3 — Material `CodeField` | 2 — full widget tree, heavy styling | +| License / maintenance | **5** / **4** | **5** / **4** | **4** / **3** | **3** / **3** (scope creep) | +| Integration cost vs #47 | **5** — incremental backend | 2 — replace widget | 3 — parallel stack | 1 — new controller + LSP surface | +| **Weighted total** | **~4.2** | **~3.5** | **~3.1** | **~3.0** | + +## Test scenarios (planned manual matrix) + +| # | Scenario | MVP expectation (syntax_highlight + TextField) | Phase 2 trigger | +|---|----------|--------------------------------------------------|-----------------| +| 1 | PG query ~200 lines | OK with debounced highlight in isolate | Jank >100ms keystroke | +| 2 | JSON document edit/save | OK; grammar + `QueryaEditorTheme` colors | Large single-line JSON | +| 3 | Paste 5000 lines | Risk: full re-highlight; **must** debounce + isolate | Switch to re_editor/code_forge | +| 4 | Theme switch during edit | OK — rebuild `HighlighterTheme` from `QueryaEditorTheme` / token map (#46) | — | + +No automated perf numbers in this spike; recommend a **#58** benchmark before Phase 2. + +## Architecture fit + +Current stack: + +``` +QueryaApp → QueryaThemeScope → SqlEditorChrome → QueryaCodeEditor (TextField MVP) +ThemeController → QueryaEditorTheme (+ VS Code colors import) +``` + +### Option A — syntax_highlight behind QueryaCodeEditor (recommended MVP) + +``` +QueryaCodeEditor + └─ TextFieldCodeEditorBackend (current) + └─ HighlightingCodeEditorBackend (#49) + └─ syntax_highlight.Highlighter → TextSpan overlay / custom EditableText layer +``` + +**Pros** + +- Aligns with existing VS Code theme pipeline (`tokenColors` → #46, `colors` → workbench). +- SQL + JSON grammars included; extend via grammar JSON drop-in. +- Smallest diff from #47; workspaces stay decoupled from editor package. + +**Cons** + +- Not a full editor: line numbers, folding, multi-cursor → custom or later package. +- Large files need **isolate** highlight (#46 / performance issue). + +### Option B — re_editor + theme adapter + +Full editor widget; map `QueryaEditorTheme` → `CodeHighlightTheme` (highlight.js class names, **not** TextMate scopes). + +**Pros:** Performance, folding, search, production-tested in Reqable. +**Cons:** Imported VS Code `.json` themes do not apply 1:1; higher migration cost from `QueryaCodeEditor`. + +### Option C — code_forge + +IDE-grade (LSP, semantic tokens, AI). Uses `re_highlight` for syntax; **desktop-only** (`dart:io`), no web. + +**Pros:** Future-proof for LSP/diagnostics. +**Cons:** Heavy; theming ≠ Querya import path; large API surface; overkill for MVP SQL client. + +### Option D — flutter_code_editor + +Mature `CodeField`, but **highlight.js** themes — same VS Code fidelity problem as re_highlight. + +## Recommendation + +### MVP — **#49 / #50: `syntax_highlight`** + +1. Add `HighlightingCodeEditorBackend` inside `QueryaCodeEditor`. +2. `await Highlighter.initialize(['sql', 'json'])`. +3. Build `HighlighterTheme` from `QueryaEditorTheme` (+ later `tokenColors` in #46). +4. Debounce highlight (50–100ms); run `highlight()` in **compute/isolate** for buffers >500 lines. +5. Keep `TextField` input path until highlight layer is stable. + +### Phase 2 candidate — **`re_editor`** + +Re-evaluate if: + +- Users report lag on 5k+ line paste, or +- We need folding / block comments / bracket matching in-editor. + +Plan: spike branch with `re_editor` only for `QueryEditorTab`, keep dialogs on MVP backend until theme adapter exists. + +### Defer — **code_forge** + +Track for a dedicated epic (LSP, diagnostics, multi-language). Not blocking Querya 0.3 theme milestone. + +**Update (#52):** See [code-forge-evaluation.md](code-forge-evaluation.md) — **NO-GO** for 0.3.x; **re_editor** before `code_forge`; conditional LSP epic ~6–10 weeks if product triggers fire. + +### Defer — **flutter_code_editor** + +No advantage over syntax_highlight for VS Code theme fidelity. + +## Proposed implementation slices + +| Issue | Package | Scope | +|-------|---------|--------| +| **#49** | syntax_highlight | SQL in `QueryEditorTab`, PG/MySQL workspace | +| **#50** | syntax_highlight | JSON in `MongoDocumentEditor` | +| **#46** | syntax_highlight | `tokenColors` → `HighlighterTheme` bridge | +| **#58** | — | Benchmarks + widget tests for theme switch | +| Phase 2 | re_editor | Replace backend if benchmarks fail | + +## Integration sketch (MVP) + +```dart +// lib/core/editor/highlighting_code_editor_backend.dart (future #49) +final theme = HighlighterTheme.fromQueryaEditorTheme(context.editorTheme); +final highlighter = Highlighter(language: 'sql', theme: theme); + +// On text change (debounced): +final span = await compute( + (args) => args.highlighter.highlight(args.text), + _HighlightArgs(highlighter, text), +); +// Apply span via RichText layer or package CodeEditor widget when upgrading +``` + +`HighlighterTheme.fromQueryaEditorTheme` maps `QueryaEditorTheme` token hues to TextMate scopes (keyword, string, comment, …) — detail in #46. + +## Risks and mitigations + +| Risk | Mitigation | +|------|------------| +| Main-isolate jank on highlight | Isolate + debounce; cap sync highlight at N lines | +| VS Code theme only sets `colors`, not `tokenColors` | Fall back to `QueryaEditorTheme` defaults (#38) | +| shadcn TextField vs highlight repaint | Start with highlight-on-idle; consider `syntax_highlight` `CodeEditor` widget in #49 PR if needed | +| Duplicate editor stacks | Single entry: `QueryaCodeEditor` only | + +## Decision + +| Phase | Choice | +|-------|--------| +| **MVP (#49, #50)** | **syntax_highlight** behind `QueryaCodeEditor` | +| **Phase 2** | **re_editor** if perf/feature gap | +| **Not now** | code_forge, flutter_code_editor as primary | + +## References + +- [syntax_highlight](https://pub.dev/packages/syntax_highlight) — TextMate, SQL/JSON grammars +- [re_editor](https://pub.dev/packages/re_editor) — Reqable editor widget +- [code_forge](https://pub.dev/packages/code_forge) — LSP + rope editor +- [flutter_code_editor](https://pub.dev/packages/flutter_code_editor) — CodeField + highlight +- Querya: `docs/theme-import.md`, `lib/core/editor/querya_code_editor.dart` diff --git a/docs/roadmap.md b/docs/roadmap.md index 46731e04..2155d6d3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,6 +2,14 @@ Living document for planned work. Not a commitment order; adjust as priorities change. +## Theme system + +- **Shipped in 0.3.0 (epic #37):** runtime themes, VS Code `colors` + `tokenColors` import, SQL/JSON + highlighting, P0 workbench migration, Preferences, tests, docs — [theme.md](theme.md). +- **Optional:** Preferences → **Animate theme changes** (off by default). +- **Later:** P2 Mongo/Redis token colors; `re_editor` if perf gap; LSP epic per + [code-forge-evaluation.md](code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). + ## Query history and favorites - **Done:** `sql_query_history` in SQLite + record/list APIs; **History** in PostgreSQL / MySQL toolbars; **Preferences → Query history limit** ([`AppSettings.getSqlHistoryMaxEntries`](lib/core/storage/app_settings.dart)). diff --git a/docs/theme-import.md b/docs/theme-import.md new file mode 100644 index 00000000..0ccacb70 --- /dev/null +++ b/docs/theme-import.md @@ -0,0 +1,85 @@ +# 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`. + +Imported `tokenColors` are persisted with the theme file and applied to SQL/JSON +syntax highlighting via `TokenStyleResolver` → `HighlighterTheme` (issue #46). + +## Supported `colors` keys + +| VS Code key | Querya target | +|-------------|---------------| +| `editor.background` | `workbench.editorBackground`, `editor.background` | +| `editor.foreground` | `editor.foreground`, `ColorScheme.foreground` | +| `editor.selectionBackground` | `editor.selection` | +| `editorLineNumber.foreground` | `editor.lineNumber` | +| `editorBracketMatch.background` | `editor.bracketMatch` | +| `editorWidget.border` | `editor.widgetBorder` (chrome border) | +| `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`). + +## Preferences UI (#43) + +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 + +Imported files are copied to app data (`themes/imported.json`) and survive restarts. + +## User overrides (#45) + +User customizations are stored as VS Code keys → hex strings in +`theme_overrides_json` (`AppSettings`). Merge order: + +``` +effectiveColors = merge(importedTheme.colors, userOverrides) +``` + +Built-in preset defaults apply for keys not present in the merged map. + +API: `ThemeController.setWorkbenchColor(key, color?)`, +`ThemeController.clearColorOverrides()` (user layer only). + +## Sample themes (manual import) + +- `themes/samples/cyberpunk-neon.json` — cyberpunk dark preset for UI + SQL/JSON tokens +- `themes/samples/cyberpunk-neon.jsonc` — same, JSONC variant + +## Fixtures (tests) + +- `test/fixtures/themes/dark_subset.json` +- `test/fixtures/themes/light_subset.json` +- `test/fixtures/themes/with_unknown_keys.json` diff --git a/docs/theme.md b/docs/theme.md new file mode 100644 index 00000000..bf4a381d --- /dev/null +++ b/docs/theme.md @@ -0,0 +1,190 @@ +# Querya theme system + +Querya Desktop uses a VS Code–inspired theme pipeline: workbench chrome colors, +editor syntax tokens, and optional import of community `.json` / `.jsonc` themes. + +## Milestone status + +**Theme system epic ([#37](https://github.com/QueryaHub/Querya-Desktop/issues/37)) — complete on `dev` (2026-05-28).** + +Delivered: models (#38), JSONC parser (#39), `ThemeController` (#40–41), Preferences + +import (#43–45), `tokenColors` → syntax highlight (#46–47, #49–50), P0 workbench tokens +(#42, #59–60), tests (#58), `docs/theme.md` (#55), Querya Light (#51), optional theme +animation (#57), editor package spikes (#48, #52). + +**Follow-up (not blocking):** P2 surfaces (Mongo/Redis explorer semantic hues), `re_editor` +if large-buffer benchmarks fail — see [code-forge-evaluation.md](code-forge-evaluation.md). + +## Architecture + +```mermaid +flowchart TB + subgraph input [Input] + VSCode["VS Code theme file\n(colors + tokenColors)"] + Prefs["Preferences overrides"] + end + + subgraph parse [Parser] + JSONC["stripJsonc"] + Manifest["VsCodeThemeManifest"] + Map["vscode_color_map"] + Tokens["TokenStyleResolver"] + end + + subgraph models [Runtime models] + WB["QueryaWorkbenchTheme"] + ED["QueryaEditorTheme"] + QT["QueryaTheme"] + end + + subgraph ui [UI] + Scope["QueryaThemeScope"] + Shadcn["ShadcnApp ColorScheme"] + Editor["QueryaCodeEditor\nsyntax_highlight"] + end + + VSCode --> JSONC --> Manifest + Manifest --> Map --> WB + Manifest --> Map --> ED + Manifest --> Tokens --> ED + Prefs --> Map + WB --> QT + ED --> QT + QT --> Scope + QT --> Shadcn + QT --> Editor +``` + +| Layer | Purpose | +|-------|---------| +| **Workbench** | Sidebar, tabs, canvas, accents, git decoration | +| **Editor** | SQL/JSON editor surface, selection, line numbers, syntax token hues | +| **ColorScheme** | shadcn/Material widgets (buttons, inputs, dialogs) | + +`ThemeController` merges layers, persists settings in `AppSettings`, and drives +`QueryaApp` via `ListenableBuilder`. + +## Built-in presets + +- **Querya Dark** — default (`QueryaThemePreset.queryaDark`) +- **Querya Light** — built-in light UI (`QueryaThemePreset.queryaLight`); slate canvas, cyan accent, WCAG AA body text +- **Imported** — after a VS Code file is imported (`QueryaThemePreset.imported`) + +Access tokens in widgets: + +```dart +final workbench = context.workbench; +final editor = context.editorTheme; +final scheme = Theme.of(context).colorScheme; +``` + +Requires `QueryaThemeScope` above the widget (provided by `QueryaApp`). + +## VS Code `colors` (workbench subset) + +Supported keys are listed in [theme-import.md](theme-import.md) and defined in +`lib/core/theme/parser/vscode_color_map.dart`. + +Merge order for the active theme: + +``` +effectiveColors = merge(importedTheme.colors, userOverrides) +``` + +Built-in preset values apply for keys not present in the merged map. + +### User override example + +Stored in `theme_overrides_json` as VS Code key → hex: + +```json +{ + "sideBar.background": "#1a1a2e", + "editor.background": "#16161e", + "focusBorder": "#89b4fa" +} +``` + +API: + +```dart +await ThemeController.instance.setWorkbenchColor('sideBar.background', color); +await ThemeController.instance.clearColorOverrides(); +``` + +## VS Code `tokenColors` (syntax highlighting) + +`tokenColors` entries map TextMate scopes to foreground/background/fontStyle. +`TokenStyleResolver` resolves scopes by longest prefix (`keyword.control.sql` → +`keyword.control` → `keyword`). + +Imported rules are: + +1. Persisted in the copied theme file under app data +2. Applied to `QueryaEditorTheme` token fields (comment, keyword, string, …) +3. Converted to `syntax_highlight` `HighlighterTheme` for `QueryaCodeEditor` + +Buffers ≥ 8KB are highlighted in a background isolate to keep typing responsive. + +## Importing a theme + +1. Open **Preferences → Appearance** +2. Choose **Theme mode** (Dark / Light / System) +3. Click **Import theme…** and select a VS Code `.json` or `.jsonc` file +4. Select the imported preset from **Color preset** + +The file must include a `colors` object (required for import). `tokenColors` are +optional but recommended for editor highlighting. + +See also: [theme-import.md](theme-import.md). + +## Adding a new workbench token + +1. Add a field to `QueryaWorkbenchTheme` (or reuse an existing one) +2. Map a VS Code key in `kVsCodeColorMap` / `kSupportedVsCodeColorKeys` +3. Handle the field in `_applyWorkbenchField` in `querya_theme_from_vscode.dart` +4. Migrate UI surfaces to `context.workbench.` instead of hardcoded colors +5. Add a fixture + unit test under `test/core/theme/` + +## Testing + +| Area | Location | +|------|----------| +| JSONC / manifest | `test/core/theme/parser/` | +| Color merge | `test/core/theme/parser/vscode_colors_merge_test.dart` | +| Fixtures | `test/fixtures/themes/` | +| ThemeController | `test/core/theme/theme_controller_test.dart` | +| Editor highlighting | `test/core/editor/` | + +Run: `flutter test test/core/theme/` + +## Theme transition animation + +Off by default. Enable in **Preferences → Appearance → Animate theme changes** to +turn on `ShadcnApp.enableThemeAnimation`. + +Manual QA (with animation enabled): + +- [ ] Toggle dark / light / system — no stuck overlay or wrong brightness on dialogs +- [ ] Switch preset (Querya Dark ↔ Light, imported) — sidebars and editor chrome animate smoothly +- [ ] Open connection dialog, settings sheet, SQL history — backgrounds readable during transition +- [ ] Resize main window while toggling theme — no layout jump or transparent holes +- [ ] Import theme while animation on — editor and workbench settle to final colors + +## Roadmap (Phase 2+) + +| Topic | Status | +|-------|--------| +| Workbench `colors` import | Done | +| Preferences UI | Done | +| SQL/JSON syntax highlighting | Done | +| `tokenColors` → highlighter | Done | +| Theme transition animation | Preferences → **Animate theme changes** (default off) | +| `code_forge` / LSP editor | **NO-GO** for 0.3 — [code-forge-evaluation.md](code-forge-evaluation.md) | + +## Related docs + +- [theme-import.md](theme-import.md) — supported `colors` keys and merge behavior +- [research_theme.md](research_theme.md) — background research (RU) +- [editor-spike-report.md](editor-spike-report.md) — code editor package evaluation (#48) +- [code-forge-evaluation.md](code-forge-evaluation.md) — `code_forge` + LSP go/no-go (#52) diff --git a/lib/app/app.dart b/lib/app/app.dart index 479d353e..06b6fdfa 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,4 +1,6 @@ -import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'app_lifecycle_cleanup.dart'; @@ -9,19 +11,32 @@ class QueryaApp extends StatelessWidget { @override Widget build(BuildContext context) { - return ShadcnApp( - title: 'Querya', - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, - debugShowCheckedModeBanner: false, - // Less churn in ShadcnAnimatedTheme; helps stability with overlay layers. - enableThemeAnimation: false, - // Avoids scroll interception fighting nested Scrollbars in data views. - enableScrollInterception: false, - home: const AppLifecycleCleanup( - child: MainScreen(), - ), + final themeController = ThemeController.instance; + + return ListenableBuilder( + listenable: themeController, + builder: (context, _) { + final queryaTheme = themeController.activeTheme; + final colorScheme = queryaTheme.colorScheme; + return ShadcnApp( + title: 'Querya', + theme: themeController.lightShadcnTheme, + darkTheme: themeController.darkShadcnTheme, + themeMode: themeController.themeMode, + materialTheme: materialThemeFromQuerya(colorScheme), + debugShowCheckedModeBanner: false, + enableThemeAnimation: themeController.themeAnimationEnabled, + enableScrollInterception: false, + // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. + builder: (context, child) => QueryaThemeScope( + data: queryaTheme, + child: child ?? const SizedBox.shrink(), + ), + home: const AppLifecycleCleanup( + child: MainScreen(), + ), + ); + }, ); } } diff --git a/lib/core/editor/highlighter_theme_from_querya.dart b/lib/core/editor/highlighter_theme_from_querya.dart new file mode 100644 index 00000000..53a854f1 --- /dev/null +++ b/lib/core/editor/highlighter_theme_from_querya.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/theme/parser/token_colors_highlighter_config.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +/// Builds a [HighlighterTheme] from [QueryaEditorTheme] and optional VS Code rules. +HighlighterTheme highlighterThemeFromQueryaEditor( + QueryaEditorTheme editor, { + List tokenColors = const [], +}) { + final wrapper = TextStyle( + color: editor.foreground, + fontFamily: editor.fontFamily, + fontSize: editor.fontSize, + ); + + final config = tokenColors.isNotEmpty + ? buildHighlighterConfigFromTokenColors(tokenColors, editor.foreground) + : buildDefaultEditorHighlighterConfig(editor); + + return HighlighterTheme.fromConfiguration(config, wrapper); +} + +/// JSON config for isolate/off-thread highlighting. +String highlighterThemeConfigJson( + QueryaEditorTheme editor, { + List tokenColors = const [], +}) { + return tokenColors.isNotEmpty + ? buildHighlighterConfigFromTokenColors(tokenColors, editor.foreground) + : buildDefaultEditorHighlighterConfig(editor); +} diff --git a/lib/core/editor/querya_code_editor.dart b/lib/core/editor/querya_code_editor.dart new file mode 100644 index 00000000..377de8c1 --- /dev/null +++ b/lib/core/editor/querya_code_editor.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'querya_code_language.dart'; +import 'querya_highlight_controller.dart'; +import 'syntax_highlight_service.dart'; + +/// Shadcn vs Material [TextField] backend for different parent widgets. +enum QueryaCodeEditorVariant { + shadcn, + material, +} + +/// Unified code editor with optional syntax highlighting (SQL/JSON). +class QueryaCodeEditor extends StatefulWidget { + const QueryaCodeEditor({ + super.key, + this.controller, + this.language = QueryaCodeLanguage.plain, + this.fontSize, + this.readOnly = false, + this.onChanged, + this.placeholder, + this.variant = QueryaCodeEditorVariant.shadcn, + this.expands = true, + this.maxLines, + this.hintText, + this.contentPadding, + this.textAlignVertical, + this.enableHighlighting = true, + }); + + final material.TextEditingController? controller; + final QueryaCodeLanguage language; + final double? fontSize; + + /// When null, uses [QueryaEditorTheme.fontSize] from scope. + final bool readOnly; + final ValueChanged? onChanged; + final Widget? placeholder; + final QueryaCodeEditorVariant variant; + final bool expands; + final int? maxLines; + final String? hintText; + final material.EdgeInsetsGeometry? contentPadding; + final material.TextAlignVertical? textAlignVertical; + + /// When true and [language] is SQL/JSON, uses [syntax_highlight] if initialized. + final bool enableHighlighting; + + @override + State createState() => _QueryaCodeEditorState(); +} + +class _QueryaCodeEditorState extends State { + material.TextEditingController? _plainController; + QueryaHighlightController? _highlightController; + bool _ownsPlainController = false; + bool _ownsHighlightController = false; + bool _syncing = false; + QueryaEditorTheme? _highlightEditorTheme; + QueryaCodeLanguage? _highlightLanguage; + int _highlightTokenColorsHash = 0; + + material.TextEditingController get _activeController => + _highlightController ?? _plainController!; + + bool get _useHighlighting => + widget.enableHighlighting && + widget.language != QueryaCodeLanguage.plain && + SyntaxHighlightService.isInitialized; + + @override + void initState() { + super.initState(); + if (!_useHighlighting) { + _initPlainController(widget.controller); + _plainController!.addListener(_onTextChanged); + } + } + + void _initPlainController(material.TextEditingController? external) { + if (external == null) { + _plainController = material.TextEditingController(); + _ownsPlainController = true; + } else { + _plainController = external; + _ownsPlainController = false; + } + } + + void _onTextChanged() { + widget.onChanged?.call(_activeController.text); + } + + void _syncFromExternal() { + final external = widget.controller; + final highlight = _highlightController; + if (external == null || highlight == null || _syncing) return; + if (external.text == highlight.text && + external.selection == highlight.selection) { + return; + } + _syncing = true; + highlight.value = external.value; + _syncing = false; + } + + void _syncToExternal() { + final external = widget.controller; + final highlight = _highlightController; + if (external == null || highlight == null || _syncing) return; + if (external.text == highlight.text && + external.selection == highlight.selection) { + return; + } + _syncing = true; + external.value = highlight.value; + _syncing = false; + } + + void _disposeHighlight() { + final highlight = _highlightController; + if (highlight == null) return; + highlight.removeListener(_onTextChanged); + highlight.removeListener(_syncToExternal); + widget.controller?.removeListener(_syncFromExternal); + if (_ownsHighlightController) { + highlight.dispose(); + } + _highlightController = null; + _highlightEditorTheme = null; + _highlightLanguage = null; + _highlightTokenColorsHash = 0; + } + + void _ensureHighlightController(QueryaTheme queryaTheme) { + if (!_useHighlighting) return; + + final editor = queryaTheme.editor; + final tokenHash = Object.hashAll(queryaTheme.tokenColors); + if (_highlightController != null && + _highlightEditorTheme == editor && + _highlightLanguage == widget.language && + _highlightTokenColorsHash == tokenHash) { + return; + } + + final pair = SyntaxHighlightService.createPair( + language: widget.language, + queryaTheme: queryaTheme, + ); + + final external = widget.controller; + final text = external?.text ?? _highlightController?.text ?? ''; + + _disposeHighlight(); + + _highlightController = QueryaHighlightController( + text: text, + language: widget.language, + lightHighlighter: pair.light, + darkHighlighter: pair.dark, + lightThemeConfig: pair.lightThemeConfig, + darkThemeConfig: pair.darkThemeConfig, + grammarJson: pair.grammarJson, + wrapperColor: editor.foreground, + ); + _ownsHighlightController = external == null; + _highlightEditorTheme = editor; + _highlightLanguage = widget.language; + _highlightTokenColorsHash = tokenHash; + + _highlightController!.addListener(_onTextChanged); + if (external != null) { + external.addListener(_syncFromExternal); + _highlightController!.addListener(_syncToExternal); + } + } + + void _switchToPlain(material.TextEditingController? external) { + _disposeHighlight(); + final text = external?.text ?? _plainController?.text ?? ''; + if (_plainController != null) { + _plainController!.removeListener(_onTextChanged); + if (_ownsPlainController) { + _plainController!.dispose(); + } + } + _initPlainController(external); + if (_ownsPlainController && text.isNotEmpty) { + _plainController!.text = text; + } + _plainController!.addListener(_onTextChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final queryaTheme = context.queryaTheme; + if (_useHighlighting) { + if (_plainController != null) { + _plainController!.removeListener(_onTextChanged); + if (_ownsPlainController) { + _plainController!.dispose(); + } + _plainController = null; + _ownsPlainController = false; + } + _ensureHighlightController(queryaTheme); + } else if (_highlightController != null) { + _highlightController!.removeListener(_onTextChanged); + _switchToPlain(widget.controller); + } else if (_plainController == null) { + _initPlainController(widget.controller); + _plainController!.addListener(_onTextChanged); + } + } + + @override + void didUpdateWidget(QueryaCodeEditor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _highlightController?.removeListener(_onTextChanged); + _plainController?.removeListener(_onTextChanged); + _disposeHighlight(); + if (_plainController != null) { + _plainController!.removeListener(_onTextChanged); + if (_ownsPlainController) { + _plainController!.dispose(); + } + _plainController = null; + } + if (_useHighlighting) { + _ensureHighlightController(context.queryaTheme); + _highlightController!.addListener(_onTextChanged); + } else { + _initPlainController(widget.controller); + _plainController!.addListener(_onTextChanged); + } + } else if (oldWidget.language != widget.language || + oldWidget.enableHighlighting != widget.enableHighlighting) { + _highlightEditorTheme = null; + _highlightLanguage = null; + _highlightTokenColorsHash = 0; + if (_useHighlighting) { + _ensureHighlightController(context.queryaTheme); + } else { + _switchToPlain(widget.controller); + } + } + } + + @override + void dispose() { + _disposeHighlight(); + if (_plainController != null) { + _plainController!.removeListener(_onTextChanged); + if (_ownsPlainController) { + _plainController!.dispose(); + } + } + super.dispose(); + } + + material.TextStyle _textStyle(QueryaEditorTheme editor) { + final size = widget.fontSize ?? editor.fontSize; + return material.TextStyle( + fontFamily: editor.fontFamily, + fontSize: size, + color: editor.foreground, + height: widget.language == QueryaCodeLanguage.json ? 1.5 : null, + ); + } + + Widget? _resolvedPlaceholder() { + if (widget.placeholder != null) return widget.placeholder; + return switch (widget.language) { + QueryaCodeLanguage.sql => const Text( + '-- Enter SQL here…\nSELECT 1;', + ), + QueryaCodeLanguage.json => const Text('{ }'), + QueryaCodeLanguage.plain => null, + }; + } + + @override + Widget build(BuildContext context) { + if (_useHighlighting) { + _ensureHighlightController(context.queryaTheme); + } + + final editor = context.editorTheme; + final style = _textStyle(editor); + final placeholder = _resolvedPlaceholder(); + final controller = _highlightController ?? _plainController!; + + if (widget.variant == QueryaCodeEditorVariant.material) { + return material.TextField( + controller: controller, + readOnly: widget.readOnly, + maxLines: widget.expands ? null : widget.maxLines, + expands: widget.expands, + style: style, + textAlignVertical: widget.textAlignVertical, + decoration: material.InputDecoration( + border: material.InputBorder.none, + hintText: widget.hintText, + contentPadding: widget.contentPadding ?? + const material.EdgeInsets.all(12), + ), + onChanged: widget.onChanged, + ); + } + + return TextField( + controller: controller, + readOnly: widget.readOnly, + maxLines: widget.expands ? null : widget.maxLines, + expands: widget.expands, + style: style, + placeholder: placeholder, + onChanged: widget.onChanged, + ); + } +} diff --git a/lib/core/editor/querya_code_language.dart b/lib/core/editor/querya_code_language.dart new file mode 100644 index 00000000..6f458bd1 --- /dev/null +++ b/lib/core/editor/querya_code_language.dart @@ -0,0 +1,6 @@ +/// Language mode for [QueryaCodeEditor] (syntax / placeholder hints). +enum QueryaCodeLanguage { + sql, + json, + plain, +} diff --git a/lib/core/editor/querya_highlight_controller.dart b/lib/core/editor/querya_highlight_controller.dart new file mode 100644 index 00000000..ae92387b --- /dev/null +++ b/lib/core/editor/querya_highlight_controller.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +import 'syntax_highlight_isolate.dart'; + +/// [TextEditingController] that applies [Highlighter] in [buildTextSpan]. +class QueryaHighlightController extends TextEditingController { + QueryaHighlightController({ + super.text, + required this.language, + required this.lightHighlighter, + required this.darkHighlighter, + required this.lightThemeConfig, + required this.darkThemeConfig, + required this.grammarJson, + required this.wrapperColor, + }); + + final QueryaCodeLanguage language; + final Highlighter lightHighlighter; + final Highlighter darkHighlighter; + final String lightThemeConfig; + final String darkThemeConfig; + final String grammarJson; + final Color wrapperColor; + + TextSpan? _cachedSpan; + String? _cachedText; + Brightness? _cachedBrightness; + int _highlightGeneration = 0; + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + final brightness = Theme.of(context).brightness; + final highlighter = brightness == Brightness.light + ? lightHighlighter + : darkHighlighter; + final themeConfig = brightness == Brightness.light + ? lightThemeConfig + : darkThemeConfig; + + if (text.length < kSyntaxHighlightIsolateThreshold) { + return TextSpan( + style: style, + children: [highlighter.highlight(text)], + ); + } + + if (_cachedText == text && + _cachedBrightness == brightness && + _cachedSpan != null) { + return TextSpan(style: style, children: [_cachedSpan!]); + } + + _scheduleIsolateHighlight( + text: text, + brightness: brightness, + themeConfig: themeConfig, + style: style, + ); + + if (_cachedSpan != null && _cachedText == text) { + return TextSpan(style: style, children: [_cachedSpan!]); + } + + return TextSpan(style: style, text: text); + } + + void _scheduleIsolateHighlight({ + required String text, + required Brightness brightness, + required String themeConfig, + required TextStyle? style, + }) { + final generation = ++_highlightGeneration; + final lang = switch (language) { + QueryaCodeLanguage.sql => 'sql', + QueryaCodeLanguage.json => 'json', + QueryaCodeLanguage.plain => 'sql', + }; + + highlightOffMainThread( + SyntaxHighlightJob( + code: text, + language: lang, + themeConfigJson: themeConfig, + grammarJson: grammarJson, + wrapperArgb: wrapperColor.toARGB32(), + ), + ).then((segments) { + if (generation != _highlightGeneration) return; + _cachedSpan = segmentsToTextSpan(segments, baseStyle: style); + _cachedText = text; + _cachedBrightness = brightness; + notifyListeners(); + }); + } + + @override + void dispose() { + _highlightGeneration++; + super.dispose(); + } +} diff --git a/lib/core/editor/sql_syntax_highlighting.dart b/lib/core/editor/sql_syntax_highlighting.dart deleted file mode 100644 index 5dfb2276..00000000 --- a/lib/core/editor/sql_syntax_highlighting.dart +++ /dev/null @@ -1,7 +0,0 @@ -/// Future work: editable SQL with syntax highlighting (see plan: syntax-highlight-epic). -/// -/// Candidates: custom [EditableText] + [TextPainter], or a dedicated code-editor package. -/// Plain [TextField] remains the source of truth until an editor is chosen. -abstract class SqlSyntaxHighlighting { - const SqlSyntaxHighlighting._(); -} diff --git a/lib/core/editor/syntax_highlight_isolate.dart b/lib/core/editor/syntax_highlight_isolate.dart new file mode 100644 index 00000000..4e1aa7ed --- /dev/null +++ b/lib/core/editor/syntax_highlight_isolate.dart @@ -0,0 +1,130 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +/// Minimum buffer size before highlighting runs in a background [compute]. +const int kSyntaxHighlightIsolateThreshold = 8192; + +/// Serializable highlight job for [compute]. +class SyntaxHighlightJob { + const SyntaxHighlightJob({ + required this.code, + required this.language, + required this.themeConfigJson, + required this.grammarJson, + required this.wrapperArgb, + }); + + final String code; + final String language; + final String themeConfigJson; + final String grammarJson; + final int wrapperArgb; +} + +/// Flat text segment returned from isolate (rebuilt as [TextSpan] on UI thread). +class HighlightSegment { + const HighlightSegment({ + required this.text, + this.colorArgb, + this.fontWeightValue, + this.fontStyleIndex, + }); + + final String text; + final int? colorArgb; + final int? fontWeightValue; + final int? fontStyleIndex; +} + +String? _loadedGrammarLanguage; + +/// Top-level entry for [compute]; do not rename (Flutter isolate requirement). +List syntaxHighlightInIsolate(SyntaxHighlightJob job) { + if (_loadedGrammarLanguage != job.language) { + Highlighter.addLanguage(job.language, job.grammarJson); + _loadedGrammarLanguage = job.language; + } + + final theme = HighlighterTheme.fromConfiguration( + job.themeConfigJson, + TextStyle(color: Color(job.wrapperArgb)), + ); + final highlighter = Highlighter(language: job.language, theme: theme); + final span = highlighter.highlight(job.code); + return _flattenSpan(span); +} + +List _flattenSpan(TextSpan span) { + final out = []; + void walk(TextSpan node) { + final style = node.style; + if (node.text != null && node.text!.isNotEmpty) { + out.add( + HighlightSegment( + text: node.text!, + colorArgb: style?.color?.toARGB32(), + fontWeightValue: style?.fontWeight?.value, + fontStyleIndex: style?.fontStyle?.index, + ), + ); + } + if (node.children != null) { + for (final child in node.children!) { + if (child is TextSpan) walk(child); + } + } + } + + walk(span); + return out; +} + +TextSpan segmentsToTextSpan( + List segments, { + TextStyle? baseStyle, +}) { + return TextSpan( + style: baseStyle, + children: [ + for (final s in segments) + TextSpan( + text: s.text, + style: _styleFromSegment(s, baseStyle), + ), + ], + ); +} + +FontWeight? _fontWeightFromValue(int? value) { + if (value == null) return null; + for (final w in FontWeight.values) { + if (w.value == value) return w; + } + return null; +} + +TextStyle? _styleFromSegment(HighlightSegment s, TextStyle? base) { + if (s.colorArgb == null && + s.fontWeightValue == null && + s.fontStyleIndex == null) { + return null; + } + return (base ?? const TextStyle()).copyWith( + color: s.colorArgb != null ? Color(s.colorArgb!) : null, + fontWeight: _fontWeightFromValue(s.fontWeightValue), + fontStyle: s.fontStyleIndex != null + ? FontStyle.values[s.fontStyleIndex!] + : null, + ); +} + +/// Runs [syntaxHighlightInIsolate] off the UI thread when [code] is large. +Future> highlightOffMainThread( + SyntaxHighlightJob job, +) { + if (job.code.length < kSyntaxHighlightIsolateThreshold) { + return Future.value(syntaxHighlightInIsolate(job)); + } + return compute(syntaxHighlightInIsolate, job); +} diff --git a/lib/core/editor/syntax_highlight_service.dart b/lib/core/editor/syntax_highlight_service.dart new file mode 100644 index 00000000..14a20118 --- /dev/null +++ b/lib/core/editor/syntax_highlight_service.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +import 'highlighter_theme_from_querya.dart'; +import 'querya_code_language.dart'; + +/// Global syntax highlighter setup for [QueryaCodeEditor]. +abstract final class SyntaxHighlightService { + static bool _initialized = false; + static String? _sqlGrammarJson; + static String? _jsonGrammarJson; + + static Future ensureInitialized() async { + if (_initialized) return; + await Highlighter.initialize(['sql', 'json']); + _sqlGrammarJson = await rootBundle.loadString( + 'packages/syntax_highlight/grammars/sql.json', + ); + _jsonGrammarJson = await rootBundle.loadString( + 'packages/syntax_highlight/grammars/json.json', + ); + _initialized = true; + } + + static bool get isInitialized => _initialized; + + static String grammarJsonFor(QueryaCodeLanguage language) { + _assertInitialized(); + return switch (language) { + QueryaCodeLanguage.sql => _sqlGrammarJson!, + QueryaCodeLanguage.json => _jsonGrammarJson!, + QueryaCodeLanguage.plain => _sqlGrammarJson!, + }; + } + + static Highlighter createHighlighter({ + required QueryaCodeLanguage language, + required QueryaEditorTheme editorTheme, + required Brightness brightness, + List tokenColors = const [], + }) { + _assertInitialized(); + final lang = switch (language) { + QueryaCodeLanguage.sql => 'sql', + QueryaCodeLanguage.json => 'json', + QueryaCodeLanguage.plain => 'sql', + }; + final theme = highlighterThemeFromQueryaEditor( + editorTheme, + tokenColors: tokenColors, + ); + return Highlighter(language: lang, theme: theme); + } + + static HighlighterPair createPair({ + required QueryaCodeLanguage language, + required QueryaTheme queryaTheme, + }) { + final tokenColors = queryaTheme.tokenColors; + return HighlighterPair( + language: language, + editorTheme: queryaTheme.editor, + tokenColors: tokenColors, + lightThemeConfig: highlighterThemeConfigJson( + queryaTheme.editor, + tokenColors: tokenColors, + ), + darkThemeConfig: highlighterThemeConfigJson( + queryaTheme.editor, + tokenColors: tokenColors, + ), + grammarJson: grammarJsonFor(language), + light: createHighlighter( + language: language, + editorTheme: queryaTheme.editor, + brightness: Brightness.light, + tokenColors: tokenColors, + ), + dark: createHighlighter( + language: language, + editorTheme: queryaTheme.editor, + brightness: Brightness.dark, + tokenColors: tokenColors, + ), + ); + } + + static void _assertInitialized() { + assert( + _initialized, + 'Call SyntaxHighlightService.ensureInitialized() before use', + ); + } +} + +/// Light/dark highlighters for Material [Theme] brightness switching. +class HighlighterPair { + const HighlighterPair({ + required this.light, + required this.dark, + required this.language, + required this.editorTheme, + required this.tokenColors, + required this.lightThemeConfig, + required this.darkThemeConfig, + required this.grammarJson, + }); + + final Highlighter light; + final Highlighter dark; + final QueryaCodeLanguage language; + final QueryaEditorTheme editorTheme; + final List tokenColors; + final String lightThemeConfig; + final String darkThemeConfig; + final String grammarJson; + + Highlighter forBrightness(Brightness brightness) => + brightness == Brightness.light ? light : dark; + + String themeConfigFor(Brightness brightness) => + brightness == Brightness.light ? lightThemeConfig : darkThemeConfig; +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 64023fce..77c537bf 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -1,5 +1,8 @@ -import 'package:flutter/foundation.dart'; +import 'dart:convert'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../theme/querya_theme_preset.dart'; import 'local_db.dart'; /// Default cap on rows shown in SQL workspace result grids (full result may be larger). @@ -47,6 +50,13 @@ abstract final class AppSettingsKeys { static const sqlResultMaxRows = 'sql_result_max_rows'; static const sqlEditorFontSizePoints = 'sql_editor_font_size_points'; static const sqlHistoryMaxEntries = 'sql_history_max_entries'; + static const themeMode = 'theme_mode'; + static const themePreset = 'theme_preset'; + static const themeOverridesJson = 'theme_overrides_json'; + static const themeImportPath = 'theme_import_path'; + static const themeImportName = 'theme_import_name'; + static const themeImportedColorsJson = 'theme_imported_colors_json'; + static const themeAnimationEnabled = 'theme_animation_enabled'; } /// Bumps [listenable] when any preference is persisted so open screens can reload. @@ -170,4 +180,205 @@ class AppSettings { ); AppSettingsRevision.bump(); } + + /// UI theme mode (dark / light / system). + Future getThemeMode() async { + final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.themeMode); + return switch (v) { + 'light' => ThemeMode.light, + 'system' => ThemeMode.system, + _ => ThemeMode.dark, + }; + } + + Future setThemeMode(ThemeMode mode) async { + final stored = switch (mode) { + ThemeMode.light => 'light', + ThemeMode.system => 'system', + ThemeMode.dark => 'dark', + }; + await LocalDb.instance.setAppSetting(AppSettingsKeys.themeMode, stored); + AppSettingsRevision.bump(); + } + + Future getThemePreset() async { + final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.themePreset); + return switch (v) { + 'querya_light' => QueryaThemePreset.queryaLight, + 'imported' => QueryaThemePreset.imported, + _ => QueryaThemePreset.queryaDark, + }; + } + + Future setThemePreset(QueryaThemePreset preset) async { + final stored = switch (preset) { + QueryaThemePreset.queryaLight => 'querya_light', + QueryaThemePreset.imported => 'imported', + QueryaThemePreset.queryaDark => 'querya_dark', + }; + await LocalDb.instance.setAppSetting(AppSettingsKeys.themePreset, stored); + AppSettingsRevision.bump(); + } + + Future getThemeImportName() async { + return LocalDb.instance.getAppSetting(AppSettingsKeys.themeImportName); + } + + Future setThemeImportName(String? name) async { + if (name == null || name.isEmpty) { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportName); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeImportName, + name, + ); + } + AppSettingsRevision.bump(); + } + + Future getThemeImportPath() async { + return LocalDb.instance.getAppSetting(AppSettingsKeys.themeImportPath); + } + + Future setThemeImportPath(String? path) async { + if (path == null || path.isEmpty) { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportPath); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeImportPath, + path, + ); + } + AppSettingsRevision.bump(); + } + + Future> getThemeImportedColors() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.themeImportedColorsJson, + ); + if (v == null || v.isEmpty) return {}; + try { + final decoded = jsonDecode(v); + if (decoded is! Map) return {}; + final out = {}; + for (final entry in decoded.entries) { + final key = entry.key?.toString(); + final value = entry.value?.toString(); + if (key != null && + key.isNotEmpty && + value != null && + value.isNotEmpty) { + out[key] = value; + } + } + return out; + } on FormatException { + return {}; + } + } + + Future setThemeImportedColors(Map colors) async { + if (colors.isEmpty) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.themeImportedColorsJson, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeImportedColorsJson, + jsonEncode(colors), + ); + } + AppSettingsRevision.bump(); + } + + Future clearThemeImport() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportPath); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportName); + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.themeImportedColorsJson, + ); + AppSettingsRevision.bump(); + } + + /// Clears import metadata without bumping (for batched clears). + Future deleteThemeImportKeys() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportPath); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportName); + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.themeImportedColorsJson, + ); + } + + Future> getThemeColorOverrides() async { + final v = + await LocalDb.instance.getAppSetting(AppSettingsKeys.themeOverridesJson); + if (v == null || v.isEmpty) return {}; + try { + final decoded = jsonDecode(v); + if (decoded is! Map) return {}; + final out = {}; + for (final entry in decoded.entries) { + final key = entry.key?.toString(); + final value = entry.value?.toString(); + if (key != null && + key.isNotEmpty && + value != null && + value.isNotEmpty) { + out[key] = value; + } + } + return out; + } on FormatException { + return {}; + } + } + + Future setThemeColorOverrides(Map overrides) async { + if (overrides.isEmpty) { + await clearThemeColorOverrides(); + return; + } + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeOverridesJson, + jsonEncode(overrides), + ); + AppSettingsRevision.bump(); + } + + Future clearThemeColorOverrides() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson); + AppSettingsRevision.bump(); + } + + /// Smooth color transitions when switching theme (off by default). + Future getThemeAnimationEnabled() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.themeAnimationEnabled, + ); + if (v == null || v.isEmpty) return false; + return v == 'true' || v == '1'; + } + + Future setThemeAnimationEnabled(bool enabled) async { + if (!enabled) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.themeAnimationEnabled, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeAnimationEnabled, + 'true', + ); + } + AppSettingsRevision.bump(); + } + + Future clearThemeSettings() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeMode); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeAnimationEnabled); + await deleteThemeImportKeys(); + AppSettingsRevision.bump(); + } } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 4e8fe381..51a9f060 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,13 +1,10 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'querya_color_scheme.dart'; +import 'querya_theme.dart'; -/// App theme: dark only. Used for both theme and darkTheme so the app is always dark. +/// App theme presets built from [QueryaTheme]. abstract class AppTheme { - static ThemeData get dark => const ThemeData.dark( - colorScheme: QueryaColorScheme.dark, - radius: 0.58, - scaling: 1, - typography: Typography.geist(), - ); + static ThemeData get dark => QueryaTheme.darkDefault.toShadcnThemeData(); + + static ThemeData get light => QueryaTheme.lightDefault.toShadcnThemeData(); } diff --git a/lib/core/theme/color_contrast.dart b/lib/core/theme/color_contrast.dart new file mode 100644 index 00000000..4f7f631d --- /dev/null +++ b/lib/core/theme/color_contrast.dart @@ -0,0 +1,38 @@ +import 'dart:math' as math; +import 'dart:ui'; + +/// Relative luminance (WCAG) for [color]. +double colorLuminance(Color color) { + double channel(double linear) { + return linear <= 0.03928 + ? linear / 12.92 + : math.pow((linear + 0.055) / 1.055, 2.4).toDouble(); + } + + final r = channel(color.r); + final g = channel(color.g); + final b = channel(color.b); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/// WCAG contrast ratio between [foreground] and [background]. +double contrastRatio(Color foreground, Color background) { + final l1 = colorLuminance(foreground); + final l2 = colorLuminance(background); + final lighter = l1 > l2 ? l1 : l2; + final darker = l1 > l2 ? l2 : l1; + return (lighter + 0.05) / (darker + 0.05); +} + +/// Picks [candidate] when readable on [background], else a softened [fallback]. +Color legibleSecondaryLabel({ + required Color candidate, + required Color background, + required Color fallback, + double minRatio = 4.5, +}) { + if (contrastRatio(candidate, background) >= minRatio) { + return candidate; + } + return fallback.withValues(alpha: 0.72); +} diff --git a/lib/core/theme/parser/apply_token_colors_to_editor.dart b/lib/core/theme/parser/apply_token_colors_to_editor.dart new file mode 100644 index 00000000..de8bcb66 --- /dev/null +++ b/lib/core/theme/parser/apply_token_colors_to_editor.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../querya_editor_theme.dart'; +import 'token_style_resolver.dart'; +import 'vscode_theme_manifest.dart'; + +/// Maps common TextMate scopes from [rules] onto [QueryaEditorTheme] fields. +QueryaEditorTheme applyTokenColorsToEditor( + QueryaEditorTheme editor, + List rules, +) { + if (rules.isEmpty) return editor; + + final resolver = TokenStyleResolver( + rules: rules, + defaultStyle: TextStyle(color: editor.foreground), + ); + + Color? colorFor(Iterable scopes) { + for (final scope in scopes) { + final c = resolver.resolve(scope).color; + if (c != null) return c; + } + return null; + } + + return editor.copyWith( + comment: colorFor([ + 'comment', + 'comment.line', + 'comment.block', + ]) ?? + editor.comment, + keyword: colorFor([ + 'keyword', + 'keyword.control', + 'keyword.operator', + 'storage.type', + ]) ?? + editor.keyword, + string: colorFor([ + 'string', + 'string.quoted', + 'string.quoted.double', + 'string.quoted.single', + ]) ?? + editor.string, + number: colorFor([ + 'constant.numeric', + 'constant.numeric.json', + 'number', + ]) ?? + editor.number, + function: colorFor([ + 'entity.name.function', + 'support.function', + ]) ?? + editor.function, + type: colorFor([ + 'entity.name.type', + 'support.type', + 'support.type.property-name', + 'support.type.property-name.json', + ]) ?? + editor.type, + ); +} diff --git a/lib/core/theme/parser/color_parser.dart b/lib/core/theme/parser/color_parser.dart new file mode 100644 index 00000000..b1c7cdc4 --- /dev/null +++ b/lib/core/theme/parser/color_parser.dart @@ -0,0 +1,61 @@ +import 'dart:ui'; + +/// Parses VS Code color strings into Flutter [Color]. +Color parseVsCodeColor(String input) { + var s = input.trim(); + if (s.isEmpty) { + throw const FormatException('Empty color string'); + } + if (s.startsWith('#')) { + s = s.substring(1); + } + if (s.length == 3) { + final r = s[0]; + final g = s[1]; + final b = s[2]; + s = '$r$r$g$g$b$b'; + return Color(int.parse('FF$s', radix: 16)); + } + if (s.length == 4) { + final r = s[0]; + final g = s[1]; + final b = s[2]; + final a = s[3]; + s = '$r$r$g$g$b$b$a$a'; + return _fromRgbaHex(s); + } + if (s.length == 6) { + return Color(int.parse('FF$s', radix: 16)); + } + if (s.length == 8) { + return _fromRgbaHex(s); + } + throw FormatException('Unsupported color format: $input'); +} + +/// Encodes a [Color] as a VS Code hex string (`#RRGGBB` or `#RRGGBBAA`). +String formatVsCodeColor(Color color) { + String channel(double component) => + (component * 255.0).round().clamp(0, 255).toRadixString(16).padLeft(2, '0'); + final rr = channel(color.r); + final gg = channel(color.g); + final bb = channel(color.b); + if (color.a < 1.0) { + final aa = channel(color.a); + return '#$rr$gg$bb$aa'; + } + return '#$rr$gg$bb'; +} + +Color _fromRgbaHex(String eight) { + final rr = eight.substring(0, 2); + final gg = eight.substring(2, 4); + final bb = eight.substring(4, 6); + final aa = eight.substring(6, 8); + return Color.fromARGB( + int.parse(aa, radix: 16), + int.parse(rr, radix: 16), + int.parse(gg, radix: 16), + int.parse(bb, radix: 16), + ); +} diff --git a/lib/core/theme/parser/jsonc_preprocessor.dart b/lib/core/theme/parser/jsonc_preprocessor.dart new file mode 100644 index 00000000..8497172b --- /dev/null +++ b/lib/core/theme/parser/jsonc_preprocessor.dart @@ -0,0 +1,90 @@ +/// Strips JSONC (comments, trailing commas) to valid JSON for [dart:convert]. +String stripJsonc(String input) { + final out = StringBuffer(); + var i = 0; + final len = input.length; + + while (i < len) { + final ch = input[i]; + final next = i + 1 < len ? input[i + 1] : ''; + + if (ch == '"') { + out.write(_copyStringLiteral(input, i)); + i = _skipStringLiteral(input, i); + continue; + } + + if (ch == '/' && next == '/') { + i += 2; + while (i < len && input[i] != '\n') { + i++; + } + continue; + } + + if (ch == '/' && next == '*') { + i += 2; + while (i < len) { + if (input[i] == '*' && i + 1 < len && input[i + 1] == '/') { + i += 2; + break; + } + i++; + } + continue; + } + + if (ch == ',') { + var j = i + 1; + while (j < len && _isWhitespace(input[j])) { + j++; + } + if (j < len && (input[j] == '}' || input[j] == ']')) { + i++; + continue; + } + } + + out.write(ch); + i++; + } + + return out.toString(); +} + +bool _isWhitespace(String c) => c == ' ' || c == '\t' || c == '\n' || c == '\r'; + +String _copyStringLiteral(String s, int start) { + final buf = StringBuffer(); + var i = start; + buf.write(s[i]); + i++; + while (i < s.length) { + final ch = s[i]; + buf.write(ch); + if (ch == '\\' && i + 1 < s.length) { + i++; + buf.write(s[i]); + } else if (ch == '"') { + i++; + break; + } + i++; + } + return buf.toString(); +} + +int _skipStringLiteral(String s, int start) { + var i = start + 1; + while (i < s.length) { + if (s[i] == '\\') { + i += 2; + continue; + } + if (s[i] == '"') { + return i + 1; + } + i++; + } + return s.length; +} 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..6e029144 --- /dev/null +++ b/lib/core/theme/parser/querya_theme_from_vscode.dart @@ -0,0 +1,221 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +import '../querya_editor_theme.dart'; +import '../querya_theme.dart'; +import '../querya_workbench_theme.dart'; +import 'apply_token_colors_to_editor.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!); + } + + if (manifest.tokenColors.isNotEmpty) { + editor = applyTokenColorsToEditor(editor, manifest.tokenColors); + } + + return QueryaTheme( + workbench: workbench, + editor: editor, + brightness: brightness, + colorScheme: colorScheme, + tokenColors: manifest.tokenColors, + ); +} + +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); + case VsCodeEditorField.selection: + return e.copyWith(selection: color); + case VsCodeEditorField.lineNumber: + return e.copyWith(lineNumber: color); + case VsCodeEditorField.bracketMatch: + return e.copyWith(bracketMatch: color); + case VsCodeEditorField.widgetBorder: + return e.copyWith(widgetBorder: color); + } +} + +/// Builds [QueryaTheme] from merged VS Code `colors` on top of [fallback]. +QueryaTheme buildQueryaThemeFromVsCodeColors({ + required Brightness brightness, + required Map colors, + QueryaTheme? fallback, +}) { + final base = fallback ?? + (brightness == Brightness.light + ? QueryaTheme.lightDefault + : QueryaTheme.darkDefault); + if (colors.isEmpty) return base; + final manifest = VsCodeThemeManifest( + type: brightness == Brightness.light ? 'light' : 'dark', + colors: colors, + ); + return buildQueryaThemeFromVsCodeManifest(manifest, fallback: base); +} diff --git a/lib/core/theme/parser/token_colors_codec.dart b/lib/core/theme/parser/token_colors_codec.dart new file mode 100644 index 00000000..0cff6efc --- /dev/null +++ b/lib/core/theme/parser/token_colors_codec.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'vscode_theme_manifest.dart'; + +/// JSON persistence for [TokenColorRule] lists (theme import storage). +List tokenColorRulesFromJson(String source) { + final decoded = jsonDecode(source); + if (decoded is! List) return const []; + final rules = []; + for (final item in decoded) { + if (item is Map) { + final rule = TokenColorRule.tryParse(item); + if (rule != null) rules.add(rule); + } + } + return rules; +} + +String tokenColorRulesToJson(List rules) { + final list = [ + for (final r in rules) + { + 'scope': r.scopes.length == 1 ? r.scopes.single : r.scopes, + 'settings': { + if (r.foreground != null) 'foreground': r.foreground, + if (r.background != null) 'background': r.background, + if (r.fontStyle != null) 'fontStyle': r.fontStyle, + }, + }, + ]; + return jsonEncode(list); +} diff --git a/lib/core/theme/parser/token_colors_highlighter_config.dart b/lib/core/theme/parser/token_colors_highlighter_config.dart new file mode 100644 index 00000000..85116107 --- /dev/null +++ b/lib/core/theme/parser/token_colors_highlighter_config.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../querya_editor_theme.dart'; +import 'vscode_theme_manifest.dart'; + +/// Builds `syntax_highlight` theme JSON from VS Code `tokenColors`. +String buildHighlighterConfigFromTokenColors( + List tokenColors, + Color fallbackForeground, +) { + final settings = >[]; + + for (final rule in tokenColors) { + final style = {}; + if (rule.foreground != null) style['foreground'] = rule.foreground; + if (rule.background != null) style['background'] = rule.background; + if (rule.fontStyle != null) style['fontStyle'] = rule.fontStyle; + if (style.isEmpty) continue; + + settings.add({ + 'scope': rule.scopes.length == 1 ? rule.scopes.single : rule.scopes, + 'settings': style, + }); + } + + settings.add({ + 'settings': {'foreground': _hex(fallbackForeground)}, + }); + + return jsonEncode({'settings': settings}); +} + +String buildDefaultEditorHighlighterConfig(QueryaEditorTheme editor) { + return jsonEncode({ + 'settings': [ + { + 'scope': [ + 'comment', + 'comment.line', + 'comment.block', + ], + 'settings': {'foreground': _hex(editor.comment)}, + }, + { + 'scope': [ + 'keyword', + 'keyword.control', + 'keyword.operator', + 'storage.type', + ], + 'settings': {'foreground': _hex(editor.keyword)}, + }, + { + 'scope': [ + 'string', + 'string.quoted', + 'string.quoted.single', + 'string.quoted.double', + ], + 'settings': {'foreground': _hex(editor.string)}, + }, + { + 'scope': [ + 'constant.numeric', + 'constant.numeric.json', + 'number', + ], + 'settings': {'foreground': _hex(editor.number)}, + }, + { + 'scope': [ + 'support.type.property-name', + 'support.type.property-name.json', + ], + 'settings': {'foreground': _hex(editor.type)}, + }, + { + 'scope': [ + 'constant.language', + 'constant.language.json', + ], + 'settings': {'foreground': _hex(editor.keyword)}, + }, + { + 'scope': ['entity.name.function', 'support.function'], + 'settings': {'foreground': _hex(editor.function)}, + }, + { + 'scope': ['entity.name.type', 'support.type'], + 'settings': {'foreground': _hex(editor.type)}, + }, + { + 'scope': ['constant.language', 'variable.language'], + 'settings': {'foreground': _hex(editor.keyword)}, + }, + { + 'settings': {'foreground': _hex(editor.foreground)}, + }, + ], + }); +} + +String _hex(Color c) { + final a = (c.a * 255).round().clamp(0, 255); + final r = (c.r * 255).round().clamp(0, 255); + final g = (c.g * 255).round().clamp(0, 255); + final b = (c.b * 255).round().clamp(0, 255); + if (a < 255) { + return '#${r.toRadixString(16).padLeft(2, '0')}' + '${g.toRadixString(16).padLeft(2, '0')}' + '${b.toRadixString(16).padLeft(2, '0')}' + '${a.toRadixString(16).padLeft(2, '0')}'; + } + return '#${r.toRadixString(16).padLeft(2, '0')}' + '${g.toRadixString(16).padLeft(2, '0')}' + '${b.toRadixString(16).padLeft(2, '0')}'; +} diff --git a/lib/core/theme/parser/token_style_resolver.dart b/lib/core/theme/parser/token_style_resolver.dart new file mode 100644 index 00000000..66e665f7 --- /dev/null +++ b/lib/core/theme/parser/token_style_resolver.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'color_parser.dart'; +import 'vscode_theme_manifest.dart'; + +/// Resolves TextMate scopes to [TextStyle] using VS Code `tokenColors` rules. +/// +/// Walks scope prefixes from most specific to least (`a.b.c` → `a.b` → `a`). +class TokenStyleResolver { + TokenStyleResolver({ + required List rules, + required TextStyle defaultStyle, + }) : _rules = rules, + _defaultStyle = defaultStyle; + + final List _rules; + final TextStyle _defaultStyle; + final Map _cache = {}; + + /// Longest-prefix match for [scope] with per-scope cache. + TextStyle resolve(String scope) => + _cache.putIfAbsent(scope, () => _resolveUncached(scope)); + + TextStyle _resolveUncached(String scope) { + for (final prefix in _scopePrefixes(scope)) { + for (final rule in _rules) { + if (rule.scopes.contains(prefix)) { + return _styleFromRule(rule); + } + } + } + return _defaultStyle; + } + + List _scopePrefixes(String scope) { + final parts = scope.split('.'); + return [ + for (var i = parts.length; i >= 1; i--) + parts.sublist(0, i).join('.'), + ]; + } + + TextStyle _styleFromRule(TokenColorRule rule) { + Color? color; + if (rule.foreground != null) { + try { + color = parseVsCodeColor(rule.foreground!); + } on FormatException { + color = null; + } + } + + FontStyle? fontStyle; + FontWeight? fontWeight; + TextDecoration? decoration; + final fs = rule.fontStyle?.toLowerCase(); + if (fs != null) { + if (fs.contains('italic')) fontStyle = FontStyle.italic; + if (fs.contains('bold')) fontWeight = FontWeight.bold; + if (fs.contains('underline')) decoration = TextDecoration.underline; + } + + return _defaultStyle.copyWith( + color: color ?? _defaultStyle.color, + fontStyle: fontStyle ?? _defaultStyle.fontStyle, + fontWeight: fontWeight ?? _defaultStyle.fontWeight, + decoration: decoration ?? _defaultStyle.decoration, + ); + } +} 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..ec19292b --- /dev/null +++ b/lib/core/theme/parser/vscode_color_map.dart @@ -0,0 +1,126 @@ +// 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, + selection, + lineNumber, + bracketMatch, + widgetBorder, +} + +/// 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), + 'editor.selectionBackground': VsCodeColorTarget.editor( + VsCodeEditorField.selection, + ), + 'editorLineNumber.foreground': VsCodeColorTarget.editor( + VsCodeEditorField.lineNumber, + ), + 'editorBracketMatch.background': VsCodeColorTarget.editor( + VsCodeEditorField.bracketMatch, + ), + 'editorWidget.border': VsCodeColorTarget.editor( + VsCodeEditorField.widgetBorder, + ), + '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', + 'editor.selectionBackground', + 'editorLineNumber.foreground', + 'editorBracketMatch.background', + 'editorWidget.border', + 'sideBar.background', + 'sideBar.foreground', + 'activityBar.background', + 'tab.activeBackground', + 'statusBar.background', + 'panel.background', + 'focusBorder', + 'input.background', + 'list.hoverBackground', + 'gitDecoration.modifiedResourceForeground', + 'gitDecoration.untrackedResourceForeground', +]; diff --git a/lib/core/theme/parser/vscode_colors_merge.dart b/lib/core/theme/parser/vscode_colors_merge.dart new file mode 100644 index 00000000..63269684 --- /dev/null +++ b/lib/core/theme/parser/vscode_colors_merge.dart @@ -0,0 +1,14 @@ +// Deep-merge VS Code `colors` maps (later layers override earlier keys). + +/// Merges VS Code color layers left-to-right; returns an unmodifiable map. +/// +/// Typical pipeline: `defaultColors` → `importedColors` → `userOverrides`. +Map mergeVsCodeColorLayers( + Iterable> layers, +) { + final merged = {}; + for (final layer in layers) { + merged.addAll(layer); + } + return Map.unmodifiable(merged); +} diff --git a/lib/core/theme/parser/vscode_theme_manifest.dart b/lib/core/theme/parser/vscode_theme_manifest.dart new file mode 100644 index 00000000..b9f5f6df --- /dev/null +++ b/lib/core/theme/parser/vscode_theme_manifest.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; + +import 'jsonc_preprocessor.dart'; + +/// Parsed VS Code theme manifest (subset used by Querya). +class VsCodeThemeManifest { + const VsCodeThemeManifest({ + this.name, + this.type, + this.colors = const {}, + this.tokenColors = const [], + }); + + final String? name; + + /// `dark` or `light` when present. + final String? type; + + final Map colors; + + final List tokenColors; + + bool get isDark => type?.toLowerCase() == 'dark'; + bool get isLight => type?.toLowerCase() == 'light'; + + factory VsCodeThemeManifest.fromJsonString(String source) { + final cleaned = stripJsonc(source); + final dynamic decoded; + try { + decoded = jsonDecode(cleaned); + } on FormatException catch (e) { + throw VsCodeThemeParseException('Invalid JSON after JSONC strip: ${e.message}'); + } + if (decoded is! Map) { + throw VsCodeThemeParseException('Theme root must be a JSON object'); + } + return VsCodeThemeManifest.fromJson(decoded); + } + + factory VsCodeThemeManifest.fromJson(Map json) { + final colorsRaw = json['colors']; + final colors = {}; + if (colorsRaw is Map) { + for (final e in colorsRaw.entries) { + final k = e.key?.toString(); + final v = e.value?.toString(); + if (k != null && k.isNotEmpty && v != null && v.isNotEmpty) { + colors[k] = v; + } + } + } + + final tokenColorsRaw = json['tokenColors']; + final rules = []; + if (tokenColorsRaw is List) { + for (final item in tokenColorsRaw) { + if (item is Map) { + final rule = TokenColorRule.tryParse(item); + if (rule != null) rules.add(rule); + } + } + } + + return VsCodeThemeManifest( + name: json['name']?.toString(), + type: json['type']?.toString(), + colors: colors, + tokenColors: rules, + ); + } +} + +/// One `tokenColors` entry from a VS Code theme file. +class TokenColorRule { + const TokenColorRule({ + required this.scopes, + this.foreground, + this.background, + this.fontStyle, + }); + + final List scopes; + final String? foreground; + final String? background; + final String? fontStyle; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TokenColorRule && + scopes.length == other.scopes.length && + _listEquals(scopes, other.scopes) && + foreground == other.foreground && + background == other.background && + fontStyle == other.fontStyle; + + @override + int get hashCode => Object.hash( + Object.hashAll(scopes), + foreground, + background, + fontStyle, + ); + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + static TokenColorRule? tryParse(Map json) { + final scopeRaw = json['scope']; + final scopes = []; + if (scopeRaw is String && scopeRaw.isNotEmpty) { + scopes.add(scopeRaw); + } else if (scopeRaw is List) { + for (final s in scopeRaw) { + final t = s?.toString(); + if (t != null && t.isNotEmpty) scopes.add(t); + } + } + if (scopes.isEmpty) return null; + + final settings = json['settings']; + if (settings is! Map) { + return TokenColorRule(scopes: scopes); + } + + return TokenColorRule( + scopes: scopes, + foreground: settings['foreground']?.toString(), + background: settings['background']?.toString(), + fontStyle: settings['fontStyle']?.toString(), + ); + } +} + +class VsCodeThemeParseException implements Exception { + VsCodeThemeParseException(this.message); + final String message; + + @override + String toString() => 'VsCodeThemeParseException: $message'; +} diff --git a/lib/core/theme/querya_color_scheme.dart b/lib/core/theme/querya_color_scheme.dart index f57e203b..a15f18a9 100644 --- a/lib/core/theme/querya_color_scheme.dart +++ b/lib/core/theme/querya_color_scheme.dart @@ -1,34 +1,9 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'querya_colors.dart'; +import 'querya_theme.dart'; -/// Dark [ColorScheme] for Querya desktop — matches [QueryaColors] tokens. +/// Shadcn [ColorScheme] presets — derived from [QueryaTheme] defaults. abstract class QueryaColorScheme { - static const ColorScheme dark = ColorScheme( - brightness: Brightness.dark, - background: QueryaColors.canvas, - foreground: Color(0xFFF8FAFC), - card: QueryaColors.surface, - cardForeground: Color(0xFFF8FAFC), - popover: QueryaColors.surface, - popoverForeground: Color(0xFFF8FAFC), - primary: QueryaColors.accentCyan, - primaryForeground: QueryaColors.onAccent, - secondary: Color(0xFF18181B), - secondaryForeground: Color(0xFFF8FAFC), - muted: Color(0xFF18181B), - mutedForeground: QueryaColors.mutedLabel, - accent: Color(0xFF27272A), - accentForeground: Color(0xFFF8FAFC), - destructive: Color(0xFFEF4444), - destructiveForeground: Color(0xFFF8FAFC), - border: QueryaColors.borderSubtle, - input: QueryaColors.borderSubtle, - ring: QueryaColors.accentCyan, - chart1: Color(0xFF2662D9), - chart2: Color(0xFF2EB88A), - chart3: Color(0xFFE88C30), - chart4: Color(0xFFAF57DB), - chart5: Color(0xFFE23670), - ); + static ColorScheme get dark => QueryaTheme.darkDefault.colorScheme; + static ColorScheme get light => QueryaTheme.lightDefault.colorScheme; } diff --git a/lib/core/theme/querya_editor_theme.dart b/lib/core/theme/querya_editor_theme.dart new file mode 100644 index 00000000..af71671c --- /dev/null +++ b/lib/core/theme/querya_editor_theme.dart @@ -0,0 +1,185 @@ +import 'dart:ui'; + +import 'querya_colors.dart'; +import 'querya_typography.dart'; + +/// Syntax and surface tokens for SQL/JSON code editors. +class QueryaEditorTheme { + const QueryaEditorTheme({ + required this.background, + required this.foreground, + required this.lineHighlight, + required this.selection, + required this.lineNumber, + required this.bracketMatch, + required this.comment, + required this.keyword, + required this.string, + required this.number, + required this.operator, + required this.function, + required this.type, + this.widgetBorder, + this.fontFamily = QueryaTypography.mono, + this.fontSize = 13, + }); + + final Color background; + final Color foreground; + final Color lineHighlight; + final Color selection; + final Color lineNumber; + final Color bracketMatch; + + /// Chrome border around editor widgets; falls back to workbench [borderSubtle]. + final Color? widgetBorder; + final Color comment; + final Color keyword; + final Color string; + final Color number; + final Color operator; + final Color function; + final Color type; + final String fontFamily; + final double fontSize; + + /// Aligned with dark workbench; VS Code Dark+–like token hues. + static const QueryaEditorTheme darkDefault = QueryaEditorTheme( + background: QueryaColors.surface, + foreground: Color(0xFFF8FAFC), + lineHighlight: Color(0xFF18181B), + selection: Color(0xFF264F78), + lineNumber: Color(0xFF858585), + bracketMatch: Color(0x33006400), + comment: Color(0xFF6A9955), + keyword: Color(0xFF569CD6), + string: Color(0xFFCE9178), + number: Color(0xFFB5CEA8), + operator: Color(0xFFD4D4D4), + function: Color(0xFFDCDCAA), + type: Color(0xFF4EC9B0), + ); + + static const QueryaEditorTheme lightDefault = QueryaEditorTheme( + background: Color(0xFFFFFFFF), + foreground: Color(0xFF1E293B), + lineHighlight: Color(0xFFF1F5F9), + selection: Color(0xFFADD6FF), + lineNumber: Color(0xFF237893), + bracketMatch: Color(0x33006400), + comment: Color(0xFF008000), + keyword: Color(0xFF0000FF), + string: Color(0xFFA31515), + number: Color(0xFF098658), + operator: Color(0xFF000000), + function: Color(0xFF795E26), + type: Color(0xFF267F99), + ); + + QueryaEditorTheme copyWith({ + Color? background, + Color? foreground, + Color? lineHighlight, + Color? selection, + Color? lineNumber, + Color? bracketMatch, + Color? widgetBorder, + bool clearWidgetBorder = false, + Color? comment, + Color? keyword, + Color? string, + Color? number, + Color? operator, + Color? function, + Color? type, + String? fontFamily, + double? fontSize, + }) { + return QueryaEditorTheme( + background: background ?? this.background, + foreground: foreground ?? this.foreground, + lineHighlight: lineHighlight ?? this.lineHighlight, + selection: selection ?? this.selection, + lineNumber: lineNumber ?? this.lineNumber, + bracketMatch: bracketMatch ?? this.bracketMatch, + widgetBorder: + clearWidgetBorder ? null : (widgetBorder ?? this.widgetBorder), + comment: comment ?? this.comment, + keyword: keyword ?? this.keyword, + string: string ?? this.string, + number: number ?? this.number, + operator: operator ?? this.operator, + function: function ?? this.function, + type: type ?? this.type, + fontFamily: fontFamily ?? this.fontFamily, + fontSize: fontSize ?? this.fontSize, + ); + } + + static QueryaEditorTheme lerp( + QueryaEditorTheme a, + QueryaEditorTheme b, + double t, + ) { + Color c(Color x, Color y) => Color.lerp(x, y, t)!; + return QueryaEditorTheme( + background: c(a.background, b.background), + foreground: c(a.foreground, b.foreground), + lineHighlight: c(a.lineHighlight, b.lineHighlight), + selection: c(a.selection, b.selection), + lineNumber: c(a.lineNumber, b.lineNumber), + bracketMatch: c(a.bracketMatch, b.bracketMatch), + widgetBorder: t < 0.5 ? a.widgetBorder : b.widgetBorder, + comment: c(a.comment, b.comment), + keyword: c(a.keyword, b.keyword), + string: c(a.string, b.string), + number: c(a.number, b.number), + operator: c(a.operator, b.operator), + function: c(a.function, b.function), + type: c(a.type, b.type), + fontFamily: t < 0.5 ? a.fontFamily : b.fontFamily, + fontSize: a.fontSize + (b.fontSize - a.fontSize) * t, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueryaEditorTheme && + background == other.background && + foreground == other.foreground && + lineHighlight == other.lineHighlight && + selection == other.selection && + lineNumber == other.lineNumber && + bracketMatch == other.bracketMatch && + widgetBorder == other.widgetBorder && + comment == other.comment && + keyword == other.keyword && + string == other.string && + number == other.number && + operator == other.operator && + function == other.function && + type == other.type && + fontFamily == other.fontFamily && + fontSize == other.fontSize; + + @override + int get hashCode => Object.hash( + background, + foreground, + lineHighlight, + selection, + lineNumber, + bracketMatch, + widgetBorder, + comment, + keyword, + string, + number, + operator, + function, + type, + fontFamily, + fontSize, + ); +} diff --git a/lib/core/theme/querya_material_theme.dart b/lib/core/theme/querya_material_theme.dart new file mode 100644 index 00000000..e7c8d2a0 --- /dev/null +++ b/lib/core/theme/querya_material_theme.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart' show ColorScheme; + +/// Material [ThemeData] aligned with Querya [ColorScheme] for dialogs, dropdowns, etc. +material.ThemeData materialThemeFromQuerya(ColorScheme scheme) { + final materialScheme = material.ColorScheme( + brightness: scheme.brightness, + primary: scheme.primary, + onPrimary: scheme.primaryForeground, + secondary: scheme.secondary, + onSecondary: scheme.secondaryForeground, + surface: scheme.popover, + onSurface: scheme.popoverForeground, + error: scheme.destructive, + onError: scheme.primaryForeground, + outline: scheme.border, + ); + + final body = material.TextStyle(color: scheme.popoverForeground); + final muted = material.TextStyle(color: scheme.mutedForeground); + + return material.ThemeData( + useMaterial3: true, + colorScheme: materialScheme, + dialogTheme: material.DialogThemeData(backgroundColor: scheme.popover), + textTheme: material.TextTheme( + bodyLarge: body, + bodyMedium: body, + bodySmall: muted, + titleLarge: body.copyWith(fontWeight: material.FontWeight.w600), + titleMedium: body.copyWith(fontWeight: material.FontWeight.w600), + labelLarge: body, + ), + dropdownMenuTheme: material.DropdownMenuThemeData( + textStyle: body, + menuStyle: material.MenuStyle( + backgroundColor: material.WidgetStatePropertyAll(scheme.popover), + surfaceTintColor: material.WidgetStatePropertyAll(scheme.popover), + ), + ), + ); +} diff --git a/lib/core/theme/querya_theme.dart b/lib/core/theme/querya_theme.dart new file mode 100644 index 00000000..13ababed --- /dev/null +++ b/lib/core/theme/querya_theme.dart @@ -0,0 +1,205 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'color_contrast.dart'; +import 'parser/vscode_theme_manifest.dart'; +import 'querya_colors.dart'; +import 'querya_editor_theme.dart'; +import 'querya_workbench_theme.dart'; + +/// Full Querya theme: workbench chrome + editor tokens + shadcn [ColorScheme]. +class QueryaTheme { + const QueryaTheme({ + required this.workbench, + required this.editor, + required this.brightness, + required this.colorScheme, + this.tokenColors = const [], + }); + + final QueryaWorkbenchTheme workbench; + final QueryaEditorTheme editor; + final Brightness brightness; + final ColorScheme colorScheme; + + /// VS Code `tokenColors` for syntax highlighting (imported themes). + final List tokenColors; + + static const QueryaTheme darkDefault = QueryaTheme( + workbench: QueryaWorkbenchTheme.darkDefault, + editor: QueryaEditorTheme.darkDefault, + brightness: Brightness.dark, + colorScheme: _darkColorScheme, + ); + + static const QueryaTheme lightDefault = QueryaTheme( + workbench: QueryaWorkbenchTheme.lightDefault, + editor: QueryaEditorTheme.lightDefault, + brightness: Brightness.light, + colorScheme: _lightColorScheme, + ); + + /// Matches [QueryaWorkbenchTheme.darkDefault] / legacy [QueryaColorScheme]. + static const ColorScheme _darkColorScheme = ColorScheme( + brightness: Brightness.dark, + background: QueryaColors.canvas, + foreground: Color(0xFFF8FAFC), + card: QueryaColors.surface, + cardForeground: Color(0xFFF8FAFC), + popover: QueryaColors.surface, + popoverForeground: Color(0xFFF8FAFC), + primary: QueryaColors.accentCyan, + primaryForeground: QueryaColors.onAccent, + secondary: Color(0xFF18181B), + secondaryForeground: Color(0xFFF8FAFC), + muted: Color(0xFF18181B), + mutedForeground: QueryaColors.mutedLabel, + accent: Color(0xFF27272A), + accentForeground: Color(0xFFF8FAFC), + destructive: Color(0xFFEF4444), + destructiveForeground: Color(0xFFF8FAFC), + border: QueryaColors.borderSubtle, + input: QueryaColors.borderSubtle, + ring: QueryaColors.accentCyan, + chart1: Color(0xFF2662D9), + chart2: Color(0xFF2EB88A), + chart3: Color(0xFFE88C30), + chart4: Color(0xFFAF57DB), + chart5: Color(0xFFE23670), + ); + + /// Matches [QueryaWorkbenchTheme.lightDefault]. + static const ColorScheme _lightColorScheme = ColorScheme( + brightness: Brightness.light, + background: Color(0xFFFAFAFA), + foreground: Color(0xFF0F172A), + card: Color(0xFFFFFFFF), + cardForeground: Color(0xFF0F172A), + popover: Color(0xFFFFFFFF), + popoverForeground: Color(0xFF0F172A), + primary: QueryaColors.accentCyan, + primaryForeground: QueryaColors.onAccent, + secondary: Color(0xFFF4F4F5), + secondaryForeground: Color(0xFF0F172A), + muted: Color(0xFFF4F4F5), + mutedForeground: Color(0xFF64748B), + accent: Color(0xFFE4E4E7), + accentForeground: Color(0xFF0F172A), + destructive: Color(0xFFDC2626), + destructiveForeground: Color(0xFFF8FAFC), + border: Color(0xFFE4E4E7), + input: Color(0xFFE4E4E7), + ring: QueryaColors.accentCyan, + chart1: Color(0xFF2662D9), + chart2: Color(0xFF2EB88A), + chart3: Color(0xFFE88C30), + chart4: Color(0xFFAF57DB), + chart5: Color(0xFFE23670), + ); + + /// Builds [ColorScheme] from [workbench] (for imported / overridden themes). + static ColorScheme colorSchemeFromWorkbench( + QueryaWorkbenchTheme w, { + required Brightness brightness, + }) { + final isDark = brightness == Brightness.dark; + final fg = isDark ? const Color(0xFFF8FAFC) : const Color(0xFF0F172A); + return ColorScheme( + brightness: brightness, + background: w.canvas, + foreground: fg, + card: w.surface, + cardForeground: fg, + popover: w.surface, + popoverForeground: fg, + primary: w.accent, + primaryForeground: w.onAccent, + secondary: isDark ? const Color(0xFF18181B) : const Color(0xFFF4F4F5), + secondaryForeground: fg, + muted: isDark ? const Color(0xFF18181B) : const Color(0xFFF4F4F5), + mutedForeground: legibleSecondaryLabel( + candidate: w.mutedForeground, + background: w.surface, + fallback: fg, + ), + accent: isDark ? const Color(0xFF27272A) : const Color(0xFFE4E4E7), + accentForeground: fg, + destructive: w.destructive, + destructiveForeground: const Color(0xFFF8FAFC), + border: w.borderSubtle, + input: w.borderSubtle, + ring: w.accent, + chart1: const Color(0xFF2662D9), + chart2: const Color(0xFF2EB88A), + chart3: const Color(0xFFE88C30), + chart4: const Color(0xFFAF57DB), + chart5: const Color(0xFFE23670), + ); + } + + QueryaTheme copyWith({ + QueryaWorkbenchTheme? workbench, + QueryaEditorTheme? editor, + Brightness? brightness, + ColorScheme? colorScheme, + List? tokenColors, + }) { + return QueryaTheme( + workbench: workbench ?? this.workbench, + editor: editor ?? this.editor, + brightness: brightness ?? this.brightness, + colorScheme: colorScheme ?? this.colorScheme, + tokenColors: tokenColors ?? this.tokenColors, + ); + } + + static QueryaTheme lerp(QueryaTheme a, QueryaTheme b, double t) { + final w = QueryaWorkbenchTheme.lerp(a.workbench, b.workbench, t); + final e = QueryaEditorTheme.lerp(a.editor, b.editor, t); + final brightness = t < 0.5 ? a.brightness : b.brightness; + return QueryaTheme( + workbench: w, + editor: e, + brightness: brightness, + colorScheme: ColorScheme.lerp(a.colorScheme, b.colorScheme, t), + tokenColors: t < 0.5 ? a.tokenColors : b.tokenColors, + ); + } + + ThemeData toShadcnThemeData({ + double radius = 0.58, + double scaling = 1, + Typography? typography, + }) { + final typo = typography ?? const Typography.geist(); + final base = brightness == Brightness.dark + ? ThemeData.dark(colorScheme: colorScheme) + : ThemeData(colorScheme: colorScheme); + return base.copyWith( + radius: () => radius, + scaling: () => scaling, + typography: () => typo, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueryaTheme && + workbench == other.workbench && + editor == other.editor && + brightness == other.brightness && + colorScheme == other.colorScheme && + _listEquals(tokenColors, other.tokenColors); + + @override + int get hashCode => + Object.hash(workbench, editor, brightness, colorScheme, tokenColors); + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/lib/core/theme/querya_theme_preset.dart b/lib/core/theme/querya_theme_preset.dart new file mode 100644 index 00000000..3b31a7f3 --- /dev/null +++ b/lib/core/theme/querya_theme_preset.dart @@ -0,0 +1,8 @@ +/// Built-in and imported theme presets. +enum QueryaThemePreset { + queryaDark, + queryaLight, + + /// VS Code theme imported from a `.json` / `.jsonc` file (#43). + imported, +} diff --git a/lib/core/theme/querya_theme_scope.dart b/lib/core/theme/querya_theme_scope.dart new file mode 100644 index 00000000..b35c72b5 --- /dev/null +++ b/lib/core/theme/querya_theme_scope.dart @@ -0,0 +1,41 @@ +import 'package:flutter/widgets.dart'; + +import 'querya_editor_theme.dart'; +import 'querya_theme.dart'; +import 'querya_workbench_theme.dart'; + +/// Provides [QueryaTheme] (workbench + editor tokens) below [ShadcnApp]. +class QueryaThemeScope extends InheritedWidget { + const QueryaThemeScope({ + super.key, + required this.data, + required super.child, + }); + + final QueryaTheme data; + + static QueryaTheme of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'QueryaThemeScope not found in context'); + return scope!.data; + } + + static QueryaTheme? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.data; + } + + @override + bool updateShouldNotify(QueryaThemeScope oldWidget) => + data != oldWidget.data; +} + +/// Convenient access to [QueryaTheme] tokens from [BuildContext]. +extension QueryaThemeContext on BuildContext { + QueryaTheme get queryaTheme => QueryaThemeScope.of(this); + + QueryaWorkbenchTheme get workbench => queryaTheme.workbench; + + QueryaEditorTheme get editorTheme => queryaTheme.editor; +} diff --git a/lib/core/theme/querya_workbench_theme.dart b/lib/core/theme/querya_workbench_theme.dart new file mode 100644 index 00000000..99261857 --- /dev/null +++ b/lib/core/theme/querya_workbench_theme.dart @@ -0,0 +1,160 @@ +import 'dart:ui'; + +import 'querya_colors.dart'; + +/// Workbench (non-editor) color tokens — sidebar, chrome, status, git decorations. +class QueryaWorkbenchTheme { + const QueryaWorkbenchTheme({ + required this.canvas, + required this.surface, + required this.sidebarBackground, + required this.editorBackground, + required this.borderSubtle, + required this.accent, + required this.onAccent, + required this.mutedForeground, + required this.destructive, + required this.success, + required this.warning, + required this.gitModified, + required this.gitUntracked, + }); + + final Color canvas; + final Color surface; + final Color sidebarBackground; + final Color editorBackground; + final Color borderSubtle; + final Color accent; + final Color onAccent; + final Color mutedForeground; + final Color destructive; + final Color success; + final Color warning; + final Color gitModified; + final Color gitUntracked; + + /// Matches current [QueryaColors] / dark UI. + static const QueryaWorkbenchTheme darkDefault = QueryaWorkbenchTheme( + canvas: QueryaColors.canvas, + surface: QueryaColors.surface, + sidebarBackground: QueryaColors.canvas, + editorBackground: QueryaColors.surface, + borderSubtle: QueryaColors.borderSubtle, + accent: QueryaColors.accentCyan, + onAccent: QueryaColors.onAccent, + mutedForeground: QueryaColors.mutedLabel, + destructive: Color(0xFFEF4444), + success: Color(0xFF4CAF50), + warning: Color(0xFFE88C30), + gitModified: Color(0xFFE88C30), + gitUntracked: Color(0xFF2EB88A), + ); + + /// Built-in light preset (slate-like canvas, cyan brand accent). + static const QueryaWorkbenchTheme lightDefault = QueryaWorkbenchTheme( + canvas: Color(0xFFFAFAFA), + surface: Color(0xFFFFFFFF), + sidebarBackground: Color(0xFFF4F4F5), + editorBackground: Color(0xFFFFFFFF), + borderSubtle: Color(0xFFE4E4E7), + accent: QueryaColors.accentCyan, + onAccent: QueryaColors.onAccent, + mutedForeground: Color(0xFF64748B), + destructive: Color(0xFFDC2626), + success: Color(0xFF16A34A), + warning: Color(0xFFD97706), + gitModified: Color(0xFFD97706), + gitUntracked: Color(0xFF16A34A), + ); + + QueryaWorkbenchTheme copyWith({ + Color? canvas, + Color? surface, + Color? sidebarBackground, + Color? editorBackground, + Color? borderSubtle, + Color? accent, + Color? onAccent, + Color? mutedForeground, + Color? destructive, + Color? success, + Color? warning, + Color? gitModified, + Color? gitUntracked, + }) { + return QueryaWorkbenchTheme( + canvas: canvas ?? this.canvas, + surface: surface ?? this.surface, + sidebarBackground: sidebarBackground ?? this.sidebarBackground, + editorBackground: editorBackground ?? this.editorBackground, + borderSubtle: borderSubtle ?? this.borderSubtle, + accent: accent ?? this.accent, + onAccent: onAccent ?? this.onAccent, + mutedForeground: mutedForeground ?? this.mutedForeground, + destructive: destructive ?? this.destructive, + success: success ?? this.success, + warning: warning ?? this.warning, + gitModified: gitModified ?? this.gitModified, + gitUntracked: gitUntracked ?? this.gitUntracked, + ); + } + + static QueryaWorkbenchTheme lerp( + QueryaWorkbenchTheme a, + QueryaWorkbenchTheme b, + double t, + ) { + Color c(Color x, Color y) => Color.lerp(x, y, t)!; + return QueryaWorkbenchTheme( + canvas: c(a.canvas, b.canvas), + surface: c(a.surface, b.surface), + sidebarBackground: c(a.sidebarBackground, b.sidebarBackground), + editorBackground: c(a.editorBackground, b.editorBackground), + borderSubtle: c(a.borderSubtle, b.borderSubtle), + accent: c(a.accent, b.accent), + onAccent: c(a.onAccent, b.onAccent), + mutedForeground: c(a.mutedForeground, b.mutedForeground), + destructive: c(a.destructive, b.destructive), + success: c(a.success, b.success), + warning: c(a.warning, b.warning), + gitModified: c(a.gitModified, b.gitModified), + gitUntracked: c(a.gitUntracked, b.gitUntracked), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueryaWorkbenchTheme && + canvas == other.canvas && + surface == other.surface && + sidebarBackground == other.sidebarBackground && + editorBackground == other.editorBackground && + borderSubtle == other.borderSubtle && + accent == other.accent && + onAccent == other.onAccent && + mutedForeground == other.mutedForeground && + destructive == other.destructive && + success == other.success && + warning == other.warning && + gitModified == other.gitModified && + gitUntracked == other.gitUntracked; + + @override + int get hashCode => Object.hash( + canvas, + surface, + sidebarBackground, + editorBackground, + borderSubtle, + accent, + onAccent, + mutedForeground, + destructive, + success, + warning, + gitModified, + gitUntracked, + ); +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart new file mode 100644 index 00000000..ac562091 --- /dev/null +++ b/lib/core/theme/theme_controller.dart @@ -0,0 +1,255 @@ +import 'package:querya_desktop/core/storage/app_settings.dart'; +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_vscode.dart'; +import 'parser/vscode_colors_merge.dart'; +import 'parser/vscode_theme_manifest.dart'; +import 'querya_theme.dart'; +import 'querya_theme_preset.dart'; +import 'theme_import_service.dart'; + +/// Active theme state: preset, optional imported colors, user overrides. +class ThemeController extends ChangeNotifier { + ThemeController._(); + + static final ThemeController instance = ThemeController._(); + + ThemeMode _themeMode = ThemeMode.dark; + QueryaThemePreset _preset = QueryaThemePreset.queryaDark; + Map _importedColors = const {}; + List _importedTokenColors = const []; + Map _userOverrides = const {}; + String? _importedThemeName; + bool _loaded = false; + bool _themeAnimationEnabled = false; + + ThemeMode get themeMode => _themeMode; + + /// When true, [QueryaApp] enables ShadcnAnimatedTheme transitions. + bool get themeAnimationEnabled => _themeAnimationEnabled; + + QueryaThemePreset get preset => _preset; + + bool get isLoaded => _loaded; + + bool get hasImportedTheme => _importedColors.isNotEmpty; + + String? get importedThemeName => _importedThemeName; + + /// User `workbench.colorCustomizations` layer (VS Code keys → hex). + Map get userColorOverrides => + Map.unmodifiable(_userOverrides); + + /// Imported theme `colors` layer (from file import). + Map get importedColors => Map.unmodifiable(_importedColors); + + /// Merged VS Code color keys for the active preset. + Map get effectiveVsCodeColors { + if (_preset == QueryaThemePreset.imported) { + return mergeVsCodeColorLayers([ + _importedColors, + _userOverrides, + ]); + } + return mergeVsCodeColorLayers([_userOverrides]); + } + + /// Parsed effective colors for supported VS Code keys only. + Map get effectiveWorkbenchColors { + final out = {}; + for (final entry in effectiveVsCodeColors.entries) { + try { + out[entry.key] = parseVsCodeColor(entry.value); + } on FormatException { + continue; + } + } + return Map.unmodifiable(out); + } + + /// Workbench + editor tokens for the current preset/mode and overrides. + QueryaTheme get activeTheme => _themeForBrightness(_effectiveBrightness()); + + ThemeData get lightShadcnTheme => + _themeForBrightness(Brightness.light).toShadcnThemeData(); + + ThemeData get darkShadcnTheme => + _themeForBrightness(Brightness.dark).toShadcnThemeData(); + + Future load() async { + final mode = await AppSettings.instance.getThemeMode(); + var preset = await AppSettings.instance.getThemePreset(); + final overrides = await AppSettings.instance.getThemeColorOverrides(); + var imported = await AppSettings.instance.getThemeImportedColors(); + _importedThemeName = await AppSettings.instance.getThemeImportName(); + + if (imported.isEmpty) { + final fromDisk = await ThemeImportService.loadPersistedColors(); + if (fromDisk != null && fromDisk.isNotEmpty) { + imported = fromDisk; + await AppSettings.instance.setThemeImportedColors(imported); + } + } + _importedTokenColors = await ThemeImportService.loadPersistedTokenColors(); + + if (preset == QueryaThemePreset.imported && imported.isEmpty) { + preset = QueryaThemePreset.queryaDark; + await AppSettings.instance.setThemePreset(preset); + } + + _themeMode = mode; + _preset = preset; + _userOverrides = Map.unmodifiable(overrides); + _importedColors = Map.unmodifiable(imported); + _themeAnimationEnabled = + await AppSettings.instance.getThemeAnimationEnabled(); + _loaded = true; + notifyListeners(); + } + + Future setThemeAnimationEnabled(bool enabled) async { + _themeAnimationEnabled = enabled; + await AppSettings.instance.setThemeAnimationEnabled(enabled); + notifyListeners(); + } + + Future setThemeMode(ThemeMode mode) async { + _themeMode = mode; + if (_preset != QueryaThemePreset.imported) { + _preset = mode == ThemeMode.light + ? QueryaThemePreset.queryaLight + : QueryaThemePreset.queryaDark; + await AppSettings.instance.setThemePreset(_preset); + } + await AppSettings.instance.setThemeMode(mode); + notifyListeners(); + } + + Future setPreset(QueryaThemePreset preset) async { + if (preset == QueryaThemePreset.imported && !hasImportedTheme) { + return; + } + _preset = preset; + if (preset == QueryaThemePreset.imported) { + await AppSettings.instance.setThemePreset(preset); + } else { + _themeMode = preset == QueryaThemePreset.queryaLight + ? ThemeMode.light + : ThemeMode.dark; + await AppSettings.instance.setThemePreset(preset); + await AppSettings.instance.setThemeMode(_themeMode); + } + notifyListeners(); + } + + /// Parses a VS Code theme file, persists it, and activates the imported preset. + Future importThemeFromFile(String path) async { + final result = await ThemeImportService.importFromPath(path); + switch (result) { + case ThemeImportSuccess( + :final name, + :final isDark, + :final colors, + :final tokenColors, + :final storedPath, + ): + _importedColors = Map.unmodifiable(colors); + _importedTokenColors = List.unmodifiable(tokenColors); + _importedThemeName = name; + _preset = QueryaThemePreset.imported; + _themeMode = isDark ? ThemeMode.dark : ThemeMode.light; + await AppSettings.instance.setThemeImportedColors(colors); + await AppSettings.instance.setThemeImportName(name); + await AppSettings.instance.setThemeImportPath(storedPath); + await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); + await AppSettings.instance.setThemeMode(_themeMode); + notifyListeners(); + return result; + case ThemeImportFailure(): + return result; + } + } + + /// Sets or clears a user override for a VS Code `colors` key. + Future setWorkbenchColor(String vscodeKey, Color? value) async { + final next = Map.from(_userOverrides); + if (value == null) { + next.remove(vscodeKey); + } else { + next[vscodeKey] = formatVsCodeColor(value); + } + _userOverrides = Map.unmodifiable(next); + await AppSettings.instance.setThemeColorOverrides(next); + notifyListeners(); + } + + /// Removes only the user override layer (keeps preset/imported theme). + Future clearColorOverrides() async { + _userOverrides = const {}; + await AppSettings.instance.clearThemeColorOverrides(); + notifyListeners(); + } + + /// Clears imported theme file and settings; falls back to Querya Dark. + Future clearImportedTheme() async { + await ThemeImportService.deletePersistedImport(); + await AppSettings.instance.clearThemeImport(); + _importedColors = const {}; + _importedTokenColors = const []; + _importedThemeName = null; + if (_preset == QueryaThemePreset.imported) { + _preset = QueryaThemePreset.queryaDark; + _themeMode = ThemeMode.dark; + await AppSettings.instance.setThemePreset(_preset); + await AppSettings.instance.setThemeMode(_themeMode); + } + notifyListeners(); + } + + Future resetToDefaults() async { + await ThemeImportService.deletePersistedImport(); + await AppSettings.instance.clearThemeSettings(); + _themeMode = ThemeMode.dark; + _preset = QueryaThemePreset.queryaDark; + _importedColors = const {}; + _importedTokenColors = const []; + _userOverrides = const {}; + _importedThemeName = null; + _themeAnimationEnabled = false; + notifyListeners(); + } + + Brightness _effectiveBrightness() { + if (_themeMode == ThemeMode.system) { + final b = WidgetsBinding.instance.platformDispatcher.platformBrightness; + return b; + } + return _themeMode == ThemeMode.light ? Brightness.light : Brightness.dark; + } + + QueryaTheme _themeForBrightness(Brightness brightness) { + final fallback = brightness == Brightness.light + ? QueryaTheme.lightDefault + : QueryaTheme.darkDefault; + final merged = effectiveVsCodeColors; + if (merged.isEmpty && _importedTokenColors.isEmpty) return fallback; + + var theme = merged.isEmpty + ? fallback + : buildQueryaThemeFromVsCodeColors( + brightness: brightness, + colors: merged, + fallback: fallback, + ); + + if (_importedTokenColors.isNotEmpty) { + theme = theme.copyWith( + tokenColors: _importedTokenColors, + editor: applyTokenColorsToEditor(theme.editor, _importedTokenColors), + ); + } + return theme; + } +} diff --git a/lib/core/theme/theme_import_service.dart b/lib/core/theme/theme_import_service.dart new file mode 100644 index 00000000..7a40e5d0 --- /dev/null +++ b/lib/core/theme/theme_import_service.dart @@ -0,0 +1,114 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'parser/vscode_theme_manifest.dart'; + +/// Result of importing a VS Code theme file. +sealed class ThemeImportResult { + const ThemeImportResult(); +} + +class ThemeImportSuccess extends ThemeImportResult { + const ThemeImportSuccess({ + required this.name, + required this.isDark, + required this.colors, + required this.tokenColors, + required this.storedPath, + }); + + final String name; + final bool isDark; + final Map colors; + final List tokenColors; + final String storedPath; +} + +class ThemeImportFailure extends ThemeImportResult { + const ThemeImportFailure(this.message); + final String message; +} + +/// Parses and persists an imported VS Code theme under app support. +abstract final class ThemeImportService { + static const String _storedFileName = 'imported.json'; + + /// Reads [sourcePath], parses JSON/JSONC, copies to app data, returns colors. + static Future importFromPath(String sourcePath) async { + try { + final source = File(sourcePath); + if (!await source.exists()) { + return const ThemeImportFailure('Theme file not found.'); + } + final raw = await source.readAsString(); + final manifest = VsCodeThemeManifest.fromJsonString(raw); + if (manifest.colors.isEmpty) { + return const ThemeImportFailure( + 'Theme file has no "colors" section to import.', + ); + } + + final storedFile = await _storedThemeFile(); + await storedFile.parent.create(recursive: true); + await storedFile.writeAsString(raw); + + final name = manifest.name?.trim().isNotEmpty == true + ? manifest.name!.trim() + : p.basenameWithoutExtension(sourcePath); + + return ThemeImportSuccess( + name: name, + isDark: manifest.isDark || !manifest.isLight, + colors: Map.unmodifiable(manifest.colors), + tokenColors: List.unmodifiable(manifest.tokenColors), + storedPath: storedFile.path, + ); + } on VsCodeThemeParseException catch (e) { + return ThemeImportFailure(e.message); + } on FormatException catch (e) { + return ThemeImportFailure(e.message); + } on IOException catch (e) { + return ThemeImportFailure(e.toString()); + } on Object catch (e) { + return ThemeImportFailure(e.toString()); + } + } + + /// Reloads colors from the persisted import file, if present. + static Future?> loadPersistedColors() async { + final manifest = await loadPersistedManifest(); + if (manifest == null || manifest.colors.isEmpty) return null; + return manifest.colors; + } + + /// Reloads `tokenColors` from the persisted import file, if present. + static Future> loadPersistedTokenColors() async { + final manifest = await loadPersistedManifest(); + return manifest?.tokenColors ?? const []; + } + + /// Full parsed manifest from the persisted import copy. + static Future loadPersistedManifest() async { + final file = await _storedThemeFile(); + if (!await file.exists()) return null; + try { + return VsCodeThemeManifest.fromJsonString(await file.readAsString()); + } on Object { + return null; + } + } + + static Future deletePersistedImport() async { + final file = await _storedThemeFile(); + if (await file.exists()) { + await file.delete(); + } + } + + static Future _storedThemeFile() async { + final support = await getApplicationSupportDirectory(); + return File(p.join(support.path, 'themes', _storedFileName)); + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index cbf1c5c4..a7461e5e 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -16,8 +16,8 @@ import 'package:flutter/material.dart' as material Widget, RepaintBoundary; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/app_theme.dart'; -import 'package:querya_desktop/core/theme/querya_colors.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -127,37 +127,34 @@ class _MainScreenState extends State { @override material.Widget build(material.BuildContext context) { - final theme = AppTheme.dark.colorScheme; + final scheme = Theme.of(context).colorScheme; return material.Scaffold( - backgroundColor: theme.background, - body: Theme( - data: AppTheme.dark, - child: WindowBorder( - color: theme.border.withValues(alpha: 0.35), - width: 1, - child: Column( - children: [ - _CustomTitleBar( - theme: theme, - onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, - ), - Divider(height: 1, color: theme.border.withValues(alpha: 0.22)), - Expanded( - child: _MainContentSplit( - connectionsPanelKey: _connectionsPanelKey, - workspace: _workspace, - onConnectionSelected: _onConnectionSelected, - onPostgresObjectSelected: _onPostgresObjectSelected, - onMysqlObjectSelected: _onMysqlObjectSelected, - onRedisDatabaseSelected: _onRedisDatabaseSelected, - onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, - onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, - onMysqlOpenSqlWorkspace: _onMysqlOpenSqlWorkspace, - onRequestNewConnection: _openNewConnectionFromHero, - ), + backgroundColor: scheme.background, + body: WindowBorder( + color: scheme.border.withValues(alpha: 0.35), + width: 1, + child: Column( + children: [ + _CustomTitleBar( + theme: scheme, + onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, + ), + Divider(height: 1, color: scheme.border.withValues(alpha: 0.22)), + Expanded( + child: _MainContentSplit( + connectionsPanelKey: _connectionsPanelKey, + workspace: _workspace, + onConnectionSelected: _onConnectionSelected, + onPostgresObjectSelected: _onPostgresObjectSelected, + onMysqlObjectSelected: _onMysqlObjectSelected, + onRedisDatabaseSelected: _onRedisDatabaseSelected, + onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, + onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, + onMysqlOpenSqlWorkspace: _onMysqlOpenSqlWorkspace, + onRequestNewConnection: _openNewConnectionFromHero, ), - ], - ), + ), + ], ), ), ); @@ -343,6 +340,7 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { @override material.Widget build(material.BuildContext context) { final c = widget.theme; + final onDestructive = context.workbench.onAccent; final buttonColors = WindowButtonColors( iconNormal: c.mutedForeground, mouseOver: c.muted.withValues(alpha: 0.5), @@ -352,10 +350,10 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { ); final closeButtonColors = WindowButtonColors( iconNormal: c.mutedForeground, - mouseOver: const Color(0xFFE53935), - mouseDown: const Color(0xFFB71C1C), - iconMouseOver: const Color(0xFFFFFFFF), - iconMouseDown: const Color(0xFFFFFFFF), + mouseOver: c.destructive, + mouseDown: c.destructive.withValues(alpha: 0.85), + iconMouseOver: onDestructive, + iconMouseDown: onDestructive, ); return material.Container( @@ -369,10 +367,10 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { child: Row( children: [ const SizedBox(width: 16), - const material.Icon( + material.Icon( material.Icons.search_rounded, size: 18, - color: QueryaColors.accentCyan, + color: context.workbench.accent, ), const Gap(8), const Text('Querya').semiBold().small(), diff --git a/lib/features/main_screen/query_editor_tab.dart b/lib/features/main_screen/query_editor_tab.dart index ea76336a..10f8f7b7 100644 --- a/lib/features/main_screen/query_editor_tab.dart +++ b/lib/features/main_screen/query_editor_tab.dart @@ -1,8 +1,8 @@ -import 'package:flutter/material.dart' as material - show EdgeInsets, Padding, TextEditingController, TextStyle; -import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:flutter/material.dart' as material show EdgeInsets, Padding, TextEditingController; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; -import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; class QueryEditorTab extends StatelessWidget { const QueryEditorTab({ @@ -19,79 +19,13 @@ class QueryEditorTab extends StatelessWidget { @override Widget build(BuildContext context) { - return _QueryEditorBody(controller: controller, fontSize: fontSize); - } -} - -class _QueryEditorBody extends StatefulWidget { - const _QueryEditorBody({this.controller, required this.fontSize}); - - final material.TextEditingController? controller; - final double fontSize; - - @override - State<_QueryEditorBody> createState() => _QueryEditorBodyState(); -} - -class _QueryEditorBodyState extends State<_QueryEditorBody> { - late material.TextEditingController _owned; - bool _ownController = false; - - @override - void initState() { - super.initState(); - if (widget.controller == null) { - _owned = material.TextEditingController(); - _ownController = true; - } else { - _owned = widget.controller!; - } - } - - @override - void didUpdateWidget(covariant _QueryEditorBody oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.fontSize != widget.fontSize) { - setState(() {}); - } - if (oldWidget.controller != widget.controller) { - if (_ownController) { - _owned.dispose(); - _ownController = false; - } - if (widget.controller == null) { - _owned = material.TextEditingController(); - _ownController = true; - } else { - _owned = widget.controller!; - } - } - } - - @override - void dispose() { - if (_ownController) { - _owned.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); return material.Padding( padding: const material.EdgeInsets.all(12), child: SqlEditorChrome( - child: TextField( - controller: _owned, - maxLines: null, - expands: true, - style: material.TextStyle( - fontFamily: QueryaTypography.mono, - fontSize: widget.fontSize, - color: theme.colorScheme.foreground, - ), - placeholder: const Text('-- Enter SQL here…\nSELECT 1;'), + child: QueryaCodeEditor( + controller: controller, + language: QueryaCodeLanguage.sql, + fontSize: fontSize, ), ), ); diff --git a/lib/features/main_screen/sql_editor_chrome.dart b/lib/features/main_screen/sql_editor_chrome.dart index 88014e14..7dcd62db 100644 --- a/lib/features/main_screen/sql_editor_chrome.dart +++ b/lib/features/main_screen/sql_editor_chrome.dart @@ -1,24 +1,58 @@ import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/theme/querya_colors.dart'; +import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -/// Outer chrome for SQL editors: subtle border, surface fill, soft cyan glow. +/// Outer chrome for SQL editors: border, surface, brand accent glow. class SqlEditorChrome extends StatelessWidget { const SqlEditorChrome({super.key, required this.child}); final Widget child; - static material.BoxDecoration inlineFieldDecoration(ThemeData theme) { - final cs = theme.colorScheme; + static const double outerRadius = 14; + static const double innerRadius = 10; + + /// Accent glow strength; slightly softer on light themes. + static double chromeGlowAlpha(Brightness brightness) => + brightness == Brightness.light ? 0.08 : 0.1; + + static double inlineGlowAlpha(Brightness brightness) => + brightness == Brightness.light ? 0.05 : 0.07; + + /// Toolbar strip above SQL editor (Postgres/MySQL workspaces). + static material.BoxDecoration sqlToolbarDecoration( + BuildContext context, + ) { + final workbench = context.workbench; return material.BoxDecoration( - color: cs.card, - borderRadius: material.BorderRadius.circular(10), + color: workbench.surface.withValues(alpha: 0.85), + border: material.Border( + bottom: material.BorderSide( + color: workbench.borderSubtle.withValues(alpha: 0.35), + ), + ), + ); + } + + /// Decoration for compact SQL fields (dialogs) from theme tokens. + static material.BoxDecoration inlineFieldDecoration( + QueryaEditorTheme editor, + QueryaWorkbenchTheme workbench, { + Brightness brightness = Brightness.dark, + }) { + final border = editor.widgetBorder ?? workbench.borderSubtle; + return material.BoxDecoration( + color: editor.background, + borderRadius: material.BorderRadius.circular(innerRadius), border: material.Border.all( - color: cs.border.withValues(alpha: 0.45), + color: border.withValues(alpha: 0.45), ), boxShadow: [ material.BoxShadow( - color: QueryaColors.accentCyan.withValues(alpha: 0.07), + color: workbench.accent.withValues( + alpha: inlineGlowAlpha(brightness), + ), blurRadius: 18, offset: const material.Offset(0, 6), ), @@ -26,14 +60,29 @@ class SqlEditorChrome extends StatelessWidget { ); } + static material.BoxDecoration inlineFieldDecorationFromContext( + BuildContext context, + ) { + return inlineFieldDecoration( + context.editorTheme, + context.workbench, + brightness: Theme.of(context).brightness, + ); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; - final glow = QueryaColors.accentCyan.withValues(alpha: 0.1); + final editor = context.editorTheme; + final workbench = context.workbench; + final brightness = Theme.of(context).brightness; + final border = editor.widgetBorder ?? workbench.borderSubtle; + final glow = workbench.accent.withValues( + alpha: chromeGlowAlpha(brightness), + ); + return material.Container( decoration: material.BoxDecoration( - borderRadius: material.BorderRadius.circular(14), + borderRadius: material.BorderRadius.circular(outerRadius), boxShadow: [ material.BoxShadow( color: glow, @@ -45,10 +94,10 @@ class SqlEditorChrome extends StatelessWidget { ), child: material.Container( decoration: material.BoxDecoration( - color: cs.card, - borderRadius: material.BorderRadius.circular(14), + color: editor.background, + borderRadius: material.BorderRadius.circular(outerRadius), border: material.Border.all( - color: cs.border.withValues(alpha: 0.5), + color: border.withValues(alpha: 0.5), ), ), clipBehavior: material.Clip.antiAlias, diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index b97cbf6a..dc89ffca 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; -import 'package:querya_desktop/core/theme/querya_colors.dart'; +import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Marketing-style empty workspace: badge, copy, mock window, primary CTA. @@ -15,8 +17,9 @@ class WorkspaceEmptyHero extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = Theme.of(context).colorScheme; + final workbench = context.workbench; + final editorTheme = context.editorTheme; return material.LayoutBuilder( builder: (context, c) { final vw = c.maxWidth; @@ -76,6 +79,8 @@ class WorkspaceEmptyHero extends StatelessWidget { material.SizedBox(height: compact ? 20 : 28), _MockAppWindow( colorScheme: cs, + workbench: workbench, + editorTheme: editorTheme, height: mockH, compact: compact, ), @@ -137,7 +142,7 @@ class _HeroBadge extends StatelessWidget { color: colorScheme.background, borderRadius: material.BorderRadius.circular(999), border: material.Border.all( - color: QueryaColors.accentCyan.withValues(alpha: 0.45), + color: colorScheme.primary.withValues(alpha: 0.45), ), ), child: material.Row( @@ -169,17 +174,21 @@ class _HeroBadge extends StatelessWidget { class _MockAppWindow extends StatelessWidget { const _MockAppWindow({ required this.colorScheme, + required this.workbench, + required this.editorTheme, required this.height, this.compact = false, }); final ColorScheme colorScheme; + final QueryaWorkbenchTheme workbench; + final QueryaEditorTheme editorTheme; final double height; final bool compact; @override Widget build(BuildContext context) { - final glow = QueryaColors.accentCyan.withValues(alpha: 0.14); + final glow = colorScheme.primary.withValues(alpha: 0.14); final sidebarW = compact ? 58.0 : 72.0; final blur = compact ? 28.0 : 40.0; final radius = compact ? 12.0 : 16.0; @@ -198,7 +207,7 @@ class _MockAppWindow extends StatelessWidget { child: material.Container( height: height, decoration: material.BoxDecoration( - color: const Color(0xFF141414), + color: workbench.surface, borderRadius: material.BorderRadius.circular(radius), border: material.Border.all( color: colorScheme.border.withValues(alpha: 0.5), @@ -222,11 +231,11 @@ class _MockAppWindow extends StatelessWidget { ), child: material.Row( children: [ - _trafficDot(const Color(0xFFFF5F57)), + _trafficDot(workbench.destructive), const material.SizedBox(width: 6), - _trafficDot(const Color(0xFFFEBC2E)), + _trafficDot(workbench.warning), const material.SizedBox(width: 6), - _trafficDot(const Color(0xFF28C840)), + _trafficDot(workbench.success), ], ), ), @@ -242,7 +251,7 @@ class _MockAppWindow extends StatelessWidget { compact ? 6 : 8, compact ? 8 : 10, ), - color: const Color(0xFF0F0F0F), + color: workbench.sidebarBackground, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ @@ -284,7 +293,7 @@ class _MockAppWindow extends StatelessWidget { margin: material.EdgeInsets.all(compact ? 8 : 10), padding: material.EdgeInsets.all(compact ? 8 : 10), decoration: material.BoxDecoration( - color: const Color(0xFF0A0A0A), + color: workbench.editorBackground, borderRadius: material.BorderRadius.circular(8), border: material.Border.all( color: colorScheme.border.withValues(alpha: 0.25), @@ -308,7 +317,7 @@ class _MockAppWindow extends StatelessWidget { fontFamily: QueryaTypography.mono, fontSize: compact ? 9 : 11, height: 1.45, - color: const Color(0xFFFBBF24), + color: editorTheme.string, ), ), ], diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index 5f9bd4b2..3c98336b 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -2,7 +2,10 @@ import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -213,6 +216,9 @@ class _MongoDocumentEditorState extends material.State { material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; final shadcnCs = shadcn.Theme.of(context).colorScheme; + final workbench = context.workbench; + final editorTheme = context.editorTheme; + final success = workbench.success; final idStr = widget.document['_id']?.toString() ?? 'New Document'; return material.Column( @@ -308,16 +314,16 @@ class _MongoDocumentEditorState extends material.State { material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 16, vertical: 8), - color: const Color(0xFF4CAF50).withValues(alpha: 0.1), + color: success.withValues(alpha: 0.1), child: Row( children: [ - const material.Icon(material.Icons.check_circle_rounded, - size: 14, color: Color(0xFF4CAF50)), + material.Icon(material.Icons.check_circle_rounded, + size: 14, color: success), const Gap(8), material.Expanded( child: Text(_success!, - style: const material.TextStyle( - color: Color(0xFF4CAF50), fontSize: 12)), + style: material.TextStyle( + color: success, fontSize: 12)), ), ], ), @@ -325,21 +331,13 @@ class _MongoDocumentEditorState extends material.State { // Editor material.Expanded( child: material.Container( - color: cs.card, - child: material.TextField( + color: editorTheme.background, + child: QueryaCodeEditor( controller: _controller, - maxLines: null, - expands: true, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 13, - color: shadcnCs.foreground, - height: 1.5, - ), - decoration: const material.InputDecoration( - border: material.InputBorder.none, - contentPadding: material.EdgeInsets.all(16), - ), + language: QueryaCodeLanguage.json, + fontSize: 13, + variant: QueryaCodeEditorVariant.material, + contentPadding: const material.EdgeInsets.all(16), ), ), ), diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart index 72dba9e5..6fbdcda3 100644 --- a/lib/features/mysql/mysql_sql_editor_dialog.dart +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; -import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -113,24 +114,17 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { child: material.SizedBox( height: 280, child: material.Container( - decoration: SqlEditorChrome.inlineFieldDecoration( - Theme.of(context), + decoration: SqlEditorChrome.inlineFieldDecorationFromContext( + context, ), - child: material.TextField( + child: QueryaCodeEditor( controller: _controller, - maxLines: null, - expands: true, + language: QueryaCodeLanguage.sql, + fontSize: 12, + variant: QueryaCodeEditorVariant.material, textAlignVertical: material.TextAlignVertical.top, - style: material.TextStyle( - fontFamily: QueryaTypography.mono, - fontSize: 12, - color: theme.foreground, - ), - decoration: const material.InputDecoration( - border: material.InputBorder.none, - contentPadding: material.EdgeInsets.all(12), - hintText: 'SELECT …', - ), + hintText: 'SELECT …', + contentPadding: const material.EdgeInsets.all(12), ), ), ), diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 1d959bc2..42386bdc 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -5,10 +5,12 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; import 'package:querya_desktop/features/main_screen/sql_query_history_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -344,12 +346,10 @@ class _MysqlSqlToolbar extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); + final accent = context.workbench.accent; return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), + decoration: SqlEditorChrome.sqlToolbarDecoration(context), child: material.Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, @@ -361,9 +361,10 @@ class _MysqlSqlToolbar extends material.StatelessWidget { OutlineButton( size: ButtonSize.small, onPressed: onOpenHistory, - leading: const material.Icon( + leading: material.Icon( material.Icons.history_rounded, size: 16, + color: accent, ), child: const Text('History'), ), @@ -376,12 +377,13 @@ class _MysqlSqlToolbar extends material.StatelessWidget { height: 16, child: material.CircularProgressIndicator( strokeWidth: 2, - color: theme.colorScheme.primary, + color: accent, ), ) - : const material.Icon( + : material.Icon( material.Icons.play_arrow_rounded, size: 18, + color: accent, ), child: const Text('Execute (F5)'), ), @@ -406,9 +408,10 @@ class _MysqlSqlToolbar extends material.StatelessWidget { const Gap(4), IconButton.ghost( onPressed: running ? null : onOpenPreferences, - icon: const material.Icon( + icon: material.Icon( material.Icons.settings_rounded, size: 20, + color: accent, ), ), ], diff --git a/lib/features/postgresql/postgres_sql_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index 9f2bcd20..8c05490f 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; -import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -140,24 +141,17 @@ class _PostgresSqlEditorDialogState extends material.State<_PostgresSqlEditorDia child: material.SizedBox( height: 280, child: material.Container( - decoration: SqlEditorChrome.inlineFieldDecoration( - Theme.of(context), + decoration: SqlEditorChrome.inlineFieldDecorationFromContext( + context, ), - child: material.TextField( + child: QueryaCodeEditor( controller: _controller, - maxLines: null, - expands: true, + language: QueryaCodeLanguage.sql, + fontSize: 12, + variant: QueryaCodeEditorVariant.material, textAlignVertical: material.TextAlignVertical.top, - style: material.TextStyle( - fontFamily: QueryaTypography.mono, - fontSize: 12, - color: theme.foreground, - ), - decoration: const material.InputDecoration( - border: material.InputBorder.none, - contentPadding: material.EdgeInsets.all(12), - hintText: 'SELECT …', - ), + hintText: 'SELECT …', + contentPadding: const material.EdgeInsets.all(12), ), ), ), diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 6df37479..f870a4c7 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -7,12 +7,14 @@ import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; import 'package:querya_desktop/features/main_screen/sql_query_history_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -517,12 +519,10 @@ class _SqlToolbar extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); + final accent = context.workbench.accent; return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), + decoration: SqlEditorChrome.sqlToolbarDecoration(context), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, @@ -538,9 +538,10 @@ class _SqlToolbar extends material.StatelessWidget { OutlineButton( size: ButtonSize.small, onPressed: onOpenHistory, - leading: const material.Icon( + leading: material.Icon( material.Icons.history_rounded, size: 16, + color: accent, ), child: const Text('History'), ), @@ -553,12 +554,13 @@ class _SqlToolbar extends material.StatelessWidget { height: 16, child: material.CircularProgressIndicator( strokeWidth: 2, - color: theme.colorScheme.primary, + color: accent, ), ) - : const material.Icon( + : material.Icon( material.Icons.play_arrow_rounded, size: 18, + color: accent, ), child: const Text('Execute (F5)'), ), @@ -594,9 +596,10 @@ class _SqlToolbar extends material.StatelessWidget { const Gap(4), IconButton.ghost( onPressed: running ? null : onOpenPreferences, - icon: const material.Icon( + icon: material.Icon( material.Icons.settings_rounded, size: 20, + color: accent, ), ), ], diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart new file mode 100644 index 00000000..84fe0961 --- /dev/null +++ b/lib/features/settings/preferences_appearance_section.dart @@ -0,0 +1,213 @@ +import 'dart:async' show unawaited; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_import_service.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Appearance / theme controls for [PreferencesDialog]. +class PreferencesAppearanceSection extends material.StatefulWidget { + const PreferencesAppearanceSection({super.key}); + + @override + material.State createState() => + _PreferencesAppearanceSectionState(); +} + +class _PreferencesAppearanceSectionState + extends material.State { + final _controller = ThemeController.instance; + String? _importError; + bool _importing = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_onThemeChanged); + } + + @override + void dispose() { + _controller.removeListener(_onThemeChanged); + super.dispose(); + } + + void _onThemeChanged() { + if (mounted) setState(() {}); + } + + Future _setThemeMode(ThemeMode mode) async { + await _controller.setThemeMode(mode); + } + + Future _setPreset(QueryaThemePreset preset) async { + await _controller.setPreset(preset); + } + + Future _pickAndImportTheme() async { + setState(() { + _importing = true; + _importError = null; + }); + try { + final file = await openFile( + acceptedTypeGroups: const [ + XTypeGroup( + label: 'VS Code theme', + extensions: ['json', 'jsonc'], + ), + ], + ); + if (file == null) return; + final path = file.path; + if (path.isEmpty) return; + final result = await _controller.importThemeFromFile(path); + if (!mounted) return; + switch (result) { + case ThemeImportSuccess(): + setState(() => _importError = null); + case ThemeImportFailure(:final message): + setState(() => _importError = message); + } + } finally { + if (mounted) { + setState(() => _importing = false); + } + } + } + + Future _resetAppearance() async { + await _controller.resetToDefaults(); + if (mounted) setState(() => _importError = null); + } + + Future _setThemeAnimation(bool enabled) async { + await _controller.setThemeAnimationEnabled(enabled); + } + + @override + material.Widget build(material.BuildContext context) { + final c = _controller; + final importedLabel = c.hasImportedTheme + ? 'Imported: ${c.importedThemeName ?? 'theme'}' + : 'Imported theme (none)'; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Appearance').semiBold().small().foreground(), + const material.SizedBox(height: 8), + material.Row( + children: [ + const Text('Theme mode').small().foreground(), + const material.SizedBox(width: 12), + PreferencesDropdownMenu( + value: c.themeMode, + onSelected: (v) { + if (v != null) unawaited(_setThemeMode(v)); + }, + entries: const [ + material.DropdownMenuEntry( + value: ThemeMode.dark, + label: 'Dark', + ), + material.DropdownMenuEntry( + value: ThemeMode.light, + label: 'Light', + ), + material.DropdownMenuEntry( + value: ThemeMode.system, + label: 'System', + ), + ], + ), + ], + ), + const material.SizedBox(height: 12), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Padding( + padding: const material.EdgeInsets.only(top: 10), + child: const Text('Color preset').small().foreground(), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: PreferencesDropdownMenu( + value: c.preset, + expandToParent: true, + onSelected: (v) { + if (v != null) unawaited(_setPreset(v)); + }, + entries: [ + const material.DropdownMenuEntry( + value: QueryaThemePreset.queryaDark, + label: 'Querya Dark', + ), + const material.DropdownMenuEntry( + value: QueryaThemePreset.queryaLight, + label: 'Querya Light', + ), + material.DropdownMenuEntry( + value: QueryaThemePreset.imported, + enabled: c.hasImportedTheme, + label: importedLabel, + ), + ], + ), + ), + ], + ), + const material.SizedBox(height: 12), + material.Row( + children: [ + const Text('Animate theme changes').small().foreground(), + const material.SizedBox(width: 12), + material.Switch( + value: c.themeAnimationEnabled, + onChanged: (v) => unawaited(_setThemeAnimation(v)), + ), + ], + ), + const material.SizedBox(height: 4), + const PreferencesHint( + 'Smooth transitions when switching dark/light or presets. Off by default for stability.', + ), + const material.SizedBox(height: 12), + material.Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlineButton( + onPressed: + _importing ? null : () => unawaited(_pickAndImportTheme()), + child: material.Text(_importing ? 'Importing…' : 'Import theme…'), + ), + OutlineButton( + onPressed: () => unawaited(_resetAppearance()), + child: const Text('Reset appearance'), + ), + ], + ), + if (_importError != null) ...[ + const material.SizedBox(height: 8), + material.Text( + _importError!, + 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.', + ), + ], + ); + } +} diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart new file mode 100644 index 00000000..0029c287 --- /dev/null +++ b/lib/features/settings/preferences_controls.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Helper / hint copy in Preferences — readable on imported themes. +class PreferencesHint extends StatelessWidget { + const PreferencesHint(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Text( + text, + style: TextStyle( + fontSize: 12, + height: 1.35, + color: cs.popoverForeground.withValues(alpha: 0.72), + ), + ); + } +} + +/// Material 3 dropdown anchored to the field (stable inside scroll views). +class PreferencesDropdownMenu extends StatelessWidget { + const PreferencesDropdownMenu({ + super.key, + required this.value, + required this.entries, + required this.onSelected, + this.width, + this.expandToParent = false, + this.enabled = true, + }); + + final T value; + final List> entries; + final ValueChanged onSelected; + + /// Fixed width for field + menu. Do not pass [double.infinity] — menu glitches. + final double? width; + + /// Fill [Expanded] parent width without stretching the popup to screen width. + final bool expandToParent; + final bool enabled; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final mTheme = material.Theme.of(context); + final textStyle = material.TextStyle( + color: cs.popoverForeground, + fontSize: 14, + ); + + return material.Theme( + data: mTheme.copyWith( + canvasColor: cs.popover, + colorScheme: mTheme.colorScheme.copyWith( + surface: cs.popover, + onSurface: cs.popoverForeground, + ), + ), + child: material.DropdownMenu( + enabled: enabled, + width: width, + expandedInsets: + expandToParent ? material.EdgeInsets.zero : null, + initialSelection: value, + onSelected: enabled ? onSelected : null, + dropdownMenuEntries: entries, + textStyle: textStyle, + inputDecorationTheme: material.InputDecorationTheme( + isDense: true, + contentPadding: const material.EdgeInsets.symmetric(vertical: 6), + enabledBorder: material.UnderlineInputBorder( + borderSide: material.BorderSide(color: cs.border), + ), + focusedBorder: material.UnderlineInputBorder( + borderSide: material.BorderSide(color: cs.ring, width: 2), + ), + ), + menuStyle: material.MenuStyle( + backgroundColor: material.WidgetStatePropertyAll(cs.popover), + surfaceTintColor: material.WidgetStatePropertyAll(cs.popover), + elevation: const material.WidgetStatePropertyAll(8), + ), + ), + ); + } +} diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 701a7ce5..1a84d5f1 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -3,6 +3,8 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/features/settings/preferences_appearance_section.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -25,7 +27,8 @@ class _PreferencesDialogContent extends material.StatefulWidget { _PreferencesDialogContentState(); } -class _PreferencesDialogContentState extends material.State<_PreferencesDialogContent> { +class _PreferencesDialogContentState + extends material.State<_PreferencesDialogContent> { bool _loading = true; int? _pgTimeout; int? _mysqlTimeout; @@ -85,202 +88,232 @@ class _PreferencesDialogContentState extends material.State<_PreferencesDialogCo material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; - return material.Container( - constraints: const material.BoxConstraints( - maxWidth: 480, - minWidth: 360, - maxHeight: 560, - ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Preferences').large().semiBold(), - const material.SizedBox(height: 6), - // ignore: prefer_const_constructors — TextStyle via shadcn extensions - Text( - 'Changes apply immediately. SQL timeouts are global for all connections of that type.', - ).muted().small(), - ], - ), - ), - material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 8), - child: _loading - ? const material.Center( - child: material.Padding( - padding: material.EdgeInsets.all(24), - child: material.CircularProgressIndicator(), - ), - ) - : material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('SQL — PostgreSQL').semiBold().small(), - const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Statement timeout').small(), - const material.SizedBox(width: 12), - SqlStatementTimeoutDropdown( - value: _pgTimeout, - onChanged: (v) => unawaited(_setPg(v)), - ), - ], - ), - const material.SizedBox(height: 24), - const Text('SQL — MySQL / MariaDB').semiBold().small(), - const material.SizedBox(height: 8), - material.Row( + final onPopover = theme.popoverForeground; + return material.DefaultTextStyle( + style: material.TextStyle(color: onPopover), + child: material.IconTheme( + data: material.IconThemeData(color: onPopover), + child: material.Container( + constraints: const material.BoxConstraints( + maxWidth: 480, + minWidth: 360, + maxHeight: 640, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.border), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Preferences').large().semiBold().foreground(), + const material.SizedBox(height: 6), + const PreferencesHint( + 'Changes apply immediately. SQL timeouts are global for all connections of that type.', + ), + ], + ), + ), + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 8), + child: _loading + ? const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(24), + child: material.CircularProgressIndicator(), + ), + ) + : material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, children: [ - const Text('Statement timeout').small(), - const material.SizedBox(width: 12), - SqlStatementTimeoutDropdown( - value: _mysqlTimeout, - onChanged: (v) => unawaited(_setMysql(v)), + const PreferencesAppearanceSection(), + const material.SizedBox(height: 24), + const Text('SQL — PostgreSQL') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + material.Row( + children: [ + const Text('Statement timeout') + .small() + .foreground(), + const material.SizedBox(width: 12), + SqlStatementTimeoutDropdown( + value: _pgTimeout, + onChanged: (v) => unawaited(_setPg(v)), + ), + ], ), - ], - ), - const material.SizedBox(height: 24), - const Text('SQL editor').semiBold().small(), - const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Max rows in results').small(), - const material.SizedBox(width: 12), - material.DropdownButton( - value: _maxRows, - onChanged: (v) { - if (v != null) unawaited(_setMaxRows(v)); - }, - items: [ - for (final n in kSqlResultMaxRowsPresets) - material.DropdownMenuItem( - value: n, - child: material.Text('$n'), - ), + const material.SizedBox(height: 24), + const Text('SQL — MySQL / MariaDB') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + material.Row( + children: [ + const Text('Statement timeout') + .small() + .foreground(), + const material.SizedBox(width: 12), + SqlStatementTimeoutDropdown( + value: _mysqlTimeout, + onChanged: (v) => unawaited(_setMysql(v)), + ), ], ), - ], - ), - const material.SizedBox(height: 12), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Padding( - padding: const material.EdgeInsets.only(top: 8), - child: const Text('Query history limit').small(), + const material.SizedBox(height: 24), + const Text('SQL editor') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + material.Row( + children: [ + const Text('Max rows in results') + .small() + .foreground(), + const material.SizedBox(width: 12), + PreferencesDropdownMenu( + value: _maxRows, + onSelected: (v) { + if (v != null) unawaited(_setMaxRows(v)); + }, + entries: [ + for (final n in kSqlResultMaxRowsPresets) + material.DropdownMenuEntry( + value: n, + label: '$n', + ), + ], + ), + ], ), - const material.SizedBox(width: 12), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - material.DropdownButton( - value: _historyMax, - isExpanded: true, - onChanged: (v) { - if (v != null) { - unawaited(_setHistoryMax(v)); - } - }, - items: [ - for (final n - in kSqlHistoryMaxEntriesPresets) - material.DropdownMenuItem( - value: n, - child: material.Text('$n entries'), - ), + const material.SizedBox(height: 12), + material.Row( + crossAxisAlignment: + material.CrossAxisAlignment.start, + children: [ + material.Padding( + padding: + const material.EdgeInsets.only(top: 8), + child: const Text('Query history limit') + .small() + .foreground(), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + children: [ + PreferencesDropdownMenu( + value: _historyMax, + expandToParent: true, + onSelected: (v) { + if (v != null) { + unawaited(_setHistoryMax(v)); + } + }, + entries: [ + for (final n + in kSqlHistoryMaxEntriesPresets) + material.DropdownMenuEntry( + value: n, + label: '$n entries', + ), + ], + ), + const material.SizedBox(height: 4), + const PreferencesHint( + 'Per connection and database; oldest queries are dropped.', + ), ], ), - const material.SizedBox(height: 4), - const Text( - 'Per connection and database; oldest queries are dropped.', - ).muted().xSmall(), - ], - ), - ), - ], - ), - const material.SizedBox(height: 12), - material.Row( - children: [ - const Text('Font size').small(), - const material.SizedBox(width: 12), - material.DropdownButton( - value: _fontSize, - onChanged: (v) { - if (v != null) unawaited(_setFont(v)); - }, - items: const [ - material.DropdownMenuItem( - value: 11.0, - child: material.Text('11 pt'), - ), - material.DropdownMenuItem( - value: 12.0, - child: material.Text('12 pt'), - ), - material.DropdownMenuItem( - value: 13.0, - child: material.Text('13 pt'), - ), - material.DropdownMenuItem( - value: 14.0, - child: material.Text('14 pt'), ), - material.DropdownMenuItem( - value: 16.0, - child: material.Text('16 pt'), - ), - material.DropdownMenuItem( - value: 18.0, - child: material.Text('18 pt'), + ], + ), + const material.SizedBox(height: 12), + material.Row( + children: [ + const Text('Font size').small().foreground(), + const material.SizedBox(width: 12), + PreferencesDropdownMenu( + value: _fontSize, + onSelected: (v) { + if (v != null) unawaited(_setFont(v)); + }, + entries: const [ + material.DropdownMenuEntry( + value: 11.0, + label: '11 pt', + ), + material.DropdownMenuEntry( + value: 12.0, + label: '12 pt', + ), + material.DropdownMenuEntry( + value: 13.0, + label: '13 pt', + ), + material.DropdownMenuEntry( + value: 14.0, + label: '14 pt', + ), + material.DropdownMenuEntry( + value: 16.0, + label: '16 pt', + ), + material.DropdownMenuEntry( + value: 18.0, + label: '18 pt', + ), + ], ), ], ), + const material.SizedBox(height: 16), + const PreferencesHint( + 'Preferences are stored locally in SQLite (non-secret keys only).', + ), ], ), - const material.SizedBox(height: 16), - // ignore: prefer_const_constructors — TextStyle via shadcn extensions - Text( - 'Preferences are stored locally in SQLite (non-secret keys only).', - ).muted().xSmall(), - ], - ), - ), - ), - material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide(color: theme.border.withValues(alpha: 0.3)), + ), ), - ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - PrimaryButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), + ), ), - ], - ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], ), - ], + ), ), ), ); diff --git a/lib/features/settings/sql_statement_timeout_dropdown.dart b/lib/features/settings/sql_statement_timeout_dropdown.dart index 80fc9c76..fa0848f0 100644 --- a/lib/features/settings/sql_statement_timeout_dropdown.dart +++ b/lib/features/settings/sql_statement_timeout_dropdown.dart @@ -1,17 +1,18 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; /// Shared dropdown values for SQL statement timeouts (PostgreSQL / MySQL). -const List> kSqlStatementTimeoutMenuItems = [ - material.DropdownMenuItem( +const List> kSqlStatementTimeoutMenuEntries = [ + material.DropdownMenuEntry( value: null, - child: material.Text('No limit'), + label: 'No limit', ), - material.DropdownMenuItem(value: 10, child: material.Text('10 s')), - material.DropdownMenuItem(value: 30, child: material.Text('30 s')), - material.DropdownMenuItem(value: 60, child: material.Text('60 s')), - material.DropdownMenuItem(value: 120, child: material.Text('2 min')), - material.DropdownMenuItem(value: 300, child: material.Text('5 min')), - material.DropdownMenuItem(value: 600, child: material.Text('10 min')), + material.DropdownMenuEntry(value: 10, label: '10 s'), + material.DropdownMenuEntry(value: 30, label: '30 s'), + material.DropdownMenuEntry(value: 60, label: '60 s'), + material.DropdownMenuEntry(value: 120, label: '2 min'), + material.DropdownMenuEntry(value: 300, label: '5 min'), + material.DropdownMenuEntry(value: 600, label: '10 min'), ]; /// Statement timeout selector used in SQL toolbars and Preferences. @@ -29,10 +30,11 @@ class SqlStatementTimeoutDropdown extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - return material.DropdownButton( + return PreferencesDropdownMenu( value: value, - onChanged: enabled ? onChanged : null, - items: kSqlStatementTimeoutMenuItems, + enabled: enabled, + onSelected: onChanged, + entries: kSqlStatementTimeoutMenuEntries, ); } } diff --git a/lib/main.dart b/lib/main.dart index cceb337c..14dbd026 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,11 +2,15 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart'; import 'app/app.dart'; +import 'core/editor/syntax_highlight_service.dart'; import 'core/storage/local_db.dart'; +import 'core/theme/theme_controller.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await LocalDb.initFfi(); + await SyntaxHighlightService.ensureInitialized(); + await ThemeController.instance.load(); runApp(const QueryaApp()); doWhenWindowReady(() { final win = appWindow; diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index c3070bc4..49d2eb91 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,8 @@ #include #include #include +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) bitsdojo_window_linux_registrar = @@ -20,4 +22,10 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); + irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); + g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); + super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 5d0c3733..0516a6e3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,8 @@ list(APPEND FLUTTER_PLUGIN_LIST bitsdojo_window_linux file_selector_linux flutter_secure_storage_linux + irondash_engine_context + super_native_extensions ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1f85d46f..5644fad5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,13 +6,19 @@ import FlutterMacOS import Foundation import bitsdojo_window_macos +import device_info_plus import file_selector_macos import flutter_secure_storage_macos +import irondash_engine_context import sqflite_darwin +import super_native_extensions func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index 1af0c98f..c244f389 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.2.1+3 +version: 0.3.0+4 @@ -24,6 +24,7 @@ dependencies: mysql_client: ^0.0.27 flutter_secure_storage: ^9.2.4 file_selector: ^1.1.0 + syntax_highlight: ^0.5.0 dev_dependencies: flutter_test: diff --git a/test/core/editor/highlighter_theme_from_querya_test.dart b/test/core/editor/highlighter_theme_from_querya_test.dart new file mode 100644 index 00000000..c12ddcfb --- /dev/null +++ b/test/core/editor/highlighter_theme_from_querya_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/editor/highlighter_theme_from_querya.dart'; +import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + await SyntaxHighlightService.ensureInitialized(); + }); + + test('highlighterThemeFromQueryaEditor produces SQL spans', () { + final theme = highlighterThemeFromQueryaEditor(QueryaTheme.darkDefault.editor); + final highlighter = Highlighter(language: 'sql', theme: theme); + final span = highlighter.highlight('SELECT 1 -- comment'); + expect(span.children, isNotNull); + expect(span.children!.length, greaterThan(1)); + }); + + test('highlighterThemeFromQueryaEditor produces JSON spans', () { + final theme = highlighterThemeFromQueryaEditor(QueryaTheme.darkDefault.editor); + final highlighter = Highlighter(language: 'json', theme: theme); + const sample = '{"name": "x", "count": 1, "ok": true, "nil": null}'; + final span = highlighter.highlight(sample); + expect(span.children, isNotNull); + expect(span.children!.length, greaterThan(3)); + }); +} diff --git a/test/core/editor/querya_code_editor_test.dart b/test/core/editor/querya_code_editor_test.dart new file mode 100644 index 00000000..33f1dc22 --- /dev/null +++ b/test/core/editor/querya_code_editor_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + await SyntaxHighlightService.ensureInitialized(); + }); + testWidgets('QueryaCodeEditor shadcn applies fontSize from props', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.SizedBox( + width: 400, + height: 200, + child: QueryaCodeEditor( + language: QueryaCodeLanguage.sql, + fontSize: 17, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final editable = tester.widget( + find.byType(material.EditableText), + ); + expect(editable.style.fontSize, 17); + }); + + testWidgets('QueryaCodeEditor material variant uses editor foreground', (tester) async { + final theme = QueryaTheme.darkDefault.copyWith( + editor: QueryaTheme.darkDefault.editor.copyWith( + foreground: const Color(0xFFABCDEF), + ), + ); + await tester.pumpWidget( + queryaThemeTestShell( + data: theme, + child: const material.SizedBox( + width: 400, + height: 200, + child: QueryaCodeEditor( + language: QueryaCodeLanguage.json, + variant: QueryaCodeEditorVariant.material, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final field = tester.widget(find.byType(material.TextField)); + expect(field.style?.color, const Color(0xFFABCDEF)); + }); + + testWidgets('SQL highlighting keeps external controller in sync', (tester) async { + final external = material.TextEditingController(); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 400, + height: 200, + child: QueryaCodeEditor( + controller: external, + language: QueryaCodeLanguage.sql, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(material.EditableText), 'SELECT 1'); + await tester.pump(); + expect(external.text, 'SELECT 1'); + external.text = 'UPDATE x'; + await tester.pump(); + expect( + tester.widget(find.byType(material.EditableText)).controller.text, + 'UPDATE x', + ); + }); + + testWidgets('onChanged fires when text updates', (tester) async { + var last = ''; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 400, + height: 200, + child: QueryaCodeEditor( + language: QueryaCodeLanguage.plain, + onChanged: (v) => last = v, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(material.EditableText), 'hello'); + expect(last, 'hello'); + }); +} diff --git a/test/core/editor/syntax_highlight_isolate_test.dart b/test/core/editor/syntax_highlight_isolate_test.dart new file mode 100644 index 00000000..97be6345 --- /dev/null +++ b/test/core/editor/syntax_highlight_isolate_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/editor/syntax_highlight_isolate.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; +import 'package:querya_desktop/core/theme/parser/token_colors_highlighter_config.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + await SyntaxHighlightService.ensureInitialized(); + }); + + test('large SQL buffer highlights off main thread', () async { + final code = List.filled(500, 'SELECT id FROM users; -- row').join('\n'); + expect(code.length, greaterThan(kSyntaxHighlightIsolateThreshold)); + + final config = buildDefaultEditorHighlighterConfig( + QueryaTheme.darkDefault.editor, + ); + final segments = await highlightOffMainThread( + SyntaxHighlightJob( + code: code, + language: 'sql', + themeConfigJson: config, + grammarJson: SyntaxHighlightService.grammarJsonFor( + QueryaCodeLanguage.sql, + ), + wrapperArgb: QueryaTheme.darkDefault.editor.foreground.toARGB32(), + ), + ); + + expect(segments, isNotEmpty); + expect(segments.map((s) => s.text).join(), code); + }); +} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 7a50557d..5b1a0d99 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; /// path_provider has no implementation in plain `flutter test`; LocalDb needs a path. class _FakePathProvider extends PathProviderPlatform { @@ -63,6 +65,7 @@ void main() { await LocalDb.instance.deleteAppSetting(AppSettingsKeys.sqlResultMaxRows); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.sqlEditorFontSizePoints); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.sqlHistoryMaxEntries); + await AppSettings.instance.clearThemeSettings(); }); group('AppSettings', () { @@ -178,6 +181,54 @@ void main() { }); }); + group('theme settings', () { + test('theme mode and preset roundtrip', () async { + expect(await AppSettings.instance.getThemeMode(), ThemeMode.dark); + expect( + await AppSettings.instance.getThemePreset(), + QueryaThemePreset.queryaDark, + ); + + await AppSettings.instance.setThemeMode(ThemeMode.light); + await AppSettings.instance.setThemePreset(QueryaThemePreset.queryaLight); + expect(await AppSettings.instance.getThemeMode(), ThemeMode.light); + expect( + await AppSettings.instance.getThemePreset(), + QueryaThemePreset.queryaLight, + ); + + await AppSettings.instance.clearThemeSettings(); + expect(await AppSettings.instance.getThemeMode(), ThemeMode.dark); + }); + + test('theme animation defaults off and roundtrip', () async { + expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse); + + await AppSettings.instance.setThemeAnimationEnabled(true); + expect(await AppSettings.instance.getThemeAnimationEnabled(), isTrue); + + await AppSettings.instance.setThemeAnimationEnabled(false); + expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse); + + await AppSettings.instance.setThemeAnimationEnabled(true); + await AppSettings.instance.clearThemeSettings(); + expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse); + }); + + test('theme color overrides json roundtrip', () async { + await AppSettings.instance.setThemeColorOverrides({ + 'sideBar.background': '#ff0000', + 'editor.background': '#1e1e1e', + }); + expect(await AppSettings.instance.getThemeColorOverrides(), { + 'sideBar.background': '#ff0000', + 'editor.background': '#1e1e1e', + }); + await AppSettings.instance.clearThemeColorOverrides(); + expect(await AppSettings.instance.getThemeColorOverrides(), isEmpty); + }); + }); + group('AppSettingsRevision', () { test('bump increments listenable value', () { final start = AppSettingsRevision.listenable.value; diff --git a/test/core/theme/color_contrast_test.dart b/test/core/theme/color_contrast_test.dart new file mode 100644 index 00000000..f89a4e2e --- /dev/null +++ b/test/core/theme/color_contrast_test.dart @@ -0,0 +1,33 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/color_contrast.dart'; + +void main() { + test('legibleSecondaryLabel keeps readable candidate', () { + const candidate = Color(0xFF94A3B8); + const background = Color(0xFF0C0C0C); + const fallback = Color(0xFFF8FAFC); + expect( + legibleSecondaryLabel( + candidate: candidate, + background: background, + fallback: fallback, + ), + candidate, + ); + }); + + test('legibleSecondaryLabel softens low-contrast candidate', () { + const candidate = Color(0xFF4A3F7A); + const background = Color(0xFF14102A); + const fallback = Color(0xFFE8F4FF); + final out = legibleSecondaryLabel( + candidate: candidate, + background: background, + fallback: fallback, + ); + expect(out, fallback.withValues(alpha: 0.72)); + expect(contrastRatio(out, background), greaterThan(4.0)); + }); +} diff --git a/test/core/theme/parser/apply_token_colors_to_editor_test.dart b/test/core/theme/parser/apply_token_colors_to_editor_test.dart new file mode 100644 index 00000000..1320eccd --- /dev/null +++ b/test/core/theme/parser/apply_token_colors_to_editor_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/apply_token_colors_to_editor.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; + +void main() { + test('applyTokenColorsToEditor maps comment and keyword scopes', () { + const rules = [ + TokenColorRule(scopes: ['comment'], foreground: '#111111'), + TokenColorRule(scopes: ['keyword'], foreground: '#222222'), + TokenColorRule(scopes: ['string'], foreground: '#333333'), + ]; + + const base = QueryaEditorTheme.darkDefault; + final next = applyTokenColorsToEditor(base, rules); + + expect(next.comment, const Color(0xFF111111)); + expect(next.keyword, const Color(0xFF222222)); + expect(next.string, const Color(0xFF333333)); + expect(next.foreground, base.foreground); + }); + + test('empty rules returns unchanged editor theme', () { + const base = QueryaEditorTheme.darkDefault; + expect(applyTokenColorsToEditor(base, const []), base); + }); +} diff --git a/test/core/theme/parser/color_parser_test.dart b/test/core/theme/parser/color_parser_test.dart new file mode 100644 index 00000000..f928edbc --- /dev/null +++ b/test/core/theme/parser/color_parser_test.dart @@ -0,0 +1,37 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart' + show formatVsCodeColor, parseVsCodeColor; + +void main() { + group('parseVsCodeColor', () { + test('6-digit hex', () { + expect(parseVsCodeColor('#1e1e1e'), const Color(0xFF1E1E1E)); + }); + + test('8-digit RRGGBBAA', () { + final c = parseVsCodeColor('#11223344'); + expect((c.a * 255).round(), 0x44); + }); + + test('3-digit shorthand', () { + expect(parseVsCodeColor('#abc'), const Color(0xFFAABBCC)); + }); + + test('4-digit shorthand with alpha', () { + final c = parseVsCodeColor('#abcd'); + expect(c, isA()); + }); + + test('invalid throws', () { + expect(() => parseVsCodeColor('nope'), throwsFormatException); + }); + + test('formatVsCodeColor roundtrip', () { + const c = Color(0xFF1E1E1E); + expect(formatVsCodeColor(c), '#1e1e1e'); + expect(parseVsCodeColor(formatVsCodeColor(c)), c); + }); + }); +} diff --git a/test/core/theme/parser/jsonc_preprocessor_test.dart b/test/core/theme/parser/jsonc_preprocessor_test.dart new file mode 100644 index 00000000..77c94752 --- /dev/null +++ b/test/core/theme/parser/jsonc_preprocessor_test.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/jsonc_preprocessor.dart'; + +void main() { + group('stripJsonc', () { + test('removes line comments outside strings', () { + const input = ''' +{ + // sidebar + "a": 1 +} +'''; + final out = stripJsonc(input); + expect(out.contains('//'), isFalse); + expect(out.contains('"a"'), isTrue); + }); + + test('preserves // inside string', () { + const input = '{"x": "http://example.com"}'; + expect(stripJsonc(input), contains('http://')); + }); + + test('removes block comments', () { + const input = '{ /* block */ "k": 2 }'; + final out = stripJsonc(input); + expect(out.contains('/*'), isFalse); + expect(out.contains('"k"'), isTrue); + }); + + test('removes trailing comma', () { + const input = '{"a": 1,}'; + expect(stripJsonc(input), '{"a": 1}'); + }); + + test('parses invalid-trailing-comma.jsonc fixture via manifest', () { + final raw = File('test/fixtures/themes/invalid-trailing-comma.jsonc') + .readAsStringSync(); + final cleaned = stripJsonc(raw); + expect(cleaned.contains('//'), isFalse); + expect(cleaned.contains(',}'), isFalse); + expect(cleaned, contains('"editor.background"')); + }); + }); +} diff --git a/test/core/theme/parser/querya_theme_merge_pipeline_test.dart b/test/core/theme/parser/querya_theme_merge_pipeline_test.dart new file mode 100644 index 00000000..df4b7177 --- /dev/null +++ b/test/core/theme/parser/querya_theme_merge_pipeline_test.dart @@ -0,0 +1,61 @@ +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_colors_merge.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('theme merge pipeline', () { + test('default → imported → user overrides', () { + const defaultLayer = { + 'editor.background': '#1e1e1e', + 'sideBar.background': '#252526', + }; + const importedLayer = { + 'editor.background': '#2d2d30', + 'sideBar.background': '#333333', + }; + const userLayer = { + 'sideBar.background': '#ff0000', + }; + + final merged = mergeVsCodeColorLayers([ + defaultLayer, + importedLayer, + userLayer, + ]); + + final theme = buildQueryaThemeFromVsCodeColors( + brightness: Brightness.dark, + colors: merged, + fallback: QueryaTheme.darkDefault, + ); + + expect(theme.workbench.editorBackground, const Color(0xFF2D2D30)); + expect(theme.workbench.sidebarBackground, const Color(0xFFFF0000)); + }); + + test('manifest import then user override on same key', () { + const src = ''' +{ + "type": "dark", + "colors": { + "editor.background": "#1e1e1e" + } +} +'''; + final imported = VsCodeThemeManifest.fromJsonString(src).colors; + final merged = mergeVsCodeColorLayers([ + imported, + {'editor.background': '#abcdef'}, + ]); + final theme = buildQueryaThemeFromVsCodeColors( + brightness: Brightness.dark, + colors: merged, + fallback: QueryaTheme.darkDefault, + ); + expect(theme.workbench.editorBackground, const Color(0xFFABCDEF)); + }); + }); +} diff --git a/test/core/theme/parser/theme_fixtures_integration_test.dart b/test/core/theme/parser/theme_fixtures_integration_test.dart new file mode 100644 index 00000000..e954ceef --- /dev/null +++ b/test/core/theme/parser/theme_fixtures_integration_test.dart @@ -0,0 +1,41 @@ +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_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + group('theme fixtures', () { + test('dracula_tokens.json parses colors and tokenColors', () { + final raw = + File('test/fixtures/themes/dracula_tokens.json').readAsStringSync(); + final manifest = VsCodeThemeManifest.fromJsonString(raw); + expect(manifest.name, 'Dracula Fixture'); + expect(manifest.isDark, isTrue); + expect(manifest.colors['editor.background'], '#282a36'); + expect(manifest.tokenColors.length, 4); + }); + + test('one_dark.json builds QueryaTheme with editor background', () { + final raw = File('test/fixtures/themes/one_dark.json').readAsStringSync(); + final manifest = VsCodeThemeManifest.fromJsonString(raw); + final theme = buildQueryaThemeFromVsCodeManifest( + manifest, + fallback: QueryaTheme.darkDefault, + ); + expect(theme.workbench.editorBackground, const Color(0xFF282C34)); + expect(theme.tokenColors.length, 3); + expect(theme.editor.comment, const Color(0xFF5C6370)); + }); + + test('invalid-trailing-comma.jsonc parses after JSONC strip', () { + final raw = File('test/fixtures/themes/invalid-trailing-comma.jsonc') + .readAsStringSync(); + final manifest = VsCodeThemeManifest.fromJsonString(raw); + expect(manifest.colors['editor.background'], '#282c34'); + expect(manifest.tokenColors.single.scopes, ['comment']); + }); + }); +} diff --git a/test/core/theme/parser/token_colors_codec_test.dart b/test/core/theme/parser/token_colors_codec_test.dart new file mode 100644 index 00000000..cb553616 --- /dev/null +++ b/test/core/theme/parser/token_colors_codec_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/token_colors_codec.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; + +void main() { + test('tokenColorRulesToJson round-trips rules', () { + const rules = [ + TokenColorRule( + scopes: ['comment', 'comment.line'], + foreground: '#6272a4', + fontStyle: 'italic', + ), + TokenColorRule( + scopes: ['keyword'], + foreground: '#ff79c6', + ), + ]; + + final json = tokenColorRulesToJson(rules); + final restored = tokenColorRulesFromJson(json); + + expect(restored.length, 2); + expect(restored.first.scopes, ['comment', 'comment.line']); + expect(restored.first.foreground, '#6272a4'); + expect(restored.first.fontStyle, 'italic'); + expect(restored[1].scopes, ['keyword']); + }); +} diff --git a/test/core/theme/parser/token_colors_dracula_sql_test.dart b/test/core/theme/parser/token_colors_dracula_sql_test.dart new file mode 100644 index 00000000..f2d8a340 --- /dev/null +++ b/test/core/theme/parser/token_colors_dracula_sql_test.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/editor/highlighter_theme_from_querya.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + await Highlighter.initialize(['sql']); + }); + + test('Dracula-like tokenColors distinguish comment, keyword, string in SQL', () { + final raw = File('test/fixtures/themes/dracula_tokens.json').readAsStringSync(); + final manifest = VsCodeThemeManifest.fromJsonString(raw); + final theme = highlighterThemeFromQueryaEditor( + QueryaTheme.darkDefault.editor, + tokenColors: manifest.tokenColors, + ); + final highlighter = Highlighter(language: 'sql', theme: theme); + const sql = 'SELECT 1 -- note\n\'hello\''; + final span = highlighter.highlight(sql); + final colors = _collectColors(span); + expect(colors.length, greaterThanOrEqualTo(3)); + expect(colors.toSet().length, greaterThanOrEqualTo(3)); + }); +} + +Set _collectColors(TextSpan span) { + final colors = {}; + void walk(TextSpan node) { + final c = node.style?.color; + if (c != null) colors.add(c); + if (node.children != null) { + for (final child in node.children!) { + if (child is TextSpan) walk(child); + } + } + } + + walk(span); + return colors; +} diff --git a/test/core/theme/parser/token_style_resolver_test.dart b/test/core/theme/parser/token_style_resolver_test.dart new file mode 100644 index 00000000..c2badd7e --- /dev/null +++ b/test/core/theme/parser/token_style_resolver_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/token_style_resolver.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; + +void main() { + test('resolves longest matching scope prefix', () { + const rules = [ + TokenColorRule( + scopes: ['comment'], + foreground: '#111111', + ), + TokenColorRule( + scopes: ['keyword'], + foreground: '#222222', + ), + ]; + final resolver = TokenStyleResolver( + rules: rules, + defaultStyle: const TextStyle(color: Color(0xFFFFFFFF)), + ); + + expect(resolver.resolve('comment.line.sql').color, const Color(0xFF111111)); + expect(resolver.resolve('keyword.control').color, const Color(0xFF222222)); + expect(resolver.resolve('unknown.scope').color, const Color(0xFFFFFFFF)); + }); + + test('caches repeated scope lookups', () { + const rules = [ + TokenColorRule(scopes: ['string'], foreground: '#ABCDEF'), + ]; + final resolver = TokenStyleResolver( + rules: rules, + defaultStyle: const TextStyle(color: Color(0xFF000000)), + ); + final a = resolver.resolve('string.quoted.double'); + final b = resolver.resolve('string.quoted.double'); + expect(identical(a, b), isTrue); + }); +} 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..d11181d6 --- /dev/null +++ b/test/core/theme/parser/vscode_color_map_test.dart @@ -0,0 +1,107 @@ +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)); + expect(theme.editor.background, const Color(0xFF1E1E1E)); + }); + + test('editor.selectionBackground maps to editor.selection', () async { + const src = ''' +{ + "type": "dark", + "colors": { + "editor.selectionBackground": "#123456" + } +} +'''; + final manifest = VsCodeThemeManifest.fromJsonString(src); + final theme = buildQueryaThemeFromVsCodeManifest(manifest); + expect(theme.editor.selection, const Color(0xFF123456)); + }); + + 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/core/theme/parser/vscode_colors_merge_test.dart b/test/core/theme/parser/vscode_colors_merge_test.dart new file mode 100644 index 00000000..4bec6971 --- /dev/null +++ b/test/core/theme/parser/vscode_colors_merge_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_colors_merge.dart'; + +void main() { + group('mergeVsCodeColorLayers', () { + test('empty layers yields empty map', () { + expect(mergeVsCodeColorLayers([]), isEmpty); + }); + + test('later layers override earlier keys', () { + final merged = mergeVsCodeColorLayers([ + {'editor.background': '#111111', 'sideBar.background': '#222222'}, + {'editor.background': '#333333', 'panel.background': '#444444'}, + {'sideBar.background': '#ff0000'}, + ]); + expect(merged['editor.background'], '#333333'); + expect(merged['panel.background'], '#444444'); + expect(merged['sideBar.background'], '#ff0000'); + }); + + test('result is unmodifiable', () { + final merged = mergeVsCodeColorLayers([ + {'a': '#111111'}, + ]); + expect( + () => merged['b'] = '#222222', + throwsUnsupportedError, + ); + }); + }); +} diff --git a/test/core/theme/parser/vscode_theme_manifest_test.dart b/test/core/theme/parser/vscode_theme_manifest_test.dart new file mode 100644 index 00000000..141b8820 --- /dev/null +++ b/test/core/theme/parser/vscode_theme_manifest_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; + +void main() { + group('VsCodeThemeManifest', () { + test('parses minimal dark theme with JSONC', () { + const src = ''' +{ + // theme + "name": "Test Dark", + "type": "dark", + "colors": { + "editor.background": "#1e1e1e", + "sideBar.background": "#252526", + }, + "tokenColors": [ + { + "scope": "comment", + "settings": { "foreground": "#6A9955" } + }, + { + "scope": ["keyword", "storage.type"], + "settings": { "foreground": "#569CD6", "fontStyle": "italic" } + }, + ], +} +'''; + final m = VsCodeThemeManifest.fromJsonString(src); + expect(m.name, 'Test Dark'); + expect(m.isDark, isTrue); + expect(m.colors['editor.background'], '#1e1e1e'); + expect(m.tokenColors.length, 2); + expect(m.tokenColors.first.scopes, ['comment']); + expect(m.tokenColors.first.foreground, '#6A9955'); + expect(m.tokenColors[1].scopes, ['keyword', 'storage.type']); + }); + + test('throws on invalid JSON', () { + expect( + () => VsCodeThemeManifest.fromJsonString('{ not json }'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/core/theme/querya_app_theme_test.dart b/test/core/theme/querya_app_theme_test.dart new file mode 100644 index 00000000..161ce2b0 --- /dev/null +++ b/test/core/theme/querya_app_theme_test.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_app_theme_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + await AppSettings.instance.clearThemeSettings(); + await ThemeController.instance.load(); + }); + + test('ThemeController shadcn themes differ for light vs dark background', () async { + final controller = ThemeController.instance; + await controller.load(); + + expect( + controller.darkShadcnTheme.colorScheme.background, + QueryaTheme.darkDefault.colorScheme.background, + ); + expect( + controller.lightShadcnTheme.colorScheme.background, + QueryaTheme.lightDefault.colorScheme.background, + ); + expect( + controller.darkShadcnTheme.colorScheme.background, + isNot(controller.lightShadcnTheme.colorScheme.background), + ); + }); + + test('setThemeMode switches activeTheme and shadcn background', () async { + final controller = ThemeController.instance; + await controller.load(); + + expect( + controller.activeTheme.colorScheme.background, + QueryaTheme.darkDefault.colorScheme.background, + ); + + await controller.setThemeMode(ThemeMode.light); + + expect( + controller.activeTheme.colorScheme.background, + QueryaTheme.lightDefault.colorScheme.background, + ); + expect( + controller.darkShadcnTheme.colorScheme.background, + isNot(controller.lightShadcnTheme.colorScheme.background), + ); + }); +} diff --git a/test/core/theme/querya_light_theme_test.dart b/test/core/theme/querya_light_theme_test.dart new file mode 100644 index 00000000..0a6d2a4b --- /dev/null +++ b/test/core/theme/querya_light_theme_test.dart @@ -0,0 +1,64 @@ +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_color_scheme.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + group('QueryaTheme.lightDefault', () { + test('colorScheme differs from dark preset', () { + final light = QueryaTheme.lightDefault.colorScheme; + final dark = QueryaTheme.darkDefault.colorScheme; + expect(light.background, isNot(dark.background)); + expect(light.foreground, isNot(dark.foreground)); + expect(light.brightness, Brightness.light); + }); + + test('QueryaColorScheme.light matches lightDefault', () { + expect( + QueryaColorScheme.light.background, + QueryaTheme.lightDefault.colorScheme.background, + ); + }); + + test('primary text on canvas meets WCAG AA contrast (4.5:1)', () { + final w = QueryaTheme.lightDefault.workbench; + final ratio = contrastRatio(w.canvas, const Color(0xFF0F172A)); + expect(ratio, greaterThanOrEqualTo(4.5)); + }); + + test('editor foreground on editor background meets WCAG AA', () { + final e = QueryaTheme.lightDefault.editor; + final ratio = contrastRatio(e.background, e.foreground); + expect(ratio, greaterThanOrEqualTo(4.5)); + }); + + test('toShadcnThemeData uses light brightness', () { + final td = QueryaTheme.lightDefault.toShadcnThemeData(); + expect(td.brightness, Brightness.light); + expect(td.colorScheme.primary, QueryaTheme.lightDefault.workbench.accent); + }); + }); +} + +/// Relative luminance contrast per WCAG 2.1. +double contrastRatio(Color a, Color b) { + final l1 = _relativeLuminance(a); + final l2 = _relativeLuminance(b); + final lighter = math.max(l1, l2); + final darker = math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +double _relativeLuminance(Color c) { + double channel(double v) { + final normalized = v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble(); + return normalized; + } + + final r = channel(c.r); + final g = channel(c.g); + final b = channel(c.b); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} diff --git a/test/core/theme/querya_material_theme_test.dart b/test/core/theme/querya_material_theme_test.dart new file mode 100644 index 00000000..1c146299 --- /dev/null +++ b/test/core/theme/querya_material_theme_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; + +void main() { + test('dark material theme uses light onSurface for dropdowns', () { + final td = materialThemeFromQuerya(QueryaTheme.darkDefault.colorScheme); + expect(td.colorScheme.brightness, material.Brightness.dark); + expect(td.colorScheme.onSurface, const material.Color(0xFFF8FAFC)); + expect(td.textTheme.bodyLarge?.color, const material.Color(0xFFF8FAFC)); + }); + + test('light material theme uses dark onSurface', () { + final td = materialThemeFromQuerya(QueryaTheme.lightDefault.colorScheme); + expect(td.colorScheme.onSurface, const material.Color(0xFF0F172A)); + }); +} diff --git a/test/core/theme/querya_theme_scope_test.dart b/test/core/theme/querya_theme_scope_test.dart new file mode 100644 index 00000000..b759edf4 --- /dev/null +++ b/test/core/theme/querya_theme_scope_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + testWidgets('QueryaThemeScope provides workbench tokens', (tester) async { + late Color seenAccent; + + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: QueryaThemeScope( + data: QueryaTheme.darkDefault, + child: material.Builder( + builder: (context) { + seenAccent = context.workbench.accent; + return const material.SizedBox(); + }, + ), + ), + ), + ); + + expect(seenAccent, QueryaTheme.darkDefault.workbench.accent); + }); + + testWidgets('QueryaThemeScope rebuilds when data changes', (tester) async { + var canvas = QueryaTheme.darkDefault.workbench.canvas; + + await tester.pumpWidget( + _ScopeHost( + data: QueryaTheme.darkDefault, + onCanvas: (c) => canvas = c, + ), + ); + + const light = QueryaTheme.lightDefault; + await tester.pumpWidget( + _ScopeHost( + data: light, + onCanvas: (c) => canvas = c, + ), + ); + + expect(canvas, light.workbench.canvas); + expect(canvas, isNot(QueryaTheme.darkDefault.workbench.canvas)); + }); +} + +class _ScopeHost extends StatelessWidget { + const _ScopeHost({ + required this.data, + required this.onCanvas, + }); + + final QueryaTheme data; + final ValueChanged onCanvas; + + @override + Widget build(BuildContext context) { + return ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: QueryaThemeScope( + data: data, + child: material.Builder( + builder: (context) { + onCanvas(context.workbench.canvas); + return const material.SizedBox(); + }, + ), + ), + ); + } +} \ No newline at end of file diff --git a/test/core/theme/querya_theme_test.dart b/test/core/theme/querya_theme_test.dart new file mode 100644 index 00000000..6541b0fa --- /dev/null +++ b/test/core/theme/querya_theme_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_color_scheme.dart'; +import 'package:querya_desktop/core/theme/querya_colors.dart'; +import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + group('QueryaWorkbenchTheme', () { + test('darkDefault matches QueryaColors', () { + const w = QueryaWorkbenchTheme.darkDefault; + expect(w.canvas, QueryaColors.canvas); + expect(w.surface, QueryaColors.surface); + expect(w.accent, QueryaColors.accentCyan); + expect(w.borderSubtle, QueryaColors.borderSubtle); + }); + + test('copyWith overrides one field', () { + const w = QueryaWorkbenchTheme.darkDefault; + final next = w.copyWith(accent: const Color(0xFFFF0000)); + expect(next.accent, const Color(0xFFFF0000)); + expect(next.canvas, w.canvas); + }); + + test('lerp at 0 returns a', () { + const a = QueryaWorkbenchTheme.darkDefault; + const b = QueryaWorkbenchTheme.lightDefault; + final m = QueryaWorkbenchTheme.lerp(a, b, 0); + expect(m.canvas, a.canvas); + }); + }); + + group('QueryaEditorTheme', () { + test('copyWith and equality', () { + const a = QueryaEditorTheme.darkDefault; + final b = a.copyWith(fontSize: 14); + expect(b.fontSize, 14); + expect(b, isNot(equals(a))); + expect(b.copyWith(fontSize: 13), equals(a)); + }); + }); + + group('QueryaTheme', () { + test('darkDefault colorScheme matches legacy QueryaColorScheme.dark', () { + final legacy = QueryaColorScheme.dark; + final next = QueryaTheme.darkDefault.colorScheme; + expect(next.background, legacy.background); + expect(next.primary, legacy.primary); + expect(next.card, legacy.card); + expect(next.border, legacy.border); + }); + + test('colorSchemeFromWorkbench uses workbench tokens', () { + const w = QueryaWorkbenchTheme( + canvas: Color(0xFF111111), + surface: Color(0xFF222222), + sidebarBackground: Color(0xFF111111), + editorBackground: Color(0xFF222222), + borderSubtle: Color(0xFF333333), + accent: Color(0xFF00FFFF), + onAccent: Color(0xFF000000), + mutedForeground: Color(0xFFAAAAAA), + destructive: Color(0xFFFF0000), + success: Color(0xFF00FF00), + warning: Color(0xFFFFFF00), + gitModified: Color(0xFFFF00FF), + gitUntracked: Color(0xFF00FF00), + ); + final cs = QueryaTheme.colorSchemeFromWorkbench( + w, + brightness: Brightness.dark, + ); + expect(cs.background, w.canvas); + expect(cs.primary, w.accent); + }); + + test('lerp interpolates editor and workbench', () { + const a = QueryaTheme.darkDefault; + const b = QueryaTheme.lightDefault; + final mid = QueryaTheme.lerp(a, b, 0.5); + expect(mid.workbench, isNot(equals(a.workbench))); + expect(mid.editor.foreground, isNot(equals(a.editor.foreground))); + }); + + test('toShadcnThemeData preserves brightness', () { + final td = QueryaTheme.darkDefault.toShadcnThemeData(); + expect(td.brightness, Brightness.dark); + expect(td.colorScheme.primary, QueryaColors.accentCyan); + }); + + test('ThemeData.lerp at 0.5 matches QueryaTheme.lerp colorScheme', () { + const a = QueryaTheme.darkDefault; + const b = QueryaTheme.lightDefault; + final qaMid = QueryaTheme.lerp(a, b, 0.5); + final tdMid = ThemeData.lerp( + a.toShadcnThemeData(), + b.toShadcnThemeData(), + 0.5, + ); + expect(tdMid.colorScheme.primary, qaMid.colorScheme.primary); + expect(tdMid.colorScheme.background, qaMid.colorScheme.background); + }); + }); +} diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart new file mode 100644 index 00000000..ece87f8c --- /dev/null +++ b/test/core/theme/theme_controller_test.dart @@ -0,0 +1,142 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_import_service.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_theme_controller_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + await AppSettings.instance.clearThemeSettings(); + await ThemeController.instance.load(); + }); + + test('load defaults to dark preset', () async { + final c = ThemeController.instance; + await c.load(); + expect(c.themeMode, ThemeMode.dark); + expect(c.preset, QueryaThemePreset.queryaDark); + expect(c.activeTheme, QueryaTheme.darkDefault); + expect(c.isLoaded, isTrue); + }); + + test('setThemeMode light persists and updates activeTheme', () async { + final c = ThemeController.instance; + await c.load(); + await c.setThemeMode(ThemeMode.light); + expect(c.activeTheme, QueryaTheme.lightDefault); + expect(await AppSettings.instance.getThemeMode(), ThemeMode.light); + expect( + await AppSettings.instance.getThemePreset(), + QueryaThemePreset.queryaLight, + ); + }); + + test('resetToDefaults restores dark', () async { + final c = ThemeController.instance; + await c.setThemeMode(ThemeMode.light); + await c.resetToDefaults(); + expect(c.themeMode, ThemeMode.dark); + expect(c.activeTheme, QueryaTheme.darkDefault); + }); + + test('setWorkbenchColor overrides sidebar and persists', () async { + final c = ThemeController.instance; + await c.load(); + await c.setWorkbenchColor( + 'sideBar.background', + const Color(0xFFFF0000), + ); + expect( + c.activeTheme.workbench.sidebarBackground, + const Color(0xFFFF0000), + ); + expect( + (await AppSettings.instance.getThemeColorOverrides())['sideBar.background'], + '#ff0000', + ); + + await c.clearColorOverrides(); + expect(c.userColorOverrides, isEmpty); + expect( + c.activeTheme.workbench.sidebarBackground, + QueryaTheme.darkDefault.workbench.sidebarBackground, + ); + }); + + test('importThemeFromFile applies imported colors to activeTheme', () async { + final c = ThemeController.instance; + await c.load(); + final fixture = File('test/fixtures/themes/dark_subset.json'); + final result = await c.importThemeFromFile(fixture.path); + expect(result, isA()); + expect(c.preset, QueryaThemePreset.imported); + expect(c.hasImportedTheme, isTrue); + expect( + c.activeTheme.workbench.editorBackground, + const Color(0xFF1E1E1E), + ); + await c.resetToDefaults(); + expect(c.preset, QueryaThemePreset.queryaDark); + }); + + test('setThemeAnimationEnabled persists and reset clears', () async { + final c = ThemeController.instance; + await c.load(); + expect(c.themeAnimationEnabled, isFalse); + + await c.setThemeAnimationEnabled(true); + expect(c.themeAnimationEnabled, isTrue); + expect(await AppSettings.instance.getThemeAnimationEnabled(), isTrue); + + await c.resetToDefaults(); + expect(c.themeAnimationEnabled, isFalse); + expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse); + }); + + test('clearColorOverrides does not reset theme mode', () async { + final c = ThemeController.instance; + await c.setThemeMode(ThemeMode.light); + await c.setWorkbenchColor('editor.background', const Color(0xFF111111)); + await c.clearColorOverrides(); + expect(c.themeMode, ThemeMode.light); + expect(c.activeTheme, QueryaTheme.lightDefault); + }); +} diff --git a/test/core/theme/theme_import_service_test.dart b/test/core/theme/theme_import_service_test.dart new file mode 100644 index 00000000..0a6fa510 --- /dev/null +++ b/test/core/theme/theme_import_service_test.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/theme/theme_import_service.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_theme_import_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + }); + + tearDown(() async { + await ThemeImportService.deletePersistedImport(); + }); + + tearDownAll(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('importFromPath parses fixture and persists copy', () async { + final fixture = File('test/fixtures/themes/dark_subset.json'); + final result = await ThemeImportService.importFromPath(fixture.path); + expect(result, isA()); + final success = result as ThemeImportSuccess; + expect(success.name, 'Fixture Dark Subset'); + expect(success.isDark, isTrue); + expect(success.colors['editor.background'], '#1e1e1e'); + + final reloaded = await ThemeImportService.loadPersistedColors(); + expect(reloaded?['editor.background'], '#1e1e1e'); + }); + + test('importFromPath persists tokenColors from dracula fixture', () async { + final fixture = File('test/fixtures/themes/dracula_tokens.json'); + final result = await ThemeImportService.importFromPath(fixture.path); + expect(result, isA()); + final success = result as ThemeImportSuccess; + expect(success.tokenColors, isNotEmpty); + + final tokens = await ThemeImportService.loadPersistedTokenColors(); + expect(tokens.length, success.tokenColors.length); + expect(tokens.first.scopes, contains('comment')); + }); + + test('importFromPath returns failure for missing file', () async { + final result = + await ThemeImportService.importFromPath('/no/such/theme.json'); + expect(result, isA()); + }); +} diff --git a/test/features/main_screen/query_editor_tab_test.dart b/test/features/main_screen/query_editor_tab_test.dart index 1104700f..47a80e22 100644 --- a/test/features/main_screen/query_editor_tab_test.dart +++ b/test/features/main_screen/query_editor_tab_test.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import '../../support/querya_theme_test_shell.dart'; + void main() { testWidgets('QueryEditorTab applies fontSize to EditableText', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - home: const material.Scaffold( + queryaThemeTestShell( + child: const material.Scaffold( body: SizedBox( width: 600, height: 400, @@ -29,9 +29,8 @@ void main() { testWidgets('QueryEditorTab default fontSize is 13', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - home: const material.Scaffold( + queryaThemeTestShell( + child: const material.Scaffold( body: SizedBox( width: 600, height: 400, diff --git a/test/features/main_screen/sql_editor_chrome_test.dart b/test/features/main_screen/sql_editor_chrome_test.dart new file mode 100644 index 00000000..42887469 --- /dev/null +++ b/test/features/main_screen/sql_editor_chrome_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart' as material; +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/querya_editor_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; +import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('SqlEditorChrome.inlineFieldDecoration', () { + test('uses editor background and workbench accent glow', () { + const editor = QueryaEditorTheme( + background: Color(0xFF111111), + foreground: Color(0xFFFFFFFF), + lineHighlight: Color(0xFF000000), + selection: Color(0xFF222222), + lineNumber: Color(0xFF333333), + bracketMatch: Color(0xFF444444), + comment: Color(0xFF555555), + keyword: Color(0xFF666666), + string: Color(0xFF777777), + number: Color(0xFF888888), + operator: Color(0xFF999999), + function: Color(0xFFAAAAAA), + type: Color(0xFFBBBBBB), + ); + const workbench = QueryaWorkbenchTheme( + canvas: Color(0xFF000000), + surface: Color(0xFF000001), + sidebarBackground: Color(0xFF000002), + editorBackground: Color(0xFF111111), + borderSubtle: Color(0xFFCCCCCC), + accent: Color(0xFF00FFFF), + onAccent: Color(0xFF000000), + mutedForeground: Color(0xFF888888), + destructive: Color(0xFFFF0000), + success: Color(0xFF00FF00), + warning: Color(0xFFFFFF00), + gitModified: Color(0xFFFF8800), + gitUntracked: Color(0xFF00FF88), + ); + + final deco = SqlEditorChrome.inlineFieldDecoration(editor, workbench); + expect(deco.color, const Color(0xFF111111)); + expect( + deco.boxShadow!.single.color, + const Color(0xFF00FFFF).withValues(alpha: 0.07), + ); + }); + + test('widgetBorder overrides workbench border', () { + const editor = QueryaEditorTheme( + background: Color(0xFFFFFFFF), + foreground: Color(0xFF000000), + lineHighlight: Color(0xFFF0F0F0), + selection: Color(0xFFADD6FF), + lineNumber: Color(0xFF237893), + bracketMatch: Color(0x33006400), + widgetBorder: Color(0xFFFF0000), + comment: Color(0xFF008000), + keyword: Color(0xFF0000FF), + string: Color(0xFFA31515), + number: Color(0xFF098658), + operator: Color(0xFF000000), + function: Color(0xFF795E26), + type: Color(0xFF267F99), + ); + final deco = SqlEditorChrome.inlineFieldDecoration( + editor, + QueryaWorkbenchTheme.lightDefault, + brightness: Brightness.light, + ); + final border = deco.border as Border; + expect(border.top.color, const Color(0xFFFF0000).withValues(alpha: 0.45)); + expect( + deco.boxShadow!.single.color, + QueryaWorkbenchTheme.lightDefault.accent.withValues(alpha: 0.05), + ); + }); + }); + + group('SqlEditorChrome widget', () { + testWidgets('applies imported editor background and border', (tester) async { + final queryaTheme = buildQueryaThemeFromVsCodeColors( + brightness: Brightness.dark, + colors: const { + 'editor.background': '#aabbcc', + 'editorWidget.border': '#112233', + 'focusBorder': '#00ffee', + }, + fallback: QueryaTheme.darkDefault, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + data: queryaTheme, + child: const material.SizedBox( + width: 320, + height: 200, + child: SqlEditorChrome( + child: material.SizedBox.expand(), + ), + ), + ), + ); + await tester.pump(); + + final containers = tester.widgetList( + find.descendant( + of: find.byType(SqlEditorChrome), + matching: find.byType(material.Container), + ), + ); + + final inner = containers.firstWhere((c) { + final d = c.decoration; + return d is material.BoxDecoration && + d.color == const Color(0xFFAABBCC); + }); + final border = (inner.decoration! as material.BoxDecoration).border as Border; + expect( + border.top.color, + const Color(0xFF112233).withValues(alpha: 0.5), + ); + + final outerGlow = containers + .map((c) => c.decoration) + .whereType() + .expand((d) => d.boxShadow ?? const []) + .map((s) => s.color) + .whereType() + .firstWhere((c) => c == const Color(0xFF00FFEE).withValues(alpha: 0.1)); + expect(outerGlow, const Color(0xFF00FFEE).withValues(alpha: 0.1)); + }); + }); +} diff --git a/test/features/main_screen/workspace_empty_hero_test.dart b/test/features/main_screen/workspace_empty_hero_test.dart new file mode 100644 index 00000000..88eeab5a --- /dev/null +++ b/test/features/main_screen/workspace_empty_hero_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/features/main_screen/workspace_empty_hero.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + testWidgets('WorkspaceEmptyHero renders with theme tokens', (tester) async { + var tapped = false; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 900, + height: 700, + child: WorkspaceEmptyHero(onNewConnection: () => tapped = true), + ), + ), + ); + await tester.pump(); + + expect(find.text('New connection'), findsOneWidget); + expect(find.textContaining('SELECT'), findsOneWidget); + + await tester.tap(find.text('New connection')); + expect(tapped, isTrue); + }); + + testWidgets('WorkspaceEmptyHero mock uses workbench surface color', (tester) async { + const surface = material.Color(0xFFABCDEF); + final theme = QueryaTheme.darkDefault.copyWith( + workbench: QueryaTheme.darkDefault.workbench.copyWith(surface: surface), + ); + + await tester.pumpWidget( + queryaThemeTestShell( + data: theme, + child: material.SizedBox( + width: 900, + height: 700, + child: WorkspaceEmptyHero(onNewConnection: () {}), + ), + ), + ); + await tester.pump(); + + final container = tester.widgetList( + find.byType(material.Container), + ).firstWhere( + (c) => c.decoration is material.BoxDecoration && + (c.decoration! as material.BoxDecoration).color == surface, + ); + expect(container.decoration, isNotNull); + }); +} diff --git a/test/features/main_screen/workspace_homes_and_preferences_test.dart b/test/features/main_screen/workspace_homes_and_preferences_test.dart index 59e71b0e..5f60927f 100644 --- a/test/features/main_screen/workspace_homes_and_preferences_test.dart +++ b/test/features/main_screen/workspace_homes_and_preferences_test.dart @@ -10,6 +10,8 @@ import 'package:querya_desktop/features/mysql/mysql_workspace_home.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import '../../support/querya_theme_test_shell.dart'; + class _FakePathProvider extends PathProviderPlatform { _FakePathProvider(this._root); final String _root; @@ -58,11 +60,8 @@ void main() { group('MysqlWorkspaceHome', () { testWidgets('shows tabs and SQL tab exposes Execute control', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, - home: const material.Scaffold( + queryaThemeTestShell( + child: const material.Scaffold( body: material.SizedBox( width: 700, height: 500, diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index d65f43e1..92943780 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/features/main_screen/workspace_panel.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/layout_overflow.dart'; +import '../../support/querya_theme_test_shell.dart'; void main() { /// Type must not be postgresql/mysql/mongodb/redis so [WorkspacePanel] uses the @@ -35,11 +35,8 @@ void main() { await pumpWidgetWithSurfaceSize( tester, entry.value, - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, - home: const material.SizedBox.expand( + queryaThemeTestShell( + child: const material.SizedBox.expand( child: WorkspacePanel(), ), ), @@ -53,11 +50,8 @@ void main() { await pumpWidgetWithSurfaceSize( tester, const material.Size(800, 600), - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, - home: const material.SizedBox.expand( + queryaThemeTestShell( + child: const material.SizedBox.expand( child: WorkspacePanel(activeConnection: stubSplitWorkspaceConnection), ), ), diff --git a/test/features/mongodb/mongo_document_editor_test.dart b/test/features/mongodb/mongo_document_editor_test.dart new file mode 100644 index 00000000..64f23985 --- /dev/null +++ b/test/features/mongodb/mongo_document_editor_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/features/mongodb/mongo_document_editor.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + await SyntaxHighlightService.ensureInitialized(); + }); + + final connection = MongoConnection(id: 1, name: 'test', host: 'localhost'); + + Future pumpEditor( + WidgetTester tester, { + QueryaTheme? theme, + Map document = const {'_id': 'abc', 'a': 1}, + }) async { + await tester.pumpWidget( + queryaThemeTestShell( + data: theme ?? QueryaTheme.darkDefault, + child: material.SizedBox( + width: 800, + height: 600, + child: MongoDocumentEditor( + connection: connection, + database: 'db', + collection: 'items', + document: document, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('Format pretty-prints valid JSON', (tester) async { + await pumpEditor(tester); + await tester.enterText( + find.byType(material.EditableText), + '{"a":1,"b":"x"}', + ); + await tester.pump(); + await tester.tap(find.text('Format')); + await tester.pump(); + + final editable = tester.widget( + find.byType(material.EditableText), + ); + expect(editable.controller.text, contains('\n')); + expect(editable.controller.text, contains(' "a"')); + expect(find.textContaining('Invalid JSON'), findsNothing); + }); + + testWidgets('invalid JSON shows error banner without breaking editor', (tester) async { + await pumpEditor(tester); + await tester.enterText(find.byType(material.EditableText), '{not json'); + await tester.pump(); + await tester.tap(find.text('Format')); + await tester.pump(); + + expect(find.textContaining('Invalid JSON'), findsOneWidget); + expect(find.byType(material.EditableText), findsOneWidget); + }); + + testWidgets('editor uses Querya editor background token', (tester) async { + const bg = material.Color(0xFF112233); + final theme = QueryaTheme.darkDefault.copyWith( + editor: QueryaTheme.darkDefault.editor.copyWith(background: bg), + ); + await pumpEditor(tester, theme: theme); + + final editorFinder = find.byType(QueryaCodeEditor); + final container = tester.widget( + find.ancestor( + of: editorFinder, + matching: find.byType(material.Container), + ).first, + ); + expect(container.color, bg); + }); +} diff --git a/test/features/settings/sql_statement_timeout_dropdown_test.dart b/test/features/settings/sql_statement_timeout_dropdown_test.dart index 96a35117..cde436bb 100644 --- a/test/features/settings/sql_statement_timeout_dropdown_test.dart +++ b/test/features/settings/sql_statement_timeout_dropdown_test.dart @@ -1,21 +1,23 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; +import '../../support/querya_theme_test_shell.dart'; void main() { - group('kSqlStatementTimeoutMenuItems', () { + group('kSqlStatementTimeoutMenuEntries', () { test('has seven entries with expected values', () { - expect(kSqlStatementTimeoutMenuItems.length, 7); - final values = kSqlStatementTimeoutMenuItems.map((e) => e.value).toList(); + expect(kSqlStatementTimeoutMenuEntries.length, 7); + final values = + kSqlStatementTimeoutMenuEntries.map((e) => e.value).toList(); expect(values, [null, 10, 30, 60, 120, 300, 600]); }); }); group('SqlStatementTimeoutDropdown', () { - testWidgets('builds DropdownButton with current value', (tester) async { + testWidgets('builds DropdownMenu with current value', (tester) async { await tester.pumpWidget( - material.MaterialApp( - home: material.Scaffold( + queryaThemeTestShell( + child: material.Scaffold( body: SqlStatementTimeoutDropdown( value: 60, onChanged: (_) {}, @@ -23,19 +25,20 @@ void main() { ), ), ); + await tester.pump(); - expect(find.byType(material.DropdownButton), findsOneWidget); - final dd = tester.widget>( - find.byType(material.DropdownButton), + expect(find.byType(material.DropdownMenu), findsOneWidget); + final menu = tester.widget>( + find.byType(material.DropdownMenu), ); - expect(dd.value, 60); - expect(dd.onChanged, isNotNull); + expect(menu.initialSelection, 60); + expect(menu.onSelected, isNotNull); }); testWidgets('disables changes when enabled is false', (tester) async { await tester.pumpWidget( - material.MaterialApp( - home: material.Scaffold( + queryaThemeTestShell( + child: material.Scaffold( body: SqlStatementTimeoutDropdown( value: 30, onChanged: (_) {}, @@ -44,32 +47,12 @@ void main() { ), ), ); + await tester.pump(); - final dd = tester.widget>( - find.byType(material.DropdownButton), + final menu = tester.widget>( + find.byType(material.DropdownMenu), ); - expect(dd.onChanged, isNull); - }); - - testWidgets('onChanged receives new selection', (tester) async { - int? last; - await tester.pumpWidget( - material.MaterialApp( - home: material.Scaffold( - body: SqlStatementTimeoutDropdown( - value: null, - onChanged: (v) => last = v, - ), - ), - ), - ); - - await tester.tap(find.byType(material.DropdownButton)); - await tester.pumpAndSettle(); - await tester.tap(find.text('30 s').last); - await tester.pumpAndSettle(); - - expect(last, 30); + expect(menu.enabled, isFalse); }); }); } 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/dracula_tokens.json b/test/fixtures/themes/dracula_tokens.json new file mode 100644 index 00000000..72291d7b --- /dev/null +++ b/test/fixtures/themes/dracula_tokens.json @@ -0,0 +1,26 @@ +{ + "name": "Dracula Fixture", + "type": "dark", + "colors": { + "editor.background": "#282a36", + "editor.foreground": "#f8f8f2" + }, + "tokenColors": [ + { + "scope": ["comment", "comment.line", "comment.block"], + "settings": { "foreground": "#6272a4" } + }, + { + "scope": ["keyword", "keyword.control", "storage.type"], + "settings": { "foreground": "#ff79c6" } + }, + { + "scope": ["string", "string.quoted.double"], + "settings": { "foreground": "#f1fa8c" } + }, + { + "scope": ["constant.numeric"], + "settings": { "foreground": "#bd93f9" } + } + ] +} diff --git a/test/fixtures/themes/invalid-trailing-comma.jsonc b/test/fixtures/themes/invalid-trailing-comma.jsonc new file mode 100644 index 00000000..b8a098f1 --- /dev/null +++ b/test/fixtures/themes/invalid-trailing-comma.jsonc @@ -0,0 +1,15 @@ +{ + // One Dark–like fixture with JSONC trailing comma + "name": "JSONC Trailing Comma", + "type": "dark", + "colors": { + "editor.background": "#282c34", + "sideBar.background": "#21252b", + }, + "tokenColors": [ + { + "scope": "comment", + "settings": { "foreground": "#5c6370" }, + }, + ], +} 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/one_dark.json b/test/fixtures/themes/one_dark.json new file mode 100644 index 00000000..1a652d9b --- /dev/null +++ b/test/fixtures/themes/one_dark.json @@ -0,0 +1,24 @@ +{ + "name": "One Dark Fixture", + "type": "dark", + "colors": { + "editor.background": "#282c34", + "editor.foreground": "#abb2bf", + "sideBar.background": "#21252b", + "activityBar.background": "#21252b" + }, + "tokenColors": [ + { + "scope": "comment", + "settings": { "foreground": "#5c6370" } + }, + { + "scope": "keyword", + "settings": { "foreground": "#c678dd" } + }, + { + "scope": "string", + "settings": { "foreground": "#98c379" } + } + ] +} 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" + } +} diff --git a/test/support/querya_theme_test_shell.dart b/test/support/querya_theme_test_shell.dart new file mode 100644 index 00000000..32f13e6b --- /dev/null +++ b/test/support/querya_theme_test_shell.dart @@ -0,0 +1,22 @@ +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Wraps [child] with [ShadcnApp] and [QueryaThemeScope] for widget tests. +Widget queryaThemeTestShell({ + required Widget child, + QueryaTheme data = QueryaTheme.darkDefault, + ThemeData? theme, +}) { + final td = theme ?? data.toShadcnThemeData(); + return ShadcnApp( + theme: td, + darkTheme: td, + themeMode: ThemeMode.dark, + builder: (context, appChild) => QueryaThemeScope( + data: data, + child: appChild ?? const SizedBox.shrink(), + ), + home: child, + ); +} diff --git a/themes/samples/README.md b/themes/samples/README.md new file mode 100644 index 00000000..ad6ee89b --- /dev/null +++ b/themes/samples/README.md @@ -0,0 +1,19 @@ +# Sample VS Code themes for Querya + +Import via **Preferences → Appearance → Import theme…** + +| File | Notes | +|------|--------| +| [cyberpunk-neon.json](cyberpunk-neon.json) | Dark cyberpunk: neon cyan/magenta, full `colors` subset + SQL/JSON `tokenColors` | +| [cyberpunk-neon.jsonc](cyberpunk-neon.jsonc) | Same palette in JSONC (comments + trailing commas) | + +After import, pick **Imported: Querya Cyberpunk Neon** in **Color preset**. + +**Quick test SQL** (syntax colors): + +```sql +-- neon comment +SELECT id, name +FROM users +WHERE active = true AND score > 42; +``` diff --git a/themes/samples/cyberpunk-neon.json b/themes/samples/cyberpunk-neon.json new file mode 100644 index 00000000..26ac9fbe --- /dev/null +++ b/themes/samples/cyberpunk-neon.json @@ -0,0 +1,96 @@ +{ + "name": "Querya Cyberpunk Neon", + "type": "dark", + "colors": { + "activityBar.background": "#050508", + "statusBar.background": "#050508", + "sideBar.background": "#0c0820", + "sideBar.foreground": "#8b7cf8", + "tab.activeBackground": "#14102a", + "panel.background": "#14102a", + "input.background": "#14102a", + "editor.background": "#0a0a14", + "editor.foreground": "#e8f4ff", + "editor.selectionBackground": "#ff2a6d44", + "editorLineNumber.foreground": "#4a3f7a", + "editorBracketMatch.background": "#00f5ff33", + "editorWidget.border": "#00f5ff66", + "focusBorder": "#00f5ff", + "list.hoverBackground": "#ff2a6d22", + "gitDecoration.modifiedResourceForeground": "#fcee09", + "gitDecoration.untrackedResourceForeground": "#39ff14" + }, + "tokenColors": [ + { + "name": "Comments", + "scope": ["comment", "comment.line", "comment.block", "punctuation.definition.comment"], + "settings": { + "foreground": "#5c4d8a", + "fontStyle": "italic" + } + }, + { + "name": "Keywords", + "scope": [ + "keyword", + "keyword.control", + "keyword.operator.logical", + "storage.type", + "storage.modifier" + ], + "settings": { + "foreground": "#ff2a6d", + "fontStyle": "bold" + } + }, + { + "name": "Strings", + "scope": ["string", "string.quoted.single", "string.quoted.double"], + "settings": { + "foreground": "#fcee09" + } + }, + { + "name": "Numbers", + "scope": ["constant.numeric", "constant.language"], + "settings": { + "foreground": "#bd00ff" + } + }, + { + "name": "Functions", + "scope": ["entity.name.function", "support.function"], + "settings": { + "foreground": "#00f5ff" + } + }, + { + "name": "Types / classes", + "scope": ["entity.name.type", "support.type"], + "settings": { + "foreground": "#8b7cf8" + } + }, + { + "name": "Variables", + "scope": ["variable", "variable.other"], + "settings": { + "foreground": "#e8f4ff" + } + }, + { + "name": "JSON keys", + "scope": ["support.type.property-name.json"], + "settings": { + "foreground": "#00f5ff" + } + }, + { + "name": "JSON strings", + "scope": ["string.quoted.double.json"], + "settings": { + "foreground": "#39ff14" + } + } + ] +} diff --git a/themes/samples/cyberpunk-neon.jsonc b/themes/samples/cyberpunk-neon.jsonc new file mode 100644 index 00000000..047eabea --- /dev/null +++ b/themes/samples/cyberpunk-neon.jsonc @@ -0,0 +1,46 @@ +// JSONC variant — tests comment stripping on import +{ + "name": "Querya Cyberpunk Neon (JSONC)", + "type": "dark", + "colors": { + "activityBar.background": "#050508", + "statusBar.background": "#050508", + "sideBar.background": "#0c0820", + "sideBar.foreground": "#8b7cf8", + "tab.activeBackground": "#14102a", + "panel.background": "#14102a", + "input.background": "#14102a", + "editor.background": "#0a0a14", + "editor.foreground": "#e8f4ff", + "editor.selectionBackground": "#ff2a6d44", + "editorLineNumber.foreground": "#4a3f7a", + "editorBracketMatch.background": "#00f5ff33", + "editorWidget.border": "#00f5ff66", + "focusBorder": "#00f5ff", // neon cyan ring + "list.hoverBackground": "#ff2a6d22", + "gitDecoration.modifiedResourceForeground": "#fcee09", + "gitDecoration.untrackedResourceForeground": "#39ff14", + }, + "tokenColors": [ + { + "scope": ["comment", "comment.line"], + "settings": { "foreground": "#5c4d8a", "fontStyle": "italic" }, + }, + { + "scope": ["keyword", "keyword.control", "storage.type"], + "settings": { "foreground": "#ff2a6d" }, + }, + { + "scope": ["string"], + "settings": { "foreground": "#fcee09" }, + }, + { + "scope": ["constant.numeric"], + "settings": { "foreground": "#bd00ff" }, + }, + { + "scope": ["entity.name.function", "support.function"], + "settings": { "foreground": "#00f5ff" }, + }, + ], +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index d1b0b32d..72592476 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,8 @@ #include #include #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { BitsdojoWindowPluginRegisterWithRegistrar( @@ -17,4 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + IrondashEngineContextPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); + SuperNativeExtensionsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 25b91502..69caccba 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,8 @@ list(APPEND FLUTTER_PLUGIN_LIST bitsdojo_window_windows file_selector_windows flutter_secure_storage_windows + irondash_engine_context + super_native_extensions ) list(APPEND FLUTTER_FFI_PLUGIN_LIST