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
17 changes: 14 additions & 3 deletions lib/core/layout/querya_split_handle.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class QueryaSplitHandle extends material.StatefulWidget {
this.semanticsValue,
this.keyboardStep = 10,
this.onDragEnd,
this.onDiscreteResize,
});

/// Direction in which the handle moves.
Expand All @@ -29,6 +30,11 @@ class QueryaSplitHandle extends material.StatefulWidget {
/// Called when a pointer drag ends (not keyboard). Use velocity for settle.
final material.ValueChanged<material.DragEndDetails>? onDragEnd;

/// Called after a keyboard / semantics step (not mid-pointer-drag).
///
/// Use to persist layout when the user resizes without a drag-end event.
final material.VoidCallback? onDiscreteResize;

@override
material.State<QueryaSplitHandle> createState() => _QueryaSplitHandleState();
}
Expand Down Expand Up @@ -60,10 +66,15 @@ class _QueryaSplitHandleState extends material.State<QueryaSplitHandle> {
_ => null,
};
if (delta == null) return material.KeyEventResult.ignored;
widget.onDragDelta(delta);
_applyDiscrete(delta);
return material.KeyEventResult.handled;
}

void _applyDiscrete(double delta) {
widget.onDragDelta(delta);
widget.onDiscreteResize?.call();
}

