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
60 changes: 54 additions & 6 deletions lib/features/command_palette/command_palette_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:querya_desktop/core/actions/querya_command_host.dart';
import 'package:querya_desktop/core/actions/querya_command_registry.dart';
import 'package:querya_desktop/core/layout/window_layout.dart';
import 'package:querya_desktop/features/command_palette/command_match_highlight.dart';
import 'package:querya_desktop/features/command_palette/list_reveal.dart';
import 'package:querya_desktop/shared/widgets/widgets.dart';

/// Opens the Command Palette (`Ctrl/Cmd+P`). Motion Off snaps via [showAppDialog].
Expand Down Expand Up @@ -36,16 +37,29 @@ class CommandPaletteDialog extends StatefulWidget {
class _CommandPaletteDialogState extends State<CommandPaletteDialog> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
final _scroll = material.ScrollController();
var _selected = 0;

/// Set by keyboard navigation: the list moves under a still pointer, and the
/// hover that causes must not take the selection back. Cleared as soon as
/// the mouse really moves.
var _ignoreHover = false;

static const _itemExtent = 36.0;
static const _listPadding = EdgeInsets.fromLTRB(8, 4, 8, 8);

@override
void initState() {
super.initState();
_controller.addListener(() => setState(() => _selected = 0));
_controller.addListener(() {
setState(() => _selected = 0);
if (_scroll.hasClients) _scroll.jumpTo(0);
});
}

@override
void dispose() {
_scroll.dispose();
_controller.dispose();
_focusNode.dispose();
super.dispose();
Expand Down Expand Up @@ -87,7 +101,30 @@ class _CommandPaletteDialogState extends State<CommandPaletteDialog> {
setState(() {
_selected = (_selected + delta) % hits.length;
if (_selected < 0) _selected += hits.length;
_ignoreHover = true;
});
material.WidgetsBinding.instance.addPostFrameCallback((_) => _reveal());
}

/// Scrolls the list so the selected row is visible (#1369).
void _reveal() {
if (!mounted || !_scroll.hasClients) return;
final hits = _hits;
if (hits.isEmpty) return;
final index = _selected.clamp(0, hits.length - 1);
final position = _scroll.position;
final target = revealOffsetForItem(
index: index,
itemExtent: _itemExtent,
offset: position.pixels,
viewportExtent: position.viewportDimension,
leadingPadding: _listPadding.top,
trailingPadding: _listPadding.bottom,
);
if (target == null) return;
_scroll.jumpTo(
target.clamp(position.minScrollExtent, position.maxScrollExtent),
);
}

@override
Expand Down Expand Up @@ -148,17 +185,26 @@ class _CommandPaletteDialogState extends State<CommandPaletteDialog> {
child: Text('No matching commands'),
),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
: material.Listener(
onPointerHover: (_) => _ignoreHover = false,
child: ListView.builder(
controller: _scroll,
padding: _listPadding,
itemCount: hits.length,
itemExtent: 36,
itemExtent: _itemExtent,
itemBuilder: (context, index) {
final command = hits[index];
final active = index == selectedIndex;
return material.InkWell(
return material.Semantics(
button: true,
selected: active,
child: material.InkWell(
key: ValueKey(command.id),
onTap: () => _run(command),
onHover: (_) => setState(() => _selected = index),
onHover: (_) {
if (_ignoreHover) return;
setState(() => _selected = index);
},
borderRadius: BorderRadius.circular(6),
child: DecoratedBox(
decoration: BoxDecoration(
Expand Down Expand Up @@ -191,9 +237,11 @@ class _CommandPaletteDialogState extends State<CommandPaletteDialog> {
),
),
),
),
);
},
),
),
),
],
),
Expand Down
24 changes: 24 additions & 0 deletions lib/features/command_palette/list_reveal.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/// The scroll offset that brings item [index] of a fixed-extent list into the
/// viewport, or null when it is already fully visible (#1369).
///
/// [offset] is the current scroll position and [viewportExtent] the visible
/// height. [leadingPadding] / [trailingPadding] are the list's own padding, so
/// the first and last rows can be revealed with their margin.
double? revealOffsetForItem({
required int index,
required double itemExtent,
required double offset,
required double viewportExtent,
double leadingPadding = 0,
double trailingPadding = 0,
}) {
final top = leadingPadding + index * itemExtent;
final bottom = top + itemExtent;
if (top < offset) {
return (top - leadingPadding).clamp(0.0, double.infinity);
}
if (bottom > offset + viewportExtent) {
return bottom + trailingPadding - viewportExtent;
}
return null;
}
54 changes: 49 additions & 5 deletions lib/features/command_palette/quick_switcher_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:querya_desktop/core/actions/querya_command_host.dart';
import 'package:querya_desktop/core/actions/querya_schema_object.dart';
import 'package:querya_desktop/core/layout/window_layout.dart';
import 'package:querya_desktop/features/command_palette/command_match_highlight.dart';
import 'package:querya_desktop/features/command_palette/list_reveal.dart';
import 'package:querya_desktop/shared/widgets/widgets.dart';

