Skip to content
Open
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
20 changes: 17 additions & 3 deletions src/Views/LauncherTabBar.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="SourceGit.Views.LauncherTabBar"
x:DataType="vm:Launcher"
x:Name="ThisControl">
x:Name="ThisControl"
DragDrop.AllowDrop="True"
DragDrop.DragEnter="DragOverTabs"
DragDrop.DragOver="DragOverTabs"
DragDrop.DragLeave="DragLeaveTabs">
<Grid ColumnDefinitions="Auto,*,Auto">
<RepeatButton Grid.Column="0"
Classes="icon_button"
Expand All @@ -36,6 +40,16 @@
SelectionChanged="OnTabsSelectionChanged"
LayoutUpdated="OnTabsLayoutUpdated">
<ListBox.Styles>
<Style Selector="Border.launcher_tab">
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="Border.tab_drag_source">
<Setter Property="Opacity" Value="0.45"/>
</Style>
<Style Selector="Border.tab_drop_target">
<Setter Property="Background" Value="{DynamicResource Brush.AccentHovered}"/>
<Setter Property="CornerRadius" Value="4"/>
</Style>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0"/>
</Style>
Expand All @@ -61,9 +75,9 @@

<ListBox.ItemTemplate>
<DataTemplate DataType="vm:LauncherPage">
<v:LauncherTabSizeBox UseFixedWidth="{Binding Source={x:Static vm:Preferences.Instance}, Path=UseFixedTabWidth}" Height="30"
<v:LauncherTabSizeBox Classes="launcher_tab"
UseFixedWidth="{Binding Source={x:Static vm:Preferences.Instance}, Path=UseFixedTabWidth}" Height="30"
Padding="0,2,0,0"
Background="Transparent"
PointerPressed="OnPointerPressedTab"
PointerMoved="OnPointerMovedOverTab"
PointerReleased="OnPointerReleasedTab"
Expand Down
170 changes: 169 additions & 1 deletion src/Views/LauncherTabBar.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
using System;
using System.IO;
using System.Linq;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.VisualTree;

namespace SourceGit.Views
{
Expand Down Expand Up @@ -254,11 +258,167 @@ private async void OnPointerMovedOverTab(object sender, PointerEventArgs e)

var data = new DataTransfer();
data.Add(DataTransferItem.Create(_dndMainTabFormat, page.Node.Id));
await DragDrop.DoDragDropAsync(_pressedTabEvent, data, DragDropEffects.Move);
try
{
BeginDragFeedback(border, page);
await DragDrop.DoDragDropAsync(_pressedTabEvent, data, DragDropEffects.Move);
}
finally
{
ClearDragFeedback();
_pressedTabEvent = null;
_startDragTab = false;
}
}
e.Handled = true;
}

private void BeginDragFeedback(Border source, ViewModels.LauncherPage page)
{
_draggedPage = page;
_dragSource = source;
_dragLayer = AdornerLayer.GetAdornerLayer(this);
if (_dragLayer != null)
{
var size = source.Bounds.Size;
var text = source.GetVisualDescendants().OfType<TextBlock>().FirstOrDefault(t => t.IsEffectivelyVisible);
var icon = page.Node.IsRepository
? (Control)new Bookmark { Value = page.Node.Bookmark }
: new Avalonia.Controls.Shapes.Path
{
Data = this.FindResource("Icons.Repositories") as Geometry,
Fill = this.FindResource("Brush.FG1") as IBrush
};
icon.Width = 12;
icon.Height = 12;
icon.Margin = new Thickness(0, 0, 8, 0);
var content = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
Margin = new Thickness(12, 0),
VerticalAlignment = VerticalAlignment.Center
};
content.Children.Add(icon);
var title = new TextBlock
{
Text = page.Node.IsRepository ? page.Node.Name : App.Text("PageTabBar.Welcome.Title"),
FontFamily = text?.FontFamily ?? FontFamily.Default,
FontSize = text?.FontSize ?? 12,
Foreground = this.FindResource("Brush.FG1") as IBrush,
TextTrimming = TextTrimming.CharacterEllipsis,
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(title, 1);
content.Children.Add(title);
_dragPreview = new Border
{
Width = size.Width,
Height = size.Height,
Background = this.FindResource("Brush.ToolBar") as IBrush,
BorderBrush = this.FindResource("Brush.Border0") as IBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(4),
Opacity = 0.85,
IsVisible = false,
ClipToBounds = true,
Child = content
};
_dropIndicator = new Border
{
Width = 3,
Height = Math.Max(0, size.Height - 4),
Background = this.FindResource("Brush.Accent") as IBrush,
IsVisible = false
};
_dragOverlay = new Canvas { IsHitTestVisible = false };
_dragOverlay.Children.Add(_dropIndicator);
_dragOverlay.Children.Add(_dragPreview);
AdornerLayer.SetAdornedElement(_dragOverlay, this);
AdornerLayer.SetIsClipEnabled(_dragOverlay, false);
_dragLayer.Children.Add(_dragOverlay);
}
source.Classes.Add("tab_drag_source");
}

private void DragOverTabs(object sender, DragEventArgs e)
{
if (_draggedPage == null || DataContext is not ViewModels.Launcher launcher)
return;

var point = e.GetPosition(this);
if (_dragPreview != null)
{
Canvas.SetLeft(_dragPreview, point.X + 12);
Canvas.SetTop(_dragPreview, point.Y + 12);
_dragPreview.IsVisible = true;
}

ClearDropTarget();
e.DragEffects = DragDropEffects.None;
var fromIndex = launcher.Pages.IndexOf(_draggedPage);
if (fromIndex < 0 || !new Rect(LauncherTabsScroller.Bounds.Size).Contains(e.GetPosition(LauncherTabsScroller)))
return;

for (var i = 0; i < LauncherTabsList.ItemCount; i++)
{
var container = LauncherTabsList.ContainerFromIndex(i);
var tab = container?.GetVisualDescendants().OfType<LauncherTabSizeBox>().FirstOrDefault();
if (tab == null || i == fromIndex)
continue;

var position = e.GetPosition(tab);
if (!new Rect(tab.Bounds.Size).Contains(position))
continue;

_dropTarget = tab;
tab.Classes.Add("tab_drop_target");
e.DragEffects = DragDropEffects.Move;
if (_dropIndicator != null && tab.TranslatePoint(new Point(i > fromIndex ? tab.Bounds.Width : 0, 2), this) is { } edge)
{
Canvas.SetLeft(_dropIndicator, edge.X - 1.5);
Canvas.SetTop(_dropIndicator, edge.Y);
_dropIndicator.IsVisible = true;
}
break;
}
e.Handled = true;
}

private void DragLeaveTabs(object sender, DragEventArgs e)
{
ClearDropTarget();
if (_dragPreview != null)
_dragPreview.IsVisible = false;
}

private void ClearDropTarget()
{
_dropTarget?.Classes.Remove("tab_drop_target");
_dropTarget = null;
if (_dropIndicator != null)
_dropIndicator.IsVisible = false;
}

private void ClearDragFeedback()
{
ClearDropTarget();
_dragSource?.Classes.Remove("tab_drag_source");
if (_dragOverlay != null)
_dragLayer?.Children.Remove(_dragOverlay);
_draggedPage = null;
_dragSource = null;
_dragOverlay = null;
_dragPreview = null;
_dropIndicator = null;
_dragLayer = null;
}

protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
ClearDragFeedback();
base.OnDetachedFromVisualTree(e);
}

private void DropTab(object sender, DragEventArgs e)
{
if (e.DataTransfer.TryGetValue(_dndMainTabFormat) is not { Length: > 0 } id)
Expand Down Expand Up @@ -286,6 +446,7 @@ private void DropTab(object sender, DragEventArgs e)
if (target == to)
return;

ClearDragFeedback();
launcher.MoveTab(target, to);

_pressedTabEvent = null;
Expand Down Expand Up @@ -427,6 +588,13 @@ private void OnCloseTab(object sender, RoutedEventArgs e)
private readonly Vector _scrollStep = new(64, 0);
private PointerPressedEventArgs _pressedTabEvent = null;
private bool _startDragTab = false;
private ViewModels.LauncherPage _draggedPage;
private Border _dragSource;
private LauncherTabSizeBox _dropTarget;
private AdornerLayer _dragLayer;
private Canvas _dragOverlay;
private Border _dragPreview;
private Border _dropIndicator;
private readonly DataFormat<string> _dndMainTabFormat = DataFormat.CreateStringApplicationFormat("sourcegit-dnd-main-tab");
}
}
123 changes: 123 additions & 0 deletions tests/TabDragFeedback/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using System.Reflection;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Styling;
using Avalonia.Threading;
using Avalonia.VisualTree;
using SourceGit.Views;

// Linux.GetXdgDirectory only honors XDG roots that already exist.
// Create both app directories before setup to prevent fallback or legacy migration.
var outputRoot = Path.GetFullPath(Environment.GetEnvironmentVariable("TEST_OUTPUT") ?? throw new Exception("Run this fixture using tests/TabDragFeedback/run.py"));
var configRoot = Path.Combine(outputRoot, "config");
var cacheRoot = Path.Combine(outputRoot, "cache");
Directory.CreateDirectory(Path.Combine(configRoot, "SourceGit"));
Directory.CreateDirectory(Path.Combine(cacheRoot, "SourceGit"));
Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configRoot);
Environment.SetEnvironmentVariable("XDG_CACHE_HOME", cacheRoot);
Environment.SetEnvironmentVariable("APPIMAGE", null);

Console.WriteLine("stage: directories");
SourceGit.Native.OS.SetupBasicDirectories();
if (SourceGit.Native.OS.BasicDirectories.ConfigDir != Path.Combine(configRoot, "SourceGit") || SourceGit.Native.OS.BasicDirectories.CacheDir != Path.Combine(cacheRoot, "SourceGit")) throw new Exception("Test directories are not isolated");
Console.WriteLine("stage: setup");
SourceGit.App.BuildAvaloniaApp().UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }).SetupWithoutStarting();
Console.WriteLine("stage: launcher");
var vm = new SourceGit.ViewModels.Launcher(null);
vm.AddNewTab();
vm.AddNewTab();
if (vm.Pages.Count != 3) throw new Exception("Expected exactly three isolated test pages");
var testCase = args.FirstOrDefault() ?? "long";
Console.WriteLine("case: " + testCase);
SourceGit.ViewModels.Preferences.Instance.UseFixedTabWidth = testCase != "variable";
if (testCase != "welcome")
{
vm.Pages[0].Node.IsRepository = true;
vm.Pages[0].Node.Name = testCase == "short"
? "SourceGit"
: "sourcegit-repository-with-a-very-long-name-for-preview-validation";
}
var bar = new LauncherTabBar { DataContext = vm };
var window = new Window { Width = 850, Height = 180, Content = bar };
Console.WriteLine("stage: show");
window.Show();
Flush();
var list = bar.FindControl<ListBox>("LauncherTabsList");
var tabs = Enumerable.Range(0, vm.Pages.Count).Select(i => list.ContainerFromIndex(i).GetVisualDescendants().OfType<LauncherTabSizeBox>().First()).ToArray();
var layer = AdornerLayer.GetAdornerLayer(bar);
var initialAdorners = layer.Children.Count;
var first = vm.Pages[0];
var order = vm.Pages.Select(p => p.Node.Id).ToArray();
var format = Field<DataFormat<string>>("_dndMainTabFormat");
var data = new DataTransfer();
data.Add(DataTransferItem.Create(format, first.Node.Id));

Console.WriteLine("stage: begin feedback");
Call("BeginDragFeedback", tabs[0], first);
Flush();
Check(tabs[0].Classes.Contains("tab_drag_source"), "source is dimmed");
Check(Math.Abs(tabs[0].Opacity - 0.45) < 0.01, "source dimming is rendered");
Check(layer.Children.Count == initialAdorners + 1, "preview adorner attached");
var over = Drag(DragDrop.DragOverEvent, tabs[1]);
Call("DragOverTabs", bar, over);
Flush();
var previewForReview = Field<Border>("_dragPreview");
var titleForReview = previewForReview.GetVisualDescendants().OfType<TextBlock>().Single();
Console.WriteLine($"REVIEW preview width={previewForReview.Bounds.Width}, title width={titleForReview.Bounds.Width}, title desired width={titleForReview.DesiredSize.Width}, preview clips={previewForReview.ClipToBounds}");
var titlePosition = titleForReview.TranslatePoint(new Point(0, 0), previewForReview)!.Value;
Check(titlePosition.X >= 0 && titlePosition.X + titleForReview.Bounds.Width <= previewForReview.Bounds.Width + 0.01, "title stays within preview width");
Check(titleForReview.TextLayout.TextLines.Any(line => line.HasCollapsed) == (testCase == "long"), "long fixed-width title has ellipsis and fitting titles stay complete");
var bookmark = previewForReview.GetVisualDescendants().OfType<Bookmark>().FirstOrDefault();
var welcomeIcon = previewForReview.GetVisualDescendants().OfType<Avalonia.Controls.Shapes.Path>().FirstOrDefault();
Check(testCase == "welcome" ? welcomeIcon != null : bookmark != null, "preview retains the correct repository or welcome icon");
Check(over.DragEffects == DragDropEffects.Move, "valid target accepts move");
Check(tabs[1].Classes.Contains("tab_drop_target"), "target highlighted");
Check(tabs[1].Background is ISolidColorBrush targetBrush &&
bar.FindResource("Brush.AccentHovered") is ISolidColorBrush expectedBrush &&
targetBrush.Color == expectedBrush.Color, "destination highlight is rendered");
Check(Field<Border>("_dragPreview").IsVisible && Field<Border>("_dropIndicator").IsVisible, "preview and insertion marker visible");
var expectedEdge = tabs[1].TranslatePoint(new Point(tabs[1].Bounds.Width, 2), bar)!.Value;
Check(Math.Abs(Canvas.GetLeft(Field<Border>("_dropIndicator")) - expectedEdge.X + 1.5) < 0.01, "rightward move marker matches insertion edge");
var screenshot = new RenderTargetBitmap(new PixelSize(850, 180));
screenshot.Render(window);
screenshot.Save(Path.Combine(Environment.GetEnvironmentVariable("TEST_OUTPUT")!, "feedback.png"));
screenshot.Dispose();

