Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .flutter-plugins-dependencies

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions lib/core/editor/highlighter_theme_from_querya.dart
Original file line number Diff line number Diff line change
@@ -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')}';
}
207 changes: 186 additions & 21 deletions lib/core/editor/querya_code_editor.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/core/theme/querya_editor_theme.dart';
import 'package:querya_desktop/core/theme/querya_theme.dart';
import 'package:querya_desktop/core/theme/querya_theme_scope.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';

import 'querya_code_language.dart';
import 'querya_highlight_controller.dart';
import 'syntax_highlight_service.dart';

/// Shadcn vs Material [TextField] backend for different parent widgets.
enum QueryaCodeEditorVariant {
shadcn,
material,
}

/// Unified code editor (MVP: plain [TextField]; highlighting in #49+).
/// Unified code editor with optional syntax highlighting (SQL/JSON).
class QueryaCodeEditor extends StatefulWidget {
const QueryaCodeEditor({
super.key,
Expand All @@ -27,6 +30,7 @@ class QueryaCodeEditor extends StatefulWidget {
this.hintText,
this.contentPadding,
this.textAlignVertical,
this.enableHighlighting = true,
});

final material.TextEditingController? controller;
Expand All @@ -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<QueryaCodeEditor> createState() => _QueryaCodeEditorState();
}

class _QueryaCodeEditorState extends State<QueryaCodeEditor> {
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();
}
Expand Down Expand Up @@ -118,13 +278,18 @@ class _QueryaCodeEditorState extends State<QueryaCodeEditor> {

@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,
Expand All @@ -141,7 +306,7 @@ class _QueryaCodeEditorState extends State<QueryaCodeEditor> {
}

return TextField(
controller: _controller,
controller: controller,
readOnly: widget.readOnly,
maxLines: widget.expands ? null : widget.maxLines,
expands: widget.expands,
Expand Down
29 changes: 29 additions & 0 deletions lib/core/editor/querya_highlight_controller.dart
Original file line number Diff line number Diff line change
@@ -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)],
);
}
}
Loading
Loading