From 6056d776f963771d207bb5d85423732ceab5a6a2 Mon Sep 17 00:00:00 2001 From: vgm Date: Tue, 6 Oct 2026 00:09:28 +0800 Subject: [PATCH] ux: show feedback while dragging repository tabs Show a pointer-following title/icon preview, dim the dragged tab, and highlight the destination with an insertion marker matching the existing reorder behavior. Clear feedback on leave, cancellation, drop and detach. Match the tab Border style key and constrain preview titles to the available width with ellipsis and clipping. Include isolated rendering and lifecycle checks for long and short fixed-width titles, welcome tabs and variable-width tabs. --- src/Views/LauncherTabBar.axaml | 20 ++- src/Views/LauncherTabBar.axaml.cs | 170 ++++++++++++++++++- tests/TabDragFeedback/Program.cs | 123 ++++++++++++++ tests/TabDragFeedback/README.md | 36 ++++ tests/TabDragFeedback/TabDragFeedback.csproj | 13 ++ tests/TabDragFeedback/run.py | 62 +++++++ 6 files changed, 420 insertions(+), 4 deletions(-) create mode 100644 tests/TabDragFeedback/Program.cs create mode 100644 tests/TabDragFeedback/README.md create mode 100644 tests/TabDragFeedback/TabDragFeedback.csproj create mode 100644 tests/TabDragFeedback/run.py diff --git a/src/Views/LauncherTabBar.axaml b/src/Views/LauncherTabBar.axaml index 76b4f7ddf7..593011f1c0 100644 --- a/src/Views/LauncherTabBar.axaml +++ b/src/Views/LauncherTabBar.axaml @@ -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"> + + + @@ -61,9 +75,9 @@ - ().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().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) @@ -286,6 +446,7 @@ private void DropTab(object sender, DragEventArgs e) if (target == to) return; + ClearDragFeedback(); launcher.MoveTab(target, to); _pressedTabEvent = null; @@ -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 _dndMainTabFormat = DataFormat.CreateStringApplicationFormat("sourcegit-dnd-main-tab"); } } diff --git a/tests/TabDragFeedback/Program.cs b/tests/TabDragFeedback/Program.cs new file mode 100644 index 0000000000..6f8f5ce83c --- /dev/null +++ b/tests/TabDragFeedback/Program.cs @@ -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("LauncherTabsList"); +var tabs = Enumerable.Range(0, vm.Pages.Count).Select(i => list.ContainerFromIndex(i).GetVisualDescendants().OfType().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>("_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("_dragPreview"); +var titleForReview = previewForReview.GetVisualDescendants().OfType().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().FirstOrDefault(); +var welcomeIcon = previewForReview.GetVisualDescendants().OfType().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("_dragPreview").IsVisible && Field("_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("_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("_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("_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().First(); +var target = list.ContainerFromIndex(0).GetVisualDescendants().OfType().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("_dropIndicator")) - expectedEdge.X + 1.5) < 0.01, "leftward move marker matches insertion edge"); +window.Close(); +Check(Field("_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(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 type, LauncherTabSizeBox target) => new(type, data, target, new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), KeyModifiers.None); diff --git a/tests/TabDragFeedback/README.md b/tests/TabDragFeedback/README.md new file mode 100644 index 0000000000..abc2d5ad5b --- /dev/null +++ b/tests/TabDragFeedback/README.md @@ -0,0 +1,36 @@ +# Repository tab drag feedback fixture + +Run from an initialized SourceGit checkout with .NET SDK 10 and Python 3: + +```sh +python3 tests/TabDragFeedback/run.py +``` + +The runner builds the real SourceGit views and executes four headless rendering +cases: a long fixed-width title, a short title, a welcome tab, and a variable-width +tab. It checks effective source opacity and destination background, preview +clipping and ellipsis, icon selection, both insertion directions, leave/re-entry, +cancellation, drop ordering, and detach cleanup. Logs and PNG previews are saved +in a new temporary directory; `--output DIRECTORY` selects its parent. + +Both XDG roots and their SourceGit subdirectories are created before application +initialization. The fixture verifies the resolved application directories and +requires exactly three initial pages. This prevents Linux's fallback to personal +configuration when an XDG root does not exist, including unintended workspace +restoration and scrolling. Each case receives fresh configuration and a private +copy of the user's existing fontconfig cache. A native font initialization crash +fails the test and is recorded in its log; the runner does not preload libraries +or retry failed cases. + +To test the same fixture against an Avalonia 12 Wayland checkout: + +```sh +python3 tests/TabDragFeedback/run.py \ + --source-project /path/to/wayland-checkout/src/SourceGit.csproj \ + --avalonia-version 12.1.4 +``` + +`--dotnet /path/to/dotnet` selects an SDK outside `PATH`. The headless package +version must match that checkout's Avalonia version. The fixture invokes feedback +handlers directly; compositor input, native drag sessions, and backend selection +need separate native Wayland/X11 interaction checks. diff --git a/tests/TabDragFeedback/TabDragFeedback.csproj b/tests/TabDragFeedback/TabDragFeedback.csproj new file mode 100644 index 0000000000..54af418455 --- /dev/null +++ b/tests/TabDragFeedback/TabDragFeedback.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + enable + 11.3.22 + $(MSBuildThisFileDirectory)../../src/SourceGit.csproj + + + + + + diff --git a/tests/TabDragFeedback/run.py b/tests/TabDragFeedback/run.py new file mode 100644 index 0000000000..8cc6c79db9 --- /dev/null +++ b/tests/TabDragFeedback/run.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Build and run the tab drag feedback fixture with isolated application state.""" + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dotnet", default="dotnet") + parser.add_argument("--source-project", type=Path) + parser.add_argument("--avalonia-version", default="11.3.22") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + fixture = Path(__file__).resolve().parent + output = (args.output or Path(tempfile.mkdtemp(prefix="sourcegit-tab-drag-"))).resolve() + output.mkdir(parents=True, exist_ok=True) + run = Path(tempfile.mkdtemp(prefix="run-", dir=output)) + env = os.environ.copy() + # Native backend selection and portable mode must not select personal state. + env.pop("WAYLAND_DISPLAY", None) + env.pop("APPIMAGE", None) + env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" + project = fixture / "TabDragFeedback.csproj" + properties = [f"-p:AvaloniaVersion={args.avalonia_version}", "-p:DisableAOT=true"] + if args.source_project: + properties.append(f"-p:SourceProject={args.source_project.resolve()}") + build = [args.dotnet, "build", str(project), "-c", "Release", + "-p:UseSharedCompilation=false", "-maxCpuCount:4", *properties] + with (run / "build.log").open("w") as log: + subprocess.run(build, cwd=fixture, env=env, stdout=log, + stderr=subprocess.STDOUT, check=True) + + # Preserve normal font discovery without writing to the user's font cache. + # Cold font-cache/native HarfBuzz failures remain visible in the case logs. + font_cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "fontconfig" + for case in ("long", "short", "welcome", "variable"): + case_dir = run / case + for relative in ("config/SourceGit", "cache/SourceGit"): + (case_dir / relative).mkdir(parents=True) + if font_cache.is_dir(): + shutil.copytree(font_cache, case_dir / "cache/fontconfig") + case_env = env | {"TEST_OUTPUT": str(case_dir), + "XDG_CONFIG_HOME": str(case_dir / "config"), + "XDG_CACHE_HOME": str(case_dir / "cache")} + command = [args.dotnet, str(fixture / "bin/Release/net10.0/TabDragFeedback.dll"), case] + with (case_dir / "test.log").open("w") as log: + result = subprocess.run(command, cwd=fixture, env=case_env, + stdout=log, stderr=subprocess.STDOUT) + if result.returncode: + raise SystemExit(f"FAIL {case}: see {case_dir / 'test.log'}") + print(f"PASS {case}: {case_dir}", flush=True) + print(f"All four cases passed. Logs and rendered previews: {run}") + + +if __name__ == "__main__": + main()