diff --git a/samples/SampleApp/DemoPages/GroupedTileListBoxDemo.axaml b/samples/SampleApp/DemoPages/GroupedTileListBoxDemo.axaml
index ba8c0568..4c0b6c1e 100644
--- a/samples/SampleApp/DemoPages/GroupedTileListBoxDemo.axaml
+++ b/samples/SampleApp/DemoPages/GroupedTileListBoxDemo.axaml
@@ -304,6 +304,7 @@
SelectionMode="Multiple" with SelectedItems bound two-way.
Pointer: click replaces, Ctrl/Cmd+click toggles, Shift+click selects a range.
+ Drag on empty space to marquee-select (Ctrl/Cmd+drag adds to the selection); drag past the top/bottom edge to auto-scroll.
Keyboard: arrows move, Shift+arrows extend the range, Ctrl/Cmd+Space toggles the current tile, Ctrl/Cmd+A selects all.
@@ -344,11 +345,10 @@
MultiSelectedItems { get; } = new();
+ // Scenario 5 source. Deliberately large and multi-grouped (many groups, many items per group,
+ // uneven counts so last rows are partial) so marquee drag-selection, cross-group rectangles, and
+ // edge auto-scroll can all be exercised. Kept separate from GroupedItems so Scenario 1 is unaffected.
+ public List MultiSelectGroupedItems { get; } = GenerateMultiSelectItems();
+
+ private static List GenerateMultiSelectItems()
+ {
+ // (group label, item count) — varied counts on purpose to produce partial last rows.
+ (string Group, int Count)[] groups =
+ {
+ ("Group A", 42),
+ ("Group B", 55),
+ ("Group C", 63),
+ ("Group D", 48),
+ ("Group E", 70),
+ ("Group F", 39),
+ ("Group G", 58),
+ ("Group H", 51),
+ };
+
+ var items = new List();
+ foreach ((string group, int count) in groups)
+ {
+ for (int i = 1; i <= count; i++)
+ {
+ items.Add(new FoodItem($"{group} · {i:D2}", group));
+ }
+ }
+
+ return items;
+ }
+
// Scenario 1: Named groups
public List GroupedItems { get; } = new()
{
diff --git a/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml b/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml
index 863384f0..109894c9 100644
--- a/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml
+++ b/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml
@@ -2,6 +2,13 @@
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+
+
+
+
@@ -29,10 +36,25 @@
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
ClipToBounds="True">
-
+
+
+
+
+
diff --git a/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs b/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs
index b5ef973d..0b8eb232 100644
--- a/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs
+++ b/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs
@@ -21,6 +21,7 @@ namespace Devolutions.AvaloniaControls.Controls;
/// Items are displayed in a wrapping grid with uniform tile sizes.
///
[TemplatePart("PART_ScrollViewer", typeof(ScrollViewer), IsRequired = true)]
+[TemplatePart("PART_SelectionRectangle", typeof(Border))]
[RequiresUnreferencedCode("BindingEvaluator require preserved types")]
public class GroupedTileListBox : TemplatedControl
{
@@ -73,6 +74,11 @@ public class GroupedTileListBox : TemplatedControl
nameof(AutoScrollToSelectedItem),
defaultValue: true);
+ public static readonly StyledProperty EnableRectangleSelectionProperty =
+ AvaloniaProperty.Register(
+ nameof(EnableRectangleSelection),
+ defaultValue: true);
+
public static readonly StyledProperty?> GroupSelectorProperty =
AvaloniaProperty.Register?>(
"GroupSelector");
@@ -106,6 +112,29 @@ public class GroupedTileListBox : TemplatedControl
private ItemsRepeater? itemsRepeater;
private ScrollViewer? scrollViewer;
private Control? content;
+ private Border? selectionRectangle;
+
+ // --- Marquee (drag-to-select rectangle) state ---
+ // Pixels the pointer must travel from the press point before a press becomes a marquee drag
+ // (so a plain click on empty space doesn't start one).
+ private const double MarqueeDragThreshold = 4.0;
+
+ // Distance from the top/bottom viewport edge that triggers auto-scroll, and the largest scroll
+ // step applied per timer tick at maximum edge depth.
+ private const double MarqueeAutoScrollEdge = 24.0;
+ private const double MarqueeAutoScrollMaxStep = 20.0;
+
+ private bool marqueePending;
+ private bool marqueeActive;
+ private bool marqueeCtrlHeld;
+ private Point marqueeViewportPress;
+ private Point marqueeViewportLastPoint;
+ private Point marqueeContentAnchor;
+ private List