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
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ CI pins a **stable** Flutter version in
version locally to avoid "works on my machine" drift. When bumping the pin, run
`flutter test` and a release smoke build before merging.

## UI motion (review rule)

For animated UI, **do not invent magic `Duration(...)` / raw curves** in widgets.

- Use `QueryaMotion` tokens (`fast` / `standard` / `slow`) via
`context.motionDuration` / `context.motionCurve` (or `QueryaMotion.effective*`).
- Interactive Fluid motion: `QueryaSpring` / `QueryaSpringController` when
`QueryaSpring.springsEnabled` (Full motion only).
- Honor Preferences Motion Full / Reduced / Off and OS `disableAnimations`.
- Mid-drag layout (split panes) stays 1:1; spring settle only on drag-end.
- Do not animate virtualized grid rows on scroll.

See [docs/motion-and-high-refresh.md](docs/motion-and-high-refresh.md) and
[docs/perf-baseline.md](docs/perf-baseline.md) (Fluid @ 120 Hz checklist).

## Tests

Widget tests that use SQLite or `path_provider` follow patterns in
Expand Down
16 changes: 14 additions & 2 deletions docs/motion-and-high-refresh.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,19 @@ Suggested order: A1 → A2 → A3 in parallel with A4; then A5; A6 closes the mi

---

## 7. References
## 7. Review rule — no magic UI durations

When reviewing PRs that touch animation:

1. Reject bare `Duration(milliseconds: …)` / ad-hoc `Curves.*` in product UI unless
wired through `QueryaMotion` (or documented physics constants in `QueryaSpring`).
2. Require Full / Reduced / Off + OS `disableAnimations` coverage for new transitions.
3. Split / resize: no spring or lag mid-drag; settle only on release / focus chrome.
4. Never stagger or fade virtualized result rows while scrolling.

Checklist for 120 Hz verification: [perf-baseline.md](perf-baseline.md) § Fluid shell.

## 8. References

- Flutter engine — high refresh rate gap: `flutter/flutter#160952`, `#90675` (ProMotion scrolling), `#94508` (`CADisableMinimumFrameDurationOnPhone` default).
- `refresh_rate` package (query/unlock/overlay/benchmark, all platforms): https://pub.dev/packages/refresh_rate
Expand All @@ -148,7 +160,7 @@ Suggested order: A1 → A2 → A3 in parallel with A4; then A5; A6 closes the mi

---

## 8. Measured results (0.4.4)
## 9. Measured results (0.4.4)

The table below shows the measured refresh rates and frame times on target monitors before and after the 0.4.4 implementation (using a profile build, measured with DevTools and `QUERYA_REFRESH_OVERLAY=true`):

Expand Down
12 changes: 12 additions & 0 deletions docs/perf-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,15 @@ To verify that the motion system conforms to the budget and does not cause jank
8. **Reduced Motion**:
- Turn on "Reduce Motion" in your OS settings or select **Preferences → Appearance → Motion → Off** (or **Reduced** for 50% speed).
- Verify that transitions complete instantly (**0 ms** for Off) or are appropriately shortened.

## Fluid shell scenarios @ 120 Hz (0.4.11+ / #342)

Repeat on a **120 Hz** display (budget **≤ 8.3 ms** build+raster). Prefer profile/release.

9. **Empty ↔ connected**: open a connection from the empty hero, then disconnect back to empty — `QueryaSwitchingBody` morph; editor/workspace state should not remount-jank.
10. **Tab strip**: switch Server / SQL / History quickly — sliding pill should redirect without brick-wall jumps.
11. **Hero quick-start ↔ recent**: with and without recent connections — FadeSlide + stagger first paint only.
12. **Results modes**: idle → run (spinner) → grid; force an error — mode keys morph; scrolling the grid must not fade rows.
13. **Dialog / dropdown**: open/close `showAppDialog` and a `QueryaDropdown` — enter fade-slide, exit uses exit curve; Motion Off snaps.
14. **Theme cross-fade**: Preferences → enable Animate theme + Motion Full; switch dark/light — shadcn + `AnimatedQueryaTheme` lerp. Repeat with Motion Off (snap).
15. **Split settle**: drag the connections sidebar handle and the SQL/results vertical split with a fling — mid-drag stays 1:1; release may soft-settle. Focus the handle — ring uses motion tokens (not mid-drag animation).
92 changes: 92 additions & 0 deletions lib/core/layout/querya_drag_settle.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/scheduler.dart';