@override
material.Widget build(material.BuildContext context) {
final colors = Theme.of(context).colorScheme;
Expand All @@ -82,8 +93,8 @@ class _QueryaSplitHandleState extends material.State<QueryaSplitHandle> {
decreasedValue: widget.semanticsValue,
focusable: true,
focused: _focused,
onIncrease: () => widget.onDragDelta(widget.keyboardStep),
onDecrease: () => widget.onDragDelta(-widget.keyboardStep),
onIncrease: () => _applyDiscrete(widget.keyboardStep),
onDecrease: () => _applyDiscrete(-widget.keyboardStep),
child: material.MouseRegion(
cursor: horizontal
? material.SystemMouseCursors.resizeColumn
Expand Down
49 changes: 49 additions & 0 deletions lib/features/main_screen/connections_panel_width_persist.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'dart:async';

/// Debounced + flush-on-dispose persistence for the connections sidebar width.
///
/// Keyboard/semantics resize has no drag-end; drag settle may outlive the
/// widget. Callers mark dirty and either debounce, wait for settle, or flush.
class ConnectionsPanelWidthPersist {
ConnectionsPanelWidthPersist({
required Future<void> Function(double width) write,
this.discreteDebounce = const Duration(milliseconds: 300),
}) : _write = write;

final Future<void> Function(double width) _write;
final Duration discreteDebounce;

Timer? _discreteTimer;
var dirty = false;

void markDirty() => dirty = true;

Future<void> persist(double width) async {
await _write(width);
dirty = false;
}

/// Keyboard / semantics step — no drag-end; debounce writes.
///
/// [currentWidth] is read when the timer fires so rapid steps persist the
/// latest value, not a stale snapshot from the first keypress.
void onDiscreteResize(double Function() currentWidth) {
markDirty();
_discreteTimer?.cancel();
_discreteTimer = Timer(discreteDebounce, () {
unawaited(persist(currentWidth()));
});
}

void cancelDiscreteTimer() => _discreteTimer?.cancel();

/// Cancel timers and flush if a resize was not yet written.
void disposeFlush(double width) {
_discreteTimer?.cancel();
_discreteTimer = null;
if (dirty) {
unawaited(_write(width));
dirty = false;
}
}
}
15 changes: 12 additions & 3 deletions lib/features/main_screen/main_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import 'package:querya_desktop/features/connections/connection_creation_flow.dar
import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart';
import 'package:querya_desktop/features/connections/connections_panel.dart';
import 'package:querya_desktop/features/connections/sqlite_connection_form.dart';
import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart';
import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart';
import 'package:querya_desktop/shared/widgets/widgets.dart';
import 'package:querya_desktop/features/mysql/mysql_object_kind.dart';
Expand Down Expand Up @@ -354,12 +355,16 @@ class _MainContentSplitState extends State<_MainContentSplit>
final ValueNotifier<double> _leftPanelWidth =
ValueNotifier(kDefaultConnectionsPanelWidth);
late final QueryaDragSettleController _widthSettle;
late final ConnectionsPanelWidthPersist _widthPersist;
VoidCallback? _persistWhenSettled;
double _lastMaxWidth = 1200;

@override
void initState() {
super.initState();
_widthPersist = ConnectionsPanelWidthPersist(
write: AppSettings.instance.setConnectionsPanelWidth,
);
_widthSettle = QueryaDragSettleController(
vsync: this,
value: kDefaultConnectionsPanelWidth,
Expand Down Expand Up @@ -387,6 +392,7 @@ class _MainContentSplitState extends State<_MainContentSplit>
}

void _schedulePersistAfterSettle() {
_widthPersist.markDirty();
final pending = _persistWhenSettled;
if (pending != null) {
_widthSettle.removeListener(pending);
Expand All @@ -395,9 +401,7 @@ class _MainContentSplitState extends State<_MainContentSplit>
if (_widthSettle.isSettling) return;
_widthSettle.removeListener(listener);
_persistWhenSettled = null;
unawaited(
AppSettings.instance.setConnectionsPanelWidth(_leftPanelWidth.value),
);
unawaited(_widthPersist.persist(_leftPanelWidth.value));
}

_persistWhenSettled = listener;
Expand All @@ -413,7 +417,9 @@ class _MainContentSplitState extends State<_MainContentSplit>
final pending = _persistWhenSettled;
if (pending != null) {
_widthSettle.removeListener(pending);
_persistWhenSettled = null;
}
_widthPersist.disposeFlush(_leftPanelWidth.value);
_widthSettle.removeListener(_onWidthSettle);
_widthSettle.dispose();
_leftPanelWidth.dispose();
Expand Down Expand Up @@ -473,7 +479,10 @@ class _MainContentSplitState extends State<_MainContentSplit>
),
);
},
onDiscreteResize: () => _widthPersist
.onDiscreteResize(() => _leftPanelWidth.value),
onDragEnd: (details) {
_widthPersist.cancelDiscreteTimer();
final velocity = details.primaryVelocity ??
details.velocity.pixelsPerSecond.dx;
_widthSettle.settle(
Expand Down
44 changes: 40 additions & 4 deletions lib/shared/widgets/querya_dropdown.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/core/layout/ui_scale.dart';
Expand Down Expand Up @@ -26,6 +28,10 @@ class QueryaDropdownItem<T> {
/// Stable dropdown built on [material.MenuAnchor] (no Overlay portal).
///
/// Visual metrics: [QueryaDropdownTokens]. Colors from shadcn [ColorScheme].
///
/// **Exit motion:** item pick and trigger-toggle delay [MenuController.close]
/// so fade-slide can run. Outside-tap / focus-loss closes via [MenuAnchor]
/// immediately (overlay removed) — that path snaps.
class QueryaDropdown<T> extends material.StatefulWidget {
const QueryaDropdown({
super.key,
Expand Down Expand Up @@ -63,6 +69,7 @@ class _QueryaDropdownState<T> extends material.State<QueryaDropdown<T>> {
List<material.Widget>? _cachedMenuChildren;
List<QueryaDropdownItem<T>>? _cachedMenuItems;
T? _cachedMenuValue;
var _closingWithExit = false;

@override
void initState() {
Expand All @@ -76,6 +83,22 @@ class _QueryaDropdownState<T> extends material.State<QueryaDropdown<T>> {
super.dispose();
}

/// Plays exit fade-slide, then removes the [MenuAnchor] overlay.
Future<void> _closeWithExit() async {
if (!_controller.isOpen || _closingWithExit) return;
_closingWithExit = true;
_menuOpen.value = false;
final duration = context.motionDuration(QueryaMotion.standard);
if (duration > QueryaMotion.instant) {
await Future<void>.delayed(duration);
}
if (!mounted) return;
if (_controller.isOpen) {
_controller.close();
}
_closingWithExit = false;
}

@override
void didUpdateWidget(covariant QueryaDropdown<T> oldWidget) {
super.didUpdateWidget(oldWidget);
Expand Down Expand Up @@ -136,7 +159,7 @@ class _QueryaDropdownState<T> extends material.State<QueryaDropdown<T>> {
colorScheme: cs,
onPick: () {
widget.onSelected(item.value);
_controller.close();
unawaited(_closeWithExit());
},
);
}
Expand Down Expand Up @@ -208,7 +231,7 @@ class _QueryaDropdownState<T> extends material.State<QueryaDropdown<T>> {
onTap: widget.enabled
? () {
if (controller.isOpen) {
controller.close();
unawaited(_closeWithExit());
} else {
controller.open();
}
Expand Down Expand Up @@ -239,8 +262,15 @@ class _QueryaDropdownState<T> extends material.State<QueryaDropdown<T>> {

final anchor = material.MenuAnchor(
controller: _controller,
onOpen: () => _menuOpen.value = true,
onClose: () => _menuOpen.value = false,
onOpen: () {
_closingWithExit = false;
_menuOpen.value = true;
},
onClose: () {
// Outside-tap / focus loss: overlay already gone — snap state only.
_closingWithExit = false;
_menuOpen.value = false;
},
crossAxisUnconstrained: false,
alignmentOffset: material.Offset(
widget.alignmentOffset.dx,
Expand Down Expand Up @@ -301,6 +331,10 @@ class _QueryaDropdownState<T> extends material.State<QueryaDropdown<T>> {
}
}

/// Enter/exit fade-slide for menu body while the overlay stays mounted.
///
/// Exit only runs when the parent delays [MenuController.close] (item pick /
/// trigger). Outside-tap removes the overlay immediately (snap).
class _QueryaDropdownMenuEnter extends material.StatelessWidget {
const _QueryaDropdownMenuEnter({
required this.openNotifier,
Expand Down Expand Up @@ -394,6 +428,8 @@ class _QueryaDropdownMenuItemState<T>
onEnter: widget.enabled ? (_) => setState(() => _hovered = true) : null,
onExit: widget.enabled ? (_) => setState(() => _hovered = false) : null,
child: material.MenuItemButton(
// Keep overlay mounted so parent can play exit fade-slide before close.
closeOnActivate: false,
style: material.MenuItemButton.styleFrom(
minimumSize: material.Size(double.infinity, itemHeight),
padding: material.EdgeInsets.zero,
Expand Down
40 changes: 40 additions & 0 deletions test/core/layout/querya_split_handle_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ void main() {
testWidgets('horizontal split handle responds to Left and Right',
(tester) async {
var totalDelta = 0.0;
var discreteCount = 0;
await tester.pumpWidget(
queryaThemeTestShell(
child: material.SizedBox(
Expand All @@ -66,6 +67,7 @@ void main() {
axis: material.Axis.horizontal,
semanticsLabel: 'Resize connections and workspace panes',
onDragDelta: (delta) => totalDelta += delta,
onDiscreteResize: () => discreteCount++,
),
const material.Expanded(child: material.SizedBox()),
],
Expand All @@ -80,8 +82,46 @@ void main() {
await tester.tap(handle);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
expect(totalDelta, 10);
expect(discreteCount, 1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
expect(totalDelta, 0);
expect(discreteCount, 2);
});

testWidgets('semantics increase/decrease call onDiscreteResize',
(tester) async {
var discreteCount = 0;
var totalDelta = 0.0;
await tester.pumpWidget(
queryaThemeTestShell(
child: material.SizedBox(
width: 400,
height: 200,
child: material.Row(
children: [
const material.Expanded(child: material.SizedBox()),
QueryaSplitHandle(
axis: material.Axis.horizontal,
semanticsLabel: 'Resize connections and workspace panes',
onDragDelta: (delta) => totalDelta += delta,
onDiscreteResize: () => discreteCount++,
),
const material.Expanded(child: material.SizedBox()),
],
),
),
),
);

final semantics = find.semantics.byLabel(
'Resize connections and workspace panes',
);
tester.semantics.increase(semantics);
expect(totalDelta, 10);
expect(discreteCount, 1);
tester.semantics.decrease(semantics);
expect(totalDelta, 0);
expect(discreteCount, 2);
});

testWidgets('focus ring uses motion-aware AnimatedContainer', (tester) async {
Expand Down
Loading
Loading