feat(mission-control): swap tiles by dragging their header
This commit is contained in:
@@ -729,7 +729,18 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
pane.Dispose();
|
pane.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors ConPtySessions' add/remove 1:1 into Panes (typed as the pane abstraction).
|
/// <summary>Swaps two tiles' slots (overview-grid drag and drop); also reorders the focus-mode tabs.</summary>
|
||||||
|
public void SwapPanes(ConPtyPaneViewModel a, ConPtyPaneViewModel b)
|
||||||
|
{
|
||||||
|
var i = ConPtySessions.IndexOf(a);
|
||||||
|
var j = ConPtySessions.IndexOf(b);
|
||||||
|
if (i < 0 || j < 0 || i == j) return;
|
||||||
|
if (i > j) (i, j) = (j, i);
|
||||||
|
ConPtySessions.Move(i, j);
|
||||||
|
ConPtySessions.Move(j - 1, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors ConPtySessions' add/remove/move 1:1 into Panes (typed as the pane abstraction).
|
||||||
private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
switch (e.Action)
|
switch (e.Action)
|
||||||
@@ -740,6 +751,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
case NotifyCollectionChangedAction.Remove:
|
case NotifyCollectionChangedAction.Remove:
|
||||||
Panes.RemoveAt(e.OldStartingIndex);
|
Panes.RemoveAt(e.OldStartingIndex);
|
||||||
break;
|
break;
|
||||||
|
case NotifyCollectionChangedAction.Move:
|
||||||
|
Panes.Move(e.OldStartingIndex, e.NewStartingIndex);
|
||||||
|
break;
|
||||||
default: // Reset
|
default: // Reset
|
||||||
Panes.Clear();
|
Panes.Clear();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
<Border DockPanel.Dock="Left" Width="3"
|
<Border DockPanel.Dock="Left" Width="3"
|
||||||
Background="{Binding IdentityColorKey, Converter={x:Static cconv:DotBrushConverter.Instance}}" />
|
Background="{Binding IdentityColorKey, Converter={x:Static cconv:DotBrushConverter.Instance}}" />
|
||||||
|
|
||||||
<!-- Header: title + close -->
|
<!-- Header: title + close; also the drag handle for reordering tiles (MissionControlView) -->
|
||||||
<Border DockPanel.Dock="Top"
|
<Border DockPanel.Dock="Top" Classes="pane-header" Cursor="SizeAll"
|
||||||
Background="{DynamicResource Surface2Brush}"
|
Background="{DynamicResource Surface2Brush}"
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
BorderThickness="0,0,0,1" Padding="8,3">
|
BorderThickness="0,0,0,1" Padding="8,3">
|
||||||
|
|||||||
@@ -16,6 +16,16 @@
|
|||||||
<mc:ConPtyPaneHost Margin="6" />
|
<mc:ConPtyPaneHost Margin="6" />
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</UserControl.DataTemplates>
|
</UserControl.DataTemplates>
|
||||||
|
<UserControl.Styles>
|
||||||
|
<!-- Drop target outline while dragging a tile by its header; matches the pane's Margin/CornerRadius. -->
|
||||||
|
<Style Selector="Border.pane-drop-hint">
|
||||||
|
<Setter Property="Margin" Value="6" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="BorderThickness" Value="2" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||||
|
<Setter Property="IsHitTestVisible" Value="False" />
|
||||||
|
</Style>
|
||||||
|
</UserControl.Styles>
|
||||||
<DockPanel LastChildFill="True" Background="{DynamicResource VoidBrush}">
|
<DockPanel LastChildFill="True" Background="{DynamicResource VoidBrush}">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ using System.ComponentModel;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using ClaudeDo.Ui.Localization;
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.MissionControl;
|
namespace ClaudeDo.Ui.Views.MissionControl;
|
||||||
|
|
||||||
@@ -33,6 +35,70 @@ public partial class MissionControlView : UserControl
|
|||||||
// Button runs its Command, and a synchronous Hide unbinds that Command first.
|
// Button runs its Command, and a synchronous Hide unbinds that Command first.
|
||||||
RecentSessionsList.AddHandler(Button.ClickEvent,
|
RecentSessionsList.AddHandler(Button.ClickEvent,
|
||||||
(_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(() => RecentSessionsButton.Flyout?.Hide()));
|
(_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(() => RecentSessionsButton.Flyout?.Hide()));
|
||||||
|
OverviewGrid.AddHandler(PointerPressedEvent, OnTilePointerPressed);
|
||||||
|
OverviewGrid.AddHandler(PointerMovedEvent, OnTilePointerMoved);
|
||||||
|
OverviewGrid.AddHandler(PointerReleasedEvent, OnTilePointerReleased);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const double DragThreshold = 4;
|
||||||
|
|
||||||
|
/// <summary>Tile presenter whose header was pressed; null when no tile drag is pending.</summary>
|
||||||
|
private ContentControl? _dragSource;
|
||||||
|
private Point _dragStart;
|
||||||
|
private bool _dragging;
|
||||||
|
private Border? _dropHint;
|
||||||
|
|
||||||
|
// Buttons in the header mark PointerPressed handled, so only the bare header starts a drag.
|
||||||
|
private void OnTilePointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!e.GetCurrentPoint(OverviewGrid).Properties.IsLeftButtonPressed || e.Source is not Visual source) return;
|
||||||
|
var chain = source.GetSelfAndVisualAncestors().ToList();
|
||||||
|
if (!chain.OfType<Border>().Any(b => b.Classes.Contains("pane-header"))) return;
|
||||||
|
_dragSource = chain.OfType<ContentControl>().FirstOrDefault(c => ReferenceEquals(c.Parent, OverviewGrid));
|
||||||
|
_dragStart = e.GetPosition(OverviewGrid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTilePointerMoved(object? sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
if (_dragSource is null) return;
|
||||||
|
if (!e.GetCurrentPoint(OverviewGrid).Properties.IsLeftButtonPressed) { EndTileDrag(); return; }
|
||||||
|
|
||||||
|
var pos = e.GetPosition(OverviewGrid);
|
||||||
|
if (!_dragging)
|
||||||
|
{
|
||||||
|
if (Math.Abs(pos.X - _dragStart.X) < DragThreshold && Math.Abs(pos.Y - _dragStart.Y) < DragThreshold) return;
|
||||||
|
_dragging = true;
|
||||||
|
_dragSource.Opacity = 0.5;
|
||||||
|
_dropHint = new Border { Classes = { "pane-drop-hint" }, IsVisible = false };
|
||||||
|
OverviewGrid.Children.Add(_dropHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = TileAt(pos);
|
||||||
|
_dropHint!.IsVisible = target is not null;
|
||||||
|
if (target is null) return;
|
||||||
|
Grid.SetColumn(_dropHint, Grid.GetColumn(target));
|
||||||
|
Grid.SetRow(_dropHint, Grid.GetRow(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTilePointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
var source = _dragSource;
|
||||||
|
var target = _dragging ? TileAt(e.GetPosition(OverviewGrid)) : null;
|
||||||
|
EndTileDrag();
|
||||||
|
if (source?.Content is ConPtyPaneViewModel a && target?.Content is ConPtyPaneViewModel b)
|
||||||
|
_vm?.SwapPanes(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ContentControl? TileAt(Point pos) => OverviewGrid.Children.OfType<ContentControl>()
|
||||||
|
.FirstOrDefault(c => !ReferenceEquals(c, _dragSource) && c.Bounds.Contains(pos));
|
||||||
|
|
||||||
|
private void EndTileDrag()
|
||||||
|
{
|
||||||
|
if (_dragSource is not null) _dragSource.Opacity = 1;
|
||||||
|
if (_dropHint is not null) OverviewGrid.Children.Remove(_dropHint);
|
||||||
|
_dragSource = null;
|
||||||
|
_dropHint = null;
|
||||||
|
_dragging = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
|
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
|
||||||
|
|||||||
@@ -491,6 +491,22 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
Assert.Equal(2, vm.ColumnCount);
|
Assert.Equal(2, vm.ColumnCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SwapPanes_SwapsSlots_InBothCollections()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = BuildVm(worker);
|
||||||
|
await vm.OpenConPtySessionAsync("t1");
|
||||||
|
await vm.OpenConPtySessionAsync("t2");
|
||||||
|
await vm.OpenConPtySessionAsync("t3");
|
||||||
|
var (a, b, c) = (vm.Panes[0], vm.Panes[1], vm.Panes[2]);
|
||||||
|
|
||||||
|
vm.SwapPanes(c, a);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { c, b, a }, vm.Panes);
|
||||||
|
Assert.Equal(new[] { c, b, a }, vm.ConPtySessions);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SetColumns_OverridesAutoCapsAtPaneCountAndPersists()
|
public async Task SetColumns_OverridesAutoCapsAtPaneCountAndPersists()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user