import 'package:querya_desktop/core/motion/querya_spring.dart';
import 'package:querya_desktop/core/motion/querya_spring_controller.dart';

/// Direct mid-drag tracking with optional spring settle on release.
///
/// While dragging, [dragTo] jumps 1:1 (no lag). On [settle], a spring inherits
/// release [velocity] toward the current value so the pane soft-stops /
/// overshoots briefly — never applied mid-drag.
class QueryaDragSettleController extends ChangeNotifier {
QueryaDragSettleController({
required TickerProvider vsync,
required double value,
this.spring = QueryaSpring.gentle,
this.maxSettleVelocity = 2.5,
}) : _value = value {
_spring = QueryaSpringController(
vsync: vsync,
value: value,
spring: spring,
);
_spring.addListener(_onSpringTick);
}

final SpringDescription spring;

/// Cap on |velocity| passed into the settle spring (value-units / second).
final double maxSettleVelocity;

late final QueryaSpringController _spring;
double _value;
var _dragging = false;

double get value => _value;
bool get isSettling => !_dragging && _spring.isAnimating;

/// Instant set (restore from settings, clamp after layout).
void jumpTo(double value) {
_dragging = false;
_spring.jumpTo(value);
if (_value == value) return;
_value = value;
notifyListeners();
}

/// Mid-drag update — cancels settle and tracks exactly.
void dragTo(double value) {
_dragging = true;
if (_spring.isAnimating) {
_spring.jumpTo(value);
} else {
_spring.jumpTo(value);
}
if (_value == value) return;
_value = value;
notifyListeners();
}

/// Drag-end settle using release velocity (same units as [value] / second).
void settle({
required double velocity,
required bool useSprings,
}) {
_dragging = false;
_spring.useSprings = useSprings;
final v = velocity.clamp(-maxSettleVelocity, maxSettleVelocity);
if (!useSprings || v.abs() < 0.02) {
_spring.jumpTo(_value);
return;
}
_spring.jumpTo(_value);
_spring.animateTo(_value, velocity: v);
}

void _onSpringTick() {
if (_dragging) return;
final next = _spring.value;
if ((next - _value).abs() < 0.0001) return;
_value = next;
notifyListeners();
}

@override
void dispose() {
_spring.removeListener(_onSpringTick);
_spring.dispose();
super.dispose();
}
}
22 changes: 16 additions & 6 deletions lib/core/layout/querya_split_handle.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import 'package:flutter/material.dart' as material;
import 'package:flutter/services.dart';
import 'package:querya_desktop/core/motion/querya_motion.dart';
import 'package:querya_desktop/core/motion/querya_motion_context.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';

