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
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ dotnet_diagnostic.CA2007.severity = none
# Font discovery failures are converted into dialog status here.
dotnet_diagnostic.CA1031.severity = none

[Views/TemplateFlyoutView.axaml.cs]
# Drag continuations must resume on Avalonia's UI thread.
dotnet_diagnostic.CA2007.severity = none

[ViewModels/*.cs]
# Reactive commands update bound properties after awaits and require the UI context.
dotnet_diagnostic.CA2007.severity = none
Expand Down
104 changes: 104 additions & 0 deletions Models/TemplateTextElementGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,15 @@ private void OnOptionsChanged(TemplateInfo info, NotifyCollectionChangedEventArg
return;
}

if (e.Action == NotifyCollectionChangedAction.Move &&
e.OldStartingIndex >= 0 &&
e.NewStartingIndex >= 0 &&
e.OldItems?.Count == 1)
{
RecordOptionMove(info, e.OldStartingIndex, e.NewStartingIndex);
return;
}

if (info.SelectedIndex >= info.Options.Count)
{
SelectByMetadata(info, TemplateTextElement.PlaceholderText, -1);
Expand Down Expand Up @@ -319,6 +328,57 @@ private void RecordOptionRemoval(TemplateInfo info, string option, int removedIn
NotifyChanged();
}

private void RecordOptionMove(TemplateInfo info, int oldIndex, int newIndex)
{
if (oldIndex == newIndex)
{
return;
}

var oldSelectedIndex = info.SelectedIndex;
var newSelectedIndex = IndexAfterMove(oldSelectedIndex, oldIndex, newIndex);

RunUndoGroup(() =>
{
_editor.Document.UndoStack.Push(new TemplateOptionMoveOperation(
this,
info,
oldIndex,
newIndex,
info.SelectedText,
oldSelectedIndex,
newSelectedIndex));
ApplySelectionMetadata(info, info.SelectedText, newSelectedIndex);
});

NotifyChanged();
}

private static int IndexAfterMove(int index, int oldIndex, int newIndex)
{
if (index < 0)
{
return index;
}

if (index == oldIndex)
{
return newIndex;
}

if (oldIndex < newIndex && index > oldIndex && index <= newIndex)
{
return index - 1;
}

if (oldIndex > newIndex && index >= newIndex && index < oldIndex)
{
return index + 1;
}

return index;
}

private void SelectByMetadata(TemplateInfo info, string text, int selectedIndex)
{
if (info.Anchor.Offset < 0)
Expand Down Expand Up @@ -801,4 +861,48 @@ private void ReplayCollectionChange(bool add)
});
}
}

private sealed class TemplateOptionMoveOperation(
TemplateTextElementGenerator generator,
TemplateInfo template,
int oldIndex,
int newIndex,
string selectedText,
int oldSelectedIndex,
int newSelectedIndex) : IUndoableOperation
{
public void Undo()
{
ReplayMove(newIndex, oldIndex);
generator.ApplySelectionMetadata(
template,
selectedText,
oldSelectedIndex);
generator.ClampCaretOffset();
generator.TemplatesChanged?.Invoke();
}

public void Redo()
{
ReplayMove(oldIndex, newIndex);
generator.ApplySelectionMetadata(
template,
selectedText,
newSelectedIndex);
generator.ClampCaretOffset();
generator.TemplatesChanged?.Invoke();
}

private void ReplayMove(int sourceIndex, int targetIndex)
{
generator.ReplayOptionChange(() =>
{
if (sourceIndex >= 0 && sourceIndex < template.Options.Count &&
targetIndex >= 0 && targetIndex < template.Options.Count)
{
template.Options.Move(sourceIndex, targetIndex);
}
});
}
}
}
125 changes: 125 additions & 0 deletions Services/DragReorder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.VisualTree;

namespace Inlay;

internal static class DragReorder
{
public static bool IsInButton(object? source, string styleClass) =>
source is Visual visual &&
(visual as Button ?? visual.FindAncestorOfType<Button>()) is { } button &&
button.Classes.Contains(styleClass);

public static TContainer? FindContainer<TContainer>(object? source)
where TContainer : Control =>
source is Visual visual
? visual as TContainer ?? visual.FindAncestorOfType<TContainer>()
: null;

// A slot past the item being moved lands one index earlier once that item
// leaves its current place.
public static int SlotToIndex(int targetSlot, int sourceIndex, int count) =>
Math.Clamp(targetSlot > sourceIndex ? targetSlot - 1 : targetSlot, 0, count - 1);

public static int DropSlot(
ItemsControl items,
int count,
Point position,
Orientation orientation)
{
var pointer = orientation == Orientation.Horizontal ? position.X : position.Y;
var lastRealizedIndex = -1;
for (var index = 0; index < count; index++)
{
if (ContainerExtent(items, items, index, orientation) is not { } extent)
{
continue;
}

lastRealizedIndex = index;
if (pointer < extent.Start + extent.Size / 2)
{
return index;
}
}

return Math.Min(lastRealizedIndex + 1, count);
}

// Where a slot's drop indicator belongs along the axis, or null when neither
// the slot nor the item ahead of it has a realized container.
public static double? SlotOffset(
ItemsControl items,
int count,
int slot,
Orientation orientation,
Visual? relativeTo = null)
{
var origin = relativeTo ?? items;
if (slot < count && ContainerExtent(items, origin, slot, orientation) is { } extent)
{
return extent.Start;
}

if (slot > 0 && ContainerExtent(items, origin, slot - 1, orientation) is { } previous)
{
return previous.Start + previous.Size;
}

return null;
}

private static (double Start, double Size)? ContainerExtent(
ItemsControl items,
Visual relativeTo,
int index,
Orientation orientation)
{
if (items.ContainerFromIndex(index) is not Control container ||
container.TranslatePoint(new Point(0, 0), relativeTo) is not { } origin)
{
return null;
}

return orientation == Orientation.Horizontal
? (origin.X, container.Bounds.Width)
: (origin.Y, container.Bounds.Height);
}
}

// Tracks the item a pointer press landed on until the pointer has travelled far
// enough to mean a drag rather than a click.
internal sealed class DragCandidate<TItem, TContainer>
where TItem : class
where TContainer : Control
{
private const double DragThreshold = 6;

private (TItem Item, TContainer Container, PointerPressedEventArgs Trigger, Point Origin)?
_pending;

public void Arm(TItem item, TContainer container, PointerPressedEventArgs trigger, Point origin) =>
_pending = (item, container, trigger, origin);

public void Clear() => _pending = null;

public (TItem Item, TContainer Container, PointerPressedEventArgs Trigger)? TryStart(Point position)
{
if (_pending is not { } pending)
{
return null;
}

var delta = position - pending.Origin;
if (Math.Abs(delta.X) < DragThreshold && Math.Abs(delta.Y) < DragThreshold)
{
return null;
}

_pending = null;
return (pending.Item, pending.Container, pending.Trigger);
}
}
13 changes: 13 additions & 0 deletions Services/TemplateChoiceDragDrop.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Avalonia.Input;

namespace Inlay;

internal sealed class TemplateChoiceDragPayload
{
public static readonly DataFormat<TemplateChoiceDragPayload> Format =
DataFormat.CreateInProcessFormat<TemplateChoiceDragPayload>(
"application/x-inlay-template-choice");

public required TemplateFlyoutView SourceView { get; init; }
public required string SourceChoice { get; init; }
}
17 changes: 17 additions & 0 deletions Tests/Behavior/Editor/TemplateTextElementGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,23 @@ public void RemovingAnEarlierChoicePreservesSelectionThroughUndoAndRedo()
AssertTemplateState(editor, generator, viewModel, ["Two", "Three"], 1);
}

[AvaloniaFact]
public void ReorderingTheSelectedChoiceIsUndoable()
{
var (editor, generator, viewModel) = CreateTemplate(
["One", "Two", "Three"],
0);

viewModel.ReorderChoice("One", 3);
AssertTemplateState(editor, generator, viewModel, ["Two", "Three", "One"], 2);

editor.Undo();
AssertTemplateState(editor, generator, viewModel, ["One", "Two", "Three"], 0);

editor.Redo();
AssertTemplateState(editor, generator, viewModel, ["Two", "Three", "One"], 2);
}

[AvaloniaTheory]
[InlineData(1)]
[InlineData(2)]
Expand Down
Loading