Skip to content

Commit be5c351

Browse files
Merge pull request #226 from ClaudioESSilva/feature/auto-scrolling
implements #225
2 parents fa8458f + 3fafcd5 commit be5c351

4 files changed

Lines changed: 115 additions & 0 deletions

File tree

src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ public PlanViewerControl()
141141
var layoutTransform = this.FindControl<Avalonia.Controls.LayoutTransformControl>("PlanLayoutTransform")!;
142142
_zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!;
143143

144+
Helpers.DataGridBehaviors.Attach(StatementsGrid);
144145
}
145146

146147
/// <summary>

src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi
6363
_slicerDaysBack = AppSettingsService.Load().QueryStoreSlicerDays;
6464
InitializeComponent();
6565
ResultsGrid.ItemsSource = _filteredRows;
66+
Helpers.DataGridBehaviors.Attach(ResultsGrid);
6667
EnsureFilterPopup();
6768
SetupColumnHeaders();
6869
PopulateDatabaseBox(databases, initialDatabase);

src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ public QueryStoreHistoryWindow(string connectionString, string queryHash,
104104
_maxHoursBack = slicerDaysBack * 24;
105105
InitializeComponent();
106106

107+
Helpers.DataGridBehaviors.Attach(HistoryDataGrid);
108+
107109
QueryIdentifierText.Text = $"Query Store History: {queryHash} in [{database}]";
108110
QueryTextBox.Text = queryText;
109111

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
using System;
2+
using Avalonia;
3+
using Avalonia.Controls;
4+
using Avalonia.Controls.Primitives;
5+
using Avalonia.Input;
6+
using Avalonia.Interactivity;
7+
using Avalonia.VisualTree;
8+
9+
namespace PlanViewer.App.Helpers;
10+
11+
/// <summary>
12+
/// Attaches middle-mouse-button pan behavior to a DataGrid.
13+
/// </summary>
14+
public static class DataGridBehaviors
15+
{
16+
/// <summary>Attach middle-click pan behavior to <paramref name="grid"/>.</summary>
17+
public static void Attach(DataGrid grid)
18+
{
19+
AttachMiddleClickPan(grid);
20+
}
21+
22+
// ─────────────────────────────────────────────────────────────────────────
23+
// Middle-mouse-button drag → pan (scroll) the grid
24+
// ─────────────────────────────────────────────────────────────────────────
25+
26+
private static void AttachMiddleClickPan(DataGrid grid)
27+
{
28+
Point panStart = default;
29+
double scrollStartH = 0, scrollStartV = 0;
30+
bool panning = false;
31+
ScrollBar? hBar = null, vBar = null;
32+
bool barsResolved = false;
33+
34+
// Avalonia's DataGrid has no ScrollViewer in its template — it manages scrolling
35+
// itself via PART_HorizontalScrollbar and PART_VerticalScrollbar. Resolve them
36+
// lazily (visual tree isn't populated until after TemplateApplied).
37+
void ResolveScrollBars()
38+
{
39+
if (barsResolved) return;
40+
barsResolved = true;
41+
foreach (var d in grid.GetVisualDescendants())
42+
{
43+
if (d is not ScrollBar sb) continue;
44+
if (sb.Name == "PART_HorizontalScrollbar") hBar = sb;
45+
else if (sb.Name == "PART_VerticalScrollbar") vBar = sb;
46+
if (hBar != null && vBar != null) break;
47+
}
48+
}
49+
50+
// Re-resolve scroll bars if the template is ever re-applied.
51+
grid.TemplateApplied += (_, _) => { barsResolved = false; hBar = null; vBar = null; };
52+
53+
// RoutingStrategies.Direct|Bubble + handledEventsToo:true ensures the handler fires
54+
// even though DataGrid rows/cells mark PointerPressed handled (for row selection).
55+
grid.AddHandler(InputElement.PointerPressedEvent, (object? _, PointerPressedEventArgs e) =>
56+
{
57+
if (e.GetCurrentPoint(grid).Properties.PointerUpdateKind != PointerUpdateKind.MiddleButtonPressed) return;
58+
59+
ResolveScrollBars();
60+
61+
panning = true;
62+
panStart = e.GetPosition(grid);
63+
scrollStartH = hBar?.Value ?? 0;
64+
scrollStartV = vBar?.Value ?? 0;
65+
e.Pointer.Capture(grid);
66+
grid.Cursor = new Cursor(StandardCursorType.SizeAll);
67+
e.Handled = true;
68+
}, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true);
69+
70+
grid.AddHandler(InputElement.PointerMovedEvent, (object? _, PointerEventArgs e) =>
71+
{
72+
if (!panning) return;
73+
74+
// Release pan if the middle button was lifted outside a PointerReleased event.
75+
if (!e.GetCurrentPoint(grid).Properties.IsMiddleButtonPressed)
76+
{
77+
panning = false;
78+
e.Pointer.Capture(null);
79+
grid.Cursor = null;
80+
return;
81+
}
82+
83+
var delta = e.GetPosition(grid) - panStart;
84+
85+
if (hBar is not null)
86+
{
87+
hBar.Value = Math.Clamp(scrollStartH - delta.X, hBar.Minimum, hBar.Maximum);
88+
// Raise Thumb.DragDeltaEvent on the scrollbar — a public routed event whose
89+
// ScrollBar class handler calls OnScroll → fires Scroll event → DataGrid
90+
// processes the new Value without any reflection on private members.
91+
hBar.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent });
92+
}
93+
if (vBar is not null)
94+
{
95+
vBar.Value = Math.Clamp(scrollStartV - delta.Y, vBar.Minimum, vBar.Maximum);
96+
vBar.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent });
97+
}
98+
99+
e.Handled = true;
100+
}, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true);
101+
102+
grid.AddHandler(InputElement.PointerReleasedEvent, (object? _, PointerReleasedEventArgs e) =>
103+
{
104+
if (!panning) return;
105+
panning = false;
106+
e.Pointer.Capture(null);
107+
grid.Cursor = null;
108+
e.Handled = true;
109+
}, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true);
110+
}
111+
}

0 commit comments

Comments
 (0)