diff --git a/.editorconfig b/.editorconfig index d808f24..4ccc4c0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/Models/TemplateTextElementGenerator.cs b/Models/TemplateTextElementGenerator.cs index 087e5b1..d90bf52 100644 --- a/Models/TemplateTextElementGenerator.cs +++ b/Models/TemplateTextElementGenerator.cs @@ -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); @@ -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) @@ -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); + } + }); + } + } } diff --git a/Services/DragReorder.cs b/Services/DragReorder.cs new file mode 100644 index 0000000..87f75e4 --- /dev/null +++ b/Services/DragReorder.cs @@ -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 - - - - + + + + + + + + + + + + + + + + _dragCandidate = new(); + private readonly DispatcherTimer _autoScrollTimer; + private Point _lastDragPosition; + private int _autoScrollDirection; + private ScrollViewer? _choiceScrollViewer; + private TemplateFlyoutViewModel? _observedViewModel; + public TemplateFlyoutView() { InitializeComponent(); + + _autoScrollTimer = new DispatcherTimer(DispatcherPriority.Input) + { + Interval = AutoScrollInterval + }; + _autoScrollTimer.Tick += OnAutoScrollTick; + + OptionsList.AddHandler( + PointerPressedEvent, + OnChoicePointerPressed, + RoutingStrategies.Tunnel, + handledEventsToo: true); + + AddHandler( + PointerMovedEvent, + OnPointerMoved, + RoutingStrategies.Tunnel, + handledEventsToo: true); + + AddHandler( + PointerReleasedEvent, + OnPointerReleased, + RoutingStrategies.Tunnel, + handledEventsToo: true); + + DragDrop.SetAllowDrop(OptionsList, true); + OptionsList.AddHandler(DragDrop.DragOverEvent, OnChoiceDragOver); + OptionsList.AddHandler(DragDrop.DragLeaveEvent, OnChoiceDragLeave); + OptionsList.AddHandler(DragDrop.DropEvent, OnChoiceDrop); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + StopAutoScroll(); + + if (_observedViewModel is not null) + { + _observedViewModel.Options.CollectionChanged -= OnOptionsChanged; + } + + _observedViewModel = DataContext as TemplateFlyoutViewModel; + if (_observedViewModel is not null) + { + _observedViewModel.Options.CollectionChanged += OnOptionsChanged; + ScheduleSelectionSynchronization(_observedViewModel); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + StopAutoScroll(); + base.OnDetachedFromVisualTree(e); } private void RemoveOptionClicked(object? sender, RoutedEventArgs e) @@ -36,4 +103,230 @@ private void NewChoiceKeyDown(object? sender, KeyEventArgs e) private void AddChoiceClicked(object? sender, RoutedEventArgs e) => Dispatcher.UIThread.Post(() => NewChoiceInput.Focus()); + + private void OnOptionsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Move && + _observedViewModel is { } viewModel) + { + ScheduleSelectionSynchronization(viewModel); + } + } + + private void ScheduleSelectionSynchronization(TemplateFlyoutViewModel viewModel) => + Dispatcher.UIThread.Post( + () => + { + if (ReferenceEquals(DataContext, viewModel)) + { + OptionsList.SelectedItem = viewModel.SelectedChoice; + } + }, + DispatcherPriority.Loaded); + + private void OnChoicePointerPressed(object? sender, PointerPressedEventArgs e) + { + var properties = e.GetCurrentPoint(this).Properties; + if (!properties.IsLeftButtonPressed || + DragReorder.IsInButton(e.Source, "option-remove") || + DataContext is not TemplateFlyoutViewModel viewModel) + { + return; + } + + if (DragReorder.FindContainer(e.Source) is { DataContext: string choice } item) + { + _dragCandidate.Arm(choice, item, e, e.GetPosition(this)); + viewModel.BeginSelectionHold(); + } + } + + private void OnPointerMoved(object? sender, PointerEventArgs e) + { + if (_dragCandidate.TryStart(e.GetPosition(this)) is not { } candidate) + { + return; + } + + if (DataContext is TemplateFlyoutViewModel viewModel) + { + viewModel.EndSelectionHold(applyHeldChoice: false); + ScheduleSelectionSynchronization(viewModel); + } + + _ = StartChoiceDragAsync(candidate.Trigger, candidate.Item, candidate.Container); + } + + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + _dragCandidate.Clear(); + (DataContext as TemplateFlyoutViewModel)?.EndSelectionHold(applyHeldChoice: true); + } + + private async Task StartChoiceDragAsync( + PointerPressedEventArgs trigger, + string choice, + ListBoxItem item) + { + using var dataTransfer = new DataTransfer(); + dataTransfer.Add(DataTransferItem.Create( + TemplateChoiceDragPayload.Format, + new TemplateChoiceDragPayload { SourceView = this, SourceChoice = choice })); + + item.Opacity = 0.5; + try + { + await DragDrop.DoDragDropAsync(trigger, dataTransfer, DragDropEffects.Move); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + // The platform refused to start a drag; the choice stays where it is. + } + finally + { + item.Opacity = 1; + StopAutoScroll(); + HideDropIndicator(); + } + } + + private void OnChoiceDragOver(object? sender, DragEventArgs e) + { + if (e.DataTransfer.TryGetValue(TemplateChoiceDragPayload.Format) is not { } payload || + !CanAcceptChoiceDrop(payload)) + { + e.DragEffects = DragDropEffects.None; + StopAutoScroll(); + HideDropIndicator(); + return; + } + + var pointInList = e.GetPosition(OptionsList); + e.DragEffects = DragDropEffects.Move; + ShowDropIndicator(CalculateChoiceDropIndex(pointInList)); + UpdateAutoScroll(pointInList); + e.Handled = true; + } + + private void OnChoiceDragLeave(object? sender, DragEventArgs e) + { + StopAutoScroll(); + HideDropIndicator(); + } + + private void OnChoiceDrop(object? sender, DragEventArgs e) + { + StopAutoScroll(); + HideDropIndicator(); + if (e.DataTransfer.TryGetValue(TemplateChoiceDragPayload.Format) is not { } payload || + !CanAcceptChoiceDrop(payload) || + DataContext is not TemplateFlyoutViewModel viewModel) + { + e.DragEffects = DragDropEffects.None; + return; + } + + viewModel.ReorderChoice( + payload.SourceChoice, + CalculateChoiceDropIndex(e.GetPosition(OptionsList))); + e.DragEffects = DragDropEffects.Move; + e.Handled = true; + } + + private void UpdateAutoScroll(Point pointInList) + { + _lastDragPosition = pointInList; + var edgeSize = Math.Min(AutoScrollEdgeSize, OptionsList.Bounds.Height / 2); + var direction = pointInList.Y <= edgeSize + ? -1 + : pointInList.Y >= OptionsList.Bounds.Height - edgeSize + ? 1 + : 0; + + if (direction == 0) + { + StopAutoScroll(); + return; + } + + _autoScrollDirection = direction; + if (!_autoScrollTimer.IsEnabled && ScrollChoiceList()) + { + _autoScrollTimer.Start(); + } + } + + private void OnAutoScrollTick(object? sender, EventArgs e) + { + if (!ScrollChoiceList()) + { + StopAutoScroll(); + } + } + + private bool ScrollChoiceList() + { + var scrollViewer = _choiceScrollViewer ??= OptionsList.FindDescendantOfType(); + if (scrollViewer is null) + { + return false; + } + + var maximumOffset = Math.Max(0, scrollViewer.Extent.Height - scrollViewer.Viewport.Height); + var newOffset = Math.Clamp( + scrollViewer.Offset.Y + _autoScrollDirection * AutoScrollStep, + 0, + maximumOffset); + if (Math.Abs(newOffset - scrollViewer.Offset.Y) < 0.5) + { + return false; + } + + scrollViewer.Offset = scrollViewer.Offset.WithY(newOffset); + OptionsList.UpdateLayout(); + ShowDropIndicator(CalculateChoiceDropIndex(_lastDragPosition)); + return true; + } + + private void StopAutoScroll() + { + _autoScrollTimer.Stop(); + _autoScrollDirection = 0; + } + + internal bool IsAutoScrollingChoices => _autoScrollTimer.IsEnabled; + + internal bool CanAcceptChoiceDrop(TemplateChoiceDragPayload? payload) => + payload is not null && + payload.SourceView == this && + DataContext is TemplateFlyoutViewModel viewModel && + viewModel.Options.Contains(payload.SourceChoice); + + private int ChoiceCount => (DataContext as TemplateFlyoutViewModel)?.Options.Count ?? 0; + + private int CalculateChoiceDropIndex(Point pointInList) => DragReorder.DropSlot( + OptionsList, + ChoiceCount, + pointInList, + Orientation.Vertical); + + private void ShowDropIndicator(int slot) + { + if (DragReorder.SlotOffset( + OptionsList, + ChoiceCount, + slot, + Orientation.Vertical, + relativeTo: OptionsListHost) is not { } y) + { + HideDropIndicator(); + return; + } + + var maximumY = Math.Max(0, OptionsListHost.Bounds.Height - ChoiceDropIndicator.Height); + ChoiceDropIndicator.Margin = new Thickness(4, Math.Clamp(y - 1, 0, maximumY), 4, 0); + ChoiceDropIndicator.IsVisible = true; + } + + private void HideDropIndicator() => ChoiceDropIndicator.IsVisible = false; }