/// Threshold where filter runs on a worker isolate (hundreds of tables).
Expand Down Expand Up @@ -53,7 +54,16 @@ class QuickSwitcherDialog extends StatefulWidget {
class _QuickSwitcherDialogState extends State<QuickSwitcherDialog> {
late final TextEditingController _controller;
final _focusNode = FocusNode();
final _scroll = material.ScrollController();
var _selected = 0;

/// Set by keyboard navigation: the list moves under a still pointer, and the
/// hover that causes must not take the selection back. Cleared as soon as
/// the mouse really moves.
var _ignoreHover = false;

static const _itemExtent = 40.0;
static const _listPadding = EdgeInsets.fromLTRB(8, 4, 8, 8);
var _loading = false;
String? _error;
List<QueryaSchemaObject> _all = const [];
Expand All @@ -78,6 +88,7 @@ class _QuickSwitcherDialogState extends State<QuickSwitcherDialog> {
@override
void dispose() {
_controller.removeListener(_onQueryChanged);
_scroll.dispose();
_controller.dispose();
_focusNode.dispose();
super.dispose();
Expand Down Expand Up @@ -105,6 +116,7 @@ class _QuickSwitcherDialogState extends State<QuickSwitcherDialog> {

void _onQueryChanged() {
setState(() => _selected = 0);
if (_scroll.hasClients) _scroll.jumpTo(0);
_applyFilter(_controller.text);
}

Expand Down Expand Up @@ -133,7 +145,28 @@ class _QuickSwitcherDialogState extends State<QuickSwitcherDialog> {
setState(() {
_selected = (_selected + delta) % _hits.length;
if (_selected < 0) _selected += _hits.length;
_ignoreHover = true;
});
material.WidgetsBinding.instance.addPostFrameCallback((_) => _reveal());
}

/// Scrolls the list so the selected row is visible (#1369).
void _reveal() {
if (!mounted || !_scroll.hasClients || _hits.isEmpty) return;
final index = _selected.clamp(0, _hits.length - 1);
final position = _scroll.position;
final target = revealOffsetForItem(
index: index,
itemExtent: _itemExtent,
offset: position.pixels,
viewportExtent: position.viewportDimension,
leadingPadding: _listPadding.top,
trailingPadding: _listPadding.bottom,
);
if (target == null) return;
_scroll.jumpTo(
target.clamp(position.minScrollExtent, position.maxScrollExtent),
);
}

@override
Expand Down Expand Up @@ -217,17 +250,26 @@ class _QuickSwitcherDialogState extends State<QuickSwitcherDialog> {
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
return material.Listener(
onPointerHover: (_) => _ignoreHover = false,
child: ListView.builder(
controller: _scroll,
padding: _listPadding,
itemCount: hits.length,
itemExtent: 40,
itemExtent: _itemExtent,
itemBuilder: (context, index) {
final object = hits[index];
final active = index == selectedIndex;
return material.InkWell(
return material.Semantics(
button: true,
selected: active,
child: material.InkWell(
key: ValueKey(object.id),
onTap: () => _open(object),
onHover: (_) => setState(() => _selected = index),
onHover: (_) {
if (_ignoreHover) return;
setState(() => _selected = index);
},
borderRadius: BorderRadius.circular(6),
child: DecoratedBox(
decoration: BoxDecoration(
Expand Down Expand Up @@ -261,8 +303,10 @@ class _QuickSwitcherDialogState extends State<QuickSwitcherDialog> {
),
),
),
),
);
},
),
);
}
}
Expand Down
38 changes: 38 additions & 0 deletions test/features/command_palette/list_reveal_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/features/command_palette/list_reveal.dart';

void main() {
// 10 rows of 40 px in a 100 px viewport, 4 px leading and 8 px trailing.
double? reveal(int index, double offset) => revealOffsetForItem(
index: index,
itemExtent: 40,
offset: offset,
viewportExtent: 100,
leadingPadding: 4,
trailingPadding: 8,
);

test('a fully visible row needs no scroll', () {
expect(reveal(0, 0), isNull);
expect(reveal(1, 0), isNull);
});

test('a row below the viewport scrolls it up to the bottom edge', () {
// Row 2 spans 84..124; the viewport shows 0..100.
expect(reveal(2, 0), 124 + 8 - 100);
});

test('a row above the viewport scrolls back to it', () {
// Row 3 spans 124..164; the viewport shows 150..250.
expect(reveal(3, 150), 120);
});

test('the first row scrolls to the very top', () {
expect(reveal(0, 60), 0);
});

test('a partly visible row is revealed', () {
// Row 2 spans 84..124; the viewport shows 20..120 → bottom 4 px hidden.
expect(reveal(2, 20), 124 + 8 - 100);
});
}
Loading
Loading