diff --git a/src/Views/LauncherTabBar.axaml b/src/Views/LauncherTabBar.axaml index 76b4f7ddf..593011f1c 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 000000000..6f8f5ce83 --- /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 000000000..abc2d5ad5 --- /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 000000000..54af41845 --- /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 000000000..8cc6c79db --- /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()