diff --git a/lib/features/main_screen/querya_status_bar.dart b/lib/features/main_screen/querya_status_bar.dart index 5523ff52..54092c0a 100644 --- a/lib/features/main_screen/querya_status_bar.dart +++ b/lib/features/main_screen/querya_status_bar.dart @@ -242,7 +242,8 @@ class QueryaStatusBar extends material.StatelessWidget { ), const Gap(3), material.Text( - '$rowCount rows${columnCount != null ? ", $columnCount cols" : ""}', + '$rowCount ${rowCount == 1 ? 'row' : 'rows'}' + '${columnCount != null ? ", $columnCount ${columnCount == 1 ? 'col' : 'cols'}" : ""}', style: material.TextStyle( fontSize: 10, fontFamily: QueryaTypography.mono, diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 6c473ed9..76cecff3 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -1345,50 +1345,58 @@ class GenericSqlWorkspaceState extends material.State { ), ], ), - bottom: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (session.planRoot != null) - material.Padding( - padding: const material.EdgeInsets.fromLTRB(8, 6, 8, 6), - child: material.Align( - alignment: material.Alignment.centerLeft, - child: material.SizedBox( - width: 200, - child: QueryaTabStrip( - labels: const ['Plan', 'Text'], - selectedIndex: session.planAsTree ? 0 : 1, - onSelected: (i) => setState(() => session.planAsTree = i == 0), + bottom: material.Padding( + // The results sit in a card like the editor above them. + padding: const material.EdgeInsets.fromLTRB(12, 0, 12, 12), + child: SqlEditorChrome( + background: context.workbench.surface, + showFocus: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (session.planRoot != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(8, 6, 8, 6), + child: material.Align( + alignment: material.Alignment.centerLeft, + child: material.SizedBox( + width: 200, + child: QueryaTabStrip( + labels: const ['Plan', 'Text'], + selectedIndex: session.planAsTree ? 0 : 1, + onSelected: (i) => setState(() => session.planAsTree = i == 0), + ), + ), ), ), + Expanded( + child: session.planRoot != null && session.planAsTree + ? PlanTreeView(root: session.planRoot!) + : ResultsTab( + columns: session.columns, + rows: session.rows, + errorMessage: session.error, + isLoading: session.running, + affectedRows: session.affectedRows, + statusLine: session.statusLine, + elapsed: session.elapsed, + stagingBuffer: session.stagingBuffer, + columnDataTypes: session.resultGridColumnDataTypes, + onApplyChanges: widget.isReadOnly || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + ) + ? null + : () => applyStagedChanges(session), + isSaving: session.savingChanges, + errorAction: widget.errorActionBuilder?.call(context, session), + ), ), - ), - Expanded( - child: session.planRoot != null && session.planAsTree - ? PlanTreeView(root: session.planRoot!) - : ResultsTab( - columns: session.columns, - rows: session.rows, - errorMessage: session.error, - isLoading: session.running, - affectedRows: session.affectedRows, - statusLine: session.statusLine, - elapsed: session.elapsed, - stagingBuffer: session.stagingBuffer, - columnDataTypes: session.resultGridColumnDataTypes, - onApplyChanges: widget.isReadOnly || - !sqlResultGridSaveEnabled( - sql: session.lastExecutedSql, - resultColumns: session.columns, - primaryKeys: session.resultGridPrimaryKeys, - ) - ? null - : () => applyStagedChanges(session), - isSaving: session.savingChanges, - errorAction: widget.errorActionBuilder?.call(context, session), - ), + ], ), - ], + ), ), ); } diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index b782c987..5fc6abfc 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -382,6 +382,27 @@ class _ResultsTabState extends material.State { super.dispose(); } + /// What the mode bar says about the rows, or null when there is nothing to + /// add. A plain row count is the status bar's (#1558); here stay the notes + /// it does not have: a truncated result, affected rows, a script's outcome, + /// and the count of a filtered grid (`3 of 12 rows`). + String? _barSummary(int shown, int total) { + final line = widget.statusLine; + if (_filterText.isNotEmpty) { + return line != null && !_plainRowCount.hasMatch(line) + ? '$line ยท $shown of $total rows' + : '$shown of $total rows'; + } + if (line != null) return _plainRowCount.hasMatch(line) ? null : line; + if (widget.affectedRows != null) { + return 'Rows affected: ${widget.affectedRows}'; + } + return '$shown rows'; + } + + /// `12 row(s).`: the driver workspaces' plain count. + static final _plainRowCount = RegExp(r'^\d+ row\(s\)\.$'); + @override material.Widget build(material.BuildContext context) { if (widget.isLoading) { @@ -519,12 +540,9 @@ class _ResultsTabState extends material.State { setState(() => _viewMode = ResultViewMode.values[i]), ), const Gap(10), - Text( - widget.statusLine ?? - (widget.affectedRows != null - ? 'Rows affected: ${widget.affectedRows}' - : '${filteredRows.length}${_filterText.isNotEmpty ? ' of ${effectiveRows.length}' : ''} rows'), - ).small().semiBold(), + if (_barSummary(filteredRows.length, effectiveRows.length) + case final summary?) + Text(summary).small().semiBold(), if (widget.elapsed != null) ...[ const Gap(8), QueryaBadge( diff --git a/lib/features/workspace/sql_editor_chrome.dart b/lib/features/workspace/sql_editor_chrome.dart index b3a35420..9aabd99d 100644 --- a/lib/features/workspace/sql_editor_chrome.dart +++ b/lib/features/workspace/sql_editor_chrome.dart @@ -4,19 +4,28 @@ 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' hide TextExtension; -/// Outer chrome for SQL editors: border, surface, brand accent glow. -class SqlEditorChrome extends StatelessWidget { - const SqlEditorChrome({super.key, required this.child}); +/// Outer chrome for SQL editors and the results under them: one rounded card +/// with a border. +class SqlEditorChrome extends StatefulWidget { + const SqlEditorChrome({ + super.key, + required this.child, + this.background, + this.showFocus = true, + }); final Widget child; + /// The card's surface; the editor background by default. + final material.Color? background; + + /// Whether the border takes the accent while something inside has the + /// keyboard. Off for a card with nothing to type in (the results). + final bool showFocus; + 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; @@ -70,38 +79,40 @@ class SqlEditorChrome extends StatelessWidget { ); } + @override + State createState() => _SqlEditorChromeState(); +} + +class _SqlEditorChromeState extends State { + bool _focused = false; + @override 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: chromeGlowAlpha(brightness), - ); + final surface = widget.background ?? editor.background; - return material.Container( - decoration: material.BoxDecoration( - borderRadius: material.BorderRadius.circular(outerRadius), - boxShadow: [ - material.BoxShadow( - color: glow, - blurRadius: 28, - spreadRadius: 0, - offset: const material.Offset(0, 10), - ), - ], - ), + // One framed card, the same for the editor and the results. No glow: the + // only accent is the border while the editor has the keyboard (#1558). + return Focus( + canRequestFocus: false, + skipTraversal: true, + onFocusChange: (v) { + if (widget.showFocus) setState(() => _focused = v); + }, child: material.Container( decoration: material.BoxDecoration( - color: editor.background, - borderRadius: material.BorderRadius.circular(outerRadius), + color: surface, + borderRadius: material.BorderRadius.circular(SqlEditorChrome.outerRadius), border: material.Border.all( - color: border.withValues(alpha: 0.5), + color: widget.showFocus && _focused + ? workbench.accent.withValues(alpha: 0.6) + : border.withValues(alpha: 0.5), ), ), clipBehavior: material.Clip.antiAlias, - child: child, + child: widget.child, ), ); } diff --git a/test/features/main_screen/querya_status_bar_test.dart b/test/features/main_screen/querya_status_bar_test.dart index d7fcbdf8..f1189609 100644 --- a/test/features/main_screen/querya_status_bar_test.dart +++ b/test/features/main_screen/querya_status_bar_test.dart @@ -26,6 +26,20 @@ void main() { expect(find.text('UTF-8'), findsNothing); }); + testWidgets('the row and column counts say one, not 1 rows (#1558)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: QueryaStatusBar(rowCount: 1, columnCount: 1), + ), + ), + ); + await tester.pump(); + + expect(find.text('1 row, 1 col'), findsOneWidget); + }); + testWidgets('QueryaStatusBar renders active connection and read-only mode', (tester) async { final conn = ConnectionRow( diff --git a/test/features/workspace/results_tab_test.dart b/test/features/workspace/results_tab_test.dart index b2f4ef90..d13841ca 100644 --- a/test/features/workspace/results_tab_test.dart +++ b/test/features/workspace/results_tab_test.dart @@ -779,6 +779,63 @@ void main() { expect(find.text('3'), findsOneWidget); }); + group('the mode bar says only what the status bar does not (#1558)', () { + Future pumpBar( + WidgetTester tester, { + String? statusLine, + int? affectedRows, + }) async { + await tester.binding.setSurfaceSize(const material.Size(900, 500)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + resultsShell( + child: material.Scaffold( + body: ResultsTab( + columns: const ['id', 'name'], + rows: const [ + ['1', 'a'], + ['2', 'b'], + ['3', 'c'], + ], + statusLine: statusLine, + affectedRows: affectedRows, + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('a plain row count is left to the status bar', (tester) async { + await pumpBar(tester, statusLine: '3 row(s).'); + expect(find.text('3 row(s).'), findsNothing); + expect(find.text('3 rows'), findsNothing); + }); + + testWidgets('a truncation note is kept', (tester) async { + await pumpBar( + tester, statusLine: 'Showing first 3 row(s) (result capped).'); + expect(find.text('Showing first 3 row(s) (result capped).'), + findsOneWidget); + }); + + testWidgets('a count with a hint is kept', (tester) async { + await pumpBar(tester, statusLine: '3 row(s). Cannot edit: no key.'); + expect(find.text('3 row(s). Cannot edit: no key.'), findsOneWidget); + }); + + testWidgets('without a status line the count is shown, as before', + (tester) async { + await pumpBar(tester); + expect(find.text('3 rows'), findsOneWidget); + }); + + testWidgets('affected rows are kept', (tester) async { + await pumpBar(tester, affectedRows: 4); + expect(find.text('Rows affected: 4'), findsOneWidget); + }); + }); + testWidgets('shows status and affected empty modes', (tester) async { await tester.pumpWidget( resultsShell( diff --git a/test/features/workspace/sql_editor_chrome_test.dart b/test/features/workspace/sql_editor_chrome_test.dart index 5d9132be..68f69b7d 100644 --- a/test/features/workspace/sql_editor_chrome_test.dart +++ b/test/features/workspace/sql_editor_chrome_test.dart @@ -11,7 +11,7 @@ import '../../support/querya_theme_test_shell.dart'; void main() { group('SqlEditorChrome.inlineFieldDecoration', () { - test('uses editor background and workbench accent glow', () { + test('uses editor background and workbench accent glow (dialog fields)', () { const editor = QueryaEditorTheme( background: Color(0xFF111111), foreground: Color(0xFFFFFFFF), @@ -128,15 +128,81 @@ void main() { const Color(0xFF112233).withValues(alpha: 0.5), ); - final outerGlow = containers + // No glow: one framed card (#1558). + final shadows = 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)); + .expand((d) => d.boxShadow ?? const []); + expect(shadows, isEmpty); + }); + + testWidgets('the border takes the accent while the editor has the keyboard', + (tester) async { + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.darkDefault, + child: material.SizedBox( + width: 320, + height: 200, + child: SqlEditorChrome( + child: Focus(focusNode: focus, child: const material.SizedBox.expand()), + ), + ), + ), + ); + + Color borderColor() { + final c = tester.widgetList(find.descendant( + of: find.byType(SqlEditorChrome), + matching: find.byType(material.Container), + )).firstWhere((c) => c.decoration is material.BoxDecoration && + (c.decoration! as material.BoxDecoration).border != null); + return ((c.decoration! as material.BoxDecoration).border! as Border) + .top + .color; + } + + final idle = borderColor(); + focus.requestFocus(); + await tester.pump(); + final focused = borderColor(); + expect(focused, + QueryaTheme.darkDefault.workbench.accent.withValues(alpha: 0.6)); + expect(idle, isNot(focused)); + }); + + testWidgets('a card with nothing to type in keeps its border', (tester) async { + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.darkDefault, + child: material.SizedBox( + width: 320, + height: 200, + child: SqlEditorChrome( + showFocus: false, + background: const Color(0xFF123456), + child: Focus(focusNode: focus, child: const material.SizedBox.expand()), + ), + ), + ), + ); + focus.requestFocus(); + await tester.pump(); + final c = tester.widgetList(find.descendant( + of: find.byType(SqlEditorChrome), + matching: find.byType(material.Container), + )).firstWhere((c) => c.decoration is material.BoxDecoration && + (c.decoration! as material.BoxDecoration).border != null); + final d = c.decoration! as material.BoxDecoration; + expect(d.color, const Color(0xFF123456)); + expect( + (d.border! as Border).top.color, + isNot(QueryaTheme.darkDefault.workbench.accent.withValues(alpha: 0.6)), + ); }); });