Skip to content
Merged
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
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 1 addition & 2 deletions src/Filee.App/Controls/DonutMenu.Progress.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
69 changes: 59 additions & 10 deletions src/Filee.App/Controls/DonutMenu.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public sealed partial class DonutMenu : Control
public static readonly StyledProperty<IReadOnlyList<DonutItem>?> ItemsProperty =
AvaloniaProperty.Register<DonutMenu, IReadOnlyList<DonutItem>?>(nameof(Items));
public static readonly StyledProperty<double> OuterRadiusProperty =
AvaloniaProperty.Register<DonutMenu, double>(nameof(OuterRadius), 150);
AvaloniaProperty.Register<DonutMenu, double>(nameof(OuterRadius), DefaultOuterRadius);
public static readonly StyledProperty<double> HoleRatioProperty =
AvaloniaProperty.Register<DonutMenu, double>(nameof(HoleRatio), 0.42);
public static readonly StyledProperty<double> SliceOpacityProperty =
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -294,11 +296,11 @@ public void SetDropTarget(int index)
/// <summary>
/// Edit mode: opens a gap at <paramref name="slot"/> (the position in the list after the drop) and shows a
/// translucent slice labelled <paramref name="label"/> there. <paramref name="removed"/> is the slice being moved,
/// which leaves its place. -1 closes the gap.
/// which leaves its place. -1 closes the gap. <paramref name="group"/>: the slice is a group, and marked as one.
/// </summary>
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)
{
Expand All @@ -309,6 +311,7 @@ public void SetDropPreview(int slot, string? label = null, int removed = -1)
_previewSlot = slot;
_previewRemoved = removed;
_previewLabel = label;
_previewGroup = group;
StartAnimation();
}

Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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);
}
}
Expand Down Expand Up @@ -1080,6 +1081,54 @@ private void DrawLabel(DrawingContext context, DonutItem item, Point center, dou
DrawCentered(context, caption, anchor.X, y + label.Height);
}

/// <summary>A slice's name and caption, and a group's mark, the name moved in to leave the mark room.</summary>
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);
}

/// <summary>
/// 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.
/// </summary>
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);
}

/// <summary>The group mark's size follows the donut's: 1 at the default radius.</summary>
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;

/// <summary>
/// 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…".
Expand Down Expand Up @@ -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);
}

/// <summary>
Expand Down
9 changes: 6 additions & 3 deletions src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -699,7 +700,9 @@ private void Rebuild()
DonutItems = used
.Select(_store.FindPreset)
.OfType<Preset>()
.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();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 7 additions & 5 deletions src/Filee.App/Views/Pages/ToolbarPage.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading