diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 82927b47..a11dad5a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -155,7 +155,8 @@ The editor's Front / Back switch (`ToolbarPageViewModel.EditingBack`) edits eith A group is one slice that opens up to six non-group actions. Its members may be conversions or interactive tools (for example the PDF Edit group contains Crop and the page editor); groups cannot contain another group. The group editor offers both kinds, and `UserDataStore.EnsureConsistency` removes missing, duplicate, nested or excess members -without excluding tools. +without excluding tools. A group slice is marked with a small chevron by its outer edge, drawn in the slice's transform +(`DonutMenu.DrawGroupMark`), which also marks a dragged group in the toolbar editor. ## Annotate tool diff --git a/src/Filee.App/Controls/DonutMenu.Progress.cs b/src/Filee.App/Controls/DonutMenu.Progress.cs index fb0fd851..94899e75 100644 --- a/src/Filee.App/Controls/DonutMenu.Progress.cs +++ b/src/Filee.App/Controls/DonutMenu.Progress.cs @@ -294,8 +294,7 @@ private void RenderProgress(DrawingContext context, Point center, double r1, dou if (hot) context.DrawGeometry(WithOpacity(AccentBrush, 0.8), null, wedge); // Labels shrink with their slice instead of vanishing when the collapse starts. - DrawLabel(context, Items![i], center, (r1 + r2) / 2, DonutGeometry.MidAngle(i, n), - DonutGeometry.Span(n) * (r1 + r2) / 2 - 10, r2 - r1 - 16, bold, regular, hot); + DrawSliceLabel(context, Items![i], center, r1, r2, DonutGeometry.MidAngle(i, n), DonutGeometry.Span(n), bold, regular, hot); } } diff --git a/src/Filee.App/Controls/DonutMenu.cs b/src/Filee.App/Controls/DonutMenu.cs index aafccfc1..df645759 100644 --- a/src/Filee.App/Controls/DonutMenu.cs +++ b/src/Filee.App/Controls/DonutMenu.cs @@ -52,7 +52,7 @@ public sealed partial class DonutMenu : Control public static readonly StyledProperty?> ItemsProperty = AvaloniaProperty.Register?>(nameof(Items)); public static readonly StyledProperty OuterRadiusProperty = - AvaloniaProperty.Register(nameof(OuterRadius), 150); + AvaloniaProperty.Register(nameof(OuterRadius), DefaultOuterRadius); public static readonly StyledProperty HoleRatioProperty = AvaloniaProperty.Register(nameof(HoleRatio), 0.42); public static readonly StyledProperty SliceOpacityProperty = @@ -125,6 +125,8 @@ public sealed partial class DonutMenu : Control private int _previewSlot = -1; private int _previewRemoved = -1; private string? _previewLabel; + private bool _previewGroup; + internal bool PreviewIsGroup => _previewGroup; private (int Index, Point Start, PointerPressedEventArgs Args)? _press; static DonutMenu() @@ -294,11 +296,11 @@ public void SetDropTarget(int index) /// /// Edit mode: opens a gap at (the position in the list after the drop) and shows a /// translucent slice labelled there. is the slice being moved, - /// which leaves its place. -1 closes the gap. + /// which leaves its place. -1 closes the gap. : the slice is a group, and marked as one. /// - public void SetDropPreview(int slot, string? label = null, int removed = -1) + public void SetDropPreview(int slot, string? label = null, int removed = -1, bool group = false) { - if (_previewSlot == slot && _previewRemoved == removed && _previewLabel == label) + if (_previewSlot == slot && _previewRemoved == removed && _previewLabel == label && _previewGroup == group) return; if (_previewSlot < 0 && slot >= 0) { @@ -309,6 +311,7 @@ public void SetDropPreview(int slot, string? label = null, int removed = -1) _previewSlot = slot; _previewRemoved = removed; _previewLabel = label; + _previewGroup = group; StartAnimation(); } @@ -900,8 +903,7 @@ private void RenderRing(DrawingContext context, Point center, double r1, double context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(emphasis, 0, 1)), null, wedge); var hot = (i == ActiveIndex || i == _dropTargetIndex) && item.IsEnabled; - DrawLabel(context, item, center, (r1 + r2) / 2, mid, span * (r1 + r2) / 2 - 10, - r2 - r1 - 16, typeface, typefaceRegular, hot); + DrawSliceLabel(context, item, center, r1, r2, mid, span, typeface, typefaceRegular, hot); } } @@ -925,8 +927,7 @@ private void RenderRing(DrawingContext context, Point center, double r1, double context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity * (slice.Item.IsEnabled ? 1 : 0.55)), BorderPen(), wedge); if (slice.Emphasis > 0.01 && slice.Item.IsEnabled) context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(slice.Emphasis, 0, 1)), null, wedge); - DrawLabel(context, slice.Item, center, (r1 + r2) / 2, slice.Mid, - slice.Span * (r1 + r2) / 2 - 10, r2 - r1 - 16, typeface, typefaceRegular, + DrawSliceLabel(context, slice.Item, center, r1, r2, slice.Mid, slice.Span, typeface, typefaceRegular, slice.Hot && slice.Item.IsEnabled); } } @@ -1080,6 +1081,54 @@ private void DrawLabel(DrawingContext context, DonutItem item, Point center, dou DrawCentered(context, caption, anchor.X, y + label.Height); } + /// A slice's name and caption, and a group's mark, the name moved in to leave the mark room. + private void DrawSliceLabel(DrawingContext context, DonutItem item, Point center, double r1, double r2, double mid, + double span, Typeface bold, Typeface regular, bool hot) + { + var room = item.IsGroup ? GroupMarkRoom * GroupMarkScale : 0; + DrawLabel(context, item, center, (r1 + r2 - room) / 2, mid, span * (r1 + r2) / 2 - 10, r2 - r1 - 16, bold, regular, hot); + DrawGroupMark(context, item, center, r2, mid, hot); + } + + /// + /// A group slice's mark: a small chevron by its outer edge pointing outward, where its ring fans out, as a + /// submenu's ▸ points to where it opens. Drawn inside the slice's transform, it opens, pops out and flips with it. + /// + private void DrawGroupMark(DrawingContext context, DonutItem item, Point center, double r2, double mid, bool hot) + { + if (!item.IsGroup) + return; + var brush = hot ? OnAccentBrush : SecondaryForeground; + if (brush is null) + return; + var size = GroupMarkScale; + var inset = GroupMarkInset * size; + var tip = DonutGeometry.PointAt(center, r2 - inset, mid); + var back = r2 - inset - GroupMarkDepth * size; + // The arms' ends sit GroupMarkHalfWidth to either side of the slice's middle line. + var turn = GroupMarkHalfWidth * size / back; + var pen = new Pen(brush, GroupMarkStroke * size, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round); + var mark = new StreamGeometry(); + using (var g = mark.Open()) + { + g.BeginFigure(DonutGeometry.PointAt(center, back, mid - turn), false); + g.LineTo(tip); + g.LineTo(DonutGeometry.PointAt(center, back, mid + turn)); + g.EndFigure(false); + } + using (context.PushOpacity(item.IsEnabled ? 1 : 0.5)) + context.DrawGeometry(null, pen, mark); + } + + /// The group mark's size follows the donut's: 1 at the default radius. + private double GroupMarkScale => OuterRadius / DefaultOuterRadius; + + private const double DefaultOuterRadius = 150; + + // The group mark at the default radius: its tip this far in from the outer edge, its arms this far back and this + // far to each side, its line this thick, and how far a group's text area gives way for it. + private const double GroupMarkInset = 7, GroupMarkDepth = 4.5, GroupMarkHalfWidth = 5, GroupMarkStroke = 1.6, GroupMarkRoom = 6; + /// /// A slice's caption under its name: one line, or two where the slice is deep enough, so a caption wider than the /// slice ("Same format" on a small donut) wraps instead of ending in "Same for…". @@ -1209,8 +1258,8 @@ private void DrawPreview(DrawingContext context, Point center, double r1, double if (_previewLabel is null || span < 0.25) return; using (context.PushOpacity(Math.Clamp((span - 0.25) / 0.3, 0, 1) * 0.9)) - DrawLabel(context, new DonutItem { Id = "", Label = _previewLabel }, center, (r1 + r2) / 2, mid, - span * (r1 + r2) / 2 - 10, r2 - r1 - 16, bold, regular, hot: false); + DrawSliceLabel(context, new DonutItem { Id = "", Label = _previewLabel, Children = _previewGroup ? [] : null }, center, + r1, r2, mid, span, bold, regular, hot: false); } /// diff --git a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs index 63258378..a3d39c72 100644 --- a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs @@ -62,6 +62,7 @@ public sealed record PaletteChip(string PresetId, string Label, string? Caption, public bool IsDimmed => Use != PresetUse.Usable; public bool CanAdd => Use != PresetUse.Unusable; public object? Tip { get; init; } + public bool IsGroup { get; init; } } public sealed partial class ToolbarPageViewModel : ObservableObject, IDisposable @@ -699,7 +700,9 @@ private void Rebuild() DonutItems = used .Select(_store.FindPreset) .OfType() - .Select(p => new DonutItem { Id = p.Id, Label = _loc.DonutName(p), Caption = Caption(p) }) + // A group is marked as one (no children: the editor doesn't fan them out), so the preview draws it as the + // donut does. + .Select(p => new DonutItem { Id = p.Id, Label = _loc.DonutName(p), Caption = Caption(p), Children = p.IsGroup ? [] : null }) .ToList(); Palette.Clear(); @@ -746,7 +749,7 @@ private void Rebuild() if (caption is not null && label.Contains(caption, StringComparison.OrdinalIgnoreCase)) caption = null; var chip = new PaletteChip(preset.Id, label, caption, reason, use) - { Tip = TipFor(preset, use, reason) }; + { Tip = TipFor(preset, use, reason), IsGroup = preset.IsGroup }; if (use != PresetUse.Unusable) Palette.Add(chip); if ((query.Length == 0 && use != PresetUse.Unusable) @@ -843,7 +846,7 @@ private void RefreshVisiblePalette() && caption?.Contains(query, StringComparison.CurrentCultureIgnoreCase) != true) continue; candidates.Add((new PaletteChip(preset.Id, label, caption, judgement.Reason, judgement.Use) - { Tip = TipFor(preset, judgement.Use, judgement.Reason) }, + { Tip = TipFor(preset, judgement.Use, judgement.Reason), IsGroup = preset.IsGroup }, preset.IsGroup ? PaletteTab.All : TabOf(preset), preset.IsGroup)); } RefreshVisiblePalette(candidates); diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs b/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs index abd33e72..2bf544be 100644 --- a/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs +++ b/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs @@ -29,8 +29,9 @@ public partial class ToolbarPage : UserControl private enum Source { Chip, Slice } - // CanAdd: fixed when the drag starts, so a chip that a search or a tab swaps under the pointer can't change it. - private sealed record DragState(Source Source, string? PresetId, int SliceIndex, string Label, Point Start, bool CanAdd = true) + // CanAdd, IsGroup: fixed when the drag starts, so a chip that a search or a tab swaps under the pointer, or slices + // that another profile replaces, can't change them. + private sealed record DragState(Source Source, string? PresetId, int SliceIndex, string Label, Point Start, bool CanAdd = true, bool IsGroup = false) { public bool Active { get; set; } } @@ -63,7 +64,8 @@ public ToolbarPage() if (Vm is null || e.Index >= Vm.DonutItems.Count) return; var p = e.Args.GetPosition(Root); - _drag = new DragState(Source.Slice, null, e.Index, Vm.DonutItems[e.Index].Label, p) { Active = true }; + var item = Vm.DonutItems[e.Index]; + _drag = new DragState(Source.Slice, null, e.Index, item.Label, p, IsGroup: item.IsGroup) { Active = true }; Donut.SetDimmedIndex(e.Index); ShowGhost(_drag.Label, p); }; @@ -246,7 +248,7 @@ private void OnChipPressed(object? sender, PointerPressedEventArgs e) if (!point.Properties.IsLeftButtonPressed || !chip.CanAdd) return; - _drag = new DragState(Source.Chip, chip.PresetId, -1, chip.Label, e.GetPosition(Root), chip.CanAdd); + _drag = new DragState(Source.Chip, chip.PresetId, -1, chip.Label, e.GetPosition(Root), chip.CanAdd, chip.IsGroup); e.Pointer.Capture(border); e.Handled = true; } @@ -291,7 +293,7 @@ private void OnRootPointerMoved(object? sender, PointerEventArgs e) ShowGhost(_drag.Label, p); var (sliceTarget, overPalette) = FindTarget(e); var removing = overPalette && _drag.Source == Source.Slice; - Donut.SetDropPreview(removing ? -1 : sliceTarget, _drag.Label, _drag.Source == Source.Slice ? _drag.SliceIndex : -1); + Donut.SetDropPreview(removing ? -1 : sliceTarget, _drag.Label, _drag.Source == Source.Slice ? _drag.SliceIndex : -1, _drag.IsGroup); PaletteBox.Classes.Set("over", removing); } diff --git a/tests/Filee.App.Tests/DonutGroupMarkTests.cs b/tests/Filee.App.Tests/DonutGroupMarkTests.cs new file mode 100644 index 00000000..6d761fd0 --- /dev/null +++ b/tests/Filee.App.Tests/DonutGroupMarkTests.cs @@ -0,0 +1,335 @@ +// A group slice's mark: a small chevron by its outer edge pointing outward, where the group's ring fans out. Drawn on +// group slices only, on the donut and in the Donut toolbar page's preview alike. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Filee.App.Controls; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.App.ViewModels.Pages; +using Filee.App.Views; +using Filee.Core.Localization; +using Filee.Core.Settings; + +namespace Filee.App.Tests; + +public class DonutGroupMarkTests +{ + /// A donut of six slices, the first a group or the same slice as a preset, drawn as it is shown. + private static byte[] Render(bool group, Action? setUp = null, string label = "\u00A0", double radius = 150, Action? after = null) + { + TestServices.EnsureInitialized(); + Motion.Configure(false, () => false); + DonutItem Slice(string name) => new() { Id = name, Label = "\u00A0" }; + var first = group + ? new DonutItem { Id = "first", Label = label, Children = [Slice("Left")] } + : new DonutItem { Id = "first", Label = label }; + var donut = new DonutMenu { Items = [first, Slice("b"), Slice("c"), Slice("d"), Slice("e"), Slice("f")], OuterRadius = radius, ReserveSubRing = true }; + setUp?.Invoke(donut); + var side = (int)(radius * 4 + 40); + var window = new Window { Content = donut, Width = side, Height = side }; + window.Show(); + try + { + Dispatcher.UIThread.RunJobs(); + for (var i = 0; i < 60; i++) + donut.Advance(1 / 60.0); + RenderTests.Pump(); + // Stepped by hand from here: the dispatcher's own clock would draw each donut a different frame. + after?.Invoke(donut); + using var image = new Avalonia.Media.Imaging.RenderTargetBitmap(new PixelSize(side, side)); + image.Render(donut); + var pixels = new byte[side * side * 4]; + var pin = System.Runtime.InteropServices.GCHandle.Alloc(pixels, System.Runtime.InteropServices.GCHandleType.Pinned); + try { image.CopyPixels(new PixelRect(0, 0, side, side), pin.AddrOfPinnedObject(), pixels.Length, side * 4); } + finally { pin.Free(); } + return pixels; + } + finally + { + window.Close(); + } + } + + // The picture's side at the default radius. + private const int Picture = 640; + + /// Where two pictures at the default radius differ, from the donut's centre. + private static List Ink(byte[] a, byte[] b) => + [.. Changed(a, b).Select(i => new Point(i % Picture - Picture / 2.0, i / Picture - Picture / 2.0))]; + + [AvaloniaFact] + public void Only_a_group_slice_has_the_chevron_by_its_outer_edge() + { + var ink = Ink(Render(true), Render(false)); + + // At the top, the first slice's outer edge, 7 to 12 px in from it, and nowhere else. + Assert.NotEmpty(ink); + Assert.All(ink, p => + { + Assert.InRange(p.X, -7, 7); + Assert.InRange(p.Y, -145, -136); + }); + } + + private static IEnumerable Changed(byte[] a, byte[] b) => + Enumerable.Range(0, a.Length / 4).Where(i => Enumerable.Range(0, 3).Any(k => Math.Abs(a[i * 4 + k] - b[i * 4 + k]) > 24)); + + [AvaloniaFact] + public void The_chevron_is_as_large_as_the_donut_says() + { + int Pixels(double radius) => Changed(Render(true, radius: radius), Render(false, radius: radius)).Count(); + + Assert.InRange(Pixels(220) / (double)Pixels(110), 2.5, 6); + } + + [AvaloniaFact] + public void A_groups_name_moves_in_to_leave_the_chevron_room() + { + // The first row the name's ink reaches, the name being what differs from the same slice without one. + int NameTop(bool group) => Changed(Render(group, label: "Rotate"), Render(group)).Min() / Picture; + + Assert.InRange(NameTop(true) - NameTop(false), 2, 4); + } + + [AvaloniaFact] + public void Lit_the_chevron_takes_the_highlights_text_colour() + { + static bool IsLime(byte[] p, int i) => p[i * 4 + 1] > 200 && p[i * 4] < 90 && p[i * 4 + 2] < 90; + static int Lime(byte[] p) => Enumerable.Range(0, p.Length / 4).Count(i => IsLime(p, i)); + void Lit(DonutMenu donut) + { + donut.OnAccentBrush = Brushes.Lime; + donut.SetExternalHighlight(0); + } + + Assert.InRange(Lime(Render(true, Lit)), 1, 400); + Assert.Equal(0, Lime(Render(false, Lit))); + } + + [AvaloniaFact] + public void A_disabled_groups_chevron_is_faded() + { + static int Strongest(byte[] p) => Enumerable.Range(0, p.Length / 4).Max(i => p[i * 4 + 1] - Math.Max(p[i * 4], p[i * 4 + 2])); + void Green(DonutMenu donut) => donut.SecondaryForeground = Brushes.Lime; + void Disabled(DonutMenu donut) + { + Green(donut); + var first = donut.Items![0]; + donut.Items = [new DonutItem { Id = first.Id, Label = first.Label, Children = first.Children, IsEnabled = false }, .. donut.Items.Skip(1)]; + } + + Assert.True(Strongest(Render(true, Disabled)) < Strongest(Render(true, Green)) * 0.6); + } + + [AvaloniaFact] + public void The_chevron_collapses_into_the_progress_ring_with_its_slice() + { + byte[] Shot(bool group) => Render(group, after: donut => + { + donut.BeginProgress(); + for (var i = 0; i < 8; i++) + donut.Advance(1 / 60.0); + }); + static double Reach(List ink) => ink.Max(p => Point.Distance(p, new Point(0, 0))); + + var collapsing = Ink(Shot(true), Shot(false)); + + // On its way to the centre, where its slice is going. + Assert.NotEmpty(collapsing); + Assert.True(Reach(collapsing) < Reach(Ink(Render(true), Render(false))) - 5); + } + + [AvaloniaFact] + public void On_a_side_slice_the_chevron_turns_with_it_and_pops_out_with_it() + { + // The first slice moved to third of six, at the lower right. Lit, it pops out and grows, its chevron with it. + static void Side(DonutMenu donut) => donut.Items = [.. donut.Items!.Skip(4), .. donut.Items!.Take(4)]; + static void Lit(DonutMenu donut) + { + Side(donut); + donut.SetExternalHighlight(2); + } + double Reach(Action setUp, double within) + { + var ink = Ink(Render(true, setUp), Render(false, setUp)); + Assert.NotEmpty(ink); + var mark = DonutGeometry.PointAt(new Point(0, 0), 141, DonutGeometry.MidAngle(2, 6)); + Assert.All(ink, p => Assert.InRange(Point.Distance(p, mark), 0, within)); + return ink.Max(p => Point.Distance(p, new Point(0, 0))); + } + + Assert.True(Reach(Lit, 24) > Reach(Side, 12) + 5); + } + + [AvaloniaFact] + public void The_flip_draws_the_chevron_on_the_old_face_too() + { + byte[] Shot(bool group) => Render(group, after: donut => + { + var back = donut.Items!.Select(i => new DonutItem { Id = "back" + i.Id, Label = "\u00A0" }).ToList(); + donut.CanFlip = true; + donut.FaceSwapping += (_, _) => donut.Items = back; + Assert.True(donut.Flip()); + for (var i = 0; i < 2; i++) + donut.Advance(1 / 60.0); + }); + static double Width(List ink) => ink.Max(p => p.X) - ink.Min(p => p.X); + + var turning = Ink(Shot(true), Shot(false)); + + // The slice at the top turns about its middle line: its chevron narrows with it. + Assert.NotEmpty(turning); + Assert.True(Width(turning) < Width(Ink(Render(true), Render(false)))); + } + + [AvaloniaFact] + public void In_the_editor_a_group_is_marked_at_the_size_of_a_preset_and_so_is_the_slice_being_dragged_in() + { + static void Editing(DonutMenu donut) + { + donut.IsEditMode = true; + donut.ReserveSubRing = false; + donut.Items = [.. donut.Items!.Select(i => i.IsGroup ? new DonutItem { Id = i.Id, Label = i.Label, Children = [] } : i)]; + } + double Measured(bool group) + { + var size = 0.0; + Render(group, Editing, after: donut => + { + donut.Measure(Size.Infinity); + size = donut.DesiredSize.Width; + }); + return size; + } + byte[] Dragging(bool group) => Render(false, Editing, after: donut => + { + donut.SetDropPreview(3, "\u00A0", group: group); + for (var i = 0; i < 60; i++) + donut.Advance(1 / 60.0); + }); + + Assert.NotEmpty(Changed(Render(true, Editing), Render(false, Editing))); + Assert.Equal(Measured(false), Measured(true)); + Assert.NotEmpty(Changed(Dragging(true), Dragging(false))); + } + + /// The Donut toolbar page on the PDF donut's front or back, and where to point at its slices. + private static (MainWindow Window, ToolbarPageViewModel Page, DonutMenu Donut, Func On) Editor(MainWindowViewModel main, bool back) + { + main.Navigate("toolbar"); + var window = new MainWindow { DataContext = main, Width = 1080, Height = 1000 }; + window.Show(); + var page = (ToolbarPageViewModel)main.CurrentPage!; + page.SelectedProfile = page.Profiles.Single(p => p.Profile.Id == "pdf"); + page.EditingBack = back; + RenderTests.Pump(); + var donut = window.GetVisualDescendants().OfType().Single(d => d.IsEditMode); + var count = page.DonutItems.Count; + Point On(double radius, int slice) => donut.TranslatePoint( + DonutGeometry.PointAt(new Point(donut.Bounds.Width / 2, donut.Bounds.Height / 2), radius, DonutGeometry.MidAngle(slice, count)), window)!.Value; + return (window, page, donut, On); + } + + private static bool Dragging(MainWindow window) => window.GetVisualDescendants().OfType().Single(c => c.Name == "Ghost").IsVisible; + + [AvaloniaFact] + public void A_profile_change_during_a_drag_does_not_crash_and_a_release_off_the_donut_changes_nothing() + { + TestServices.EnsureInitialized(); + Motion.Configure(false, () => false); + using var main = new MainWindowViewModel(AppHost.Services, AppHost.Get(), AppHost.Get(), AppHost.Get()); + var (window, page, donut, on) = Editor(main, back: false); + try + { + var count = page.DonutItems.Count; + var other = page.Profiles.Where(p => p.Profile.Id != "pdf").MinBy(p => p.Profile.PresetIds.Count)!; + var before = page.Profiles.Select(p => string.Join(",", p.Profile.PresetIds)).ToList(); + + window.MouseDown(on(donut.OuterRadius - 40, count - 1), MouseButton.Left); + window.MouseMove(on(donut.OuterRadius - 20, count - 1)); + window.MouseMove(on(donut.OuterRadius - 30, count - 2)); + Assert.True(Dragging(window)); + page.SelectedProfile = other; + RenderTests.Pump(); + Assert.True(page.DonutItems.Count < count); + + window.MouseMove(new Point(5, 5)); + window.MouseUp(new Point(5, 5), MouseButton.Left); + + Assert.False(Dragging(window)); + Assert.Equal(before, page.Profiles.Select(p => string.Join(",", p.Profile.PresetIds))); + } + finally + { + window.Close(); + } + } + + [AvaloniaTheory] + [InlineData(true)] + [InlineData(false)] + public void Dragging_a_slice_shows_it_marked_as_it_is_on_the_donut(bool group) + { + TestServices.EnsureInitialized(); + Motion.Configure(false, () => false); + using var main = new MainWindowViewModel(AppHost.Services, AppHost.Get(), AppHost.Get(), AppHost.Get()); + var (window, page, donut, on) = Editor(main, back: true); + try + { + Assert.Contains(page.DonutItems, i => i.IsGroup == group); + var slice = page.DonutItems.ToList().FindIndex(i => i.IsGroup == group); + var next = (slice + 1) % page.DonutItems.Count; + + window.MouseDown(on(donut.OuterRadius - 40, slice), MouseButton.Left); + window.MouseMove(on(donut.OuterRadius - 20, slice)); + window.MouseMove(on(donut.OuterRadius - 40, next)); + + Assert.True(Dragging(window)); + Assert.Equal(group, donut.PreviewIsGroup); + + window.MouseMove(new Point(5, 5)); + window.MouseUp(new Point(5, 5), MouseButton.Left); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void The_toolbar_preview_marks_group_slices_as_groups() + { + TestServices.EnsureInitialized(); + using var main = new MainWindowViewModel(AppHost.Services, AppHost.Get(), AppHost.Get(), AppHost.Get()); + main.Navigate("toolbar"); + var window = new MainWindow { DataContext = main, Width = 1080, Height = 1000 }; + window.Show(); + try + { + var page = (ToolbarPageViewModel)main.CurrentPage!; + page.SelectedProfile = page.Profiles.Single(p => p.Profile.Id == "pdf"); + page.EditingBack = true; + RenderTests.Pump(); + var store = AppHost.Get(); + Assert.Contains(page.DonutItems, i => i.IsGroup); + Assert.All(page.DonutItems, i => Assert.Equal(store.FindPreset(i.Id)?.IsGroup == true, i.IsGroup)); + // The chips the page shows, whatever the tab or the search. + page.SelectedTab = page.Tabs.First(); + RenderTests.Pump(); + Assert.Contains(page.VisiblePalette, c => c.IsGroup); + Assert.All(page.VisiblePalette, c => Assert.Equal(store.FindPreset(c.PresetId)?.IsGroup == true, c.IsGroup)); + RenderTests.Save(window, "page-toolbar-group-mark.png"); + } + finally + { + window.Close(); + } + } +}