From 10047ee4f3d6f1d8798a9f85059868e1a3115989 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:06:50 +0300 Subject: [PATCH 01/31] ci: automate PR milestone/labels from linked theme issues --- .cursor/rules/gitflow.md | 43 +++++++- .github/ISSUE_TEMPLATE/theme_task.yml | 47 ++++++++ .github/pull_request_template.md | 37 +++++++ .github/workflows/issue-theme-milestone.yml | 50 +++++++++ .../workflows/pr-linked-issue-metadata.yml | 103 ++++++++++++++++++ 5 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/theme_task.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/issue-theme-milestone.yml create mode 100644 .github/workflows/pr-linked-issue-metadata.yml 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/.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}`); + } + } From 633836e8e33491533c3c793a935c27eff56b3eac Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:11:48 +0300 Subject: [PATCH 02/31] feat(theme): add QueryaTheme, workbench and editor token models Introduces QueryaWorkbenchTheme, QueryaEditorTheme, and QueryaTheme with dark/light defaults, lerp/copyWith, and shadcn ColorScheme mapping. AppTheme now builds from QueryaTheme.darkDefault (visual parity). Closes #38 --- lib/core/theme/app_theme.dart | 13 +- lib/core/theme/querya_color_scheme.dart | 33 +--- lib/core/theme/querya_editor_theme.dart | 156 ++++++++++++++++++ lib/core/theme/querya_theme.dart | 180 +++++++++++++++++++++ lib/core/theme/querya_workbench_theme.dart | 160 ++++++++++++++++++ test/core/theme/querya_theme_test.dart | 92 +++++++++++ 6 files changed, 597 insertions(+), 37 deletions(-) create mode 100644 lib/core/theme/querya_editor_theme.dart create mode 100644 lib/core/theme/querya_theme.dart create mode 100644 lib/core/theme/querya_workbench_theme.dart create mode 100644 test/core/theme/querya_theme_test.dart 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/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..30e653f6 --- /dev/null +++ b/lib/core/theme/querya_editor_theme.dart @@ -0,0 +1,156 @@ +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.comment, + required this.keyword, + required this.string, + required this.number, + required this.operator, + required this.function, + required this.type, + this.fontFamily = QueryaTypography.mono, + this.fontSize = 13, + }); + + final Color background; + final Color foreground; + final Color lineHighlight; + final Color selection; + 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), + 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), + 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? 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, + 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), + 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 && + 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, + comment, + keyword, + string, + number, + operator, + function, + type, + fontFamily, + fontSize, + ); +} diff --git a/lib/core/theme/querya_theme.dart b/lib/core/theme/querya_theme.dart new file mode 100644 index 00000000..8db6117f --- /dev/null +++ b/lib/core/theme/querya_theme.dart @@ -0,0 +1,180 @@ +import 'package:shadcn_flutter/shadcn_flutter.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, + }); + + final QueryaWorkbenchTheme workbench; + final QueryaEditorTheme editor; + final Brightness brightness; + final ColorScheme colorScheme; + + static final QueryaTheme darkDefault = QueryaTheme( + workbench: QueryaWorkbenchTheme.darkDefault, + editor: QueryaEditorTheme.darkDefault, + brightness: Brightness.dark, + colorScheme: _darkColorScheme, + ); + + static final QueryaTheme lightDefault = QueryaTheme( + workbench: QueryaWorkbenchTheme.lightDefault, + editor: QueryaEditorTheme.lightDefault, + brightness: Brightness.light, + colorScheme: _lightColorScheme, + ); + + static final ColorScheme _darkColorScheme = ColorScheme( + brightness: Brightness.dark, + background: QueryaWorkbenchTheme.darkDefault.canvas, + foreground: Color(0xFFF8FAFC), + card: QueryaWorkbenchTheme.darkDefault.surface, + cardForeground: Color(0xFFF8FAFC), + popover: QueryaWorkbenchTheme.darkDefault.surface, + popoverForeground: Color(0xFFF8FAFC), + primary: QueryaWorkbenchTheme.darkDefault.accent, + primaryForeground: QueryaWorkbenchTheme.darkDefault.onAccent, + secondary: Color(0xFF18181B), + secondaryForeground: Color(0xFFF8FAFC), + muted: Color(0xFF18181B), + mutedForeground: QueryaWorkbenchTheme.darkDefault.mutedForeground, + accent: Color(0xFF27272A), + accentForeground: Color(0xFFF8FAFC), + destructive: QueryaWorkbenchTheme.darkDefault.destructive, + destructiveForeground: Color(0xFFF8FAFC), + border: QueryaWorkbenchTheme.darkDefault.borderSubtle, + input: QueryaWorkbenchTheme.darkDefault.borderSubtle, + ring: QueryaWorkbenchTheme.darkDefault.accent, + chart1: Color(0xFF2662D9), + chart2: Color(0xFF2EB88A), + chart3: Color(0xFFE88C30), + chart4: Color(0xFFAF57DB), + chart5: Color(0xFFE23670), + ); + + static final ColorScheme _lightColorScheme = ColorScheme( + brightness: Brightness.light, + background: QueryaWorkbenchTheme.lightDefault.canvas, + foreground: Color(0xFF0F172A), + card: QueryaWorkbenchTheme.lightDefault.surface, + cardForeground: Color(0xFF0F172A), + popover: QueryaWorkbenchTheme.lightDefault.surface, + popoverForeground: Color(0xFF0F172A), + primary: QueryaWorkbenchTheme.lightDefault.accent, + primaryForeground: QueryaWorkbenchTheme.lightDefault.onAccent, + secondary: Color(0xFFF4F4F5), + secondaryForeground: Color(0xFF0F172A), + muted: Color(0xFFF4F4F5), + mutedForeground: QueryaWorkbenchTheme.lightDefault.mutedForeground, + accent: Color(0xFFE4E4E7), + accentForeground: Color(0xFF0F172A), + destructive: QueryaWorkbenchTheme.lightDefault.destructive, + destructiveForeground: Color(0xFFF8FAFC), + border: QueryaWorkbenchTheme.lightDefault.borderSubtle, + input: QueryaWorkbenchTheme.lightDefault.borderSubtle, + ring: QueryaWorkbenchTheme.lightDefault.accent, + 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: w.mutedForeground, + 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, + }) { + return QueryaTheme( + workbench: workbench ?? this.workbench, + editor: editor ?? this.editor, + brightness: brightness ?? this.brightness, + colorScheme: colorScheme ?? this.colorScheme, + ); + } + + 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), + ); + } + + ThemeData toShadcnThemeData({ + double radius = 0.58, + double scaling = 1, + Typography? typography, + }) { + final typo = typography ?? 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; + + @override + int get hashCode => + Object.hash(workbench, editor, brightness, colorScheme); +} diff --git a/lib/core/theme/querya_workbench_theme.dart b/lib/core/theme/querya_workbench_theme.dart new file mode 100644 index 00000000..ddfd783d --- /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), + ); + + /// Placeholder light preset (#51 will refine). + 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/test/core/theme/querya_theme_test.dart b/test/core/theme/querya_theme_test.dart new file mode 100644 index 00000000..b9a3d434 --- /dev/null +++ b/test/core/theme/querya_theme_test.dart @@ -0,0 +1,92 @@ +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', () { + final a = QueryaTheme.darkDefault; + final 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); + }); + }); +} From 48a30f9d2ceaef3058d50ad255696e10b7abf4f0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:12:32 +0300 Subject: [PATCH 03/31] feat(theme): VS Code theme JSON/JSONC parser Adds stripJsonc preprocessor and VsCodeThemeManifest with colors and tokenColors parsing for theme import pipeline. Closes #39 --- lib/core/theme/parser/jsonc_preprocessor.dart | 90 +++++++++++++ .../theme/parser/vscode_theme_manifest.dart | 120 ++++++++++++++++++ .../theme/parser/jsonc_preprocessor_test.dart | 35 +++++ .../parser/vscode_theme_manifest_test.dart | 45 +++++++ 4 files changed, 290 insertions(+) create mode 100644 lib/core/theme/parser/jsonc_preprocessor.dart create mode 100644 lib/core/theme/parser/vscode_theme_manifest.dart create mode 100644 test/core/theme/parser/jsonc_preprocessor_test.dart create mode 100644 test/core/theme/parser/vscode_theme_manifest_test.dart 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/vscode_theme_manifest.dart b/lib/core/theme/parser/vscode_theme_manifest.dart new file mode 100644 index 00000000..b6e43008 --- /dev/null +++ b/lib/core/theme/parser/vscode_theme_manifest.dart @@ -0,0 +1,120 @@ +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; + + 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/test/core/theme/parser/jsonc_preprocessor_test.dart b/test/core/theme/parser/jsonc_preprocessor_test.dart new file mode 100644 index 00000000..1759bfde --- /dev/null +++ b/test/core/theme/parser/jsonc_preprocessor_test.dart @@ -0,0 +1,35 @@ +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}'); + }); + }); +} 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()), + ); + }); + }); +} From b3aed0dae98c88abe24b359a8410ac5e5950d4fa Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:12:57 +0300 Subject: [PATCH 04/31] feat(theme): parse VS Code HEX/RGBA color strings Adds parseVsCodeColor for #RGB, #RGBA, #RRGGBB, #RRGGBBAA formats. Closes #53 --- lib/core/theme/parser/color_parser.dart | 47 +++++++++++++++++++ test/core/theme/parser/color_parser_test.dart | 29 ++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 lib/core/theme/parser/color_parser.dart create mode 100644 test/core/theme/parser/color_parser_test.dart diff --git a/lib/core/theme/parser/color_parser.dart b/lib/core/theme/parser/color_parser.dart new file mode 100644 index 00000000..065a1bc8 --- /dev/null +++ b/lib/core/theme/parser/color_parser.dart @@ -0,0 +1,47 @@ +import 'dart:ui'; + +/// Parses VS Code color strings into Flutter [Color]. +Color parseVsCodeColor(String input) { + var s = input.trim(); + if (s.isEmpty) { + throw 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'); +} + +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/test/core/theme/parser/color_parser_test.dart b/test/core/theme/parser/color_parser_test.dart new file mode 100644 index 00000000..78c4fdd9 --- /dev/null +++ b/test/core/theme/parser/color_parser_test.dart @@ -0,0 +1,29 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; + +void main() { + group('parseVsCodeColor', () { + test('6-digit hex', () { + expect(parseVsCodeColor('#1e1e1e'), const Color(0xFF1E1E1E)); + }); + + test('8-digit RRGGBBAA', () { + expect(parseVsCodeColor('#11223344').alpha, 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); + }); + }); +} From 3ec611103f6d509952e5f38fbff1a5b846d48f48 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:14:56 +0300 Subject: [PATCH 05/31] fix(theme): satisfy prefer_const_* lints in QueryaTheme Use const ColorScheme presets via QueryaColors and const Typography.geist() so flutter analyze exits cleanly in CI. --- lib/core/theme/querya_theme.dart | 53 ++++++++++++++------------ test/core/theme/querya_theme_test.dart | 4 +- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/lib/core/theme/querya_theme.dart b/lib/core/theme/querya_theme.dart index 8db6117f..7982b02b 100644 --- a/lib/core/theme/querya_theme.dart +++ b/lib/core/theme/querya_theme.dart @@ -1,5 +1,6 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'querya_colors.dart'; import 'querya_editor_theme.dart'; import 'querya_workbench_theme.dart'; @@ -17,41 +18,42 @@ class QueryaTheme { final Brightness brightness; final ColorScheme colorScheme; - static final QueryaTheme darkDefault = QueryaTheme( + static const QueryaTheme darkDefault = QueryaTheme( workbench: QueryaWorkbenchTheme.darkDefault, editor: QueryaEditorTheme.darkDefault, brightness: Brightness.dark, colorScheme: _darkColorScheme, ); - static final QueryaTheme lightDefault = QueryaTheme( + static const QueryaTheme lightDefault = QueryaTheme( workbench: QueryaWorkbenchTheme.lightDefault, editor: QueryaEditorTheme.lightDefault, brightness: Brightness.light, colorScheme: _lightColorScheme, ); - static final ColorScheme _darkColorScheme = ColorScheme( + /// Matches [QueryaWorkbenchTheme.darkDefault] / legacy [QueryaColorScheme]. + static const ColorScheme _darkColorScheme = ColorScheme( brightness: Brightness.dark, - background: QueryaWorkbenchTheme.darkDefault.canvas, + background: QueryaColors.canvas, foreground: Color(0xFFF8FAFC), - card: QueryaWorkbenchTheme.darkDefault.surface, + card: QueryaColors.surface, cardForeground: Color(0xFFF8FAFC), - popover: QueryaWorkbenchTheme.darkDefault.surface, + popover: QueryaColors.surface, popoverForeground: Color(0xFFF8FAFC), - primary: QueryaWorkbenchTheme.darkDefault.accent, - primaryForeground: QueryaWorkbenchTheme.darkDefault.onAccent, + primary: QueryaColors.accentCyan, + primaryForeground: QueryaColors.onAccent, secondary: Color(0xFF18181B), secondaryForeground: Color(0xFFF8FAFC), muted: Color(0xFF18181B), - mutedForeground: QueryaWorkbenchTheme.darkDefault.mutedForeground, + mutedForeground: QueryaColors.mutedLabel, accent: Color(0xFF27272A), accentForeground: Color(0xFFF8FAFC), - destructive: QueryaWorkbenchTheme.darkDefault.destructive, + destructive: Color(0xFFEF4444), destructiveForeground: Color(0xFFF8FAFC), - border: QueryaWorkbenchTheme.darkDefault.borderSubtle, - input: QueryaWorkbenchTheme.darkDefault.borderSubtle, - ring: QueryaWorkbenchTheme.darkDefault.accent, + border: QueryaColors.borderSubtle, + input: QueryaColors.borderSubtle, + ring: QueryaColors.accentCyan, chart1: Color(0xFF2662D9), chart2: Color(0xFF2EB88A), chart3: Color(0xFFE88C30), @@ -59,27 +61,28 @@ class QueryaTheme { chart5: Color(0xFFE23670), ); - static final ColorScheme _lightColorScheme = ColorScheme( + /// Matches [QueryaWorkbenchTheme.lightDefault]. + static const ColorScheme _lightColorScheme = ColorScheme( brightness: Brightness.light, - background: QueryaWorkbenchTheme.lightDefault.canvas, + background: Color(0xFFFAFAFA), foreground: Color(0xFF0F172A), - card: QueryaWorkbenchTheme.lightDefault.surface, + card: Color(0xFFFFFFFF), cardForeground: Color(0xFF0F172A), - popover: QueryaWorkbenchTheme.lightDefault.surface, + popover: Color(0xFFFFFFFF), popoverForeground: Color(0xFF0F172A), - primary: QueryaWorkbenchTheme.lightDefault.accent, - primaryForeground: QueryaWorkbenchTheme.lightDefault.onAccent, + primary: QueryaColors.accentCyan, + primaryForeground: QueryaColors.onAccent, secondary: Color(0xFFF4F4F5), secondaryForeground: Color(0xFF0F172A), muted: Color(0xFFF4F4F5), - mutedForeground: QueryaWorkbenchTheme.lightDefault.mutedForeground, + mutedForeground: Color(0xFF64748B), accent: Color(0xFFE4E4E7), accentForeground: Color(0xFF0F172A), - destructive: QueryaWorkbenchTheme.lightDefault.destructive, + destructive: Color(0xFFDC2626), destructiveForeground: Color(0xFFF8FAFC), - border: QueryaWorkbenchTheme.lightDefault.borderSubtle, - input: QueryaWorkbenchTheme.lightDefault.borderSubtle, - ring: QueryaWorkbenchTheme.lightDefault.accent, + border: Color(0xFFE4E4E7), + input: Color(0xFFE4E4E7), + ring: QueryaColors.accentCyan, chart1: Color(0xFF2662D9), chart2: Color(0xFF2EB88A), chart3: Color(0xFFE88C30), @@ -154,7 +157,7 @@ class QueryaTheme { double scaling = 1, Typography? typography, }) { - final typo = typography ?? Typography.geist(); + final typo = typography ?? const Typography.geist(); final base = brightness == Brightness.dark ? ThemeData.dark(colorScheme: colorScheme) : ThemeData(colorScheme: colorScheme); diff --git a/test/core/theme/querya_theme_test.dart b/test/core/theme/querya_theme_test.dart index b9a3d434..6e28b719 100644 --- a/test/core/theme/querya_theme_test.dart +++ b/test/core/theme/querya_theme_test.dart @@ -76,8 +76,8 @@ void main() { }); test('lerp interpolates editor and workbench', () { - final a = QueryaTheme.darkDefault; - final b = QueryaTheme.lightDefault; + 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))); From 1b8500154b3907b55159671d62059d02f29ddaa0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:19:21 +0300 Subject: [PATCH 06/31] feat(theme): add QueryaThemeScope for workbench and editor tokens Exposes context.workbench / context.editorTheme via InheritedWidget. Wraps app home with QueryaTheme.darkDefault until ThemeController (#40). Closes #54 --- lib/app/app.dart | 9 ++- lib/core/theme/querya_theme_scope.dart | 41 ++++++++++ test/core/theme/querya_theme_scope_test.dart | 81 ++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 lib/core/theme/querya_theme_scope.dart create mode 100644 test/core/theme/querya_theme_scope_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 479d353e..88b7b2c9 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_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'app_lifecycle_cleanup.dart'; @@ -19,8 +21,11 @@ class QueryaApp extends StatelessWidget { enableThemeAnimation: false, // Avoids scroll interception fighting nested Scrollbars in data views. enableScrollInterception: false, - home: const AppLifecycleCleanup( - child: MainScreen(), + home: const QueryaThemeScope( + data: QueryaTheme.darkDefault, + child: AppLifecycleCleanup( + child: MainScreen(), + ), ), ); } 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/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 From 5ce32580063bbfbcb5581e433e898fd36b17ba72 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:23:12 +0300 Subject: [PATCH 07/31] feat(theme): ThemeController and runtime theme in QueryaApp Persist theme mode and preset in AppSettings, load on startup, and rebuild ShadcnApp from ThemeController via ListenableBuilder. Closes #40 Closes #41 --- lib/app/app.dart | 41 +++++----- lib/core/storage/app_settings.dart | 48 +++++++++++- lib/core/theme/parser/color_parser.dart | 2 +- lib/core/theme/querya_theme_preset.dart | 5 ++ lib/core/theme/theme_controller.dart | 76 ++++++++++++++++++ lib/main.dart | 2 + test/core/storage/app_settings_test.dart | 24 ++++++ test/core/theme/parser/color_parser_test.dart | 3 +- test/core/theme/theme_controller_test.dart | 78 +++++++++++++++++++ 9 files changed, 258 insertions(+), 21 deletions(-) create mode 100644 lib/core/theme/querya_theme_preset.dart create mode 100644 lib/core/theme/theme_controller.dart create mode 100644 test/core/theme/theme_controller_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 88b7b2c9..cf3769a6 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,6 +1,5 @@ -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:querya_desktop/core/theme/theme_controller.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'app_lifecycle_cleanup.dart'; @@ -11,22 +10,28 @@ 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 QueryaThemeScope( - data: QueryaTheme.darkDefault, - child: AppLifecycleCleanup( - child: MainScreen(), - ), - ), + final themeController = ThemeController.instance; + + return ListenableBuilder( + listenable: themeController, + builder: (context, _) { + final queryaTheme = themeController.activeTheme; + return ShadcnApp( + title: 'Querya', + theme: themeController.lightShadcnTheme, + darkTheme: themeController.darkShadcnTheme, + themeMode: themeController.themeMode, + debugShowCheckedModeBanner: false, + enableThemeAnimation: false, + enableScrollInterception: false, + home: QueryaThemeScope( + data: queryaTheme, + child: const AppLifecycleCleanup( + child: MainScreen(), + ), + ), + ); + }, ); } } diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 64023fce..8ff1c188 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -1,5 +1,6 @@ -import 'package:flutter/foundation.dart'; +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 +48,8 @@ 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'; } /// Bumps [listenable] when any preference is persisted so open screens can reload. @@ -170,4 +173,47 @@ 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, + _ => QueryaThemePreset.queryaDark, + }; + } + + Future setThemePreset(QueryaThemePreset preset) async { + final stored = switch (preset) { + QueryaThemePreset.queryaLight => 'querya_light', + QueryaThemePreset.queryaDark => 'querya_dark', + }; + await LocalDb.instance.setAppSetting(AppSettingsKeys.themePreset, stored); + AppSettingsRevision.bump(); + } + + Future clearThemeSettings() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeMode); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset); + AppSettingsRevision.bump(); + } } diff --git a/lib/core/theme/parser/color_parser.dart b/lib/core/theme/parser/color_parser.dart index 065a1bc8..c4cae9f8 100644 --- a/lib/core/theme/parser/color_parser.dart +++ b/lib/core/theme/parser/color_parser.dart @@ -4,7 +4,7 @@ import 'dart:ui'; Color parseVsCodeColor(String input) { var s = input.trim(); if (s.isEmpty) { - throw FormatException('Empty color string'); + throw const FormatException('Empty color string'); } if (s.startsWith('#')) { s = s.substring(1); diff --git a/lib/core/theme/querya_theme_preset.dart b/lib/core/theme/querya_theme_preset.dart new file mode 100644 index 00000000..cb80b4c7 --- /dev/null +++ b/lib/core/theme/querya_theme_preset.dart @@ -0,0 +1,5 @@ +/// Built-in theme presets (imported VS Code themes — #44). +enum QueryaThemePreset { + queryaDark, + queryaLight, +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart new file mode 100644 index 00000000..fafd0eab --- /dev/null +++ b/lib/core/theme/theme_controller.dart @@ -0,0 +1,76 @@ +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'querya_theme.dart'; +import 'querya_theme_preset.dart'; + +/// Active theme state: preset + [ThemeMode], persisted via [AppSettings]. +class ThemeController extends ChangeNotifier { + ThemeController._(); + + static final ThemeController instance = ThemeController._(); + + ThemeMode _themeMode = ThemeMode.dark; + QueryaThemePreset _preset = QueryaThemePreset.queryaDark; + bool _loaded = false; + + ThemeMode get themeMode => _themeMode; + + QueryaThemePreset get preset => _preset; + + bool get isLoaded => _loaded; + + /// Workbench + editor tokens for the current preset/mode. + QueryaTheme get activeTheme { + if (_themeMode == ThemeMode.system) { + final b = WidgetsBinding.instance.platformDispatcher.platformBrightness; + return b == Brightness.dark + ? QueryaTheme.darkDefault + : QueryaTheme.lightDefault; + } + return _preset == QueryaThemePreset.queryaLight + ? QueryaTheme.lightDefault + : QueryaTheme.darkDefault; + } + + ThemeData get lightShadcnTheme => + QueryaTheme.lightDefault.toShadcnThemeData(); + + ThemeData get darkShadcnTheme => QueryaTheme.darkDefault.toShadcnThemeData(); + + Future load() async { + final mode = await AppSettings.instance.getThemeMode(); + final preset = await AppSettings.instance.getThemePreset(); + _themeMode = mode; + _preset = preset; + _loaded = true; + notifyListeners(); + } + + Future setThemeMode(ThemeMode mode) async { + _themeMode = mode; + _preset = mode == ThemeMode.light + ? QueryaThemePreset.queryaLight + : QueryaThemePreset.queryaDark; + await AppSettings.instance.setThemeMode(mode); + await AppSettings.instance.setThemePreset(_preset); + notifyListeners(); + } + + Future setPreset(QueryaThemePreset preset) async { + _preset = preset; + _themeMode = preset == QueryaThemePreset.queryaLight + ? ThemeMode.light + : ThemeMode.dark; + await AppSettings.instance.setThemePreset(preset); + await AppSettings.instance.setThemeMode(_themeMode); + notifyListeners(); + } + + Future resetToDefaults() async { + await AppSettings.instance.clearThemeSettings(); + _themeMode = ThemeMode.dark; + _preset = QueryaThemePreset.queryaDark; + notifyListeners(); + } +} diff --git a/lib/main.dart b/lib/main.dart index cceb337c..d6436b4c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,10 +3,12 @@ import 'package:flutter/material.dart'; import 'app/app.dart'; import 'core/storage/local_db.dart'; +import 'core/theme/theme_controller.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await LocalDb.initFfi(); + await ThemeController.instance.load(); runApp(const QueryaApp()); doWhenWindowReady(() { final win = appWindow; diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 7a50557d..5770cb7a 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,27 @@ 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); + }); + }); + group('AppSettingsRevision', () { test('bump increments listenable value', () { final start = AppSettingsRevision.listenable.value; diff --git a/test/core/theme/parser/color_parser_test.dart b/test/core/theme/parser/color_parser_test.dart index 78c4fdd9..014598de 100644 --- a/test/core/theme/parser/color_parser_test.dart +++ b/test/core/theme/parser/color_parser_test.dart @@ -10,7 +10,8 @@ void main() { }); test('8-digit RRGGBBAA', () { - expect(parseVsCodeColor('#11223344').alpha, 0x44); + final c = parseVsCodeColor('#11223344'); + expect((c.a * 255).round(), 0x44); }); test('3-digit shorthand', () { diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart new file mode 100644 index 00000000..3da820c4 --- /dev/null +++ b/test/core/theme/theme_controller_test.dart @@ -0,0 +1,78 @@ +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: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); + }); +} From 184445c2e3f6517db71e5fbcf0ccaf75c48001d5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:24:54 +0300 Subject: [PATCH 08/31] feat(theme): map VS Code workbench colors subset to QueryaTheme Add vscode_color_map, buildQueryaThemeFromVsCodeManifest, fixture tests, and docs/theme-import.md for supported keys and fallback behavior. Closes #44 --- docs/theme-import.md | 50 +++++ lib/core/theme/parser/color_parser.dart | 2 +- .../parser/querya_theme_from_vscode.dart | 189 ++++++++++++++++++ lib/core/theme/parser/vscode_color_map.dart | 106 ++++++++++ test/core/theme/parser/color_parser_test.dart | 3 +- .../theme/parser/vscode_color_map_test.dart | 92 +++++++++ test/fixtures/themes/dark_subset.json | 14 ++ test/fixtures/themes/light_subset.json | 10 + test/fixtures/themes/with_unknown_keys.json | 9 + 9 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 docs/theme-import.md create mode 100644 lib/core/theme/parser/querya_theme_from_vscode.dart create mode 100644 lib/core/theme/parser/vscode_color_map.dart create mode 100644 test/core/theme/parser/vscode_color_map_test.dart create mode 100644 test/fixtures/themes/dark_subset.json create mode 100644 test/fixtures/themes/light_subset.json create mode 100644 test/fixtures/themes/with_unknown_keys.json diff --git a/docs/theme-import.md b/docs/theme-import.md new file mode 100644 index 00000000..25d25e83 --- /dev/null +++ b/docs/theme-import.md @@ -0,0 +1,50 @@ +# VS Code theme import (workbench colors) + +Querya can apply a **subset** of VS Code theme JSON / JSONC `colors` to +`QueryaWorkbenchTheme`, `QueryaEditorTheme`, and the shadcn `ColorScheme`. + +Syntax highlighting (`tokenColors`) is tracked separately (issue #46). + +## Supported `colors` keys + +| VS Code key | Querya target | +|-------------|---------------| +| `editor.background` | `workbench.editorBackground`, `editor.background` | +| `editor.foreground` | `editor.foreground`, `ColorScheme.foreground` | +| `sideBar.background` | `workbench.sidebarBackground` | +| `sideBar.foreground` | `workbench.mutedForeground` | +| `activityBar.background` | `workbench.canvas` | +| `tab.activeBackground` | `workbench.surface` | +| `statusBar.background` | `workbench.canvas` | +| `panel.background` | `workbench.surface` | +| `focusBorder` | `workbench.accent`, `ColorScheme.ring` | +| `input.background` | `workbench.surface` | +| `list.hoverBackground` | `ColorScheme.accent` | +| `gitDecoration.modifiedResourceForeground` | `workbench.gitModified` | +| `gitDecoration.untrackedResourceForeground` | `workbench.gitUntracked` | + +Implementation: `lib/core/theme/parser/vscode_color_map.dart`, +`lib/core/theme/parser/querya_theme_from_vscode.dart`. + +## Behavior + +- **`type`**: `"dark"` or `"light"` in the manifest selects brightness and + default fallback (`QueryaTheme.darkDefault` / `lightDefault`). +- **Missing keys**: unchanged from the fallback theme. +- **Unknown keys**: ignored; in debug builds a line is printed to the console. +- **Invalid color values**: skipped for that key only. + +## Color formats + +Hex strings as in VS Code: `#RRGGBB`, `#RRGGBBAA`, `#RGB`, `#RGBA` (see +`parseVsCodeColor`). + +## JSONC + +Comments and trailing commas are stripped before parse (`stripJsonc`). + +## Fixtures (tests) + +- `test/fixtures/themes/dark_subset.json` +- `test/fixtures/themes/light_subset.json` +- `test/fixtures/themes/with_unknown_keys.json` diff --git a/lib/core/theme/parser/color_parser.dart b/lib/core/theme/parser/color_parser.dart index 065a1bc8..c4cae9f8 100644 --- a/lib/core/theme/parser/color_parser.dart +++ b/lib/core/theme/parser/color_parser.dart @@ -4,7 +4,7 @@ import 'dart:ui'; Color parseVsCodeColor(String input) { var s = input.trim(); if (s.isEmpty) { - throw FormatException('Empty color string'); + throw const FormatException('Empty color string'); } if (s.startsWith('#')) { s = s.substring(1); diff --git a/lib/core/theme/parser/querya_theme_from_vscode.dart b/lib/core/theme/parser/querya_theme_from_vscode.dart new file mode 100644 index 00000000..68dfca79 --- /dev/null +++ b/lib/core/theme/parser/querya_theme_from_vscode.dart @@ -0,0 +1,189 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +import '../querya_editor_theme.dart'; +import '../querya_theme.dart'; +import '../querya_workbench_theme.dart'; +import 'color_parser.dart'; +import 'vscode_color_map.dart'; +import 'vscode_theme_manifest.dart'; + +/// Builds a [QueryaTheme] from a parsed VS Code manifest. +/// +/// Missing keys keep values from [fallback] (defaults by manifest `type`). +QueryaTheme buildQueryaThemeFromVsCodeManifest( + VsCodeThemeManifest manifest, { + QueryaTheme? fallback, + void Function(String unknownVsCodeKey)? onUnknownColorKey, +}) { + final base = fallback ?? _defaultFallbackFor(manifest); + final brightness = _brightnessFrom(manifest, base); + + var workbench = base.workbench; + var editor = base.editor; + Color? schemeForeground; + Color? schemeBackground; + Color? schemeCard; + Color? schemeBorder; + Color? schemeInput; + Color? schemeRing; + Color? schemeMutedForeground; + Color? schemeAccent; + + for (final entry in manifest.colors.entries) { + final target = kVsCodeColorMap[entry.key]; + if (target == null) { + onUnknownColorKey?.call(entry.key); + if (kDebugMode) { + debugPrint('VsCode theme: ignored color key "${entry.key}"'); + } + continue; + } + + final Color color; + try { + color = parseVsCodeColor(entry.value); + } on FormatException { + if (kDebugMode) { + debugPrint( + 'VsCode theme: invalid color for "${entry.key}": ${entry.value}', + ); + } + continue; + } + + if (target.workbench != null) { + workbench = _applyWorkbenchField(workbench, target.workbench!, color); + if (target.workbench == VsCodeWorkbenchField.editorBackground) { + editor = editor.copyWith(background: color); + } + } else if (target.editor != null) { + editor = _applyEditorField(editor, target.editor!, color); + } else if (target.colorScheme != null) { + switch (target.colorScheme!) { + case VsCodeColorSchemeField.foreground: + schemeForeground = color; + case VsCodeColorSchemeField.background: + schemeBackground = color; + case VsCodeColorSchemeField.card: + schemeCard = color; + case VsCodeColorSchemeField.border: + schemeBorder = color; + case VsCodeColorSchemeField.input: + schemeInput = color; + case VsCodeColorSchemeField.ring: + schemeRing = color; + case VsCodeColorSchemeField.mutedForeground: + schemeMutedForeground = color; + case VsCodeColorSchemeField.accent: + schemeAccent = color; + } + } + } + + if (editor.background != workbench.editorBackground) { + editor = editor.copyWith(background: workbench.editorBackground); + } + + var colorScheme = QueryaTheme.colorSchemeFromWorkbench( + workbench, + brightness: brightness, + ); + + final editorForegroundChanged = + editor.foreground != base.editor.foreground; + if (schemeForeground != null || editorForegroundChanged) { + final fg = schemeForeground ?? editor.foreground; + colorScheme = colorScheme.copyWith( + foreground: () => fg, + cardForeground: () => fg, + popoverForeground: () => fg, + ); + } + if (schemeBackground != null) { + colorScheme = colorScheme.copyWith(background: () => schemeBackground!); + } + if (schemeCard != null) { + colorScheme = colorScheme.copyWith( + card: () => schemeCard!, + popover: () => schemeCard!, + ); + } + if (schemeBorder != null) { + colorScheme = colorScheme.copyWith(border: () => schemeBorder!); + } + if (schemeInput != null) { + colorScheme = colorScheme.copyWith(input: () => schemeInput!); + } + if (schemeRing != null) { + colorScheme = colorScheme.copyWith(ring: () => schemeRing!); + } + if (schemeMutedForeground != null) { + colorScheme = colorScheme.copyWith( + mutedForeground: () => schemeMutedForeground!, + ); + } + if (schemeAccent != null) { + colorScheme = colorScheme.copyWith(accent: () => schemeAccent!); + } + + return QueryaTheme( + workbench: workbench, + editor: editor, + brightness: brightness, + colorScheme: colorScheme, + ); +} + +QueryaTheme _defaultFallbackFor(VsCodeThemeManifest manifest) { + if (manifest.isLight) return QueryaTheme.lightDefault; + if (manifest.isDark) return QueryaTheme.darkDefault; + return QueryaTheme.darkDefault; +} + +Brightness _brightnessFrom(VsCodeThemeManifest manifest, QueryaTheme base) { + if (manifest.isLight) return Brightness.light; + if (manifest.isDark) return Brightness.dark; + return base.brightness; +} + +QueryaWorkbenchTheme _applyWorkbenchField( + QueryaWorkbenchTheme w, + VsCodeWorkbenchField field, + Color color, +) { + switch (field) { + case VsCodeWorkbenchField.canvas: + return w.copyWith(canvas: color); + case VsCodeWorkbenchField.surface: + return w.copyWith(surface: color); + case VsCodeWorkbenchField.sidebarBackground: + return w.copyWith(sidebarBackground: color); + case VsCodeWorkbenchField.editorBackground: + return w.copyWith(editorBackground: color); + case VsCodeWorkbenchField.borderSubtle: + return w.copyWith(borderSubtle: color); + case VsCodeWorkbenchField.accent: + return w.copyWith(accent: color); + case VsCodeWorkbenchField.mutedForeground: + return w.copyWith(mutedForeground: color); + case VsCodeWorkbenchField.gitModified: + return w.copyWith(gitModified: color); + case VsCodeWorkbenchField.gitUntracked: + return w.copyWith(gitUntracked: color); + } +} + +QueryaEditorTheme _applyEditorField( + QueryaEditorTheme e, + VsCodeEditorField field, + Color color, +) { + switch (field) { + case VsCodeEditorField.background: + return e.copyWith(background: color); + case VsCodeEditorField.foreground: + return e.copyWith(foreground: color); + } +} diff --git a/lib/core/theme/parser/vscode_color_map.dart b/lib/core/theme/parser/vscode_color_map.dart new file mode 100644 index 00000000..bc910627 --- /dev/null +++ b/lib/core/theme/parser/vscode_color_map.dart @@ -0,0 +1,106 @@ +// Supported VS Code `colors` keys → Querya workbench / editor tokens. +// Unknown keys are ignored; see kSupportedVsCodeColorKeys and docs/theme-import.md. + +/// Workbench token updated from a VS Code color key. +enum VsCodeWorkbenchField { + canvas, + surface, + sidebarBackground, + editorBackground, + borderSubtle, + accent, + mutedForeground, + gitModified, + gitUntracked, +} + +/// Editor token updated from a VS Code color key. +enum VsCodeEditorField { + background, + foreground, +} + +/// Optional direct [ColorScheme] fields (shadcn) beyond workbench derivation. +enum VsCodeColorSchemeField { + foreground, + background, + card, + border, + input, + ring, + mutedForeground, + accent, +} + +/// Maps one VS Code `colors` entry to Querya tokens. +class VsCodeColorTarget { + const VsCodeColorTarget.workbench(this.workbench) + : editor = null, + colorScheme = null; + + const VsCodeColorTarget.editor(this.editor) + : workbench = null, + colorScheme = null; + + const VsCodeColorTarget.scheme(this.colorScheme) + : workbench = null, + editor = null; + + final VsCodeWorkbenchField? workbench; + final VsCodeEditorField? editor; + final VsCodeColorSchemeField? colorScheme; +} + +/// VS Code key → Querya target. Keys not listed are ignored. +const Map kVsCodeColorMap = { + 'editor.background': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.editorBackground, + ), + 'editor.foreground': VsCodeColorTarget.editor(VsCodeEditorField.foreground), + 'sideBar.background': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.sidebarBackground, + ), + 'sideBar.foreground': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.mutedForeground, + ), + 'activityBar.background': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.canvas, + ), + 'tab.activeBackground': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.surface, + ), + 'statusBar.background': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.canvas, + ), + 'panel.background': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.surface, + ), + 'focusBorder': VsCodeColorTarget.workbench(VsCodeWorkbenchField.accent), + 'input.background': VsCodeColorTarget.workbench(VsCodeWorkbenchField.surface), + 'list.hoverBackground': VsCodeColorTarget.scheme( + VsCodeColorSchemeField.accent, + ), + 'gitDecoration.modifiedResourceForeground': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.gitModified, + ), + 'gitDecoration.untrackedResourceForeground': VsCodeColorTarget.workbench( + VsCodeWorkbenchField.gitUntracked, + ), +}; + +/// Documented subset of supported VS Code keys (stable API). +const List kSupportedVsCodeColorKeys = [ + 'editor.background', + 'editor.foreground', + 'sideBar.background', + 'sideBar.foreground', + 'activityBar.background', + 'tab.activeBackground', + 'statusBar.background', + 'panel.background', + 'focusBorder', + 'input.background', + 'list.hoverBackground', + 'gitDecoration.modifiedResourceForeground', + 'gitDecoration.untrackedResourceForeground', +]; diff --git a/test/core/theme/parser/color_parser_test.dart b/test/core/theme/parser/color_parser_test.dart index 78c4fdd9..014598de 100644 --- a/test/core/theme/parser/color_parser_test.dart +++ b/test/core/theme/parser/color_parser_test.dart @@ -10,7 +10,8 @@ void main() { }); test('8-digit RRGGBBAA', () { - expect(parseVsCodeColor('#11223344').alpha, 0x44); + final c = parseVsCodeColor('#11223344'); + expect((c.a * 255).round(), 0x44); }); test('3-digit shorthand', () { diff --git a/test/core/theme/parser/vscode_color_map_test.dart b/test/core/theme/parser/vscode_color_map_test.dart new file mode 100644 index 00000000..9f556af2 --- /dev/null +++ b/test/core/theme/parser/vscode_color_map_test.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_from_vscode.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_color_map.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + group('kVsCodeColorMap', () { + test('documents every mapped key in supported list', () { + for (final key in kVsCodeColorMap.keys) { + expect(kSupportedVsCodeColorKeys, contains(key)); + } + }); + }); + + group('buildQueryaThemeFromVsCodeManifest', () { + Future fixture(String name) async { + final path = 'test/fixtures/themes/$name'; + return File(path).readAsString(); + } + + test('dark_subset fixture maps workbench and editor', () async { + final src = await fixture('dark_subset.json'); + final manifest = VsCodeThemeManifest.fromJsonString(src); + final theme = buildQueryaThemeFromVsCodeManifest(manifest); + + expect(manifest.isDark, isTrue); + expect(theme.brightness, Brightness.dark); + expect(theme.workbench.editorBackground, const Color(0xFF1E1E1E)); + expect(theme.workbench.sidebarBackground, const Color(0xFF252526)); + expect(theme.editor.foreground, const Color(0xFFD4D4D4)); + expect(theme.workbench.canvas, const Color(0xFF007ACC)); + expect(theme.workbench.accent, const Color(0xFF007FD4)); + expect(theme.workbench.gitModified, const Color(0xFFE2C08D)); + expect(theme.workbench.gitUntracked, const Color(0xFF73C991)); + expect(theme.colorScheme.foreground, const Color(0xFFD4D4D4)); + }); + + test('light_subset fixture uses light brightness', () async { + final src = await fixture('light_subset.json'); + final manifest = VsCodeThemeManifest.fromJsonString(src); + final theme = buildQueryaThemeFromVsCodeManifest(manifest); + + expect(manifest.isLight, isTrue); + expect(theme.brightness, Brightness.light); + expect(theme.workbench.editorBackground, const Color(0xFFFFFFFF)); + expect(theme.workbench.sidebarBackground, const Color(0xFFF3F3F3)); + // `input.background` and `panel.background` both map to surface; last wins. + expect(theme.workbench.surface, const Color(0xFFFFFFFF)); + }); + + test('unknown keys are reported and defaults kept for unmapped tokens', () async { + final src = await fixture('with_unknown_keys.json'); + final manifest = VsCodeThemeManifest.fromJsonString(src); + final unknown = []; + final theme = buildQueryaThemeFromVsCodeManifest( + manifest, + onUnknownColorKey: unknown.add, + ); + + expect(unknown, contains('titleBar.activeBackground')); + expect(unknown, contains('workbench.colorCustomizations.unsupported')); + expect(theme.workbench.editorBackground, const Color(0xFF2D2D30)); + expect( + theme.workbench.destructive, + QueryaTheme.darkDefault.workbench.destructive, + ); + }); + + test('missing keys fall back to dark default', () async { + const src = ''' +{ + "type": "dark", + "colors": { + "editor.background": "#111111" + } +} +'''; + final manifest = VsCodeThemeManifest.fromJsonString(src); + final theme = buildQueryaThemeFromVsCodeManifest(manifest); + + expect(theme.workbench.editorBackground, const Color(0xFF111111)); + expect( + theme.workbench.accent, + QueryaTheme.darkDefault.workbench.accent, + ); + }); + }); +} diff --git a/test/fixtures/themes/dark_subset.json b/test/fixtures/themes/dark_subset.json new file mode 100644 index 00000000..ea32e77b --- /dev/null +++ b/test/fixtures/themes/dark_subset.json @@ -0,0 +1,14 @@ +{ + "name": "Fixture Dark Subset", + "type": "dark", + "colors": { + "editor.background": "#1e1e1e", + "editor.foreground": "#d4d4d4", + "sideBar.background": "#252526", + "sideBar.foreground": "#cccccc", + "statusBar.background": "#007acc", + "focusBorder": "#007fd4", + "gitDecoration.modifiedResourceForeground": "#e2c08d", + "gitDecoration.untrackedResourceForeground": "#73c991" + } +} diff --git a/test/fixtures/themes/light_subset.json b/test/fixtures/themes/light_subset.json new file mode 100644 index 00000000..38ea2f3a --- /dev/null +++ b/test/fixtures/themes/light_subset.json @@ -0,0 +1,10 @@ +{ + "name": "Fixture Light Subset", + "type": "light", + "colors": { + "editor.background": "#ffffff", + "sideBar.background": "#f3f3f3", + "panel.background": "#f8f8f8", + "input.background": "#ffffff" + } +} diff --git a/test/fixtures/themes/with_unknown_keys.json b/test/fixtures/themes/with_unknown_keys.json new file mode 100644 index 00000000..f5fc356e --- /dev/null +++ b/test/fixtures/themes/with_unknown_keys.json @@ -0,0 +1,9 @@ +{ + "name": "Fixture Unknown Keys", + "type": "dark", + "colors": { + "editor.background": "#2d2d30", + "workbench.colorCustomizations.unsupported": "#ff00ff", + "titleBar.activeBackground": "#3c3c3c" + } +} From af629f32f0e0ed0b0e437292e5bc0bead71b9e66 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:29:16 +0300 Subject: [PATCH 09/31] feat(theme): user color overrides with deep merge pipeline Persist VS Code color overrides in AppSettings, merge with imported layer in ThemeController, and apply via buildQueryaThemeFromVsCodeColors. Closes #45 --- docs/theme-import.md | 14 +++ lib/core/storage/app_settings.dart | 45 +++++++++ lib/core/theme/parser/color_parser.dart | 14 +++ .../parser/querya_theme_from_vscode.dart | 18 ++++ .../theme/parser/vscode_colors_merge.dart | 14 +++ lib/core/theme/theme_controller.dart | 93 ++++++++++++++++--- test/core/storage/app_settings_test.dart | 13 +++ test/core/theme/parser/color_parser_test.dart | 9 +- .../querya_theme_merge_pipeline_test.dart | 61 ++++++++++++ .../parser/vscode_colors_merge_test.dart | 31 +++++++ test/core/theme/theme_controller_test.dart | 33 +++++++ 11 files changed, 331 insertions(+), 14 deletions(-) create mode 100644 lib/core/theme/parser/vscode_colors_merge.dart create mode 100644 test/core/theme/parser/querya_theme_merge_pipeline_test.dart create mode 100644 test/core/theme/parser/vscode_colors_merge_test.dart diff --git a/docs/theme-import.md b/docs/theme-import.md index 25d25e83..2097fcad 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -43,6 +43,20 @@ Hex strings as in VS Code: `#RRGGBB`, `#RRGGBBAA`, `#RGB`, `#RGBA` (see Comments and trailing commas are stripped before parse (`stripJsonc`). +## 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). + ## Fixtures (tests) - `test/fixtures/themes/dark_subset.json` diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 8ff1c188..660ab4f3 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../theme/querya_theme_preset.dart'; @@ -50,6 +52,7 @@ abstract final class AppSettingsKeys { static const sqlHistoryMaxEntries = 'sql_history_max_entries'; static const themeMode = 'theme_mode'; static const themePreset = 'theme_preset'; + static const themeOverridesJson = 'theme_overrides_json'; } /// Bumps [listenable] when any preference is persisted so open screens can reload. @@ -211,9 +214,51 @@ class AppSettings { AppSettingsRevision.bump(); } + 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(); + } + Future clearThemeSettings() async { await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeMode); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson); AppSettingsRevision.bump(); } } diff --git a/lib/core/theme/parser/color_parser.dart b/lib/core/theme/parser/color_parser.dart index c4cae9f8..b1c7cdc4 100644 --- a/lib/core/theme/parser/color_parser.dart +++ b/lib/core/theme/parser/color_parser.dart @@ -33,6 +33,20 @@ Color parseVsCodeColor(String input) { 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); diff --git a/lib/core/theme/parser/querya_theme_from_vscode.dart b/lib/core/theme/parser/querya_theme_from_vscode.dart index 68dfca79..0b5c4c99 100644 --- a/lib/core/theme/parser/querya_theme_from_vscode.dart +++ b/lib/core/theme/parser/querya_theme_from_vscode.dart @@ -187,3 +187,21 @@ QueryaEditorTheme _applyEditorField( return e.copyWith(foreground: 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/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/theme_controller.dart b/lib/core/theme/theme_controller.dart index fafd0eab..94bea9ad 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,10 +1,13 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'parser/color_parser.dart'; +import 'parser/querya_theme_from_vscode.dart'; +import 'parser/vscode_colors_merge.dart'; import 'querya_theme.dart'; import 'querya_theme_preset.dart'; -/// Active theme state: preset + [ThemeMode], persisted via [AppSettings]. +/// Active theme state: preset, optional imported colors, user overrides. class ThemeController extends ChangeNotifier { ThemeController._(); @@ -12,6 +15,8 @@ class ThemeController extends ChangeNotifier { ThemeMode _themeMode = ThemeMode.dark; QueryaThemePreset _preset = QueryaThemePreset.queryaDark; + Map _importedColors = const {}; + Map _userOverrides = const {}; bool _loaded = false; ThemeMode get themeMode => _themeMode; @@ -20,29 +25,48 @@ class ThemeController extends ChangeNotifier { bool get isLoaded => _loaded; - /// Workbench + editor tokens for the current preset/mode. - QueryaTheme get activeTheme { - if (_themeMode == ThemeMode.system) { - final b = WidgetsBinding.instance.platformDispatcher.platformBrightness; - return b == Brightness.dark - ? QueryaTheme.darkDefault - : QueryaTheme.lightDefault; + /// User `workbench.colorCustomizations` layer (VS Code keys → hex). + Map get userColorOverrides => + Map.unmodifiable(_userOverrides); + + /// Imported theme `colors` layer (from file import; empty until wired). + Map get importedColors => Map.unmodifiable(_importedColors); + + /// Merged VS Code color keys: imported → user overrides. + Map get effectiveVsCodeColors => mergeVsCodeColorLayers([ + _importedColors, + _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 _preset == QueryaThemePreset.queryaLight - ? QueryaTheme.lightDefault - : QueryaTheme.darkDefault; + return Map.unmodifiable(out); } + /// Workbench + editor tokens for the current preset/mode and overrides. + QueryaTheme get activeTheme => _themeForBrightness(_effectiveBrightness()); + ThemeData get lightShadcnTheme => - QueryaTheme.lightDefault.toShadcnThemeData(); + _themeForBrightness(Brightness.light).toShadcnThemeData(); - ThemeData get darkShadcnTheme => QueryaTheme.darkDefault.toShadcnThemeData(); + ThemeData get darkShadcnTheme => + _themeForBrightness(Brightness.dark).toShadcnThemeData(); Future load() async { final mode = await AppSettings.instance.getThemeMode(); final preset = await AppSettings.instance.getThemePreset(); + final overrides = await AppSettings.instance.getThemeColorOverrides(); _themeMode = mode; _preset = preset; + _userOverrides = Map.unmodifiable(overrides); _loaded = true; notifyListeners(); } @@ -67,10 +91,53 @@ class ThemeController extends ChangeNotifier { notifyListeners(); } + /// 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(); + } + Future resetToDefaults() async { await AppSettings.instance.clearThemeSettings(); _themeMode = ThemeMode.dark; _preset = QueryaThemePreset.queryaDark; + _importedColors = const {}; + _userOverrides = const {}; 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) return fallback; + return buildQueryaThemeFromVsCodeColors( + brightness: brightness, + colors: merged, + fallback: fallback, + ); + } } diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 5770cb7a..1b8d60a6 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -200,6 +200,19 @@ void main() { await AppSettings.instance.clearThemeSettings(); expect(await AppSettings.instance.getThemeMode(), ThemeMode.dark); }); + + 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', () { diff --git a/test/core/theme/parser/color_parser_test.dart b/test/core/theme/parser/color_parser_test.dart index 014598de..f928edbc 100644 --- a/test/core/theme/parser/color_parser_test.dart +++ b/test/core/theme/parser/color_parser_test.dart @@ -1,7 +1,8 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart' + show formatVsCodeColor, parseVsCodeColor; void main() { group('parseVsCodeColor', () { @@ -26,5 +27,11 @@ void main() { 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/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/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/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 3da820c4..0d4b7e4c 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -75,4 +75,37 @@ void main() { 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('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); + }); } From 16008c48616aeb920a3651338b86775f3eb7bdfc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:34:14 +0300 Subject: [PATCH 10/31] feat(theme): migrate P0 workbench surfaces off QueryaColors Use QueryaThemeScope workbench accent and ColorScheme.primary in title bar, empty hero, and SQL editor chrome. Closes #42 --- lib/features/main_screen/main_screen.dart | 6 +++--- lib/features/main_screen/sql_editor_chrome.dart | 5 ++--- lib/features/main_screen/workspace_empty_hero.dart | 5 ++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index cbf1c5c4..54f8a3dc 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -17,7 +17,7 @@ import 'package:flutter/material.dart' as material 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: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'; @@ -369,10 +369,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/sql_editor_chrome.dart b/lib/features/main_screen/sql_editor_chrome.dart index 88014e14..e4441b5b 100644 --- a/lib/features/main_screen/sql_editor_chrome.dart +++ b/lib/features/main_screen/sql_editor_chrome.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/theme/querya_colors.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Outer chrome for SQL editors: subtle border, surface fill, soft cyan glow. @@ -18,7 +17,7 @@ class SqlEditorChrome extends StatelessWidget { ), boxShadow: [ material.BoxShadow( - color: QueryaColors.accentCyan.withValues(alpha: 0.07), + color: cs.primary.withValues(alpha: 0.07), blurRadius: 18, offset: const material.Offset(0, 6), ), @@ -30,7 +29,7 @@ class SqlEditorChrome extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final cs = theme.colorScheme; - final glow = QueryaColors.accentCyan.withValues(alpha: 0.1); + final glow = cs.primary.withValues(alpha: 0.1); return material.Container( decoration: material.BoxDecoration( borderRadius: material.BorderRadius.circular(14), diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index b97cbf6a..68fc5729 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -1,6 +1,5 @@ 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_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -137,7 +136,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( @@ -179,7 +178,7 @@ class _MockAppWindow extends StatelessWidget { @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; From af46339efdf77448d5b612b69cc552f42a096691 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:38:18 +0300 Subject: [PATCH 11/31] feat(theme): appearance settings and VS Code theme import (#43) Add Preferences Appearance section, ThemeImportService with persisted import file, ThemeController.importThemeFromFile, and imported preset. Closes #43 --- docs/theme-import.md | 11 + lib/core/storage/app_settings.dart | 95 +++++++++ lib/core/theme/querya_theme_preset.dart | 5 +- lib/core/theme/theme_controller.dart | 101 +++++++-- lib/core/theme/theme_import_service.dart | 101 +++++++++ .../preferences_appearance_section.dart | 192 ++++++++++++++++++ lib/features/settings/preferences_dialog.dart | 5 +- test/core/theme/theme_controller_test.dart | 17 ++ .../core/theme/theme_import_service_test.dart | 60 ++++++ 9 files changed, 572 insertions(+), 15 deletions(-) create mode 100644 lib/core/theme/theme_import_service.dart create mode 100644 lib/features/settings/preferences_appearance_section.dart create mode 100644 test/core/theme/theme_import_service_test.dart diff --git a/docs/theme-import.md b/docs/theme-import.md index 2097fcad..b4e2cdaa 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -43,6 +43,17 @@ Hex strings as in VS Code: `#RRGGBB`, `#RRGGBBAA`, `#RGB`, `#RGBA` (see 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 diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 660ab4f3..85b13b38 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -53,6 +53,9 @@ abstract final class AppSettingsKeys { 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'; } /// Bumps [listenable] when any preference is persisted so open screens can reload. @@ -201,6 +204,7 @@ class AppSettings { final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.themePreset); return switch (v) { 'querya_light' => QueryaThemePreset.queryaLight, + 'imported' => QueryaThemePreset.imported, _ => QueryaThemePreset.queryaDark, }; } @@ -208,12 +212,102 @@ class AppSettings { 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); @@ -259,6 +353,7 @@ class AppSettings { await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeMode); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson); + await deleteThemeImportKeys(); AppSettingsRevision.bump(); } } diff --git a/lib/core/theme/querya_theme_preset.dart b/lib/core/theme/querya_theme_preset.dart index cb80b4c7..3b31a7f3 100644 --- a/lib/core/theme/querya_theme_preset.dart +++ b/lib/core/theme/querya_theme_preset.dart @@ -1,5 +1,8 @@ -/// Built-in theme presets (imported VS Code themes — #44). +/// 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/theme_controller.dart b/lib/core/theme/theme_controller.dart index 94bea9ad..4b38e8d2 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -6,6 +6,7 @@ import 'parser/querya_theme_from_vscode.dart'; import 'parser/vscode_colors_merge.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 { @@ -17,6 +18,7 @@ class ThemeController extends ChangeNotifier { QueryaThemePreset _preset = QueryaThemePreset.queryaDark; Map _importedColors = const {}; Map _userOverrides = const {}; + String? _importedThemeName; bool _loaded = false; ThemeMode get themeMode => _themeMode; @@ -25,18 +27,27 @@ class ThemeController extends ChangeNotifier { 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; empty until wired). + /// Imported theme `colors` layer (from file import). Map get importedColors => Map.unmodifiable(_importedColors); - /// Merged VS Code color keys: imported → user overrides. - Map get effectiveVsCodeColors => mergeVsCodeColorLayers([ + /// 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 { @@ -62,35 +73,82 @@ class ThemeController extends ChangeNotifier { Future load() async { final mode = await AppSettings.instance.getThemeMode(); - final preset = await AppSettings.instance.getThemePreset(); + 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); + } + } + + 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); _loaded = true; notifyListeners(); } Future setThemeMode(ThemeMode mode) async { _themeMode = mode; - _preset = mode == ThemeMode.light - ? QueryaThemePreset.queryaLight - : QueryaThemePreset.queryaDark; + if (_preset != QueryaThemePreset.imported) { + _preset = mode == ThemeMode.light + ? QueryaThemePreset.queryaLight + : QueryaThemePreset.queryaDark; + await AppSettings.instance.setThemePreset(_preset); + } await AppSettings.instance.setThemeMode(mode); - await AppSettings.instance.setThemePreset(_preset); notifyListeners(); } Future setPreset(QueryaThemePreset preset) async { + if (preset == QueryaThemePreset.imported && !hasImportedTheme) { + return; + } _preset = preset; - _themeMode = preset == QueryaThemePreset.queryaLight - ? ThemeMode.light - : ThemeMode.dark; - await AppSettings.instance.setThemePreset(preset); - await AppSettings.instance.setThemeMode(_themeMode); + 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 storedPath): + _importedColors = Map.unmodifiable(colors); + _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); @@ -111,12 +169,29 @@ class ThemeController extends ChangeNotifier { notifyListeners(); } + /// Clears imported theme file and settings; falls back to Querya Dark. + Future clearImportedTheme() async { + await ThemeImportService.deletePersistedImport(); + await AppSettings.instance.clearThemeImport(); + _importedColors = 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 {}; _userOverrides = const {}; + _importedThemeName = null; notifyListeners(); } diff --git a/lib/core/theme/theme_import_service.dart b/lib/core/theme/theme_import_service.dart new file mode 100644 index 00000000..3d1d3459 --- /dev/null +++ b/lib/core/theme/theme_import_service.dart @@ -0,0 +1,101 @@ +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.storedPath, + }); + + final String name; + final bool isDark; + final Map colors; + 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), + 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 file = await _storedThemeFile(); + if (!await file.exists()) return null; + try { + final manifest = + VsCodeThemeManifest.fromJsonString(await file.readAsString()); + if (manifest.colors.isEmpty) return null; + return manifest.colors; + } 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/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart new file mode 100644 index 00000000..21280fd4 --- /dev/null +++ b/lib/features/settings/preferences_appearance_section.dart @@ -0,0 +1,192 @@ +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/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); + } + + @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(), + const material.SizedBox(height: 8), + material.Row( + children: [ + const Text('Theme mode').small(), + const material.SizedBox(width: 12), + material.DropdownButton( + value: c.themeMode, + onChanged: (v) { + if (v != null) unawaited(_setThemeMode(v)); + }, + items: const [ + material.DropdownMenuItem( + value: ThemeMode.dark, + child: material.Text('Dark'), + ), + material.DropdownMenuItem( + value: ThemeMode.light, + child: material.Text('Light'), + ), + material.DropdownMenuItem( + value: ThemeMode.system, + child: material.Text('System'), + ), + ], + ), + ], + ), + const material.SizedBox(height: 12), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Padding( + padding: const material.EdgeInsets.only(top: 8), + child: const Text('Color preset').small(), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.DropdownButton( + value: c.preset, + isExpanded: true, + onChanged: (v) { + if (v != null) unawaited(_setPreset(v)); + }, + items: [ + const material.DropdownMenuItem( + value: QueryaThemePreset.queryaDark, + child: material.Text('Querya Dark'), + ), + const material.DropdownMenuItem( + value: QueryaThemePreset.queryaLight, + child: material.Text('Querya Light'), + ), + material.DropdownMenuItem( + value: QueryaThemePreset.imported, + enabled: c.hasImportedTheme, + child: material.Text(importedLabel), + ), + ], + ), + ), + ], + ), + 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 Text( + 'Import VS Code theme JSON/JSONC (.colors subset). Changes apply immediately.', + ).muted().xSmall(), + ], + ); + } +} diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 701a7ce5..d510469a 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -3,6 +3,7 @@ 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/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -89,7 +90,7 @@ class _PreferencesDialogContentState extends material.State<_PreferencesDialogCo constraints: const material.BoxConstraints( maxWidth: 480, minWidth: 360, - maxHeight: 560, + maxHeight: 640, ), decoration: material.BoxDecoration( color: theme.popover, @@ -128,6 +129,8 @@ class _PreferencesDialogContentState extends material.State<_PreferencesDialogCo : material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ + const PreferencesAppearanceSection(), + const material.SizedBox(height: 24), const Text('SQL — PostgreSQL').semiBold().small(), const material.SizedBox(height: 8), material.Row( diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 0d4b7e4c..c1b87cd8 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -7,6 +7,7 @@ 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 { @@ -100,6 +101,22 @@ void main() { ); }); + 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('clearColorOverrides does not reset theme mode', () async { final c = ThemeController.instance; await c.setThemeMode(ThemeMode.light); 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..da998cb8 --- /dev/null +++ b/test/core/theme/theme_import_service_test.dart @@ -0,0 +1,60 @@ +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 returns failure for missing file', () async { + final result = + await ThemeImportService.importFromPath('/no/such/theme.json'); + expect(result, isA()); + }); +} From 6f9722473555e4c78f825adddd90c573ca886fc2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:46:00 +0300 Subject: [PATCH 12/31] feat(theme): SqlEditorChrome and editor tokens from QueryaTheme (#60) Use QueryaEditorTheme for SQL editor chrome, background, and text; extend VS Code color map for selection/lineNumber/bracket/border keys; align PG/MySQL SQL toolbar icons with workbench accent. Closes #60 --- docs/theme-import.md | 4 + .../parser/querya_theme_from_vscode.dart | 8 ++ lib/core/theme/parser/vscode_color_map.dart | 20 +++++ lib/core/theme/querya_editor_theme.dart | 29 ++++++++ .../main_screen/query_editor_tab.dart | 8 +- .../main_screen/sql_editor_chrome.dart | 49 ++++++++---- .../mysql/mysql_sql_editor_dialog.dart | 10 +-- lib/features/mysql/mysql_sql_workspace.dart | 13 +++- .../postgres_sql_editor_dialog.dart | 10 +-- .../postgresql/postgres_sql_workspace.dart | 13 +++- .../theme/parser/vscode_color_map_test.dart | 15 ++++ .../main_screen/query_editor_tab_test.dart | 13 ++-- .../main_screen/sql_editor_chrome_test.dart | 74 +++++++++++++++++++ .../workspace_homes_and_preferences_test.dart | 9 +-- .../workspace_panel_layout_test.dart | 16 ++-- test/support/querya_theme_test_shell.dart | 22 ++++++ 16 files changed, 254 insertions(+), 59 deletions(-) create mode 100644 test/features/main_screen/sql_editor_chrome_test.dart create mode 100644 test/support/querya_theme_test_shell.dart diff --git a/docs/theme-import.md b/docs/theme-import.md index b4e2cdaa..2c899d26 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -11,6 +11,10 @@ Syntax highlighting (`tokenColors`) is tracked separately (issue #46). |-------------|---------------| | `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` | diff --git a/lib/core/theme/parser/querya_theme_from_vscode.dart b/lib/core/theme/parser/querya_theme_from_vscode.dart index 0b5c4c99..f82551e1 100644 --- a/lib/core/theme/parser/querya_theme_from_vscode.dart +++ b/lib/core/theme/parser/querya_theme_from_vscode.dart @@ -185,6 +185,14 @@ QueryaEditorTheme _applyEditorField( 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); } } diff --git a/lib/core/theme/parser/vscode_color_map.dart b/lib/core/theme/parser/vscode_color_map.dart index bc910627..ec19292b 100644 --- a/lib/core/theme/parser/vscode_color_map.dart +++ b/lib/core/theme/parser/vscode_color_map.dart @@ -18,6 +18,10 @@ enum VsCodeWorkbenchField { enum VsCodeEditorField { background, foreground, + selection, + lineNumber, + bracketMatch, + widgetBorder, } /// Optional direct [ColorScheme] fields (shadcn) beyond workbench derivation. @@ -57,6 +61,18 @@ const Map kVsCodeColorMap = { 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, ), @@ -92,6 +108,10 @@ const Map kVsCodeColorMap = { const List kSupportedVsCodeColorKeys = [ 'editor.background', 'editor.foreground', + 'editor.selectionBackground', + 'editorLineNumber.foreground', + 'editorBracketMatch.background', + 'editorWidget.border', 'sideBar.background', 'sideBar.foreground', 'activityBar.background', diff --git a/lib/core/theme/querya_editor_theme.dart b/lib/core/theme/querya_editor_theme.dart index 30e653f6..af71671c 100644 --- a/lib/core/theme/querya_editor_theme.dart +++ b/lib/core/theme/querya_editor_theme.dart @@ -10,6 +10,8 @@ class QueryaEditorTheme { required this.foreground, required this.lineHighlight, required this.selection, + required this.lineNumber, + required this.bracketMatch, required this.comment, required this.keyword, required this.string, @@ -17,6 +19,7 @@ class QueryaEditorTheme { required this.operator, required this.function, required this.type, + this.widgetBorder, this.fontFamily = QueryaTypography.mono, this.fontSize = 13, }); @@ -25,6 +28,11 @@ class QueryaEditorTheme { 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; @@ -41,6 +49,8 @@ class QueryaEditorTheme { foreground: Color(0xFFF8FAFC), lineHighlight: Color(0xFF18181B), selection: Color(0xFF264F78), + lineNumber: Color(0xFF858585), + bracketMatch: Color(0x33006400), comment: Color(0xFF6A9955), keyword: Color(0xFF569CD6), string: Color(0xFFCE9178), @@ -55,6 +65,8 @@ class QueryaEditorTheme { foreground: Color(0xFF1E293B), lineHighlight: Color(0xFFF1F5F9), selection: Color(0xFFADD6FF), + lineNumber: Color(0xFF237893), + bracketMatch: Color(0x33006400), comment: Color(0xFF008000), keyword: Color(0xFF0000FF), string: Color(0xFFA31515), @@ -69,6 +81,10 @@ class QueryaEditorTheme { Color? foreground, Color? lineHighlight, Color? selection, + Color? lineNumber, + Color? bracketMatch, + Color? widgetBorder, + bool clearWidgetBorder = false, Color? comment, Color? keyword, Color? string, @@ -84,6 +100,10 @@ class QueryaEditorTheme { 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, @@ -107,6 +127,9 @@ class QueryaEditorTheme { 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), @@ -127,6 +150,9 @@ class QueryaEditorTheme { 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 && @@ -143,6 +169,9 @@ class QueryaEditorTheme { foreground, lineHighlight, selection, + lineNumber, + bracketMatch, + widgetBorder, comment, keyword, string, diff --git a/lib/features/main_screen/query_editor_tab.dart b/lib/features/main_screen/query_editor_tab.dart index ea76336a..484d5f98 100644 --- a/lib/features/main_screen/query_editor_tab.dart +++ b/lib/features/main_screen/query_editor_tab.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart' as material show EdgeInsets, Padding, TextEditingController, TextStyle; -import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -78,7 +78,7 @@ class _QueryEditorBodyState extends State<_QueryEditorBody> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final editor = context.editorTheme; return material.Padding( padding: const material.EdgeInsets.all(12), child: SqlEditorChrome( @@ -87,9 +87,9 @@ class _QueryEditorBodyState extends State<_QueryEditorBody> { maxLines: null, expands: true, style: material.TextStyle( - fontFamily: QueryaTypography.mono, + fontFamily: editor.fontFamily, fontSize: widget.fontSize, - color: theme.colorScheme.foreground, + color: editor.foreground, ), placeholder: const Text('-- Enter SQL here…\nSELECT 1;'), ), diff --git a/lib/features/main_screen/sql_editor_chrome.dart b/lib/features/main_screen/sql_editor_chrome.dart index e4441b5b..6be04c0c 100644 --- a/lib/features/main_screen/sql_editor_chrome.dart +++ b/lib/features/main_screen/sql_editor_chrome.dart @@ -1,23 +1,33 @@ 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_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; + + /// Decoration for compact SQL fields (dialogs) from theme tokens. + static material.BoxDecoration inlineFieldDecoration( + QueryaEditorTheme editor, + QueryaWorkbenchTheme workbench, + ) { + final border = editor.widgetBorder ?? workbench.borderSubtle; return material.BoxDecoration( - color: cs.card, - borderRadius: material.BorderRadius.circular(10), + 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: cs.primary.withValues(alpha: 0.07), + color: workbench.accent.withValues(alpha: 0.07), blurRadius: 18, offset: const material.Offset(0, 6), ), @@ -25,14 +35,25 @@ class SqlEditorChrome extends StatelessWidget { ); } + static material.BoxDecoration inlineFieldDecorationFromContext( + BuildContext context, + ) { + return inlineFieldDecoration( + context.editorTheme, + context.workbench, + ); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; - final glow = cs.primary.withValues(alpha: 0.1); + final editor = context.editorTheme; + final workbench = context.workbench; + final border = editor.widgetBorder ?? workbench.borderSubtle; + final glow = workbench.accent.withValues(alpha: 0.1); + return material.Container( decoration: material.BoxDecoration( - borderRadius: material.BorderRadius.circular(14), + borderRadius: material.BorderRadius.circular(_outerRadius), boxShadow: [ material.BoxShadow( color: glow, @@ -44,10 +65,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/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart index 72dba9e5..f49148fd 100644 --- a/lib/features/mysql/mysql_sql_editor_dialog.dart +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -1,6 +1,6 @@ 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/theme/querya_theme_scope.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,8 +113,8 @@ 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( controller: _controller, @@ -122,9 +122,9 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { expands: true, textAlignVertical: material.TextAlignVertical.top, style: material.TextStyle( - fontFamily: QueryaTypography.mono, + fontFamily: context.editorTheme.fontFamily, fontSize: 12, - color: theme.foreground, + color: context.editorTheme.foreground, ), decoration: const material.InputDecoration( border: material.InputBorder.none, diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 1d959bc2..4fcc70fc 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -5,6 +5,7 @@ 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'; @@ -345,6 +346,7 @@ 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( @@ -361,9 +363,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 +379,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 +410,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..e119be5b 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -1,6 +1,6 @@ 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/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -140,8 +140,8 @@ 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( controller: _controller, @@ -149,9 +149,9 @@ class _PostgresSqlEditorDialogState extends material.State<_PostgresSqlEditorDia expands: true, textAlignVertical: material.TextAlignVertical.top, style: material.TextStyle( - fontFamily: QueryaTypography.mono, + fontFamily: context.editorTheme.fontFamily, fontSize: 12, - color: theme.foreground, + color: context.editorTheme.foreground, ), decoration: const material.InputDecoration( border: material.InputBorder.none, diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 6df37479..20df05f8 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -7,6 +7,7 @@ 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'; @@ -518,6 +519,7 @@ 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( @@ -538,9 +540,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 +556,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 +598,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/test/core/theme/parser/vscode_color_map_test.dart b/test/core/theme/parser/vscode_color_map_test.dart index 9f556af2..d11181d6 100644 --- a/test/core/theme/parser/vscode_color_map_test.dart +++ b/test/core/theme/parser/vscode_color_map_test.dart @@ -37,6 +37,21 @@ void main() { 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 { 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..c8aaa5fc --- /dev/null +++ b/test/features/main_screen/sql_editor_chrome_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_editor_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'; + +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, + ); + final border = deco.border as Border; + expect(border.top.color, const Color(0xFFFF0000).withValues(alpha: 0.45)); + }); + }); +} 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/support/querya_theme_test_shell.dart b/test/support/querya_theme_test_shell.dart new file mode 100644 index 00000000..565a3d2c --- /dev/null +++ b/test/support/querya_theme_test_shell.dart @@ -0,0 +1,22 @@ +import 'package:flutter/widgets.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'; + +/// 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, + home: QueryaThemeScope( + data: data, + child: child, + ), + ); +} From 19d6a7178421a41558140103982b15d1d2f29a35 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:47:43 +0300 Subject: [PATCH 13/31] fix(test): remove unnecessary widgets import in theme test shell --- test/support/querya_theme_test_shell.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/support/querya_theme_test_shell.dart b/test/support/querya_theme_test_shell.dart index 565a3d2c..c3acc186 100644 --- a/test/support/querya_theme_test_shell.dart +++ b/test/support/querya_theme_test_shell.dart @@ -1,4 +1,3 @@ -import 'package:flutter/widgets.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'; From 2e22bb1a20db0b22b71d5756d186f4730d637d27 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:51:45 +0300 Subject: [PATCH 14/31] feat(editor): add QueryaCodeEditor abstraction (#47) Introduce QueryaCodeEditor with shadcn/Material TextField backends, migrate QueryEditorTab, SQL editor dialogs, and Mongo JSON editor. Closes #47 --- lib/core/editor/querya_code_editor.dart | 153 ++++++++++++++++++ lib/core/editor/querya_code_language.dart | 6 + lib/core/editor/sql_syntax_highlighting.dart | 11 +- .../main_screen/query_editor_tab.dart | 82 +--------- .../mongodb/mongo_document_editor.dart | 20 +-- .../mysql/mysql_sql_editor_dialog.dart | 22 +-- .../postgres_sql_editor_dialog.dart | 22 +-- test/core/editor/querya_code_editor_test.dart | 75 +++++++++ 8 files changed, 269 insertions(+), 122 deletions(-) create mode 100644 lib/core/editor/querya_code_editor.dart create mode 100644 lib/core/editor/querya_code_language.dart create mode 100644 test/core/editor/querya_code_editor_test.dart diff --git a/lib/core/editor/querya_code_editor.dart b/lib/core/editor/querya_code_editor.dart new file mode 100644 index 00000000..157aa8b1 --- /dev/null +++ b/lib/core/editor/querya_code_editor.dart @@ -0,0 +1,153 @@ +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_scope.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'querya_code_language.dart'; + +/// Shadcn vs Material [TextField] backend for different parent widgets. +enum QueryaCodeEditorVariant { + shadcn, + material, +} + +/// Unified code editor (MVP: plain [TextField]; highlighting in #49+). +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, + }); + + 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; + + @override + State createState() => _QueryaCodeEditorState(); +} + +class _QueryaCodeEditorState extends State { + late material.TextEditingController _controller; + bool _ownsController = false; + + @override + void initState() { + super.initState(); + _initController(widget.controller); + _controller.addListener(_onTextChanged); + } + + void _initController(material.TextEditingController? external) { + if (external == null) { + _controller = material.TextEditingController(); + _ownsController = true; + } else { + _controller = external; + _ownsController = false; + } + } + + void _onTextChanged() { + widget.onChanged?.call(_controller.text); + } + + @override + void didUpdateWidget(QueryaCodeEditor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller?.removeListener(_onTextChanged); + if (_ownsController) { + _controller.dispose(); + } + _initController(widget.controller); + _controller.addListener(_onTextChanged); + } + } + + @override + void dispose() { + _controller.removeListener(_onTextChanged); + if (_ownsController) { + _controller.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) { + final editor = context.editorTheme; + final style = _textStyle(editor); + final placeholder = _resolvedPlaceholder(); + + 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/sql_syntax_highlighting.dart b/lib/core/editor/sql_syntax_highlighting.dart index 5dfb2276..38286044 100644 --- a/lib/core/editor/sql_syntax_highlighting.dart +++ b/lib/core/editor/sql_syntax_highlighting.dart @@ -1,7 +1,4 @@ -/// 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._(); -} +// Future: syntax highlighting backend for [QueryaCodeEditor] (#49, #50). +// +// MVP uses plain TextField via [QueryaCodeEditor]. Candidates: syntax_highlight, +// re_editor, code_forge (see issue #48). diff --git a/lib/features/main_screen/query_editor_tab.dart b/lib/features/main_screen/query_editor_tab.dart index 484d5f98..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_theme_scope.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 editor = context.editorTheme; return material.Padding( padding: const material.EdgeInsets.all(12), child: SqlEditorChrome( - child: TextField( - controller: _owned, - maxLines: null, - expands: true, - style: material.TextStyle( - fontFamily: editor.fontFamily, - fontSize: widget.fontSize, - color: editor.foreground, - ), - placeholder: const Text('-- Enter SQL here…\nSELECT 1;'), + child: QueryaCodeEditor( + controller: controller, + language: QueryaCodeLanguage.sql, + fontSize: fontSize, ), ), ); diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index 5f9bd4b2..b9d89048 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -2,6 +2,8 @@ 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/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -326,20 +328,12 @@ class _MongoDocumentEditorState extends material.State { material.Expanded( child: material.Container( color: cs.card, - child: material.TextField( + 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 f49148fd..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_theme_scope.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'; @@ -116,21 +117,14 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { 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: context.editorTheme.fontFamily, - fontSize: 12, - color: context.editorTheme.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_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index e119be5b..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_theme_scope.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'; @@ -143,21 +144,14 @@ class _PostgresSqlEditorDialogState extends material.State<_PostgresSqlEditorDia 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: context.editorTheme.fontFamily, - fontSize: 12, - color: context.editorTheme.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/test/core/editor/querya_code_editor_test.dart b/test/core/editor/querya_code_editor_test.dart new file mode 100644 index 00000000..3830f830 --- /dev/null +++ b/test/core/editor/querya_code_editor_test.dart @@ -0,0 +1,75 @@ +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/theme/querya_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + 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('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'); + }); +} From 43ae2518e4b1809be393e4f133db554066e03a98 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:56:09 +0300 Subject: [PATCH 15/31] docs(editor): spike report for code editor package selection (#48) Add editor-spike-report.md with scorecard, test matrix, and MVP recommendation (syntax_highlight for #49/#50, re_editor Phase 2). Closes #48 --- docs/editor-spike-report.md | 170 ++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/editor-spike-report.md diff --git a/docs/editor-spike-report.md b/docs/editor-spike-report.md new file mode 100644 index 00000000..0e12beae --- /dev/null +++ b/docs/editor-spike-report.md @@ -0,0 +1,170 @@ +# 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. + +### 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` From f3b17fb41074ca56bf0139ebf0f1ae3535a1d629 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:00:53 +0300 Subject: [PATCH 16/31] feat(editor): SQL syntax highlighting via syntax_highlight (#49) Wire QueryaHighlightController into QueryaCodeEditor for SQL/JSON, map QueryaEditorTheme tokens to HighlighterTheme, and init grammars at startup. --- .flutter-plugins-dependencies | 2 +- .../editor/highlighter_theme_from_querya.dart | 82 +++++++ lib/core/editor/querya_code_editor.dart | 207 ++++++++++++++++-- .../editor/querya_highlight_controller.dart | 29 +++ lib/core/editor/syntax_highlight_service.dart | 71 ++++++ lib/main.dart | 2 + linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 6 + pubspec.yaml | 1 + .../highlighter_theme_from_querya_test.dart | 21 ++ test/core/editor/querya_code_editor_test.dart | 31 +++ .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 14 files changed, 448 insertions(+), 22 deletions(-) create mode 100644 lib/core/editor/highlighter_theme_from_querya.dart create mode 100644 lib/core/editor/querya_highlight_controller.dart create mode 100644 lib/core/editor/syntax_highlight_service.dart create mode 100644 test/core/editor/highlighter_theme_from_querya_test.dart 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/lib/core/editor/highlighter_theme_from_querya.dart b/lib/core/editor/highlighter_theme_from_querya.dart new file mode 100644 index 00000000..325a90f2 --- /dev/null +++ b/lib/core/editor/highlighter_theme_from_querya.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +/// Builds a [HighlighterTheme] from [QueryaEditorTheme] token colors. +HighlighterTheme highlighterThemeFromQueryaEditor(QueryaEditorTheme editor) { + final wrapper = TextStyle( + color: editor.foreground, + fontFamily: editor.fontFamily, + fontSize: editor.fontSize, + ); + + final config = 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', 'number'], + 'settings': {'foreground': _hex(editor.number)}, + }, + { + '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)}, + }, + ], + }); + + return HighlighterTheme.fromConfiguration(config, wrapper); +} + +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/editor/querya_code_editor.dart b/lib/core/editor/querya_code_editor.dart index 157aa8b1..4076c042 100644 --- a/lib/core/editor/querya_code_editor.dart +++ b/lib/core/editor/querya_code_editor.dart @@ -1,9 +1,12 @@ 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 { @@ -11,7 +14,7 @@ enum QueryaCodeEditorVariant { material, } -/// Unified code editor (MVP: plain [TextField]; highlighting in #49+). +/// Unified code editor with optional syntax highlighting (SQL/JSON). class QueryaCodeEditor extends StatefulWidget { const QueryaCodeEditor({ super.key, @@ -27,6 +30,7 @@ class QueryaCodeEditor extends StatefulWidget { this.hintText, this.contentPadding, this.textAlignVertical, + this.enableHighlighting = true, }); final material.TextEditingController? controller; @@ -44,53 +48,209 @@ class QueryaCodeEditor extends StatefulWidget { 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 { - late material.TextEditingController _controller; - bool _ownsController = false; + material.TextEditingController? _plainController; + QueryaHighlightController? _highlightController; + bool _ownsPlainController = false; + bool _ownsHighlightController = false; + bool _syncing = false; + QueryaEditorTheme? _highlightEditorTheme; + QueryaCodeLanguage? _highlightLanguage; + + material.TextEditingController get _activeController => + _highlightController ?? _plainController!; + + bool get _useHighlighting => + widget.enableHighlighting && + widget.language != QueryaCodeLanguage.plain && + SyntaxHighlightService.isInitialized; @override void initState() { super.initState(); - _initController(widget.controller); - _controller.addListener(_onTextChanged); + if (!_useHighlighting) { + _initPlainController(widget.controller); + _plainController!.addListener(_onTextChanged); + } } - void _initController(material.TextEditingController? external) { + void _initPlainController(material.TextEditingController? external) { if (external == null) { - _controller = material.TextEditingController(); - _ownsController = true; + _plainController = material.TextEditingController(); + _ownsPlainController = true; } else { - _controller = external; - _ownsController = false; + _plainController = external; + _ownsPlainController = false; } } void _onTextChanged() { - widget.onChanged?.call(_controller.text); + 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; + } + + void _ensureHighlightController(QueryaTheme queryaTheme) { + if (!_useHighlighting) return; + + final editor = queryaTheme.editor; + if (_highlightController != null && + _highlightEditorTheme == editor && + _highlightLanguage == widget.language) { + 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, + lightHighlighter: pair.light, + darkHighlighter: pair.dark, + ); + _ownsHighlightController = external == null; + _highlightEditorTheme = editor; + _highlightLanguage = widget.language; + + _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) { - oldWidget.controller?.removeListener(_onTextChanged); - if (_ownsController) { - _controller.dispose(); + _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; + if (_useHighlighting) { + _ensureHighlightController(context.queryaTheme); + } else { + _switchToPlain(widget.controller); } - _initController(widget.controller); - _controller.addListener(_onTextChanged); } } @override void dispose() { - _controller.removeListener(_onTextChanged); - if (_ownsController) { - _controller.dispose(); + _disposeHighlight(); + if (_plainController != null) { + _plainController!.removeListener(_onTextChanged); + if (_ownsPlainController) { + _plainController!.dispose(); + } } super.dispose(); } @@ -118,13 +278,18 @@ class _QueryaCodeEditorState extends State { @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, + controller: controller, readOnly: widget.readOnly, maxLines: widget.expands ? null : widget.maxLines, expands: widget.expands, @@ -141,7 +306,7 @@ class _QueryaCodeEditorState extends State { } return TextField( - controller: _controller, + controller: controller, readOnly: widget.readOnly, maxLines: widget.expands ? null : widget.maxLines, expands: widget.expands, diff --git a/lib/core/editor/querya_highlight_controller.dart b/lib/core/editor/querya_highlight_controller.dart new file mode 100644 index 00000000..88123627 --- /dev/null +++ b/lib/core/editor/querya_highlight_controller.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +/// [TextEditingController] that applies [Highlighter] in [buildTextSpan]. +class QueryaHighlightController extends TextEditingController { + QueryaHighlightController({ + super.text, + required this.lightHighlighter, + required this.darkHighlighter, + }); + + final Highlighter lightHighlighter; + final Highlighter darkHighlighter; + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + final highlighter = Theme.of(context).brightness == Brightness.light + ? lightHighlighter + : darkHighlighter; + return TextSpan( + style: style, + children: [highlighter.highlight(text)], + ); + } +} diff --git a/lib/core/editor/syntax_highlight_service.dart b/lib/core/editor/syntax_highlight_service.dart new file mode 100644 index 00000000..a22bd6e0 --- /dev/null +++ b/lib/core/editor/syntax_highlight_service.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.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 Future ensureInitialized() async { + if (_initialized) return; + await Highlighter.initialize(['sql', 'json']); + _initialized = true; + } + + static bool get isInitialized => _initialized; + + static Highlighter createHighlighter({ + required QueryaCodeLanguage language, + required QueryaEditorTheme editorTheme, + required Brightness brightness, + }) { + _assertInitialized(); + final lang = switch (language) { + QueryaCodeLanguage.sql => 'sql', + QueryaCodeLanguage.json => 'json', + QueryaCodeLanguage.plain => 'sql', + }; + final theme = highlighterThemeFromQueryaEditor(editorTheme); + return Highlighter(language: lang, theme: theme); + } + + static HighlighterPair createPair({ + required QueryaCodeLanguage language, + required QueryaTheme queryaTheme, + }) { + return HighlighterPair( + light: createHighlighter( + language: language, + editorTheme: queryaTheme.editor, + brightness: Brightness.light, + ), + dark: createHighlighter( + language: language, + editorTheme: queryaTheme.editor, + brightness: Brightness.dark, + ), + ); + } + + 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}); + + final Highlighter light; + final Highlighter dark; + + Highlighter forBrightness(Brightness brightness) => + brightness == Brightness.light ? light : dark; +} diff --git a/lib/main.dart b/lib/main.dart index d6436b4c..14dbd026 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,12 +2,14 @@ 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(() { 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..8219a245 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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..ddb91c77 --- /dev/null +++ b/test/core/editor/highlighter_theme_from_querya_test.dart @@ -0,0 +1,21 @@ +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/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)); + }); +} diff --git a/test/core/editor/querya_code_editor_test.dart b/test/core/editor/querya_code_editor_test.dart index 3830f830..33f1dc22 100644 --- a/test/core/editor/querya_code_editor_test.dart +++ b/test/core/editor/querya_code_editor_test.dart @@ -2,12 +2,17 @@ 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( @@ -54,6 +59,32 @@ void main() { 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( 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 From 385254d2ccecbdaaf63347bbd0ed5fd099179f10 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:01:13 +0300 Subject: [PATCH 17/31] chore(editor): remove obsolete sql_syntax_highlighting stub --- lib/core/editor/sql_syntax_highlighting.dart | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 lib/core/editor/sql_syntax_highlighting.dart diff --git a/lib/core/editor/sql_syntax_highlighting.dart b/lib/core/editor/sql_syntax_highlighting.dart deleted file mode 100644 index 38286044..00000000 --- a/lib/core/editor/sql_syntax_highlighting.dart +++ /dev/null @@ -1,4 +0,0 @@ -// Future: syntax highlighting backend for [QueryaCodeEditor] (#49, #50). -// -// MVP uses plain TextField via [QueryaCodeEditor]. Candidates: syntax_highlight, -// re_editor, code_forge (see issue #48). From ea3dd9c9ed68327abd3c4678414f5db321369bdb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:03:47 +0300 Subject: [PATCH 18/31] fix(test): remove unused material import in highlighter test --- test/core/editor/highlighter_theme_from_querya_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/core/editor/highlighter_theme_from_querya_test.dart b/test/core/editor/highlighter_theme_from_querya_test.dart index ddb91c77..1a249a37 100644 --- a/test/core/editor/highlighter_theme_from_querya_test.dart +++ b/test/core/editor/highlighter_theme_from_querya_test.dart @@ -1,4 +1,3 @@ -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/editor/syntax_highlight_service.dart'; From 1a610212dccb27cfe6396904957a7508a86bc0f7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:09:21 +0300 Subject: [PATCH 19/31] feat(editor): JSON highlighting in Mongo document editor (#50) Map JSON grammar scopes in HighlighterTheme, wire mongo editor to workbench success and editor background tokens, and add format/validation tests. --- .../editor/highlighter_theme_from_querya.dart | 20 ++++- .../mongodb/mongo_document_editor.dart | 16 ++-- .../highlighter_theme_from_querya_test.dart | 9 ++ .../mongodb/mongo_document_editor_test.dart | 87 +++++++++++++++++++ 4 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 test/features/mongodb/mongo_document_editor_test.dart diff --git a/lib/core/editor/highlighter_theme_from_querya.dart b/lib/core/editor/highlighter_theme_from_querya.dart index 325a90f2..07773739 100644 --- a/lib/core/editor/highlighter_theme_from_querya.dart +++ b/lib/core/editor/highlighter_theme_from_querya.dart @@ -41,9 +41,27 @@ HighlighterTheme highlighterThemeFromQueryaEditor(QueryaEditorTheme editor) { 'settings': {'foreground': _hex(editor.string)}, }, { - 'scope': ['constant.numeric', 'number'], + '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)}, diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index b9d89048..3c98336b 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -5,6 +5,7 @@ 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; @@ -215,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( @@ -310,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)), ), ], ), @@ -327,7 +331,7 @@ class _MongoDocumentEditorState extends material.State { // Editor material.Expanded( child: material.Container( - color: cs.card, + color: editorTheme.background, child: QueryaCodeEditor( controller: _controller, language: QueryaCodeLanguage.json, diff --git a/test/core/editor/highlighter_theme_from_querya_test.dart b/test/core/editor/highlighter_theme_from_querya_test.dart index 1a249a37..c12ddcfb 100644 --- a/test/core/editor/highlighter_theme_from_querya_test.dart +++ b/test/core/editor/highlighter_theme_from_querya_test.dart @@ -17,4 +17,13 @@ void main() { 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/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); + }); +} From 775d2d87403f539624df5704b52eaa74f23ebdf2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:15:40 +0300 Subject: [PATCH 20/31] feat(theme): VS Code tokenColors resolution for syntax highlighting (#46) Add TokenStyleResolver, bridge tokenColors to HighlighterTheme on import, and offload highlighting for buffers over 8KB to a background isolate. --- docs/theme-import.md | 3 +- .../editor/highlighter_theme_from_querya.dart | 103 +++----------- lib/core/editor/querya_code_editor.dart | 13 +- .../editor/querya_highlight_controller.dart | 86 +++++++++++- lib/core/editor/syntax_highlight_isolate.dart | 130 ++++++++++++++++++ lib/core/editor/syntax_highlight_service.dart | 60 +++++++- .../parser/apply_token_colors_to_editor.dart | 67 +++++++++ .../parser/querya_theme_from_vscode.dart | 6 + lib/core/theme/parser/token_colors_codec.dart | 32 +++++ .../token_colors_highlighter_config.dart | 119 ++++++++++++++++ .../theme/parser/token_style_resolver.dart | 70 ++++++++++ .../theme/parser/vscode_theme_manifest.dart | 26 ++++ lib/core/theme/querya_theme.dart | 21 ++- lib/core/theme/theme_controller.dart | 38 ++++- lib/core/theme/theme_import_service.dart | 21 ++- .../editor/syntax_highlight_isolate_test.dart | 36 +++++ .../parser/token_colors_dracula_sql_test.dart | 46 +++++++ .../parser/token_style_resolver_test.dart | 40 ++++++ test/fixtures/themes/dracula_tokens.json | 26 ++++ 19 files changed, 838 insertions(+), 105 deletions(-) create mode 100644 lib/core/editor/syntax_highlight_isolate.dart create mode 100644 lib/core/theme/parser/apply_token_colors_to_editor.dart create mode 100644 lib/core/theme/parser/token_colors_codec.dart create mode 100644 lib/core/theme/parser/token_colors_highlighter_config.dart create mode 100644 lib/core/theme/parser/token_style_resolver.dart create mode 100644 test/core/editor/syntax_highlight_isolate_test.dart create mode 100644 test/core/theme/parser/token_colors_dracula_sql_test.dart create mode 100644 test/core/theme/parser/token_style_resolver_test.dart create mode 100644 test/fixtures/themes/dracula_tokens.json diff --git a/docs/theme-import.md b/docs/theme-import.md index 2c899d26..28b85937 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -3,7 +3,8 @@ Querya can apply a **subset** of VS Code theme JSON / JSONC `colors` to `QueryaWorkbenchTheme`, `QueryaEditorTheme`, and the shadcn `ColorScheme`. -Syntax highlighting (`tokenColors`) is tracked separately (issue #46). +Imported `tokenColors` are persisted with the theme file and applied to SQL/JSON +syntax highlighting via `TokenStyleResolver` → `HighlighterTheme` (issue #46). ## Supported `colors` keys diff --git a/lib/core/editor/highlighter_theme_from_querya.dart b/lib/core/editor/highlighter_theme_from_querya.dart index 07773739..53a854f1 100644 --- a/lib/core/editor/highlighter_theme_from_querya.dart +++ b/lib/core/editor/highlighter_theme_from_querya.dart @@ -1,100 +1,33 @@ -import 'dart:convert'; - 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] token colors. -HighlighterTheme highlighterThemeFromQueryaEditor(QueryaEditorTheme editor) { +/// 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 = 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)}, - }, - ], - }); + final config = tokenColors.isNotEmpty + ? buildHighlighterConfigFromTokenColors(tokenColors, editor.foreground) + : buildDefaultEditorHighlighterConfig(editor); return HighlighterTheme.fromConfiguration(config, wrapper); } -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')}'; +/// 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 index 4076c042..377de8c1 100644 --- a/lib/core/editor/querya_code_editor.dart +++ b/lib/core/editor/querya_code_editor.dart @@ -63,6 +63,7 @@ class _QueryaCodeEditorState extends State { bool _syncing = false; QueryaEditorTheme? _highlightEditorTheme; QueryaCodeLanguage? _highlightLanguage; + int _highlightTokenColorsHash = 0; material.TextEditingController get _activeController => _highlightController ?? _plainController!; @@ -133,15 +134,18 @@ class _QueryaCodeEditorState extends State { _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) { + _highlightLanguage == widget.language && + _highlightTokenColorsHash == tokenHash) { return; } @@ -157,12 +161,18 @@ class _QueryaCodeEditorState extends State { _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) { @@ -235,6 +245,7 @@ class _QueryaCodeEditorState extends State { oldWidget.enableHighlighting != widget.enableHighlighting) { _highlightEditorTheme = null; _highlightLanguage = null; + _highlightTokenColorsHash = 0; if (_useHighlighting) { _ensureHighlightController(context.queryaTheme); } else { diff --git a/lib/core/editor/querya_highlight_controller.dart b/lib/core/editor/querya_highlight_controller.dart index 88123627..ae92387b 100644 --- a/lib/core/editor/querya_highlight_controller.dart +++ b/lib/core/editor/querya_highlight_controller.dart @@ -1,16 +1,34 @@ 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({ @@ -18,12 +36,74 @@ class QueryaHighlightController extends TextEditingController { TextStyle? style, required bool withComposing, }) { - final highlighter = Theme.of(context).brightness == Brightness.light + final brightness = Theme.of(context).brightness; + final highlighter = brightness == Brightness.light ? lightHighlighter : darkHighlighter; - return TextSpan( + 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, - children: [highlighter.highlight(text)], ); + + 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/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 index a22bd6e0..14a20118 100644 --- a/lib/core/editor/syntax_highlight_service.dart +++ b/lib/core/editor/syntax_highlight_service.dart @@ -1,4 +1,6 @@ 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'; @@ -9,19 +11,37 @@ 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) { @@ -29,7 +49,10 @@ abstract final class SyntaxHighlightService { QueryaCodeLanguage.json => 'json', QueryaCodeLanguage.plain => 'sql', }; - final theme = highlighterThemeFromQueryaEditor(editorTheme); + final theme = highlighterThemeFromQueryaEditor( + editorTheme, + tokenColors: tokenColors, + ); return Highlighter(language: lang, theme: theme); } @@ -37,16 +60,31 @@ abstract final class SyntaxHighlightService { 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, ), ); } @@ -61,11 +99,29 @@ abstract final class SyntaxHighlightService { /// Light/dark highlighters for Material [Theme] brightness switching. class HighlighterPair { - const HighlighterPair({required this.light, required this.dark}); + 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/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/querya_theme_from_vscode.dart b/lib/core/theme/parser/querya_theme_from_vscode.dart index f82551e1..6e029144 100644 --- a/lib/core/theme/parser/querya_theme_from_vscode.dart +++ b/lib/core/theme/parser/querya_theme_from_vscode.dart @@ -5,6 +5,7 @@ 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'; @@ -128,11 +129,16 @@ QueryaTheme buildQueryaThemeFromVsCodeManifest( 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, ); } 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_theme_manifest.dart b/lib/core/theme/parser/vscode_theme_manifest.dart index b6e43008..b9f5f6df 100644 --- a/lib/core/theme/parser/vscode_theme_manifest.dart +++ b/lib/core/theme/parser/vscode_theme_manifest.dart @@ -84,6 +84,32 @@ class TokenColorRule { 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 = []; diff --git a/lib/core/theme/querya_theme.dart b/lib/core/theme/querya_theme.dart index 7982b02b..a941a96d 100644 --- a/lib/core/theme/querya_theme.dart +++ b/lib/core/theme/querya_theme.dart @@ -1,5 +1,6 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'parser/vscode_theme_manifest.dart'; import 'querya_colors.dart'; import 'querya_editor_theme.dart'; import 'querya_workbench_theme.dart'; @@ -11,6 +12,7 @@ class QueryaTheme { required this.editor, required this.brightness, required this.colorScheme, + this.tokenColors = const [], }); final QueryaWorkbenchTheme workbench; @@ -18,6 +20,9 @@ class QueryaTheme { 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, @@ -131,12 +136,14 @@ class QueryaTheme { 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, ); } @@ -149,6 +156,7 @@ class QueryaTheme { editor: e, brightness: brightness, colorScheme: ColorScheme.lerp(a.colorScheme, b.colorScheme, t), + tokenColors: t < 0.5 ? a.tokenColors : b.tokenColors, ); } @@ -175,9 +183,18 @@ class QueryaTheme { workbench == other.workbench && editor == other.editor && brightness == other.brightness && - colorScheme == other.colorScheme; + colorScheme == other.colorScheme && + _listEquals(tokenColors, other.tokenColors); @override int get hashCode => - Object.hash(workbench, editor, brightness, colorScheme); + 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/theme_controller.dart b/lib/core/theme/theme_controller.dart index 4b38e8d2..70eeec09 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,9 +1,11 @@ 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'; @@ -17,6 +19,7 @@ class ThemeController extends ChangeNotifier { ThemeMode _themeMode = ThemeMode.dark; QueryaThemePreset _preset = QueryaThemePreset.queryaDark; Map _importedColors = const {}; + List _importedTokenColors = const []; Map _userOverrides = const {}; String? _importedThemeName; bool _loaded = false; @@ -85,6 +88,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemeImportedColors(imported); } } + _importedTokenColors = await ThemeImportService.loadPersistedTokenColors(); if (preset == QueryaThemePreset.imported && imported.isEmpty) { preset = QueryaThemePreset.queryaDark; @@ -132,8 +136,15 @@ class ThemeController extends ChangeNotifier { Future importThemeFromFile(String path) async { final result = await ThemeImportService.importFromPath(path); switch (result) { - case ThemeImportSuccess(:final name, :final isDark, :final colors, :final storedPath): + 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; @@ -174,6 +185,7 @@ class ThemeController extends ChangeNotifier { await ThemeImportService.deletePersistedImport(); await AppSettings.instance.clearThemeImport(); _importedColors = const {}; + _importedTokenColors = const []; _importedThemeName = null; if (_preset == QueryaThemePreset.imported) { _preset = QueryaThemePreset.queryaDark; @@ -190,6 +202,7 @@ class ThemeController extends ChangeNotifier { _themeMode = ThemeMode.dark; _preset = QueryaThemePreset.queryaDark; _importedColors = const {}; + _importedTokenColors = const []; _userOverrides = const {}; _importedThemeName = null; notifyListeners(); @@ -208,11 +221,22 @@ class ThemeController extends ChangeNotifier { ? QueryaTheme.lightDefault : QueryaTheme.darkDefault; final merged = effectiveVsCodeColors; - if (merged.isEmpty) return fallback; - return buildQueryaThemeFromVsCodeColors( - brightness: brightness, - colors: merged, - fallback: fallback, - ); + 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 index 3d1d3459..7a40e5d0 100644 --- a/lib/core/theme/theme_import_service.dart +++ b/lib/core/theme/theme_import_service.dart @@ -15,12 +15,14 @@ class ThemeImportSuccess extends ThemeImportResult { 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; } @@ -60,6 +62,7 @@ abstract final class ThemeImportService { name: name, isDark: manifest.isDark || !manifest.isLight, colors: Map.unmodifiable(manifest.colors), + tokenColors: List.unmodifiable(manifest.tokenColors), storedPath: storedFile.path, ); } on VsCodeThemeParseException catch (e) { @@ -75,13 +78,23 @@ abstract final class ThemeImportService { /// 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 { - final manifest = - VsCodeThemeManifest.fromJsonString(await file.readAsString()); - if (manifest.colors.isEmpty) return null; - return manifest.colors; + return VsCodeThemeManifest.fromJsonString(await file.readAsString()); } on Object { return null; } 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/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/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" } + } + ] +} From a79d207f532751b8351e455cad446b83121e22dd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:30:06 +0300 Subject: [PATCH 21/31] test(theme): expand theme pipeline unit and fixture coverage (#58) Add Dracula/One Dark/JSONC fixtures, integration tests for tokenColors import, codec/resolver coverage, and ThemeController shadcn theme tests. --- .../apply_token_colors_to_editor_test.dart | 28 ++++++ .../theme/parser/jsonc_preprocessor_test.dart | 11 +++ .../theme_fixtures_integration_test.dart | 41 +++++++++ .../theme/parser/token_colors_codec_test.dart | 28 ++++++ test/core/theme/querya_app_theme_test.dart | 86 +++++++++++++++++++ .../core/theme/theme_import_service_test.dart | 12 +++ .../themes/invalid-trailing-comma.jsonc | 15 ++++ test/fixtures/themes/one_dark.json | 24 ++++++ 8 files changed, 245 insertions(+) create mode 100644 test/core/theme/parser/apply_token_colors_to_editor_test.dart create mode 100644 test/core/theme/parser/theme_fixtures_integration_test.dart create mode 100644 test/core/theme/parser/token_colors_codec_test.dart create mode 100644 test/core/theme/querya_app_theme_test.dart create mode 100644 test/fixtures/themes/invalid-trailing-comma.jsonc create mode 100644 test/fixtures/themes/one_dark.json 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/jsonc_preprocessor_test.dart b/test/core/theme/parser/jsonc_preprocessor_test.dart index 1759bfde..77c94752 100644 --- a/test/core/theme/parser/jsonc_preprocessor_test.dart +++ b/test/core/theme/parser/jsonc_preprocessor_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/parser/jsonc_preprocessor.dart'; @@ -31,5 +33,14 @@ void main() { 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/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/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/theme_import_service_test.dart b/test/core/theme/theme_import_service_test.dart index da998cb8..0a6fa510 100644 --- a/test/core/theme/theme_import_service_test.dart +++ b/test/core/theme/theme_import_service_test.dart @@ -52,6 +52,18 @@ void main() { 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'); 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/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" } + } + ] +} From 34de2125eb452368a5d1456b4d8a375087bb1405 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:31:04 +0300 Subject: [PATCH 22/31] docs(theme): add theme system guide and README link (#55) Document workbench vs editor architecture, VS Code import, overrides, tokenColors pipeline, and testing pointers. --- README.md | 1 + docs/roadmap.md | 5 ++ docs/theme.md | 164 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 docs/theme.md 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/roadmap.md b/docs/roadmap.md index 46731e04..1f2a3973 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,6 +2,11 @@ Living document for planned work. Not a commitment order; adjust as priorities change. +## Theme system + +- **Done:** runtime themes, VS Code `colors` import, `tokenColors` syntax highlighting — see [theme.md](theme.md). +- **Later:** animated theme transitions ([#57](https://github.com/QueryaHub/Querya-Desktop/issues/57)), advanced editor (LSP / `code_forge` spike). + ## 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.md b/docs/theme.md new file mode 100644 index 00000000..f542f248 --- /dev/null +++ b/docs/theme.md @@ -0,0 +1,164 @@ +# 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. + +## 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** — light UI (`QueryaThemePreset.queryaLight`) +- **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/` + +## Roadmap (Phase 2+) + +| Topic | Status | +|-------|--------| +| Workbench `colors` import | Done | +| Preferences UI | Done | +| SQL/JSON syntax highlighting | Done | +| `tokenColors` → highlighter | Done | +| Theme transition animation | [#57](https://github.com/QueryaHub/Querya-Desktop/issues/57) | +| `code_forge` / LSP editor | [#52](https://github.com/QueryaHub/Querya-Desktop/issues/52) | + +## 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 From 6f66130bf064a0fa19f4441cbd1e0e5b483643aa Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:34:29 +0300 Subject: [PATCH 23/31] refactor(theme): migrate P0 workbench surfaces to design tokens (#59) Replace hardcoded colors in workspace empty hero mock and window close button with workbench/editor theme tokens; add widget tests. Part of #42. --- lib/features/main_screen/main_screen.dart | 9 +-- .../main_screen/workspace_empty_hero.dart | 28 +++++++--- .../workspace_empty_hero_test.dart | 55 +++++++++++++++++++ 3 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 test/features/main_screen/workspace_empty_hero_test.dart diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 54f8a3dc..628f7446 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -343,6 +343,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 +353,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( diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index 68fc5729..dc89ffca 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -1,6 +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_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. @@ -14,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; @@ -75,6 +79,8 @@ class WorkspaceEmptyHero extends StatelessWidget { material.SizedBox(height: compact ? 20 : 28), _MockAppWindow( colorScheme: cs, + workbench: workbench, + editorTheme: editorTheme, height: mockH, compact: compact, ), @@ -168,11 +174,15 @@ 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; @@ -197,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), @@ -221,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), ], ), ), @@ -241,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: [ @@ -283,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), @@ -307,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/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); + }); +} From 438644ef037289bd3b3aead626a7ed547c125844 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:40:51 +0300 Subject: [PATCH 24/31] feat(theme): SqlEditorChrome tokens for SQL workspaces (#60) Add toolbar decoration helper, brightness-aware accent glow, and widget tests for imported editor colors; wire PG/MySQL toolbars to theme tokens. --- .../main_screen/sql_editor_chrome.dart | 47 +++++++++++--- lib/features/mysql/mysql_sql_workspace.dart | 6 +- .../postgresql/postgres_sql_workspace.dart | 6 +- .../main_screen/sql_editor_chrome_test.dart | 65 +++++++++++++++++++ 4 files changed, 107 insertions(+), 17 deletions(-) diff --git a/lib/features/main_screen/sql_editor_chrome.dart b/lib/features/main_screen/sql_editor_chrome.dart index 6be04c0c..7dcd62db 100644 --- a/lib/features/main_screen/sql_editor_chrome.dart +++ b/lib/features/main_screen/sql_editor_chrome.dart @@ -10,24 +10,49 @@ class SqlEditorChrome extends StatelessWidget { final Widget child; - static const double _outerRadius = 14; - static const double _innerRadius = 10; + 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: 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, - ) { + QueryaWorkbenchTheme workbench, { + Brightness brightness = Brightness.dark, + }) { final border = editor.widgetBorder ?? workbench.borderSubtle; return material.BoxDecoration( color: editor.background, - borderRadius: material.BorderRadius.circular(_innerRadius), + borderRadius: material.BorderRadius.circular(innerRadius), border: material.Border.all( color: border.withValues(alpha: 0.45), ), boxShadow: [ material.BoxShadow( - color: workbench.accent.withValues(alpha: 0.07), + color: workbench.accent.withValues( + alpha: inlineGlowAlpha(brightness), + ), blurRadius: 18, offset: const material.Offset(0, 6), ), @@ -41,6 +66,7 @@ class SqlEditorChrome extends StatelessWidget { return inlineFieldDecoration( context.editorTheme, context.workbench, + brightness: Theme.of(context).brightness, ); } @@ -48,12 +74,15 @@ class SqlEditorChrome extends StatelessWidget { Widget build(BuildContext context) { 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: 0.1); + final glow = workbench.accent.withValues( + alpha: chromeGlowAlpha(brightness), + ); return material.Container( decoration: material.BoxDecoration( - borderRadius: material.BorderRadius.circular(_outerRadius), + borderRadius: material.BorderRadius.circular(outerRadius), boxShadow: [ material.BoxShadow( color: glow, @@ -66,7 +95,7 @@ class SqlEditorChrome extends StatelessWidget { child: material.Container( decoration: material.BoxDecoration( color: editor.background, - borderRadius: material.BorderRadius.circular(_outerRadius), + borderRadius: material.BorderRadius.circular(outerRadius), border: material.Border.all( color: border.withValues(alpha: 0.5), ), diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 4fcc70fc..42386bdc 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -10,6 +10,7 @@ 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'; @@ -345,13 +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, diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 20df05f8..f870a4c7 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -14,6 +14,7 @@ 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'; @@ -518,13 +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, diff --git a/test/features/main_screen/sql_editor_chrome_test.dart b/test/features/main_screen/sql_editor_chrome_test.dart index c8aaa5fc..42887469 100644 --- a/test/features/main_screen/sql_editor_chrome_test.dart +++ b/test/features/main_screen/sql_editor_chrome_test.dart @@ -1,9 +1,14 @@ +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', () { @@ -66,9 +71,69 @@ void main() { 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)); }); }); } From 8540c565aad6ce8a123f6ddc44ecc233b3ba8e93 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:44:50 +0300 Subject: [PATCH 25/31] feat(theme): enable Querya Light preset in main shell (#51) Stop forcing AppTheme.dark in MainScreen so ThemeMode.light applies; document light preset and add WCAG contrast unit tests. --- docs/theme.md | 2 +- lib/core/theme/querya_workbench_theme.dart | 2 +- lib/features/main_screen/main_screen.dart | 57 +++++++++-------- test/core/theme/querya_light_theme_test.dart | 64 ++++++++++++++++++++ 4 files changed, 93 insertions(+), 32 deletions(-) create mode 100644 test/core/theme/querya_light_theme_test.dart diff --git a/docs/theme.md b/docs/theme.md index f542f248..660386f9 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -55,7 +55,7 @@ flowchart TB ## Built-in presets - **Querya Dark** — default (`QueryaThemePreset.queryaDark`) -- **Querya Light** — light UI (`QueryaThemePreset.queryaLight`) +- **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: diff --git a/lib/core/theme/querya_workbench_theme.dart b/lib/core/theme/querya_workbench_theme.dart index ddfd783d..99261857 100644 --- a/lib/core/theme/querya_workbench_theme.dart +++ b/lib/core/theme/querya_workbench_theme.dart @@ -51,7 +51,7 @@ class QueryaWorkbenchTheme { gitUntracked: Color(0xFF2EB88A), ); - /// Placeholder light preset (#51 will refine). + /// Built-in light preset (slate-like canvas, cyan brand accent). static const QueryaWorkbenchTheme lightDefault = QueryaWorkbenchTheme( canvas: Color(0xFFFAFAFA), surface: Color(0xFFFFFFFF), diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 628f7446..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_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, ), - ], - ), + ), + ], ), ), ); 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; +} From e46770f056410a3d671f9d99945d3217bb795aeb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:50:50 +0300 Subject: [PATCH 26/31] feat(theme): optional animated theme transitions (#57) Add AppSettings flag (default off), Preferences toggle, and wire ShadcnApp enableThemeAnimation. Document manual QA checklist; align ThemeData.lerp tests. --- docs/roadmap.md | 2 +- docs/theme.md | 15 ++++++++++- lib/app/app.dart | 2 +- lib/core/storage/app_settings.dart | 25 +++++++++++++++++++ lib/core/theme/theme_controller.dart | 13 ++++++++++ .../preferences_appearance_section.dart | 19 ++++++++++++++ test/core/storage/app_settings_test.dart | 14 +++++++++++ test/core/theme/querya_theme_test.dart | 13 ++++++++++ test/core/theme/theme_controller_test.dart | 14 +++++++++++ 9 files changed, 114 insertions(+), 3 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 1f2a3973..e3081b54 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -5,7 +5,7 @@ Living document for planned work. Not a commitment order; adjust as priorities c ## Theme system - **Done:** runtime themes, VS Code `colors` import, `tokenColors` syntax highlighting — see [theme.md](theme.md). -- **Later:** animated theme transitions ([#57](https://github.com/QueryaHub/Querya-Desktop/issues/57)), advanced editor (LSP / `code_forge` spike). +- **Later:** advanced editor (LSP / `code_forge` spike). Theme transitions: Preferences → **Animate theme changes** (off by default). ## Query history and favorites diff --git a/docs/theme.md b/docs/theme.md index 660386f9..1765fc02 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -146,6 +146,19 @@ See also: [theme-import.md](theme-import.md). 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 | @@ -154,7 +167,7 @@ Run: `flutter test test/core/theme/` | Preferences UI | Done | | SQL/JSON syntax highlighting | Done | | `tokenColors` → highlighter | Done | -| Theme transition animation | [#57](https://github.com/QueryaHub/Querya-Desktop/issues/57) | +| Theme transition animation | Preferences → **Animate theme changes** (default off) | | `code_forge` / LSP editor | [#52](https://github.com/QueryaHub/Querya-Desktop/issues/52) | ## Related docs diff --git a/lib/app/app.dart b/lib/app/app.dart index cf3769a6..95220c95 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -22,7 +22,7 @@ class QueryaApp extends StatelessWidget { darkTheme: themeController.darkShadcnTheme, themeMode: themeController.themeMode, debugShowCheckedModeBanner: false, - enableThemeAnimation: false, + enableThemeAnimation: themeController.themeAnimationEnabled, enableScrollInterception: false, home: QueryaThemeScope( data: queryaTheme, diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 85b13b38..77c537bf 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -56,6 +56,7 @@ abstract final class AppSettingsKeys { 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. @@ -349,10 +350,34 @@ class AppSettings { 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/theme_controller.dart b/lib/core/theme/theme_controller.dart index 70eeec09..ac562091 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -23,9 +23,13 @@ class ThemeController extends ChangeNotifier { 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; @@ -99,10 +103,18 @@ class ThemeController extends ChangeNotifier { _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) { @@ -205,6 +217,7 @@ class ThemeController extends ChangeNotifier { _importedTokenColors = const []; _userOverrides = const {}; _importedThemeName = null; + _themeAnimationEnabled = false; notifyListeners(); } diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 21280fd4..2c520d0c 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -84,6 +84,10 @@ class _PreferencesAppearanceSectionState if (mounted) setState(() => _importError = null); } + Future _setThemeAnimation(bool enabled) async { + await _controller.setThemeAnimationEnabled(enabled); + } + @override material.Widget build(material.BuildContext context) { final c = _controller; @@ -158,6 +162,21 @@ class _PreferencesAppearanceSectionState ], ), const material.SizedBox(height: 12), + material.Row( + children: [ + const Text('Animate theme changes').small(), + const material.SizedBox(width: 12), + material.Switch( + value: c.themeAnimationEnabled, + onChanged: (v) => unawaited(_setThemeAnimation(v)), + ), + ], + ), + const material.SizedBox(height: 4), + const Text( + 'Smooth transitions when switching dark/light or presets. Off by default for stability.', + ).muted().xSmall(), + const material.SizedBox(height: 12), material.Wrap( spacing: 8, runSpacing: 8, diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 1b8d60a6..5b1a0d99 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -201,6 +201,20 @@ void main() { 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', diff --git a/test/core/theme/querya_theme_test.dart b/test/core/theme/querya_theme_test.dart index 6e28b719..6541b0fa 100644 --- a/test/core/theme/querya_theme_test.dart +++ b/test/core/theme/querya_theme_test.dart @@ -88,5 +88,18 @@ void main() { 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 index c1b87cd8..ece87f8c 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -117,6 +117,20 @@ void main() { 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); From 49fc199b1f9713d94a0b2ef01c1c8b3edbd03d1b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:55:32 +0300 Subject: [PATCH 27/31] docs(editor): code_forge and LSP evaluation (#52) Add go/no-go spike doc with epic breakdown; NO-GO for 0.3, re_editor before code_forge. Cross-link editor-spike-report, theme.md, and roadmap. --- docs/code-forge-evaluation.md | 188 ++++++++++++++++++++++++++++++++++ docs/editor-spike-report.md | 2 + docs/roadmap.md | 2 +- docs/theme.md | 5 +- 4 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 docs/code-forge-evaluation.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 index 0e12beae..33248a21 100644 --- a/docs/editor-spike-report.md +++ b/docs/editor-spike-report.md @@ -113,6 +113,8 @@ Plan: spike branch with `re_editor` only for `QueryEditorTab`, keep dialogs on M 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. diff --git a/docs/roadmap.md b/docs/roadmap.md index e3081b54..d599dfbf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -5,7 +5,7 @@ Living document for planned work. Not a commitment order; adjust as priorities c ## Theme system - **Done:** runtime themes, VS Code `colors` import, `tokenColors` syntax highlighting — see [theme.md](theme.md). -- **Later:** advanced editor (LSP / `code_forge` spike). Theme transitions: Preferences → **Animate theme changes** (off by default). +- **Later:** `re_editor` if perf gap; LSP epic only per [code-forge-evaluation.md](code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). Theme animation: Preferences → **Animate theme changes** (off by default). ## Query history and favorites diff --git a/docs/theme.md b/docs/theme.md index 1765fc02..01b927fe 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -168,10 +168,11 @@ Manual QA (with animation enabled): | SQL/JSON syntax highlighting | Done | | `tokenColors` → highlighter | Done | | Theme transition animation | Preferences → **Animate theme changes** (default off) | -| `code_forge` / LSP editor | [#52](https://github.com/QueryaHub/Querya-Desktop/issues/52) | +| `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 +- [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) From 59a4c9e9b1d8496678e8928043510a3cd695dfd5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 12:00:10 +0300 Subject: [PATCH 28/31] docs: mark theme system epic #37 complete Record milestone closure in theme.md and roadmap; note P2 and editor follow-ups. --- docs/roadmap.md | 7 +++++-- docs/theme.md | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index d599dfbf..ae43cc29 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,8 +4,11 @@ Living document for planned work. Not a commitment order; adjust as priorities c ## Theme system -- **Done:** runtime themes, VS Code `colors` import, `tokenColors` syntax highlighting — see [theme.md](theme.md). -- **Later:** `re_editor` if perf gap; LSP epic only per [code-forge-evaluation.md](code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). Theme animation: Preferences → **Animate theme changes** (off by default). +- **Done (epic #37, 2026-05-28):** 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 diff --git a/docs/theme.md b/docs/theme.md index 01b927fe..bf4a381d 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -3,6 +3,18 @@ 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 From 8515b3b6d5b85098c4a63b335a9a20f3d50c373a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 12:25:52 +0300 Subject: [PATCH 29/31] fix(theme): preferences UI, material theme, and scope for dialogs QueryaThemeScope on ShadcnApp.builder for SQL editor and overlays. Material theme from Querya ColorScheme; legible muted labels; DropdownMenu in Preferences (no full-screen menu glitch). --- lib/app/app.dart | 13 +- lib/core/theme/color_contrast.dart | 38 ++ lib/core/theme/querya_material_theme.dart | 42 ++ lib/core/theme/querya_theme.dart | 7 +- .../preferences_appearance_section.dart | 60 +-- .../settings/preferences_controls.dart | 91 ++++ lib/features/settings/preferences_dialog.dart | 396 ++++++++++-------- .../sql_statement_timeout_dropdown.dart | 26 +- test/core/theme/color_contrast_test.dart | 33 ++ .../theme/querya_material_theme_test.dart | 18 + .../sql_statement_timeout_dropdown_test.dart | 57 +-- test/support/querya_theme_test_shell.dart | 5 +- 12 files changed, 518 insertions(+), 268 deletions(-) create mode 100644 lib/core/theme/color_contrast.dart create mode 100644 lib/core/theme/querya_material_theme.dart create mode 100644 lib/features/settings/preferences_controls.dart create mode 100644 test/core/theme/color_contrast_test.dart create mode 100644 test/core/theme/querya_material_theme_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 95220c95..06b6fdfa 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,3 +1,4 @@ +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'; @@ -16,19 +17,23 @@ class QueryaApp extends StatelessWidget { 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, - home: QueryaThemeScope( + // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. + builder: (context, child) => QueryaThemeScope( data: queryaTheme, - child: const AppLifecycleCleanup( - child: MainScreen(), - ), + child: child ?? const SizedBox.shrink(), + ), + home: const AppLifecycleCleanup( + child: MainScreen(), ), ); }, 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/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 index a941a96d..13ababed 100644 --- a/lib/core/theme/querya_theme.dart +++ b/lib/core/theme/querya_theme.dart @@ -1,5 +1,6 @@ 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'; @@ -115,7 +116,11 @@ class QueryaTheme { secondary: isDark ? const Color(0xFF18181B) : const Color(0xFFF4F4F5), secondaryForeground: fg, muted: isDark ? const Color(0xFF18181B) : const Color(0xFFF4F4F5), - mutedForeground: w.mutedForeground, + mutedForeground: legibleSecondaryLabel( + candidate: w.mutedForeground, + background: w.surface, + fallback: fg, + ), accent: isDark ? const Color(0xFF27272A) : const Color(0xFFE4E4E7), accentForeground: fg, destructive: w.destructive, diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 2c520d0c..84fe0961 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -5,6 +5,7 @@ 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'; @@ -98,29 +99,29 @@ class _PreferencesAppearanceSectionState return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - const Text('Appearance').semiBold().small(), + const Text('Appearance').semiBold().small().foreground(), const material.SizedBox(height: 8), material.Row( children: [ - const Text('Theme mode').small(), + const Text('Theme mode').small().foreground(), const material.SizedBox(width: 12), - material.DropdownButton( + PreferencesDropdownMenu( value: c.themeMode, - onChanged: (v) { + onSelected: (v) { if (v != null) unawaited(_setThemeMode(v)); }, - items: const [ - material.DropdownMenuItem( + entries: const [ + material.DropdownMenuEntry( value: ThemeMode.dark, - child: material.Text('Dark'), + label: 'Dark', ), - material.DropdownMenuItem( + material.DropdownMenuEntry( value: ThemeMode.light, - child: material.Text('Light'), + label: 'Light', ), - material.DropdownMenuItem( + material.DropdownMenuEntry( value: ThemeMode.system, - child: material.Text('System'), + label: 'System', ), ], ), @@ -131,30 +132,30 @@ class _PreferencesAppearanceSectionState crossAxisAlignment: material.CrossAxisAlignment.start, children: [ material.Padding( - padding: const material.EdgeInsets.only(top: 8), - child: const Text('Color preset').small(), + padding: const material.EdgeInsets.only(top: 10), + child: const Text('Color preset').small().foreground(), ), const material.SizedBox(width: 12), material.Expanded( - child: material.DropdownButton( + child: PreferencesDropdownMenu( value: c.preset, - isExpanded: true, - onChanged: (v) { + expandToParent: true, + onSelected: (v) { if (v != null) unawaited(_setPreset(v)); }, - items: [ - const material.DropdownMenuItem( + entries: [ + const material.DropdownMenuEntry( value: QueryaThemePreset.queryaDark, - child: material.Text('Querya Dark'), + label: 'Querya Dark', ), - const material.DropdownMenuItem( + const material.DropdownMenuEntry( value: QueryaThemePreset.queryaLight, - child: material.Text('Querya Light'), + label: 'Querya Light', ), - material.DropdownMenuItem( + material.DropdownMenuEntry( value: QueryaThemePreset.imported, enabled: c.hasImportedTheme, - child: material.Text(importedLabel), + label: importedLabel, ), ], ), @@ -164,7 +165,7 @@ class _PreferencesAppearanceSectionState const material.SizedBox(height: 12), material.Row( children: [ - const Text('Animate theme changes').small(), + const Text('Animate theme changes').small().foreground(), const material.SizedBox(width: 12), material.Switch( value: c.themeAnimationEnabled, @@ -173,16 +174,17 @@ class _PreferencesAppearanceSectionState ], ), const material.SizedBox(height: 4), - const Text( + const PreferencesHint( 'Smooth transitions when switching dark/light or presets. Off by default for stability.', - ).muted().xSmall(), + ), const material.SizedBox(height: 12), material.Wrap( spacing: 8, runSpacing: 8, children: [ OutlineButton( - onPressed: _importing ? null : () => unawaited(_pickAndImportTheme()), + onPressed: + _importing ? null : () => unawaited(_pickAndImportTheme()), child: material.Text(_importing ? 'Importing…' : 'Import theme…'), ), OutlineButton( @@ -202,9 +204,9 @@ class _PreferencesAppearanceSectionState ), ], const material.SizedBox(height: 4), - const Text( + const PreferencesHint( 'Import VS Code theme JSON/JSONC (.colors subset). Changes apply immediately.', - ).muted().xSmall(), + ), ], ); } 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 d510469a..1a84d5f1 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -4,6 +4,7 @@ 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'; @@ -26,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; @@ -86,204 +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: 640, - ), - 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 PreferencesAppearanceSection(), - const material.SizedBox(height: 24), - 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/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/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/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/support/querya_theme_test_shell.dart b/test/support/querya_theme_test_shell.dart index c3acc186..32f13e6b 100644 --- a/test/support/querya_theme_test_shell.dart +++ b/test/support/querya_theme_test_shell.dart @@ -13,9 +13,10 @@ Widget queryaThemeTestShell({ theme: td, darkTheme: td, themeMode: ThemeMode.dark, - home: QueryaThemeScope( + builder: (context, appChild) => QueryaThemeScope( data: data, - child: child, + child: appChild ?? const SizedBox.shrink(), ), + home: child, ); } From 0eed8aed735479d43522794196df304ccf941387 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 12:25:55 +0300 Subject: [PATCH 30/31] chore(theme): add cyberpunk sample themes for import testing VS Code JSON/JSONC fixtures under themes/samples/ with README. --- docs/theme-import.md | 5 ++ themes/samples/README.md | 19 ++++++ themes/samples/cyberpunk-neon.json | 96 +++++++++++++++++++++++++++++ themes/samples/cyberpunk-neon.jsonc | 46 ++++++++++++++ 4 files changed, 166 insertions(+) create mode 100644 themes/samples/README.md create mode 100644 themes/samples/cyberpunk-neon.json create mode 100644 themes/samples/cyberpunk-neon.jsonc diff --git a/docs/theme-import.md b/docs/theme-import.md index 28b85937..0ccacb70 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -73,6 +73,11 @@ 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` 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" }, + }, + ], +} From cafe4e34c176e1c4f4f4f38a551246e6b228c38e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 12:25:55 +0300 Subject: [PATCH 31/31] chore(release): bump version to 0.3.0+4 Theme system milestone: CHANGELOG and roadmap for 0.3.0 release. --- CHANGELOG.md | 23 +++++++++++++++++++++++ docs/roadmap.md | 2 +- pubspec.yaml | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) 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/docs/roadmap.md b/docs/roadmap.md index ae43cc29..2155d6d3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,7 +4,7 @@ Living document for planned work. Not a commitment order; adjust as priorities c ## Theme system -- **Done (epic #37, 2026-05-28):** runtime themes, VS Code `colors` + `tokenColors` import, SQL/JSON +- **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 diff --git a/pubspec.yaml b/pubspec.yaml index 8219a245..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