/// Accessible split-pane handle that supports mouse dragging and arrow keys.
///
/// Mid-drag updates are 1:1 via [onDragDelta]. [onDragEnd] receives velocity for
/// optional spring settle (callers should not animate mid-drag).
class QueryaSplitHandle extends material.StatefulWidget {
const QueryaSplitHandle({
super.key,
Expand All @@ -20,7 +25,9 @@ class QueryaSplitHandle extends material.StatefulWidget {
final String semanticsLabel;
final String? semanticsValue;
final double keyboardStep;
final material.VoidCallback? onDragEnd;

/// Called when a pointer drag ends (not keyboard). Use velocity for settle.
final material.ValueChanged<material.DragEndDetails>? onDragEnd;

@override
material.State<QueryaSplitHandle> createState() => _QueryaSplitHandleState();
Expand Down Expand Up @@ -61,6 +68,9 @@ class _QueryaSplitHandleState extends material.State<QueryaSplitHandle> {
material.Widget build(material.BuildContext context) {
final colors = Theme.of(context).colorScheme;
final horizontal = widget.axis == material.Axis.horizontal;
final duration = context.motionDuration(QueryaMotion.fast);
final curve = context.motionCurve(QueryaMotion.enter);

return material.Focus(
focusNode: _focusNode,
onFocusChange: (value) => setState(() => _focused = value),
Expand All @@ -85,14 +95,14 @@ class _QueryaSplitHandleState extends material.State<QueryaSplitHandle> {
onHorizontalDragUpdate: horizontal
? (event) => widget.onDragDelta(event.delta.dx)
: null,
onHorizontalDragEnd:
horizontal ? (_) => widget.onDragEnd?.call() : null,
onHorizontalDragEnd: horizontal ? widget.onDragEnd : null,
onVerticalDragUpdate: horizontal
? null
: (event) => widget.onDragDelta(event.delta.dy),
onVerticalDragEnd:
horizontal ? null : (_) => widget.onDragEnd?.call(),
child: material.Container(
onVerticalDragEnd: horizontal ? null : widget.onDragEnd,
child: material.AnimatedContainer(
duration: duration,
curve: curve,
width: horizontal ? 6 : null,
height: horizontal ? null : 6,
decoration: material.BoxDecoration(
Expand Down
88 changes: 80 additions & 8 deletions lib/core/layout/vertical_split_pane.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/core/layout/querya_drag_settle.dart';
import 'package:querya_desktop/core/layout/querya_split_handle.dart';
import 'package:querya_desktop/core/motion/querya_spring.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';

/// Holds top/bottom panes for [VerticalSplitPane] [ValueListenableBuilder.child].
Expand All @@ -14,7 +16,10 @@ class SplitPanePair extends StatelessWidget {
}

/// Vertical split whose drag updates [fraction] without rebuilding [top]/[bottom].
class VerticalSplitPane extends StatelessWidget {
///
/// Mid-drag is 1:1; on release a spring settle uses drag velocity when springs
/// are enabled.
class VerticalSplitPane extends StatefulWidget {
const VerticalSplitPane({
super.key,
required this.fraction,
Expand All @@ -32,20 +37,76 @@ class VerticalSplitPane extends StatelessWidget {
final double maxFraction;
final Key? handleKey;

@override
State<VerticalSplitPane> createState() => _VerticalSplitPaneState();
}

class _VerticalSplitPaneState extends State<VerticalSplitPane>
with SingleTickerProviderStateMixin {
late final QueryaDragSettleController _settle;
var _syncingFromSettle = false;

@override
void initState() {
super.initState();
_settle = QueryaDragSettleController(
vsync: this,
value: widget.fraction.value,
);
_settle.addListener(_onSettleChanged);
widget.fraction.addListener(_onFractionExternal);
}

@override
void didUpdateWidget(covariant VerticalSplitPane oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.fraction != widget.fraction) {
oldWidget.fraction.removeListener(_onFractionExternal);
widget.fraction.addListener(_onFractionExternal);
_settle.jumpTo(widget.fraction.value);
}
}

@override
void dispose() {
widget.fraction.removeListener(_onFractionExternal);
_settle.removeListener(_onSettleChanged);
_settle.dispose();
super.dispose();
}

void _onFractionExternal() {
if (_syncingFromSettle) return;
if ((_settle.value - widget.fraction.value).abs() > 0.0001) {
_settle.jumpTo(widget.fraction.value);
}
}

void _onSettleChanged() {
_syncingFromSettle = true;
widget.fraction.value = _settle.value
.clamp(widget.minFraction, widget.maxFraction)
.toDouble();
_syncingFromSettle = false;
}

double _clamp(double raw) =>
raw.clamp(widget.minFraction, widget.maxFraction).toDouble();

@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final totalHeight = constraints.maxHeight;
return ValueListenableBuilder<double>(
valueListenable: fraction,
valueListenable: widget.fraction,
builder: (context, value, panes) {
final pair = panes! as SplitPanePair;
final topFlex = (value * 100)
.round()
.clamp(
(minFraction * 100).round(),
(maxFraction * 100).round(),
(widget.minFraction * 100).round(),
(widget.maxFraction * 100).round(),
)
.toInt();
final bottomFlex = 100 - topFlex;
Expand All @@ -54,21 +115,32 @@ class VerticalSplitPane extends StatelessWidget {
children: [
Expanded(flex: topFlex, child: pair.top),
QueryaSplitHandle(
key: handleKey,
key: widget.handleKey,
axis: material.Axis.vertical,
semanticsLabel: 'Resize query and output panes',
semanticsValue: '${(value * 100).round()}% top pane',
onDragDelta: (dy) {
if (totalHeight <= 0) return;
fraction.value = (fraction.value + dy / totalHeight)
.clamp(minFraction, maxFraction);
_settle.dragTo(
_clamp(_settle.value + dy / totalHeight),
);
},
onDragEnd: (details) {
if (totalHeight <= 0) return;
final velocity =
details.primaryVelocity ??
details.velocity.pixelsPerSecond.dy;
_settle.settle(
velocity: velocity / totalHeight,
useSprings: QueryaSpring.springsEnabled(context),
);
},
),
Expanded(flex: bottomFlex, child: pair.bottom),
],
);
},
child: SplitPanePair(top: top, bottom: bottom),
child: SplitPanePair(top: widget.top, bottom: widget.bottom),
);
},
);
Expand Down
Loading
Loading