diff --git a/lib/theme/sql_highlight.dart b/lib/theme/sql_highlight.dart new file mode 100644 index 0000000..4cd1f4c --- /dev/null +++ b/lib/theme/sql_highlight.dart @@ -0,0 +1,162 @@ +/// SQL / DDL 语法高亮的共享层:配色表 + 只读文本的同步 TextSpan 渲染。 +/// +/// 三条约定: +/// - 配色是中调色、与 [AppPalette] 无关的固定值(明亮近似 SSMS / VS Code Light+, +/// 暗黑近似 VS Code Dark+),因此**不进主题定制弹窗**,也不走 token 取色; +/// 以前它在三个文件里各抄了一份(query_page / table_designer_page / +/// routine_design_page),改一处漏两处,现在只有这里一份。 +/// - **只读**展示位(详情面板 DDL 页、建库 / 修改库的脚本预览、结构同步脚本) +/// 用 [highlightedSqlSpan] + `SelectableText.rich`:同步出帧,首帧就是彩色, +/// 不存在「先裸文本再等 isolate 回帧」的慢半拍。可编辑处仍走 re_editor 的 +/// `CodeEditor`(它把解析放 isolate,但带行号槽 / 查找栏 / 编辑器外壳, +/// 塞进窄面板过重),两边共用本文件的配色表。 +/// - 解析失败、或解析结果**掉了字符**,一律退回裸文本:高亮是锦上添花, +/// 不能反过来把 DDL 弄没了一截。 +library; + +import 'package:flutter/painting.dart'; +import 'package:re_highlight/languages/sql.dart'; +import 'package:re_highlight/re_highlight.dart'; + +import 'app_theme.dart'; + +/// 明亮主题配色(键 = re_highlight 的 token scope) +const Map sqlLightTheme = { + 'keyword': TextStyle(color: Color(0xff0000ff)), + 'literal': TextStyle(color: Color(0xff001080)), + 'name': TextStyle(color: Color(0xff001080)), + 'attr': TextStyle(color: Color(0xff001080)), + 'variable': TextStyle(color: Color(0xff001080)), + 'meta': TextStyle(color: Color(0xff001080)), + 'string': TextStyle(color: Color(0xffa31515)), + 'quote': TextStyle(color: Color(0xffa31515)), + 'regexp': TextStyle(color: Color(0xffa31515)), + 'comment': TextStyle(color: Color(0xff008000), fontStyle: FontStyle.italic), + 'doctag': TextStyle(color: Color(0xff800000)), + 'section': TextStyle(color: Color(0xff800000)), + 'number': TextStyle(color: Color(0xff098658)), + 'symbol': TextStyle(color: Color(0xff098658)), + 'type': TextStyle(color: Color(0xff267f99)), + 'class-title': TextStyle(color: Color(0xff267f99)), + 'built_in': TextStyle(color: Color(0xff795e26)), + 'title': TextStyle(color: Color(0xff795e26)), +}; + +/// 暗黑主题配色 +const Map sqlDarkTheme = { + 'keyword': TextStyle(color: Color(0xff569cd6)), + 'literal': TextStyle(color: Color(0xff569cd6)), + 'name': TextStyle(color: Color(0xff9cdcfe)), + 'attr': TextStyle(color: Color(0xff9cdcfe)), + 'variable': TextStyle(color: Color(0xff9cdcfe)), + 'meta': TextStyle(color: Color(0xff9cdcfe)), + 'string': TextStyle(color: Color(0xffce9178)), + 'quote': TextStyle(color: Color(0xffce9178)), + 'regexp': TextStyle(color: Color(0xffd16969)), + 'comment': TextStyle(color: Color(0xff6a9955), fontStyle: FontStyle.italic), + 'doctag': TextStyle(color: Color(0xff6a9955)), + 'section': TextStyle(color: Color(0xff6a9955)), + 'number': TextStyle(color: Color(0xffb5cea8)), + 'symbol': TextStyle(color: Color(0xffb5cea8)), + 'type': TextStyle(color: Color(0xff4ec9b0)), + 'class-title': TextStyle(color: Color(0xff4ec9b0)), + 'built_in': TextStyle(color: Color(0xffdcdcaa)), + 'title': TextStyle(color: Color(0xffdcdcaa)), +}; + +/// 交给 `CodeHighlightTheme.theme` 的完整配色:明暗取表 + 覆盖 `root`。 +/// +/// `root` 是 re_editor 给「没命中任何 scope 的裸文本」查的键,不覆盖就会掉回 +/// 它自带的默认色(亮色主题下是深色,反过来也一样),换肤时文字跟着变黑 / 变白。 +Map sqlEditorTheme({ + required bool isDark, + required Color foreground, +}) => + { + ...(isDark ? sqlDarkTheme : sqlLightTheme), + 'root': TextStyle(color: foreground), + }; + +/// 只读代码文本的统一字体:Consolas 优先、回落 monospace +/// (与既有脚本预览一致;可编辑的 CodeEditor 用的是 'monospace',不在此列)。 +/// +/// [height] 传 null = 沿用 Flutter 默认行距(各展示位原有的行高不同, +/// 这里不替它们统一,免得顺手改排版)。 +/// +/// `decoration: none` 是硬性要求——高亮出来的子 span 只带 color, +/// 一旦哪天脱离 Material 的 DefaultTextStyle 兜底,就会冒出双黄线 / 加粗。 +TextStyle sqlCodeStyle({ + required Color color, + double fontSize = 12, + double? height = 1.45, +}) => + TextStyle( + fontFamily: 'Consolas', + fontFamilyFallback: const ['monospace'], + fontSize: fontSize, + height: height, + color: color, + decoration: TextDecoration.none, + ); + +Highlight? _highlighter; + +/// 复用同一个 [Highlight]:langSql 整套语法在首次 `highlight()` 时才编译 +/// (`_compileLanguage` 把结果缓存在 Mode 上),每次新建实例等于白付一遍编译。 +Highlight get _sqlHighlighter => + _highlighter ??= (Highlight()..registerLanguage('sql', langSql)); + +/// 单条 memo:详情面板 / 脚本预览在 hover、换肤等重建里会反复传同一段文本, +/// 解析虽然只有毫秒级,但没必要每次 build 都重跑。换文本即失效。 +({String code, bool isDark, TextStyle base})? _lastRequest; +TextSpan? _lastSpan; + +/// 把一段 SQL / DDL 解析成带高亮的 [TextSpan],供 `Text.rich` / +/// `SelectableText.rich` 直接渲染。 +/// +/// 字体与明暗都从 [palette] 推导:明暗口径沿用全应用既有的「按 background 亮度」 +/// 判法,不走 `Theme.of` —— 定制色板下两者可能不一致。字号 / 行高各展示位不同, +/// 故仍留参数。 +/// +/// 返回的是**根 span**,兜底字体([sqlCodeStyle])挂在它身上,命中的 scope 子 span +/// 只带自己的颜色与斜体、字号与字体沿用它——所以调用方直接 `Text.rich(span)` 即可, +/// **不要再传 `style:`**(传了会被这里的根样式盖掉,反而看不出问题)。 +/// 解析异常或语法不认识时返回纯文本 span,不抛。 +TextSpan highlightedSqlSpan( + String code, { + required AppPalette palette, + double fontSize = 12, + double? height = 1.45, +}) { + final isDark = palette.background.computeLuminance() < 0.5; + final base = sqlCodeStyle( + color: palette.foreground, + fontSize: fontSize, + height: height, + ); + if (code.isEmpty) return TextSpan(text: code, style: base); + final request = (code: code, isDark: isDark, base: base); + final cached = _lastSpan; + if (cached != null && request == _lastRequest) return cached; + + TextSpan span; + try { + final renderer = TextSpanRenderer( + base, + isDark ? sqlDarkTheme : sqlLightTheme, + ); + _sqlHighlighter.highlight(code: code, language: 'sql').render(renderer); + final inner = renderer.span; + span = inner == null + ? TextSpan(text: code, style: base) + : TextSpan(style: base, children: [inner]); + } catch (_) { + span = TextSpan(text: code, style: base); + } + // 自校验:scope 切分一旦掉了字符(撞上奇怪的字节序列时真会发生), + // 宁可整段退回裸文本,也不能让界面上的 DDL 少一截——少的那截没人看得见。 + if (span.toPlainText() != code) span = TextSpan(text: code, style: base); + _lastRequest = request; + _lastSpan = span; + return span; +} diff --git a/lib/widgets/create_database_dialog.dart b/lib/widgets/create_database_dialog.dart index 75bd76a..5b7a324 100644 --- a/lib/widgets/create_database_dialog.dart +++ b/lib/widgets/create_database_dialog.dart @@ -7,6 +7,7 @@ import '../data/create_database_catalog.dart'; import '../data/db_create_options.dart'; import '../data/db_data.dart'; import '../theme/app_theme.dart'; +import '../theme/sql_highlight.dart'; /// 校验失败提示的红:与 `mcp_settings_dialog.dart` / `query_page.dart` 同一 /// 中调色,主题无关(色板里没有语义错误色)。 @@ -614,15 +615,9 @@ class _CreateDatabaseDialogState extends State { ), child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: SelectableText( - script, - style: TextStyle( - fontFamily: 'Consolas', - fontFamilyFallback: const ['monospace'], - fontSize: 12, - color: t.foreground, - decoration: TextDecoration.none, - ), + child: SelectableText.rich( + // 与详情面板 DDL 页同款高亮;行距沿用这里原本的默认值,不顺手改排版。 + highlightedSqlSpan(script, palette: t, height: null), ), ), ); diff --git a/lib/widgets/database_edit_dialog.dart b/lib/widgets/database_edit_dialog.dart index f559b37..5685df5 100644 --- a/lib/widgets/database_edit_dialog.dart +++ b/lib/widgets/database_edit_dialog.dart @@ -8,6 +8,7 @@ import '../data/database_edit_catalog.dart'; import '../data/db_data.dart'; import '../data/db_edit_options.dart'; import '../theme/app_theme.dart'; +import '../theme/sql_highlight.dart'; /// 校验失败提示的红:与「新建数据库」同一中调色,主题无关(色板里没有语义错误色)。 const Color _kErrorColor = Color(0xffd93025); @@ -635,15 +636,9 @@ class _DatabaseEditDialogState extends State { ), child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: SelectableText( - script, - style: TextStyle( - fontFamily: 'Consolas', - fontFamilyFallback: const ['monospace'], - fontSize: 12, - color: t.foreground, - decoration: TextDecoration.none, - ), + child: SelectableText.rich( + // 与详情面板 DDL 页同款高亮;行距沿用这里原本的默认值,不顺手改排版。 + highlightedSqlSpan(script, palette: t, height: null), ), ), ), diff --git a/lib/widgets/database_info.dart b/lib/widgets/database_info.dart index 636af82..1ba49e7 100644 --- a/lib/widgets/database_info.dart +++ b/lib/widgets/database_info.dart @@ -11,6 +11,7 @@ import '../data/drivers/kv_driver.dart'; import '../data/table_design.dart'; import '../l10n/locale_config.dart'; import '../theme/app_theme.dart'; +import '../theme/sql_highlight.dart'; import 'object_category_icon.dart'; // 右侧对象详情面板:根据当前选中节点展示不同信息。 @@ -996,15 +997,10 @@ class _DdlText extends StatelessWidget { child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB( _panePadding, 14, _panePadding, _panePadding), - child: Text( - sql, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 12, - height: 1.45, - color: t.foreground, - ), - ), + // 与表设计器「SQL 预览」同款高亮:同步解析出帧,首帧就是彩色, + // 不会有「先裸文本、等 isolate 回帧再上色」的慢半拍。 + // 可划选(局部复制),右上角的整段复制按钮照旧保留。 + child: SelectableText.rich(highlightedSqlSpan(sql, palette: t)), ), ), Positioned( diff --git a/lib/widgets/matview_design_page.dart b/lib/widgets/matview_design_page.dart index c5a3191..37b0e6c 100644 --- a/lib/widgets/matview_design_page.dart +++ b/lib/widgets/matview_design_page.dart @@ -10,8 +10,8 @@ import '../data/db_data.dart'; import '../data/matview_sql.dart'; import '../l10n/locale_config.dart'; import '../theme/app_theme.dart'; +import '../theme/sql_highlight.dart'; import 'query_page.dart' show formatSql; -import 'routine_design_page.dart' show sqlLightTheme, sqlDarkTheme; import 'sql_find_bar.dart'; /// 实体化视图设计器(按 Navicat 的实体化视图编辑器还原)。 @@ -1159,10 +1159,7 @@ class _MatViewDesignPageState extends State { languages: { 'sql': CodeHighlightThemeMode(mode: langSql), }, - theme: { - ...(isDark ? sqlDarkTheme : sqlLightTheme), - 'root': TextStyle(color: t.foreground), - }, + theme: sqlEditorTheme(isDark: isDark, foreground: t.foreground), ), ), indicatorBuilder: showLineNumbers diff --git a/lib/widgets/query_page.dart b/lib/widgets/query_page.dart index ba07124..aa26e26 100644 --- a/lib/widgets/query_page.dart +++ b/lib/widgets/query_page.dart @@ -17,6 +17,7 @@ import '../data/sql_completions.dart'; import '../data/sql_split.dart'; import '../l10n/locale_config.dart'; import '../theme/app_theme.dart'; +import '../theme/sql_highlight.dart'; import 'result_export_dialog.dart'; import 'sql_find_bar.dart'; @@ -1319,10 +1320,7 @@ class _QueryPageState extends State { languages: { 'sql': CodeHighlightThemeMode(mode: langSql), }, - theme: { - ...(isDark ? _sqlDarkTheme : _sqlLightTheme), - 'root': TextStyle(color: t.foreground), - }, + theme: sqlEditorTheme(isDark: isDark, foreground: t.foreground), ), ), indicatorBuilder: @@ -2252,54 +2250,6 @@ String formatSql(String input) { return lines.join('\n'); } -// ──────────────────────────────────────────────────────────── -// SQL 语法高亮配色(re_highlight 主题映射,中调色主题无关) -// ──────────────────────────────────────────────────────────── - -/// 明亮主题:近似 SSMS / VS Code Light+(关键字蓝、字符串红、注释绿) -const Map _sqlLightTheme = { - 'keyword': TextStyle(color: Color(0xff0000ff)), - 'literal': TextStyle(color: Color(0xff001080)), - 'name': TextStyle(color: Color(0xff001080)), - 'attr': TextStyle(color: Color(0xff001080)), - 'variable': TextStyle(color: Color(0xff001080)), - 'meta': TextStyle(color: Color(0xff001080)), - 'string': TextStyle(color: Color(0xffa31515)), - 'quote': TextStyle(color: Color(0xffa31515)), - 'regexp': TextStyle(color: Color(0xffa31515)), - 'comment': TextStyle(color: Color(0xff008000), fontStyle: FontStyle.italic), - 'doctag': TextStyle(color: Color(0xff800000)), - 'section': TextStyle(color: Color(0xff800000)), - 'number': TextStyle(color: Color(0xff098658)), - 'symbol': TextStyle(color: Color(0xff098658)), - 'type': TextStyle(color: Color(0xff267f99)), - 'class-title': TextStyle(color: Color(0xff267f99)), - 'built_in': TextStyle(color: Color(0xff795e26)), - 'title': TextStyle(color: Color(0xff795e26)), -}; - -/// 暗黑主题:近似 VS Code Dark+ -const Map _sqlDarkTheme = { - 'keyword': TextStyle(color: Color(0xff569cd6)), - 'literal': TextStyle(color: Color(0xff569cd6)), - 'name': TextStyle(color: Color(0xff9cdcfe)), - 'attr': TextStyle(color: Color(0xff9cdcfe)), - 'variable': TextStyle(color: Color(0xff9cdcfe)), - 'meta': TextStyle(color: Color(0xff9cdcfe)), - 'string': TextStyle(color: Color(0xffce9178)), - 'quote': TextStyle(color: Color(0xffce9178)), - 'regexp': TextStyle(color: Color(0xffd16969)), - 'comment': TextStyle(color: Color(0xff6a9955), fontStyle: FontStyle.italic), - 'doctag': TextStyle(color: Color(0xff6a9955)), - 'section': TextStyle(color: Color(0xff6a9955)), - 'number': TextStyle(color: Color(0xffb5cea8)), - 'symbol': TextStyle(color: Color(0xffb5cea8)), - 'type': TextStyle(color: Color(0xff4ec9b0)), - 'class-title': TextStyle(color: Color(0xff4ec9b0)), - 'built_in': TextStyle(color: Color(0xffdcdcaa)), - 'title': TextStyle(color: Color(0xffdcdcaa)), -}; - // ──────────────────────────────────────────────────────────── // SQL 补全提示面板 // ──────────────────────────────────────────────────────────── diff --git a/lib/widgets/routine_design_page.dart b/lib/widgets/routine_design_page.dart index b8495e7..fc9509d 100644 --- a/lib/widgets/routine_design_page.dart +++ b/lib/widgets/routine_design_page.dart @@ -6,6 +6,7 @@ import 'package:re_highlight/languages/sql.dart'; import '../app/app_state.dart'; import '../data/routine_sql.dart'; import '../theme/app_theme.dart'; +import '../theme/sql_highlight.dart'; import 'sql_find_bar.dart'; /// 例程设计页:展示并编辑过程 / 函数 / 视图的 CREATE 定义。 @@ -827,10 +828,7 @@ class _RoutineDesignPageState extends State { languages: { 'sql': CodeHighlightThemeMode(mode: langSql), }, - theme: { - ...(isDark ? sqlDarkTheme : sqlLightTheme), - 'root': TextStyle(color: t.foreground), - }, + theme: sqlEditorTheme(isDark: isDark, foreground: t.foreground), ), ), indicatorBuilder: @@ -906,47 +904,3 @@ class _RoutineDesignPageState extends State { ), ); } - -/// SQL 语法高亮配色(与查询页一致:明亮近似 SSMS / VS Code Light+, -/// 暗黑近似 VS Code Dark+;中调色主题无关) -const Map sqlLightTheme = { - 'keyword': TextStyle(color: Color(0xff0000ff)), - 'literal': TextStyle(color: Color(0xff001080)), - 'name': TextStyle(color: Color(0xff001080)), - 'attr': TextStyle(color: Color(0xff001080)), - 'variable': TextStyle(color: Color(0xff001080)), - 'meta': TextStyle(color: Color(0xff001080)), - 'string': TextStyle(color: Color(0xffa31515)), - 'quote': TextStyle(color: Color(0xffa31515)), - 'regexp': TextStyle(color: Color(0xffa31515)), - 'comment': TextStyle(color: Color(0xff008000), fontStyle: FontStyle.italic), - 'doctag': TextStyle(color: Color(0xff800000)), - 'section': TextStyle(color: Color(0xff800000)), - 'number': TextStyle(color: Color(0xff098658)), - 'symbol': TextStyle(color: Color(0xff098658)), - 'type': TextStyle(color: Color(0xff267f99)), - 'class-title': TextStyle(color: Color(0xff267f99)), - 'built_in': TextStyle(color: Color(0xff795e26)), - 'title': TextStyle(color: Color(0xff795e26)), -}; - -const Map sqlDarkTheme = { - 'keyword': TextStyle(color: Color(0xff569cd6)), - 'literal': TextStyle(color: Color(0xff569cd6)), - 'name': TextStyle(color: Color(0xff9cdcfe)), - 'attr': TextStyle(color: Color(0xff9cdcfe)), - 'variable': TextStyle(color: Color(0xff9cdcfe)), - 'meta': TextStyle(color: Color(0xff9cdcfe)), - 'string': TextStyle(color: Color(0xffce9178)), - 'quote': TextStyle(color: Color(0xffce9178)), - 'regexp': TextStyle(color: Color(0xffd16969)), - 'comment': TextStyle(color: Color(0xff6a9955), fontStyle: FontStyle.italic), - 'doctag': TextStyle(color: Color(0xff6a9955)), - 'section': TextStyle(color: Color(0xff6a9955)), - 'number': TextStyle(color: Color(0xffb5cea8)), - 'symbol': TextStyle(color: Color(0xffb5cea8)), - 'type': TextStyle(color: Color(0xff4ec9b0)), - 'class-title': TextStyle(color: Color(0xff4ec9b0)), - 'built_in': TextStyle(color: Color(0xffdcdcaa)), - 'title': TextStyle(color: Color(0xffdcdcaa)), -}; diff --git a/lib/widgets/schema_sync_dialog.dart b/lib/widgets/schema_sync_dialog.dart index 5e27801..81083ff 100644 --- a/lib/widgets/schema_sync_dialog.dart +++ b/lib/widgets/schema_sync_dialog.dart @@ -13,6 +13,7 @@ import '../data/db_types.dart'; import '../data/drivers/db_driver.dart'; import '../data/schema_sync.dart'; import '../theme/app_theme.dart'; +import '../theme/sql_highlight.dart'; import 'object_category_icon.dart'; /// 打开「工具 → 结构同步」弹窗。 @@ -1436,18 +1437,20 @@ class _SchemaSyncDialogState extends State { Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(8), - // DDL 和脚本一样要能划选复制。 - child: SelectableText( - showEmpty ? emptyHint : body, - style: TextStyle( - fontFamily: 'Consolas', - fontFamilyFallback: const ['monospace'], - fontSize: 12, - height: 1.4, - color: showEmpty ? t.mutedForeground : t.foreground, - decoration: TextDecoration.none, - ), - ), + // DDL 和脚本一样要能划选复制。空提示是文案不是 SQL, + // 走高亮解析只会把「(源不存在该对象)」染成怪色,故分支渲染。 + child: showEmpty + ? SelectableText( + // showEmpty 的定义里已含 emptyHint != null,这里不用再判空。 + emptyHint, + style: sqlCodeStyle( + color: t.mutedForeground, + fontSize: 12, + height: 1.4, + ), + ) + : SelectableText.rich( + highlightedSqlSpan(body, palette: t, height: 1.4)), ), ), ], @@ -1523,17 +1526,18 @@ class _SchemaSyncDialogState extends State { padding: const EdgeInsets.all(10), // 只读但要能划选复制 → SelectableText(不用 Input/Textarea,那会带上 // 编辑框的面与边框,和这里的「纯文本区」观感不符)。 - child: SelectableText( - script.isEmpty ? '(未勾选任何可部署对象)' : script, - style: TextStyle( - fontFamily: 'Consolas', - fontFamilyFallback: const ['monospace'], - fontSize: 12, - height: 1.4, - color: script.isEmpty ? t.mutedForeground : t.foreground, - decoration: TextDecoration.none, - ), - ), + // 空态那行是提示文案,不参与高亮(理由同 _monoCard)。 + child: script.isEmpty + ? SelectableText( + '(未勾选任何可部署对象)', + style: sqlCodeStyle( + color: t.mutedForeground, + fontSize: 12, + height: 1.4, + ), + ) + : SelectableText.rich( + highlightedSqlSpan(script, palette: t, height: 1.4)), ), ), ), @@ -1771,17 +1775,17 @@ class _DeployPreview extends StatelessWidget { ), child: SingleChildScrollView( padding: const EdgeInsets.all(8), - child: Text( - script.isEmpty ? '(无)' : script, - style: TextStyle( - fontFamily: 'Consolas', - fontFamilyFallback: const ['monospace'], - fontSize: 12, - height: 1.4, - color: t.foreground, - decoration: TextDecoration.none, - ), - ), + child: script.isEmpty + ? Text( + '(无)', + style: sqlCodeStyle( + color: t.foreground, + fontSize: 12, + height: 1.4, + ), + ) + : Text.rich( + highlightedSqlSpan(script, palette: t, height: 1.4)), ), ), ], diff --git a/lib/widgets/table_designer_page.dart b/lib/widgets/table_designer_page.dart index 836ecdf..aa9f00f 100644 --- a/lib/widgets/table_designer_page.dart +++ b/lib/widgets/table_designer_page.dart @@ -12,6 +12,7 @@ import '../data/db_metadata.dart'; import '../data/drivers/db_driver.dart'; import '../data/table_design.dart'; import '../theme/app_theme.dart'; +import '../theme/sql_highlight.dart'; import 'index_column_picker_dialog.dart'; import 'sql_find_bar.dart'; @@ -2513,10 +2514,7 @@ class _TableDesignerPageState extends State { cursorColor: t.foreground, codeTheme: CodeHighlightTheme( languages: {'sql': CodeHighlightThemeMode(mode: langSql)}, - theme: { - ...(isDark ? _sqlDarkTheme : _sqlLightTheme), - 'root': TextStyle(color: t.foreground), - }, + theme: sqlEditorTheme(isDark: isDark, foreground: t.foreground), ), ), indicatorBuilder: @@ -3575,46 +3573,3 @@ class _ModelTextareaState extends State<_ModelTextarea> { ); } } - -// SQL 语法高亮配色(与查询页同款,中调色主题无关) -const Map _sqlLightTheme = { - 'keyword': TextStyle(color: Color(0xff0000ff)), - 'literal': TextStyle(color: Color(0xff001080)), - 'name': TextStyle(color: Color(0xff001080)), - 'attr': TextStyle(color: Color(0xff001080)), - 'variable': TextStyle(color: Color(0xff001080)), - 'meta': TextStyle(color: Color(0xff001080)), - 'string': TextStyle(color: Color(0xffa31515)), - 'quote': TextStyle(color: Color(0xffa31515)), - 'regexp': TextStyle(color: Color(0xffa31515)), - 'comment': TextStyle(color: Color(0xff008000), fontStyle: FontStyle.italic), - 'doctag': TextStyle(color: Color(0xff800000)), - 'section': TextStyle(color: Color(0xff800000)), - 'number': TextStyle(color: Color(0xff098658)), - 'symbol': TextStyle(color: Color(0xff098658)), - 'type': TextStyle(color: Color(0xff267f99)), - 'class-title': TextStyle(color: Color(0xff267f99)), - 'built_in': TextStyle(color: Color(0xff795e26)), - 'title': TextStyle(color: Color(0xff795e26)), -}; - -const Map _sqlDarkTheme = { - 'keyword': TextStyle(color: Color(0xff569cd6)), - 'literal': TextStyle(color: Color(0xff569cd6)), - 'name': TextStyle(color: Color(0xff9cdcfe)), - 'attr': TextStyle(color: Color(0xff9cdcfe)), - 'variable': TextStyle(color: Color(0xff9cdcfe)), - 'meta': TextStyle(color: Color(0xff9cdcfe)), - 'string': TextStyle(color: Color(0xffce9178)), - 'quote': TextStyle(color: Color(0xffce9178)), - 'regexp': TextStyle(color: Color(0xffd16969)), - 'comment': TextStyle(color: Color(0xff6a9955), fontStyle: FontStyle.italic), - 'doctag': TextStyle(color: Color(0xff6a9955)), - 'section': TextStyle(color: Color(0xff6a9955)), - 'number': TextStyle(color: Color(0xffb5cea8)), - 'symbol': TextStyle(color: Color(0xffb5cea8)), - 'type': TextStyle(color: Color(0xff4ec9b0)), - 'class-title': TextStyle(color: Color(0xff4ec9b0)), - 'built_in': TextStyle(color: Color(0xffdcdcaa)), - 'title': TextStyle(color: Color(0xffdcdcaa)), -}; diff --git a/lib/widgets/user_design_page.dart b/lib/widgets/user_design_page.dart index 0540ffe..6ba967b 100644 --- a/lib/widgets/user_design_page.dart +++ b/lib/widgets/user_design_page.dart @@ -8,7 +8,7 @@ import '../app/app_state.dart'; import '../data/user_sql.dart'; import '../l10n/locale_config.dart'; import '../theme/app_theme.dart'; -import 'routine_design_page.dart' show sqlDarkTheme, sqlLightTheme; +import '../theme/sql_highlight.dart'; import 'sql_find_bar.dart'; /// 用户 / 角色(User & Role)设计页。 @@ -1050,10 +1050,7 @@ class _UserDesignPageState extends State { languages: { 'sql': CodeHighlightThemeMode(mode: langSql), }, - theme: { - ...(isDark ? sqlDarkTheme : sqlLightTheme), - 'root': TextStyle(color: t.foreground), - }, + theme: sqlEditorTheme(isDark: isDark, foreground: t.foreground), ), ), indicatorBuilder: diff --git a/lib/widgets/view_design_page.dart b/lib/widgets/view_design_page.dart index 28390b0..097a63f 100644 --- a/lib/widgets/view_design_page.dart +++ b/lib/widgets/view_design_page.dart @@ -9,8 +9,8 @@ import '../app/app_state.dart'; import '../data/db_data.dart'; import '../data/routine_sql.dart'; import '../theme/app_theme.dart'; +import '../theme/sql_highlight.dart'; import 'query_page.dart' show formatSql; -import 'routine_design_page.dart' show sqlLightTheme, sqlDarkTheme; import 'sql_find_bar.dart'; import 'view_rule_dialog.dart'; @@ -1275,10 +1275,7 @@ class _ViewDesignPageState extends State { languages: { 'sql': CodeHighlightThemeMode(mode: langSql), }, - theme: { - ...(isDark ? sqlDarkTheme : sqlLightTheme), - 'root': TextStyle(color: t.foreground), - }, + theme: sqlEditorTheme(isDark: isDark, foreground: t.foreground), ), ), indicatorBuilder: showLineNumbers diff --git a/test/create_database_dialog_test.dart b/test/create_database_dialog_test.dart index 538b1a7..dba128c 100644 --- a/test/create_database_dialog_test.dart +++ b/test/create_database_dialog_test.dart @@ -108,7 +108,9 @@ void main() { await tester.tap(find.text('SQL 预览')); await tester.pumpAndSettle(); - expect(find.textContaining('CREATE DATABASE "demo"'), findsOneWidget); + // 预览区现在是带语法高亮的富文本,finder 必须开 findRichText 才看得见。 + expect(find.textContaining('CREATE DATABASE "demo"', findRichText: true), + findsOneWidget); expect(tester.takeException(), isNull); }); diff --git a/test/database_edit_dialog_test.dart b/test/database_edit_dialog_test.dart index 9436d20..ad75768 100644 --- a/test/database_edit_dialog_test.dart +++ b/test/database_edit_dialog_test.dart @@ -217,7 +217,10 @@ void main() { expect(find.text('hstore'), findsOneWidget); await _showTab(tester, 'SQL 预览'); - expect(find.textContaining('CREATE EXTENSION IF NOT EXISTS "pg_trgm"'), + // SQL 预览区现在是带语法高亮的富文本,finder 必须开 findRichText 才看得见。 + expect( + find.textContaining('CREATE EXTENSION IF NOT EXISTS "pg_trgm"', + findRichText: true), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -233,7 +236,8 @@ void main() { expect(find.text('hstore'), findsOneWidget); await _showTab(tester, 'SQL 预览'); - expect(find.textContaining('DROP EXTENSION IF EXISTS "hstore"'), + expect(find.textContaining('DROP EXTENSION IF EXISTS "hstore"', + findRichText: true), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -250,7 +254,9 @@ void main() { await tester.pumpAndSettle(); await _showTab(tester, 'SQL 预览'); - expect(find.textContaining('CREATE EXTENSION IF NOT EXISTS "btree_gin"'), + expect( + find.textContaining('CREATE EXTENSION IF NOT EXISTS "btree_gin"', + findRichText: true), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -309,7 +315,8 @@ void main() { testWidgets('SQL 预览无改动时显示说明而非空框', (tester) async { await _open(tester); await _showTab(tester, 'SQL 预览'); - expect(find.textContaining('没有需要执行的改动'), findsOneWidget); + expect(find.textContaining('没有需要执行的改动', findRichText: true), + findsOneWidget); expect(tester.takeException(), isNull); }); } diff --git a/test/database_info_detail_test.dart b/test/database_info_detail_test.dart index d00b66b..4571d35 100644 --- a/test/database_info_detail_test.dart +++ b/test/database_info_detail_test.dart @@ -234,8 +234,18 @@ void main() { await tester.pump(); expect(driver.lastDefinitionKind, 'table'); - expect(find.textContaining('CREATE TABLE `bx11_yz_payorder`'), + // DDL 页现在是带语法高亮的富文本,finder 必须开 findRichText 才看得见。 + expect(find.textContaining('CREATE TABLE `bx11_yz_payorder`', + findRichText: true), findsOneWidget); + // 正文可划选(局部复制),按内容定位以免被面板上其它可选文本干扰。 + final ddlTexts = tester + .widgetList(find.byType(SelectableText)) + .where((w) => (w.textSpan?.toPlainText() ?? w.data ?? '') + .contains('CREATE TABLE')) + .toList(); + expect(ddlTexts, hasLength(1)); + expect(ddlTexts.single.selectionEnabled, isTrue); // 信息页正文此时被 DDL 页替换 expect(find.text('引擎'), findsNothing); }); diff --git a/test/schema_sync_dialog_test.dart b/test/schema_sync_dialog_test.dart index c9a8fd0..7883910 100644 --- a/test/schema_sync_dialog_test.dart +++ b/test/schema_sync_dialog_test.dart @@ -379,13 +379,17 @@ void main() { await tester.tap(find.text('v_only_src')); await tester.pumpAndSettle(); expect(find.textContaining('源 · v_only_src'), findsOneWidget); - expect(find.textContaining('CREATE VIEW v_only_src'), findsWidgets); + // DDL / 脚本区现在是带高亮的富文本,finder 必须开 findRichText 才看得见。 + expect(find.textContaining('CREATE VIEW v_only_src', findRichText: true), + findsWidgets); // 切到底部「部署脚本」页:拼接勾选对象的语句(删除未勾,不在脚本里) await tester.tap(find.text('部署脚本')); await tester.pumpAndSettle(); - expect(find.textContaining('CREATE VIEW v_only_src'), findsWidgets); - expect(find.textContaining('DROP VIEW v_only_tgt'), findsNothing); + expect(find.textContaining('CREATE VIEW v_only_src', findRichText: true), + findsWidgets); + expect(find.textContaining('DROP VIEW v_only_tgt', findRichText: true), + findsNothing); // 下一步 → 部署页:目标服务器说明 + 复制脚本 + 部署选项,「开始」可用 await tester.tap(find.text('下一步')); @@ -405,7 +409,7 @@ void main() { // 执行过程落在消息日志里(部署开始会自动切到这个标签页) final logText = tester .widgetList(find.byType(SelectableText)) - .map((w) => w.data ?? '') + .map(_selectablePlainText) .join('\n'); expect(logText, contains('--End--')); expect(logText, contains('Result: OK')); @@ -510,7 +514,7 @@ void main() { // 勾了「在消息日志中包含部署查询」→ 日志展开 SQL,成功与失败各记一条 final logText = tester .widgetList(find.byType(SelectableText)) - .map((w) => w.data ?? '') + .map(_selectablePlainText) .join('\n'); expect(logText, contains('Query:')); expect(logText, contains('Result: OK')); @@ -584,7 +588,7 @@ void main() { .textEditingValue .selection; expect(sel.isCollapsed, isFalse, reason: '脚本应能划出选区'); - expect(sel.textInside(script.data!), isNotEmpty); + expect(sel.textInside(_selectablePlainText(script)), isNotEmpty); // 消息日志页同样是可划选文本(空态占位亦为 SelectableText)。 await tester.tap(find.text('消息日志')); @@ -595,6 +599,11 @@ void main() { }); } +/// 取只读文本区的内容:裸文本走 `data`,带语法高亮的是 `textSpan` +/// (见 lib/theme/sql_highlight.dart),两者都当成纯字符串看。 +String _selectablePlainText(SelectableText w) => + w.data ?? w.textSpan?.toPlainText() ?? ''; + /// 放大测试视口,避免 1040×700 大弹窗被默认 800×600 压扁 / 命中重叠。 void _bigSurface(WidgetTester tester) { tester.view.physicalSize = const Size(1600, 1200); diff --git a/test/sql_highlight_test.dart b/test/sql_highlight_test.dart new file mode 100644 index 0000000..13e1433 --- /dev/null +++ b/test/sql_highlight_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:daro/theme/app_theme.dart'; +import 'package:daro/theme/sql_highlight.dart'; + +/// 钉住只读 SQL / DDL 高亮(详情面板 DDL 页、建库改库脚本预览、结构同步脚本)的口径。 +/// +/// 全部离线:不建驱动、不连库,只喂字符串。 +/// +/// 最要紧的一条是**文本无损**——高亮器是在原串上做 scope 切分, +/// 一旦某个 scope 把字符吃掉,界面上就是「DDL 少了一截」而不会报错。 +void main() { + const ddl = "CREATE DATABASE `mysql` /*!140100 DEFAULT CHARACTER SET " + "utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT " + "ENCRYPTION='N' */"; + + List flatten(InlineSpan root) { + final out = []; + void walk(InlineSpan span) { + if (span is TextSpan) { + out.add(span); + for (final child in span.children ?? const []) { + walk(child); + } + } + } + + walk(root); + return out; + } + + String plainText(InlineSpan root) => + flatten(root).map((s) => s.text ?? '').join(); + + group('文本无损', () { + test('真实 MySQL 库 DDL 拼接回来与原文逐字相等', () { + final span = highlightedSqlSpan(ddl, palette: AppTheme.light); + expect(plainText(span), ddl); + }); + + test('多语句 / 注释 / 空行也不掉字符', () { + const sql = 'CREATE TABLE `t` (\n' + ' `id` int NOT NULL AUTO_INCREMENT, -- 主键\n' + ' `name` varchar(64) DEFAULT NULL,\n' + ' PRIMARY KEY (`id`)\n' + ') ENGINE=InnoDB;\n' + '\n' + "ALTER TABLE `t` ADD COLUMN `c` int COMMENT 'x';"; + expect(plainText(highlightedSqlSpan(sql, palette: AppTheme.light)), sql); + }); + + test('空串直接给纯文本 span,不进解析器', () { + final span = highlightedSqlSpan('', palette: AppTheme.light); + expect(span.toPlainText(), ''); + expect(span.style, isNotNull); + }); + + test('语法上说不通的输入只退回裸文本,不抛', () { + const junk = "SELECT '未闭合 /* ` ][ \u0000 \u0019 %%% ~~~ ^^^"; + for (final palette in [AppTheme.light, AppTheme.dark]) { + expect(() => highlightedSqlSpan(junk, palette: palette), returnsNormally); + expect(plainText(highlightedSqlSpan(junk, palette: palette)), junk, + reason: '掉了字符就必须整段退回裸文本,界面不能少一截'); + } + }); + }); + + group('确实上了色', () { + test('明亮主题下 CREATE 走 keyword 色', () { + final span = + highlightedSqlSpan('CREATE TABLE t (a INT)', palette: AppTheme.light); + final keyword = sqlLightTheme['keyword']!.color!; + // 按「包含」而非「等于」判:语法器可能把 CREATE TABLE 当成一个词组命中。 + expect( + flatten(span).any((s) => + (s.text ?? '').toUpperCase().contains('CREATE') && + s.style?.color == keyword), + isTrue, + reason: '关键字没上色 = 高亮其实没生效', + ); + }); + + test('暗色主题取的是另一张表', () { + final span = + highlightedSqlSpan('CREATE TABLE t (a INT)', palette: AppTheme.dark); + final darkKeyword = sqlDarkTheme['keyword']!.color!; + final lightKeyword = sqlLightTheme['keyword']!.color!; + expect(darkKeyword, isNot(lightKeyword)); + expect( + flatten(span).any((s) => + (s.text ?? '').toUpperCase().contains('CREATE') && + s.style?.color == darkKeyword), + isTrue, + ); + }); + + test('注释是斜体', () { + final span = highlightedSqlSpan('SELECT 1 -- 备注\n', palette: AppTheme.light); + expect( + flatten(span) + .any((s) => s.style?.fontStyle == FontStyle.italic && s.text != null), + isTrue, + ); + }); + }); + + group('字号与字体由根 span 兜底', () { + test('根 span 带统一字体,命中的子 span 不各自塞字号', () { + final span = highlightedSqlSpan(ddl, palette: AppTheme.light); + expect(span.style?.fontFamily, 'Consolas'); + expect(span.style?.fontSize, 12); + expect(span.style?.height, 1.45); + expect(span.style?.decoration, TextDecoration.none); + // 没命中任何 scope 的文本直接拿兜底样式(它本来就带字号),要看的是命中的那些。 + final base = sqlCodeStyle(color: AppTheme.light.foreground); + final themed = flatten(span) + .where((s) => !identical(s, span) && s.style != base) + .toList(); + expect(themed, isNotEmpty); + expect(themed.any((s) => s.style?.fontSize != null), isFalse, + reason: '子 span 自带字号会盖掉调用方的字号参数'); + }); + + test('fontSize / height 参数能覆盖', () { + final span = highlightedSqlSpan(ddl, + palette: AppTheme.light, fontSize: 13, height: null); + expect(span.style?.fontSize, 13); + expect(span.style?.height, isNull, reason: 'null = 沿用默认行距,各展示位不统一'); + expect(plainText(span), ddl); + }); + }); + + group('memo 缓存', () { + test('同文本同色板命中同一实例', () { + final a = highlightedSqlSpan(ddl, palette: AppTheme.light); + final b = highlightedSqlSpan(ddl, palette: AppTheme.light); + expect(identical(a, b), isTrue); + }); + + test('换文本 / 换主题都作废', () { + final a = highlightedSqlSpan(ddl, palette: AppTheme.light); + expect(identical(a, highlightedSqlSpan('SELECT 1', palette: AppTheme.light)), + isFalse); + expect(identical(a, highlightedSqlSpan(ddl, palette: AppTheme.dark)), isFalse); + expect( + identical(a, + highlightedSqlSpan(ddl, palette: AppTheme.light, fontSize: 13)), + isFalse); + }); + }); + + group('配色表只有一份', () { + test('明暗两张表的 scope 集合一致', () { + expect(sqlDarkTheme.keys.toSet(), sqlLightTheme.keys.toSet()); + }); + + test('sqlEditorTheme 覆盖 root(编辑器兜底前景随主题)', () { + final theme = + sqlEditorTheme(isDark: false, foreground: AppTheme.light.foreground); + expect(theme.length, sqlLightTheme.length + 1); + expect(theme['root']!.color, AppTheme.light.foreground); + expect(theme['keyword']!.color, sqlLightTheme['keyword']!.color); + }); + }); +}