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'); + }); +}