Call("DragLeaveTabs", bar, new DragEventArgs(DragDrop.DragLeaveEvent, data, bar, new Point(0, 0), KeyModifiers.None));
Check(!Field<Border>("_dragPreview").IsVisible && !tabs[1].Classes.Contains("tab_drop_target"), "leave clears feedback");
Call("DragOverTabs", bar, Drag(DragDrop.DragOverEvent, tabs[2]));
Check(tabs[2].Classes.Contains("tab_drop_target"), "enter another target restores feedback");
Call("ClearDragFeedback");
Check(order.SequenceEqual(vm.Pages.Select(p => p.Node.Id)), "cancel leaves order unchanged");
Check(layer.Children.Count == initialAdorners && Field<Border>("_dragPreview") == null, "cancel removes preview");
Check(Math.Abs(tabs[0].Opacity - 1) < 0.01 &&
tabs[2].Background is ISolidColorBrush clearedTargetBrush &&
clearedTargetBrush.Color == Colors.Transparent, "cancel restores rendered tab styles");

Console.WriteLine("stage: begin feedback");
Call("BeginDragFeedback", tabs[0], first);
Call("DragOverTabs", bar, Drag(DragDrop.DragOverEvent, tabs[1]));
Call("DropTab", tabs[1], Drag(DragDrop.DropEvent, tabs[1]));
Check(vm.Pages[1] == first && vm.Pages[0].Node.Id == order[1], "drop preserves existing reorder behavior");
Check(layer.Children.Count == initialAdorners && !tabs[0].Classes.Contains("tab_drag_source") && !tabs[1].Classes.Contains("tab_drop_target"), "drop clears all feedback");
Flush();
var source = list.ContainerFromIndex(1).GetVisualDescendants().OfType<LauncherTabSizeBox>().First();
var target = list.ContainerFromIndex(0).GetVisualDescendants().OfType<LauncherTabSizeBox>().First();
Call("BeginDragFeedback", source, first);
Call("DragOverTabs", bar, Drag(DragDrop.DragOverEvent, target));
expectedEdge = target.TranslatePoint(new Point(0, 2), bar)!.Value;
Check(Math.Abs(Canvas.GetLeft(Field<Border>("_dropIndicator")) - expectedEdge.X + 1.5) < 0.01, "leftward move marker matches insertion edge");
window.Close();
Check(Field<Border>("_dragPreview") == null, "detach clears preview");
Console.WriteLine("PASS all feedback lifecycle and reorder checks");

void Flush() { Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); Dispatcher.UIThread.RunJobs(); }
T Field<T>(string name) => (T)typeof(LauncherTabBar).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(bar)!;
void Call(string name, params object[] args) => typeof(LauncherTabBar).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(bar, args);
void Check(bool condition, string message) { if (!condition) throw new Exception(message); Console.WriteLine("PASS " + message); }
DragEventArgs Drag(Avalonia.Interactivity.RoutedEvent<DragEventArgs> type, LauncherTabSizeBox target) => new(type, data, target, new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), KeyModifiers.None);
Loading