Add project files.

This commit is contained in:
Ankitkumar Satapara
2026-04-17 22:31:58 +05:30
commit 21aaef6776
473 changed files with 50152 additions and 0 deletions

View File

@@ -0,0 +1,370 @@
using System.Windows;
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <summary>
/// Represents an abstract base class for managing drag interactions within a UI element.
/// Provides a framework for handling input gestures such as starting, canceling, and completing drag interactions.
/// </summary>
/// <typeparam name="TElement">The type of <see cref="FrameworkElement"/> that owns the state.</typeparam>
public abstract class DragState<TElement> : InputElementState<TElement>, IInputHandler
where TElement : FrameworkElement
{
private enum InteractionState
{
/// <summary>
/// Indicates that no drag interaction is active. This is the initial state or the state after a drag interaction has been canceled or completed.
/// </summary>
Ready,
/// <summary>
/// Indicates that a drag interaction is currently active and handling input events. This state is entered when a drag begins.
/// </summary>
InProgress,
/// <summary>
/// Indicates that a drag interaction is in the process of ending. This state is used to handle toggled interactions (see <see cref="IsToggle"/>).
/// </summary>
Ending
}
/// <summary>
/// Gets the gesture used to cancel the drag interaction, if defined.
/// </summary>
protected InputGesture? CancelGesture { get; }
/// <summary>
/// Gets the gesture used to begin the drag interaction.
/// </summary>
protected InputGesture BeginGesture { get; }
/// <summary>
/// Indicates whether the element has a context menu associated with it.
/// </summary>
/// <remarks>This property is used to suppress the context menu when a drag interaction is performed using the right mouse button.</remarks>
protected virtual bool HasContextMenu => Element.ContextMenu != null;
/// <summary>
/// Determines if the drag interaction can begin (see <see cref="OnBegin(InputEventArgs)"/>).
/// </summary>
protected virtual bool CanBegin { get; } = true;
/// <summary>
/// Determines if the drag interaction can be canceled (see <see cref="OnCancel(InputEventArgs)"/>).
/// </summary>
protected virtual bool CanCancel { get; } = true;
/// <summary>
/// Indicates if the drag gesture is a toggle, meaning the same gesture can be used to both start and stop the interaction.
/// </summary>
protected virtual bool IsToggle { get; }
/// <summary>
/// Gets or sets the UI element used to calculate the mouse position during the drag interaction.
/// </summary>
protected IInputElement PositionElement { get; set; }
private InteractionState _interactionState;
private Point _initialPosition;
/// <summary>
/// Initializes a new instance of the <see cref="DragState{TElement}"/> class with a begin gesture.
/// </summary>
/// <param name="element">The element associated with this state.</param>
/// <param name="beginGesture">The gesture used to start the drag interaction.</param>
public DragState(TElement element, InputGesture beginGesture) : base(element)
{
BeginGesture = beginGesture;
PositionElement = element;
}
/// <summary>
/// Initializes a new instance of the <see cref="DragState{TElement}"/> class with begin and cancel gestures.
/// </summary>
/// <param name="element">The element associated with this state.</param>
/// <param name="beginGesture">The gesture used to start the drag interaction.</param>
/// <param name="cancelGesture">The gesture used to cancel the drag interaction.</param>
public DragState(TElement element, InputGesture beginGesture, InputGesture cancelGesture)
: this(element, beginGesture)
{
CancelGesture = cancelGesture;
}
void IInputHandler.HandleEvent(InputEventArgs e)
{
if (_interactionState == InteractionState.Ready && TryBeginDragging(e))
{
return;
}
if (_interactionState == InteractionState.Ending && TryEndDragging(e))
{
return;
}
if (_interactionState == InteractionState.InProgress)
{
if (TryEndDragging(e) || TryCancelDragging(e) || TrySuppressContextMenu(e))
{
return;
}
}
TryHandleEvent(e);
}
#region Interaction logic
// Begin the interaction on gesture press
private bool TryBeginDragging(InputEventArgs e)
{
if (IsInputEventPressed(e) && CanBegin && BeginGesture.Matches(e.Source, e))
{
BeginDrag(e);
return true;
}
return false;
}
private bool TryEndDragging(InputEventArgs e)
{
if (IsInputCaptureLost(e))
{
EndDrag(e);
return true;
}
if (IsToggle && _interactionState == InteractionState.InProgress)
{
return TryDeferToggleInteractionEnd(e);
}
return TryEndInteraction(e);
}
// Delay ending toggle interaction until the gesture is released
private bool TryDeferToggleInteractionEnd(InputEventArgs e)
{
if (IsInputEventPressed(e) && BeginGesture.Matches(e.Source, e))
{
_interactionState = InteractionState.Ending;
HandleEvent(e);
return true;
}
return false;
}
// End the interaction on gesture release
private bool TryEndInteraction(InputEventArgs e)
{
if (IsInputEventReleased(e) && BeginGesture.Matches(e.Source, e))
{
EndDrag(e);
return true;
}
return false;
}
// Cancel the interaction
private bool TryCancelDragging(InputEventArgs e)
{
if (CanCancel && IsInputEventReleased(e) && CancelGesture?.Matches(e.Source, e) is true)
{
CancelDrag(e);
return true;
}
return false;
}
// Suppress the context menu if a toggle interaction is in progress
private bool TrySuppressContextMenu(InputEventArgs e)
{
if (IsToggle && e is MouseButtonEventArgs mbe && mbe.ChangedButton == MouseButton.Right)
{
e.Handled = true;
HandleEvent(e);
return true;
}
return false;
}
private void TryHandleEvent(InputEventArgs e)
{
if (_interactionState == InteractionState.InProgress || _interactionState == InteractionState.Ending)
{
HandleEvent(e);
}
}
internal void BeginDrag(InputEventArgs e)
{
// Avoid stealing mouse capture from other elements
if (CanCaptureInput(e))
{
RequiresInputCapture = IsToggle;
_interactionState = InteractionState.InProgress;
_initialPosition = GetInitialPosition(e);
HandleEvent(e); // Handle the event, otherwise CaptureMouse will send a MouseMove event and the current event will be handled out of order
OnBegin(e);
e.Handled = true;
Element.Focus();
CaptureInput(e);
}
}
private void EndDrag(InputEventArgs e)
{
_interactionState = InteractionState.Ready;
HandleEvent(e);
// Suppress the context menu if the mouse moved beyond the defined drag threshold
if (HasContextMenu && e is MouseButtonEventArgs mbe && mbe.ChangedButton == MouseButton.Right)
{
double dragThreshold = NodifyEditor.MouseActionSuppressionThreshold * NodifyEditor.MouseActionSuppressionThreshold;
double dragDistance = (mbe.GetPosition(PositionElement) - _initialPosition).LengthSquared;
if (dragDistance > dragThreshold)
{
OnEnd(e);
e.Handled = true;
}
else
{
OnCancel(e);
}
}
else
{
OnEnd(e);
e.Handled = true;
}
RequiresInputCapture = false;
}
private void CancelDrag(InputEventArgs e)
{
_interactionState = InteractionState.Ready;
HandleEvent(e);
OnCancel(e);
e.Handled = true;
RequiresInputCapture = false;
}
#endregion
/// <summary>
/// Retrieves the initial position of the input event relative to the <see cref="PositionElement"/>.
/// </summary>
/// <param name="e">The <see cref="InputEventArgs"/> representing the input event.</param>
/// <remarks>
/// This position is used to calculate the drag distance, to determine whether
/// the context menu can appear or if the action is considered a drag operation. The behavior is influenced
/// by the <see cref="NodifyEditor.MouseActionSuppressionThreshold"/>.
/// </remarks>
protected virtual Point GetInitialPosition(InputEventArgs e)
{
if (e is MouseEventArgs me)
{
return me.GetPosition(PositionElement);
}
return default;
}
/// <summary>
/// Determines whether input capture can be acquired for the <see cref="InputElementState{TElement}.Element" />.
/// </summary>
/// <param name="e">The <see cref="InputEventArgs"/> representing the input event.</param>
/// <remarks>Must return true if the input is already captured by the current element.</remarks>
protected virtual bool CanCaptureInput(InputEventArgs e)
=> Mouse.Captured == null || Element.IsMouseCaptured;
/// <summary>
/// Captures input for the element.
/// </summary>
/// <param name="e">The <see cref="InputEventArgs"/> representing the input event.</param>
protected virtual void CaptureInput(InputEventArgs e)
=> Element.CaptureMouse();
/// <summary>
/// Determines whether input capture has been lost.
/// </summary>
/// <param name="e">The <see cref="InputEventArgs"/> representing the input event.</param>
protected virtual bool IsInputCaptureLost(InputEventArgs e)
=> e.RoutedEvent == UIElement.LostMouseCaptureEvent;
/// <summary>
/// Determines if the given input event represents the release of an input gesture.
/// </summary>
/// <param name="e">The input event to evaluate.</param>
/// <returns>True if the event represents the release of a gesture; otherwise, false.</returns>
protected virtual bool IsInputEventReleased(InputEventArgs e)
{
if (e is MouseButtonEventArgs mbe && mbe.ButtonState == MouseButtonState.Released)
return true;
if (e is KeyEventArgs ke && ke.IsUp)
return true;
if (e is MouseWheelEventArgs mwe && mwe.MiddleButton == MouseButtonState.Released)
return true;
return false;
}
/// <summary>
/// Determines if the given input event represents the press of an input gesture.
/// </summary>
/// <param name="e">The input event to evaluate.</param>
/// <returns>True if the event represents the press of a gesture; otherwise, false.</returns>
protected virtual bool IsInputEventPressed(InputEventArgs e)
{
if (e is MouseButtonEventArgs mbe && mbe.ButtonState == MouseButtonState.Pressed)
return true;
if (e is KeyEventArgs ke && ke.IsDown)
return true;
if (e is MouseWheelEventArgs mwe && mwe.MiddleButton == MouseButtonState.Pressed)
return true;
return false;
}
/// <summary>
/// Called when the drag interaction begins. Override to provide custom behavior.
/// </summary>
/// <param name="e">The input event that started the interaction.</param>
protected virtual void OnBegin(InputEventArgs e)
{
}
/// <summary>
/// Called when the drag interaction ends. Override to provide custom behavior.
/// </summary>
/// <param name="e">The input event that ended the interaction.</param>
protected virtual void OnEnd(InputEventArgs e)
{
}
/// <summary>
/// Called when the drag interaction is canceled. Override to provide custom behavior.
/// </summary>
/// <param name="e">The input event that canceled the interaction.</param>
protected virtual void OnCancel(InputEventArgs e)
{
}
}
}

View File

@@ -0,0 +1,12 @@
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <inheritdoc cref="MultiGesture.Match.All" />
public sealed class AllGestures : MultiGesture
{
public AllGestures(params InputGesture[] gestures) : base(Match.All, gestures)
{
}
}
}

View File

@@ -0,0 +1,12 @@
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <inheritdoc cref="MultiGesture.Match.Any" />
public sealed class AnyGesture : MultiGesture
{
public AnyGesture(params InputGesture[] gestures) : base(Match.Any, gestures)
{
}
}
}

View File

@@ -0,0 +1,653 @@
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <summary>Gestures used by built-in controls inside the <see cref="NodifyEditor"/>.</summary>
public class EditorGestures
{
public static readonly EditorGestures Mappings = new EditorGestures();
/// <summary>Gestures for the selection.</summary>
public class SelectionGestures
{
/// <summary>Disable selection gestures.</summary>
public static readonly SelectionGestures None = new SelectionGestures(MouseAction.None);
/// <summary>
/// Initializes a new instance of the <see cref="SelectionGestures"/> class with specified mouse action
/// and a flag indicating whether modifier keys should be ignored when releasing the mouse button.
/// </summary>
/// <param name="mouseAction">The mouse action to trigger the gestures.</param>
/// <param name="ignoreModifierKeysOnRelease">
/// A value indicating whether modifier keys (Alt, Shift, Control) should be ignored when the mouse button is released.
/// </param>
public SelectionGestures(MouseAction mouseAction, bool ignoreModifierKeysOnRelease)
{
Replace = new MouseGesture(mouseAction);
Remove = new MouseGesture(mouseAction, ModifierKeys.Alt, ignoreModifierKeysOnRelease);
Append = new MouseGesture(mouseAction, ModifierKeys.Shift, ignoreModifierKeysOnRelease);
Invert = new MouseGesture(mouseAction, ModifierKeys.Control, ignoreModifierKeysOnRelease);
Select = new AnyGesture(Replace, Remove, Append, Invert);
Cancel = new KeyGesture(Key.Escape);
}
/// <summary>
/// Initializes a new instance of the <see cref="SelectionGestures"/> class with a specified mouse action.
/// Modifier keys will be ignored when releasing the mouse button.
/// </summary>
/// <param name="mouseAction">The mouse action to trigger the gestures.</param>
public SelectionGestures(MouseAction mouseAction)
: this(mouseAction, true)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SelectionGestures"/> class with a flag indicating
/// whether modifier keys should be ignored when releasing the mouse button.
/// The default mouse action is <see cref="MouseAction.LeftClick"/>.
/// </summary>
/// <param name="ignoreModifierKeysOnRelease">
/// A value indicating whether modifier keys (Alt, Shift, Control) should be ignored when the mouse button is released.
/// </param>
public SelectionGestures(bool ignoreModifierKeysOnRelease)
: this(MouseAction.LeftClick, ignoreModifierKeysOnRelease)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SelectionGestures"/> class with default values:
/// the mouse action is <see cref="MouseAction.LeftClick"/>, and modifier keys are ignored when releasing the mouse button.
/// </summary>
public SelectionGestures() : this(true)
{
}
/// <summary>Gesture to replace previous selection with the selected items.</summary>
/// <remarks>Defaults to <see cref="MouseAction.LeftClick"/>.</remarks>
public InputGestureRef Replace { get; }
/// <summary>Gesture to remove the selected items from the previous selection.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Alt"/>+<see cref="MouseAction.LeftClick"/>.</remarks>
public InputGestureRef Remove { get; }
/// <summary>Gesture to add the new selected items to the previous selection.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Shift"/>+<see cref="MouseAction.LeftClick"/>.</remarks>
public InputGestureRef Append { get; }
/// <summary>Gesture to invert the selected items.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Control"/>+<see cref="MouseAction.LeftClick"/>.</remarks>
public InputGestureRef Invert { get; }
/// <summary>Cancel the current selection operation reverting to the previous selection.</summary>
/// <remarks>Defaults to <see cref="Key.Escape"/>.</remarks>
public InputGestureRef Cancel { get; }
/// <summary>Gesture used to start selecting using a <see cref="SelectionGestures"/> strategy.</summary>
public InputGestureRef Select { get; }
/// <summary>Copies from the specified gestures.</summary>
/// <param name="gestures">The gestures to copy.</param>
public void Apply(SelectionGestures gestures)
{
Replace.Value = gestures.Replace.Value;
Remove.Value = gestures.Remove.Value;
Append.Value = gestures.Append.Value;
Invert.Value = gestures.Invert.Value;
Select.Value = gestures.Select.Value;
Cancel.Value = gestures.Cancel.Value;
}
/// <summary>
/// Unbinds the all the gestures used for selection.
/// </summary>
public void Unbind()
=> Apply(None);
}
/// <summary>Gestures for the item containers.</summary>
public class ItemContainerGestures
{
public ItemContainerGestures()
{
Selection = new SelectionGestures();
Selection.Select.Value = new AnyGesture(
Selection.Replace,
Selection.Remove,
Selection.Append,
Selection.Invert);
Drag = new AnyGesture(Selection.Replace, Selection.Remove, Selection.Append, Selection.Invert);
CancelAction = new AnyGesture(new MouseGesture(MouseAction.RightClick), new KeyGesture(Key.Escape));
}
/// <summary>Gesture to select the container using a <see cref="SelectionGestures"/> strategy.</summary>
/// <remarks>Defaults to <see cref="MouseAction.LeftClick"/> or any of the <see cref="SelectionGestures"/> gestures.</remarks>
public SelectionGestures Selection { get; }
/// <summary>Gesture to start and complete a dragging operation.</summary>
/// <remarks>Using a <see cref="Selection"/> strategy to drag from a new selection.
/// <br /> Defaults to any of the <see cref="Selection"/> gestures.
/// </remarks>
public InputGestureRef Drag { get; }
/// <summary>Gesture to cancel the dragging operation.</summary>
/// <remarks>Defaults to <see cref="MouseAction.RightClick"/> or <see cref="Key.Escape"/>.</remarks>
public InputGestureRef CancelAction { get; }
/// <summary>Copies from the specified gestures.</summary>
/// <param name="gestures">The gestures to copy.</param>
public void Apply(ItemContainerGestures gestures)
{
Selection.Apply(gestures.Selection);
Drag.Value = gestures.Drag.Value;
CancelAction.Value = gestures.CancelAction.Value;
}
/// <summary>
/// Unbinds all the gestures.
/// </summary>
public void Unbind()
{
Selection.Unbind();
Drag.Unbind();
CancelAction.Unbind();
}
}
/// <summary>
/// Keyboard gestures used for navigating the editor and moving selected items.
/// </summary>
public class DirectionalNavigationGestures
{
public DirectionalNavigationGestures(ModifierKeys modifierKeys = ModifierKeys.None)
{
Up = new KeyGesture(Key.Up, modifierKeys);
Left = new KeyGesture(Key.Left, modifierKeys);
Down = new KeyGesture(Key.Down, modifierKeys);
Right = new KeyGesture(Key.Right, modifierKeys);
}
public DirectionalNavigationGestures(Key triggerKey, ModifierKeys modifierKeys = ModifierKeys.None, bool repeated = false)
{
Up = new KeyComboGesture(triggerKey, Key.Up, modifierKeys) { AllowRepeatingComboKey = repeated };
Left = new KeyComboGesture(triggerKey, Key.Left, modifierKeys) { AllowRepeatingComboKey = repeated };
Down = new KeyComboGesture(triggerKey, Key.Down, modifierKeys) { AllowRepeatingComboKey = repeated };
Right = new KeyComboGesture(triggerKey, Key.Right, modifierKeys) { AllowRepeatingComboKey = repeated };
}
/// <summary>
/// Gesture used for navigating or moving upward.
/// </summary>
public InputGestureRef Up { get; }
/// <summary>
/// Gesture used for navigating or moving left.
/// </summary>
public InputGestureRef Left { get; }
/// <summary>
/// Gesture used for navigating or moving downward.
/// </summary>
public InputGestureRef Down { get; }
/// <summary>
/// Gesture used for navigating or moving right.
/// </summary>
public InputGestureRef Right { get; }
/// <summary>Copies from the specified gestures.</summary>
/// <param name="gestures">The gestures to copy.</param>
public void Apply(DirectionalNavigationGestures gestures)
{
Up.Value = gestures.Up.Value;
Left.Value = gestures.Left.Value;
Down.Value = gestures.Down.Value;
Right.Value = gestures.Right.Value;
}
/// <summary>
/// Unbinds all the gestures.
/// </summary>
public void Unbind()
{
Up.Unbind();
Left.Unbind();
Down.Unbind();
Right.Unbind();
}
}
/// <summary>Gestures for the editor.</summary>
public class NodifyEditorGestures
{
/// <summary>
/// Keyboard gestures used for navigation, selection, and manipulation in the editor.
/// </summary>
public class KeyboardGestures
{
public KeyboardGestures()
{
Pan = new DirectionalNavigationGestures(Key.Space, repeated: true);
DragSelection = new DirectionalNavigationGestures(ModifierKeys.Control);
NavigateSelection = new DirectionalNavigationGestures(ModifierKeys.None);
ToggleSelected = new AnyGesture(new KeyGesture(Key.Space), new KeyGesture(Key.Enter));
DeselectAll = new KeyGesture(Key.Escape);
NextNavigationLayer = new KeyGesture(Key.OemCloseBrackets, ModifierKeys.Control);
PrevNavigationLayer = new KeyGesture(Key.OemOpenBrackets, ModifierKeys.Control);
}
/// <summary>
/// Directional gestures used for panning the viewport.
/// </summary>
/// <remarks>Defaults to <see cref="Key.Space"/>+arrow keys.</remarks>
public DirectionalNavigationGestures Pan { get; }
/// <summary>
/// Directional gestures used for dragging the selected items.
/// </summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Control"/>+arrow keys.</remarks>
public DirectionalNavigationGestures DragSelection { get; }
/// <summary>
/// Directional gestures used to navigate the selection focus (e.g., between nodes).
/// </summary>
/// <remarks>Defaults to arrow keys.</remarks>
public DirectionalNavigationGestures NavigateSelection { get; }
/// <summary>
/// Gesture used to toggle the selected state of the currently focused item.
/// </summary>
/// <remarks>Defaults to <see cref="Key.Space"/></remarks>
public InputGestureRef ToggleSelected { get; }
/// <summary>
/// Gesture used to clear the current selection.
/// </summary>
/// <remarks>Defaults to <see cref="Key.Escape"/>.</remarks>
public InputGestureRef DeselectAll { get; }
/// <summary>
/// Gesture used to activate the previous keyboard navigation layer.
/// </summary>
/// <remarks><see cref="ModifierKeys.Control"/>+<see cref="Key.OemCloseBrackets"/>.</remarks>
public InputGestureRef NextNavigationLayer { get; }
/// <summary>
/// Gesture used to activate the next keyboard navigation layer.
/// </summary>
/// <remarks><see cref="ModifierKeys.Control"/>+<see cref="Key.OemOpenBrackets"/>.</remarks>
public InputGestureRef PrevNavigationLayer { get; }
/// <summary>Copies from the specified gestures.</summary>
/// <param name="gestures">The gestures to copy.</param>
public void Apply(KeyboardGestures gestures)
{
Pan.Apply(gestures.Pan);
DragSelection.Apply(gestures.DragSelection);
NavigateSelection.Apply(gestures.NavigateSelection);
ToggleSelected.Value = gestures.ToggleSelected.Value;
DeselectAll.Value = gestures.DeselectAll.Value;
NextNavigationLayer.Value = gestures.NextNavigationLayer.Value;
PrevNavigationLayer.Value = gestures.PrevNavigationLayer.Value;
}
/// <summary>
/// Unbinds all the gestures.
/// </summary>
public void Unbind()
{
Pan.Unbind();
DragSelection.Unbind();
NavigateSelection.Unbind();
ToggleSelected.Unbind();
DeselectAll.Unbind();
NextNavigationLayer.Unbind();
PrevNavigationLayer.Unbind();
}
}
public NodifyEditorGestures()
{
Keyboard = new KeyboardGestures();
Selection = new SelectionGestures();
SelectAll = ApplicationCommands.SelectAll.InputGestures[0].AsRef();
Cutting = new MouseGesture(MouseAction.LeftClick, ModifierKeys.Alt | ModifierKeys.Shift, true);
PushItems = new MouseGesture(MouseAction.LeftClick, ModifierKeys.Control | ModifierKeys.Shift, true);
Pan = new AnyGesture(new MouseGesture(MouseAction.RightClick), new MouseGesture(MouseAction.MiddleClick));
ZoomModifierKey = ModifierKeys.None;
ZoomIn = new AnyGesture(new KeyGesture(Key.OemPlus, ModifierKeys.Control), new KeyGesture(Key.Add, ModifierKeys.Control));
ZoomOut = new AnyGesture(new KeyGesture(Key.OemMinus, ModifierKeys.Control), new KeyGesture(Key.Subtract, ModifierKeys.Control));
ResetViewport = new KeyGesture(Key.Home);
FitToScreen = new KeyGesture(Key.Home, ModifierKeys.Shift);
CancelAction = new AnyGesture(new MouseGesture(MouseAction.RightClick), new KeyGesture(Key.Escape));
PanWithMouseWheel = false;
PanHorizontalModifierKey = ModifierKeys.Shift;
PanVerticalModifierKey = ModifierKeys.None;
}
public KeyboardGestures Keyboard { get; }
/// <summary>Gesture used to start selecting using a <see cref="SelectionGestures"/> strategy.</summary>
public SelectionGestures Selection { get; }
/// <summary>Gesture used to select all <see cref="Nodify.ItemContainer"/>s in the editor.</summary>
public InputGestureRef SelectAll { get; }
/// <summary>Gesture used to start cutting connections.</summary>
public InputGestureRef Cutting { get; }
/// <summary>Gesture used to start panning.</summary>
/// <remarks>Defaults to <see cref="MouseAction.RightClick"/> or <see cref="MouseAction.MiddleClick"/>.</remarks>
public InputGestureRef Pan { get; }
/// <summary>Whether panning using mouse wheel is allowed.</summary>
/// <remarks>Set the <see cref="ZoomModifierKey"/> to allow zooming using the mouse wheel.</remarks>
public bool PanWithMouseWheel { get; set; }
/// <summary>The modifier key required to start panning vertically with the mouse wheel (see <see cref="PanWithMouseWheel"/>)</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.None"/>.</remarks>
public ModifierKeys PanVerticalModifierKey { get; set; }
/// <summary>The modifier key required to start panning horizontally with the mouse wheel (see <see cref="PanWithMouseWheel"/>)</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Shift"/>.</remarks>
public ModifierKeys PanHorizontalModifierKey { get; set; }
/// <summary>Gesture used to start pushing.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Control"/>+<see cref="ModifierKeys.Shift"/>+<see cref="MouseAction.LeftClick"/>.</remarks>
public InputGestureRef PushItems { get; }
/// <summary>The key modifier required to start zooming by mouse wheel.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.None"/>.</remarks>
public ModifierKeys ZoomModifierKey { get; set; }
/// <summary>Gesture used to zoom in.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Control"/>+<see cref="Key.OemPlus"/>.</remarks>
public InputGestureRef ZoomIn { get; }
/// <summary>Gesture used to zoom out.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Control"/>+<see cref="Key.OemMinus"/>.</remarks>
public InputGestureRef ZoomOut { get; }
/// <summary>Gesture used to move the editor's viewport location to (0, 0) and set the zoom to 1.</summary>
/// <remarks>Defaults to <see cref="Key.Home"/>.</remarks>
public InputGestureRef ResetViewport { get; }
/// <summary>Gesture used to fit as many containers as possible into the viewport.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Shift"/>+<see cref="Key.Home"/>.</remarks>
public InputGestureRef FitToScreen { get; }
/// <summary>Gesture to cancel the current operation.</summary>
/// <remarks>Defaults to <see cref="MouseAction.RightClick"/> or <see cref="Key.Escape"/>.</remarks>
public InputGestureRef CancelAction { get; }
/// <summary>Copies from the specified gestures.</summary>
/// <param name="gestures">The gestures to copy.</param>
public void Apply(NodifyEditorGestures gestures)
{
Keyboard.Apply(gestures.Keyboard);
Selection.Apply(gestures.Selection);
SelectAll.Value = gestures.SelectAll.Value;
Cutting.Value = gestures.Cutting.Value;
PushItems.Value = gestures.PushItems.Value;
Pan.Value = gestures.Pan.Value;
ZoomModifierKey = gestures.ZoomModifierKey;
ZoomIn.Value = gestures.ZoomIn.Value;
ZoomOut.Value = gestures.ZoomOut.Value;
ResetViewport.Value = gestures.ResetViewport.Value;
FitToScreen.Value = gestures.FitToScreen.Value;
CancelAction.Value = gestures.CancelAction.Value;
PanWithMouseWheel = gestures.PanWithMouseWheel;
PanHorizontalModifierKey = gestures.PanHorizontalModifierKey;
PanVerticalModifierKey = gestures.PanVerticalModifierKey;
}
/// <summary>
/// Unbinds all the gestures.
/// </summary>
public void Unbind()
{
Keyboard.Unbind();
Selection.Unbind();
SelectAll.Unbind();
Cutting.Unbind();
Pan.Unbind();
PushItems.Unbind();
ZoomIn.Unbind();
ZoomOut.Unbind();
ResetViewport.Unbind();
FitToScreen.Unbind();
CancelAction.Unbind();
}
}
/// <summary>Gestures used by the <see cref="Connector"/>.</summary>
public class ConnectorGestures
{
public ConnectorGestures()
{
Disconnect = new AnyGesture(new MouseGesture(MouseAction.LeftClick, ModifierKeys.Alt), new KeyGesture(Key.Delete));
Connect = new AnyGesture(new MouseGesture(MouseAction.LeftClick), new KeyGesture(Key.Space));
CancelAction = new AnyGesture(new MouseGesture(MouseAction.RightClick), new KeyGesture(Key.Escape));
}
/// <summary>Gesture to call the <see cref="Connector.DisconnectCommand"/>.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Alt"/>+<see cref="MouseAction.LeftClick"/> or <see cref="Key.Delete"/>.</remarks>
public InputGestureRef Disconnect { get; }
/// <summary>Gesture to start and complete a pending connection.</summary>
/// <remarks>Defaults to <see cref="MouseAction.LeftClick"/>.</remarks>
public InputGestureRef Connect { get; }
/// <summary>Gesture to cancel the pending connection.</summary>
/// <remarks>Defaults to <see cref="MouseAction.RightClick"/> or <see cref="Key.Escape"/>.</remarks>
public InputGestureRef CancelAction { get; }
/// <summary>Copies from the specified gestures.</summary>
/// <param name="gestures">The gestures to copy.</param>
public void Apply(ConnectorGestures gestures)
{
Disconnect.Value = gestures.Disconnect.Value;
Connect.Value = gestures.Connect.Value;
CancelAction.Value = gestures.CancelAction.Value;
}
/// <summary>
/// Unbinds all the gestures.
/// </summary>
public void Unbind()
{
Disconnect.Unbind();
Connect.Unbind();
CancelAction.Unbind();
}
}
/// <summary>Gestures used by the <see cref="BaseConnection"/>.</summary>
public class ConnectionGestures
{
public ConnectionGestures()
{
Split = new MouseGesture(MouseAction.LeftDoubleClick);
Selection = new SelectionGestures(MouseAction.LeftClick);
Disconnect = new MouseGesture(MouseAction.LeftClick, ModifierKeys.Alt);
}
/// <summary>Gesture to call the <see cref="BaseConnection.SplitCommand"/> command.</summary>
/// <remarks>Defaults to <see cref="MouseAction.LeftDoubleClick"/>.</remarks>
public InputGestureRef Split { get; }
/// <summary>Gesture used to start selecting using a <see cref="SelectionGestures"/> strategy.</summary>
public SelectionGestures Selection { get; }
/// <summary>Gesture to call the <see cref="BaseConnection.DisconnectCommand"/> command.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Alt"/>+<see cref="MouseAction.LeftClick"/>.</remarks>
public InputGestureRef Disconnect { get; }
/// <summary>Copies from the specified gestures.</summary>
/// <param name="gestures">The gestures to copy.</param>
public void Apply(ConnectionGestures gestures)
{
Split.Value = gestures.Split.Value;
Disconnect.Value = gestures.Disconnect.Value;
Selection.Apply(gestures.Selection);
}
/// <summary>
/// Unbinds all the gestures.
/// </summary>
public void Unbind()
{
Split.Unbind();
Selection.Unbind();
Disconnect.Unbind();
}
}
/// <summary>Gestures for the <see cref="GroupingNode"/>.</summary>
public class GroupingNodeGestures
{
public GroupingNodeGestures()
{
SwitchMovementMode = ModifierKeys.Shift;
ToggleContentSelection = new AnyGesture(new KeyGesture(Key.Space, ModifierKeys.Control), new KeyGesture(Key.Enter, ModifierKeys.Control));
}
/// <summary>The key modifier that will toggle between <see cref="GroupingMovementMode"/>s.</summary>
/// <remarks>The modifier must be allowed by the <see cref="ItemContainer.Drag"/> gesture.
/// <br /> Defaults to <see cref="ModifierKeys.Shift"/>.
/// </remarks>
public ModifierKeys SwitchMovementMode { get; set; }
/// <summary>Gesture to toggle the content selection of the <see cref="GroupingNode"/> when it is selected.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Control"/>+<see cref="Key.Space"/>.</remarks>
public InputGestureRef ToggleContentSelection { get; }
/// <summary>Copies from the specified gestures.</summary>
/// <param name="gestures">The gestures to copy.</param>
public void Apply(GroupingNodeGestures gestures)
{
SwitchMovementMode = gestures.SwitchMovementMode;
ToggleContentSelection.Value = gestures.ToggleContentSelection.Value;
}
/// <summary>
/// Unbinds all the gestures.
/// </summary>
public void Unbind()
{
ToggleContentSelection.Unbind();
}
}
/// <summary>Gestures used by the <see cref="Nodify.Minimap"/> control.</summary>
public class MinimapGestures
{
public MinimapGestures()
{
Pan = new DirectionalNavigationGestures();
DragViewport = new MouseGesture(MouseAction.LeftClick);
ResetViewport = new KeyGesture(Key.Home);
CancelAction = new AnyGesture(new MouseGesture(MouseAction.RightClick), new KeyGesture(Key.Escape));
ZoomIn = new AnyGesture(new KeyGesture(Key.OemPlus, ModifierKeys.Control), new KeyGesture(Key.Add, ModifierKeys.Control));
ZoomOut = new AnyGesture(new KeyGesture(Key.OemMinus, ModifierKeys.Control), new KeyGesture(Key.Subtract, ModifierKeys.Control));
ZoomModifierKey = ModifierKeys.None;
}
/// <summary>
/// Directional gestures used for panning the viewport.
/// </summary>
/// <remarks>Defaults to <see cref="Key.Space"/>+arrow keys.</remarks>
public DirectionalNavigationGestures Pan { get; }
/// <summary>Gesture to move the viewport inside the <see cref="Minimap" />.</summary>
public InputGestureRef DragViewport { get; }
/// <summary>Gesture to move the viewport inside the <see cref="Minimap" />.</summary>
public InputGestureRef ResetViewport { get; }
/// <summary>Gesture to cancel the panning operation.</summary>
/// <remarks>Defaults to <see cref="MouseAction.RightClick"/> or <see cref="Key.Escape"/>.</remarks>
public InputGestureRef CancelAction { get; }
/// <summary>Gesture used to zoom in.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Control"/>+<see cref="Key.OemPlus"/>.</remarks>
public InputGestureRef ZoomIn { get; }
/// <summary>Gesture used to zoom out.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.Control"/>+<see cref="Key.OemMinus"/>.</remarks>
public InputGestureRef ZoomOut { get; }
/// <summary>The key modifier required to start zooming by mouse wheel.</summary>
/// <remarks>Defaults to <see cref="ModifierKeys.None"/>.</remarks>
public ModifierKeys ZoomModifierKey { get; set; }
/// <summary>Copies from the specified gestures.</summary>
/// <param name="gestures">The gestures to copy.</param>
public void Apply(MinimapGestures gestures)
{
Pan.Apply(gestures.Pan);
ZoomIn.Value = gestures.ZoomIn.Value;
ZoomOut.Value = gestures.ZoomOut.Value;
ResetViewport.Value = gestures.ResetViewport.Value;
DragViewport.Value = gestures.DragViewport.Value;
CancelAction.Value = gestures.CancelAction.Value;
ZoomModifierKey = gestures.ZoomModifierKey;
}
/// <summary>
/// Unbinds all the gestures.
/// </summary>
public void Unbind()
{
Pan.Unbind();
ZoomIn.Unbind();
ZoomOut.Unbind();
ResetViewport.Unbind();
DragViewport.Unbind();
CancelAction.Unbind();
}
}
/// <summary>Gestures for the editor.</summary>
public NodifyEditorGestures Editor { get; } = new NodifyEditorGestures();
/// <summary>Gestures for the item container.</summary>
public ItemContainerGestures ItemContainer { get; } = new ItemContainerGestures();
/// <summary>Gestures for the connector.</summary>
public ConnectorGestures Connector { get; } = new ConnectorGestures();
/// <summary>Gestures for the connection.</summary>
public ConnectionGestures Connection { get; } = new ConnectionGestures();
/// <summary>Gestures for the grouping node.</summary>
public GroupingNodeGestures GroupingNode { get; } = new GroupingNodeGestures();
/// <summary>Gestures for the minimap.</summary>
public MinimapGestures Minimap { get; } = new MinimapGestures();
/// <summary>Copies from the specified gestures.</summary>
/// <param name="gestures">The gestures to copy.</param>
public void Apply(EditorGestures gestures)
{
Editor.Apply(gestures.Editor);
ItemContainer.Apply(gestures.ItemContainer);
Connector.Apply(gestures.Connector);
Connection.Apply(gestures.Connection);
GroupingNode.Apply(gestures.GroupingNode);
Minimap.Apply(gestures.Minimap);
}
/// <summary>
/// Unbinds all the gestures used by the editor and its controls.
/// </summary>
public void Unbind()
{
Editor.Unbind();
ItemContainer.Unbind();
Connector.Unbind();
Connection.Unbind();
Minimap.Unbind();
}
}
}

View File

@@ -0,0 +1,58 @@
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <summary>
/// An input gesture that allows changing its logic at runtime without changing its reference.
/// Useful for classes that capture the object reference without the posibility of updating it. (e.g. <see cref="EditorCommands"/>)
/// </summary>
public sealed class InputGestureRef : InputGesture
{
/// <summary>The referenced gesture.</summary>
public InputGesture Value { get; set; } = MultiGesture.None;
private InputGestureRef() { }
internal InputGestureRef(InputGesture gesture)
{
Value = gesture;
}
public override bool Matches(object targetElement, InputEventArgs inputEventArgs)
{
return Value.Matches(targetElement, inputEventArgs);
}
public static implicit operator InputGestureRef(MouseGesture gesture)
=> new InputGestureRef { Value = gesture };
public static implicit operator InputGestureRef(System.Windows.Input.MouseGesture gesture)
=> new InputGestureRef { Value = gesture };
public static implicit operator InputGestureRef(KeyGesture gesture)
=> new InputGestureRef { Value = gesture };
public static implicit operator InputGestureRef(MultiGesture gesture)
=> new InputGestureRef { Value = gesture };
/// <summary>
/// Unbinds the current gesture.
/// </summary>
public void Unbind()
=> Value = MultiGesture.None;
}
/// <summary>
/// Extension methods for the <see cref="InputGestureRef"/> class.
/// </summary>
public static class InputGestureRefExtensions
{
/// <summary>
/// Creates a new <see cref="InputGestureRef"/> from the specified gesture.
/// </summary>
public static InputGestureRef AsRef(this InputGesture gesture)
{
return new InputGestureRef(gesture);
}
}
}

View File

@@ -0,0 +1,131 @@
using System.Windows;
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <summary>
/// Represents a keyboard gesture that requires a trigger key to be held down
/// before pressing a combo key. For example, press and hold Space, then press Left arrow.
/// </summary>
public class KeyComboGesture : KeyGesture
{
private static readonly WeakReferenceCollection<KeyComboGesture> _allCombos = new WeakReferenceCollection<KeyComboGesture>(16);
private bool _isTriggerDown;
private int _comboCounter;
/// <summary>
/// Gets a value indicating whether the combo gesture has been performed at least once.
/// </summary>
private bool HasBeenPerformedAtLeastOnce => _comboCounter > 0;
/// <summary>
/// Gets or sets the key that must be pressed first to activate this combo gesture.
/// </summary>
public Key TriggerKey { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the combo key can be repeatedly triggered
/// without releasing the trigger key.
/// </summary>
public bool AllowRepeatingComboKey { get; set; }
static KeyComboGesture()
{
EventManager.RegisterClassHandler(typeof(UIElement), UIElement.PreviewKeyUpEvent, new KeyEventHandler(HandleKeyUp), true);
EventManager.RegisterClassHandler(typeof(UIElement), UIElement.LostKeyboardFocusEvent, new KeyboardFocusChangedEventHandler(HandleFocusLost), true);
}
/// <summary>
/// Initializes a new instance of the <see cref="KeyComboGesture"/> class with the specified trigger and combo keys.
/// </summary>
/// <param name="triggerKey">The key that must be pressed first.</param>
/// <param name="comboKey">The combo key pressed while the trigger key is held.</param>
public KeyComboGesture(Key triggerKey, Key comboKey) : this(triggerKey, comboKey, ModifierKeys.None, string.Empty)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="KeyComboGesture"/> class with the specified trigger and combo keys and modifiers.
/// </summary>
/// <param name="triggerKey">The key that must be pressed first.</param>
/// <param name="comboKey">The combo key pressed while the trigger key is held.</param>
/// <param name="modifiers">Any modifier keys required for the combo key.</param>
public KeyComboGesture(Key triggerKey, Key comboKey, ModifierKeys modifiers) : this(triggerKey, comboKey, modifiers, string.Empty)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="KeyComboGesture"/> class with the specified trigger key,
/// combo key, modifiers, and display string.
/// </summary>
/// <param name="triggerKey">The key that must be pressed first.</param>
/// <param name="comboKey">The combo key pressed while the trigger key is held.</param>
/// <param name="modifiers">Any modifier keys required for the combo key.</param>
/// <param name="displayString">The display string representing the gesture.</param>
public KeyComboGesture(Key triggerKey, Key comboKey, ModifierKeys modifiers, string displayString) : base(comboKey, modifiers, displayString)
{
TriggerKey = triggerKey;
_allCombos.Add(this);
}
private static void HandleFocusLost(object sender, KeyboardFocusChangedEventArgs e)
{
foreach (var combo in _allCombos)
{
combo.Reset();
}
}
private static void HandleKeyUp(object sender, KeyEventArgs e)
{
foreach (var combo in _allCombos)
{
if (e.Key == combo.TriggerKey)
{
// We don't want to handle the event if only the trigger key was pressed.
if (combo.HasBeenPerformedAtLeastOnce)
{
e.Handled = true;
}
combo.Reset();
}
}
}
private void Reset()
{
_isTriggerDown = false;
_comboCounter = 0;
}
public override bool Matches(object targetElement, InputEventArgs inputEventArgs)
{
if (inputEventArgs is KeyEventArgs { IsDown: true } keyArgs)
{
if (keyArgs.Key == TriggerKey)
{
_isTriggerDown = true;
}
// The combo key only triggers the combo on key down
bool matches = _isTriggerDown && base.Matches(targetElement, inputEventArgs);
if (!matches)
{
return false;
}
_comboCounter++;
if (!AllowRepeatingComboKey)
{
_isTriggerDown = false;
}
return matches;
}
return false;
}
}
}

View File

@@ -0,0 +1,153 @@
using System.Linq;
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <summary>
/// Represents a mouse gesture that optionally includes a specific key press as part of the gesture.
/// </summary>
public sealed class MouseGesture : System.Windows.Input.MouseGesture
{
/// <summary>
/// Gets or sets the key that must be pressed to match this gesture.
/// </summary>
public Key Key { get; set; }
/// <summary>
/// Whether to ignore modifier keys when releasing the mouse button.
/// </summary>
public bool IgnoreModifierKeysOnRelease { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="MouseGesture"/> class with the specified mouse action, modifier keys, and a specific key.
/// </summary>
/// <param name="action">The action associated with this gesture.</param>
/// <param name="modifiers">The modifiers associated with this gesture.</param>
/// <param name="key">The key required to match the gesture.</param>
public MouseGesture(MouseAction action, ModifierKeys modifiers, Key key) : base(action, modifiers)
{
Key = key;
}
/// <summary>
/// Initializes a new instance of the <see cref="MouseGesture"/> class with the specified mouse action and key.
/// </summary>
/// <param name="action">The action associated with this gesture.</param>
/// <param name="key">The key required to match the gesture.</param>
public MouseGesture(MouseAction action, Key key) : base(action)
{
Key = key;
}
/// <inheritdoc />
public MouseGesture(MouseAction action, ModifierKeys modifiers)
: base(action, modifiers)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="MouseGesture"/> class with the specified mouse action and modifier keys.
/// </summary>
/// <param name="action">The action associated with this gesture.</param>
/// <param name="modifiers">The modifiers required to match the gesture.</param>
/// <param name="ignoreModifierKeysOnRelease">Whether to ignore modifiers when releasing the mouse button.</param>
public MouseGesture(MouseAction action, ModifierKeys modifiers, bool ignoreModifierKeysOnRelease)
: base(action, modifiers)
{
IgnoreModifierKeysOnRelease = ignoreModifierKeysOnRelease;
}
/// <inheritdoc />
public MouseGesture(MouseAction action) : base(action)
{
}
/// <inheritdoc />
public MouseGesture()
{
}
/// <inheritdoc />
public override bool Matches(object targetElement, InputEventArgs inputEventArgs)
{
if (inputEventArgs is MouseButtonEventArgs || inputEventArgs is MouseWheelEventArgs)
{
bool matches = base.Matches(targetElement, inputEventArgs);
if (IgnoreModifierKeysOnRelease && IsButtonReleased(inputEventArgs))
{
ModifierKeys prevModifiers = Modifiers;
Modifiers = ModifierKeys.None;
matches |= base.Matches(targetElement, inputEventArgs);
Modifiers = prevModifiers;
}
return matches && MatchesKeyboard();
}
return false;
}
/// <summary>
/// Checks whether the required key is pressed or no keys are pressed when <see cref="Key"/> is <see cref="Key.None"/>.
/// </summary>
private bool MatchesKeyboard()
{
if (Key is Key.None)
{
return !IsAnyKeyPressed();
}
return Keyboard.IsKeyDown(Key);
}
private static readonly Key[] _allKeys = new[]
{
// Alphanumeric
Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J,
Key.K, Key.L, Key.M, Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T,
Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z,
Key.D0, Key.D1, Key.D2, Key.D3, Key.D4, Key.D5, Key.D6, Key.D7, Key.D8, Key.D9,
// Punctuation and symbols
Key.Oem3, Key.OemMinus, Key.OemPlus, Key.OemOpenBrackets, Key.OemCloseBrackets,
Key.Oem5, Key.Oem1, Key.OemQuotes, Key.OemComma, Key.OemPeriod, Key.Oem2,
// Function keys
Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.F6, Key.F7, Key.F8,
Key.F9, Key.F10, Key.F11, Key.F12,
// Navigation
Key.Left, Key.Right, Key.Up, Key.Down,
Key.PageUp, Key.PageDown, Key.Home, Key.End,
// Editing
Key.Back, Key.Delete, Key.Insert,
// Special
Key.Space, Key.Return, Key.Escape, Key.Tab,
// Numeric keypad
Key.NumPad0, Key.NumPad1, Key.NumPad2, Key.NumPad3, Key.NumPad4,
Key.NumPad5, Key.NumPad6, Key.NumPad7, Key.NumPad8, Key.NumPad9,
Key.Multiply, Key.Add, Key.Subtract, Key.Divide, Key.Decimal
};
/// <summary>
/// Determines whether any key (excluding modifiers) is currently pressed.
/// </summary>
private static bool IsAnyKeyPressed()
=> _allKeys.Any(Keyboard.IsKeyDown);
private static bool IsButtonReleased(InputEventArgs e)
{
if (e is MouseButtonEventArgs mbe && mbe.ButtonState == MouseButtonState.Released)
return true;
if (e is MouseWheelEventArgs mwe && mwe.MiddleButton == MouseButtonState.Released)
return true;
return false;
}
}
}

View File

@@ -0,0 +1,68 @@
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <summary>Combines multiple input gestures.</summary>
public class MultiGesture : InputGesture
{
public static readonly MultiGesture None = new MultiGesture(Match.Any);
/// <summary>The strategy used by <see cref="Matches(object, InputEventArgs)"/>.</summary>
public enum Match
{
/// <summary>At least one gesture must match.</summary>
Any,
/// <summary>All gestures must match.</summary>
All
}
private readonly InputGesture[] _gestures;
private readonly Match _match;
/// <summary>Constructs an instance of a <see cref="MultiGesture"/>.</summary>
/// <param name="match">The matching strategy.</param>
/// <param name="gestures">The input gestures.</param>
public MultiGesture(Match match, params InputGesture[] gestures)
{
_gestures = gestures;
_match = match;
}
/// <inheritdoc />
public override bool Matches(object targetElement, InputEventArgs inputEventArgs)
{
if (_match == Match.Any)
{
return MatchesAny(targetElement, inputEventArgs);
}
return MatchesAll(targetElement, inputEventArgs);
}
private bool MatchesAll(object targetElement, InputEventArgs inputEventArgs)
{
for (int i = 0; i < _gestures.Length; i++)
{
if (!_gestures[i].Matches(targetElement, inputEventArgs))
{
return false;
}
}
return true;
}
private bool MatchesAny(object targetElement, InputEventArgs inputEventArgs)
{
for (int i = 0; i < _gestures.Length; i++)
{
if (_gestures[i].Matches(targetElement, inputEventArgs))
{
return true;
}
}
return false;
}
}
}

View File

@@ -0,0 +1,34 @@
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <summary>
/// Defines a contract for handling input events within an element or system.
/// </summary>
public interface IInputHandler
{
/// <summary>
/// Handles a given input event, such as a mouse or keyboard interaction.
/// </summary>
/// <param name="e">The <see cref="InputEventArgs"/> representing the input event.</param>
/// <remarks>
/// This method is invoked when an input event is dispatched to the handler. Implementations should
/// handle the event logic and optionally mark the event as handled.
/// </remarks>
void HandleEvent(InputEventArgs e);
/// <summary>
/// Gets a value indicating whether the handler requires input capture to remain active.
/// </summary>
/// <remarks>
/// This property can be used to determine whether it is safe to release mouse capture, especially during toggled interactions. <br />
/// Toggled interactions usually involve two steps, and it is important to keep the input capture active until the interaction is completed.
/// </remarks>
bool RequiresInputCapture { get; }
/// <summary>
/// Gets or sets a value indicating whether events that have been handled should be processed too.
/// </summary>
bool ProcessHandledEvents { get; }
}
}

View File

@@ -0,0 +1,95 @@
using System.Windows;
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <summary>
/// Represents a base class for handling input events in a specific state for a framework element.
/// </summary>
/// <typeparam name="TElement">The type of the framework element that owns this state.</typeparam>
public abstract class InputElementState<TElement> : IInputHandler
where TElement : FrameworkElement
{
/// <summary>
/// Gets the owner of the state.
/// </summary>
protected TElement Element { get; }
public bool RequiresInputCapture { get; protected set; }
public bool ProcessHandledEvents { get; protected set; }
/// <summary>
/// Initializes a new instance of the <see cref="InputElementState{TElement}"/> class.
/// </summary>
/// <param name="element">The framework element that owns this state.</param>
protected InputElementState(TElement element)
{
Element = element;
}
/// <inheritdoc cref="UIElement.OnMouseDown(MouseButtonEventArgs)"/>
protected virtual void OnMouseDown(MouseButtonEventArgs e) { }
/// <inheritdoc cref="UIElement.OnMouseUp(MouseButtonEventArgs)"/>
protected virtual void OnMouseUp(MouseButtonEventArgs e) { }
/// <inheritdoc cref="UIElement.OnMouseMove(MouseEventArgs)"/>
protected virtual void OnMouseMove(MouseEventArgs e) { }
/// <inheritdoc cref="UIElement.OnMouseWheel(MouseWheelEventArgs)"/>
protected virtual void OnMouseWheel(MouseWheelEventArgs e) { }
/// <inheritdoc cref="UIElement.OnKeyUp(KeyEventArgs)"/>
protected virtual void OnKeyUp(KeyEventArgs e) { }
/// <inheritdoc cref="UIElement.OnKeyDown(KeyEventArgs)"/>
protected virtual void OnKeyDown(KeyEventArgs e) { }
/// <inheritdoc cref="UIElement.OnLostMouseCapture(MouseEventArgs)"/>
protected virtual void OnLostMouseCapture(MouseEventArgs e) { }
/// <summary>
/// Called for any input event that is not explicitly handled by other methods.
/// </summary>
/// <param name="e">The input event arguments.</param>
protected virtual void OnEvent(InputEventArgs e) { }
/// <summary>
/// Processes the input event by invoking the appropriate handler method based on the routed event.
/// </summary>
/// <param name="e">The input event arguments.</param>
public void HandleEvent(InputEventArgs e)
{
if (e.RoutedEvent == UIElement.MouseMoveEvent)
{
OnMouseMove((MouseEventArgs)e);
}
else if (e.RoutedEvent == UIElement.MouseDownEvent)
{
OnMouseDown((MouseButtonEventArgs)e);
}
else if (e.RoutedEvent == UIElement.MouseUpEvent)
{
OnMouseUp((MouseButtonEventArgs)e);
}
else if (e.RoutedEvent == UIElement.MouseWheelEvent)
{
OnMouseWheel((MouseWheelEventArgs)e);
}
else if (e.RoutedEvent == UIElement.LostMouseCaptureEvent)
{
OnLostMouseCapture((MouseEventArgs)e);
}
else if (e.RoutedEvent == UIElement.KeyDownEvent)
{
OnKeyDown((KeyEventArgs)e);
}
else if (e.RoutedEvent == UIElement.KeyUpEvent)
{
OnKeyUp((KeyEventArgs)e);
}
OnEvent(e);
}
}
}

View File

@@ -0,0 +1,75 @@
using System.Windows;
using System.Windows.Input;
namespace Nodify.Interactivity
{
public partial class InputElementStateStack<TElement> where TElement : FrameworkElement
{
/// <summary>
/// Represents a specialized state for handling drag interactions.
/// </summary>
public abstract class DragState : DragState<TElement>, IInputElementState, IInputHandler
{
/// <summary>
/// Gets the state stack managing this state.
/// </summary>
public InputElementStateStack<TElement> Stack { get; }
private readonly InputEventArgs _mouseEventArgs = new MouseEventArgs(Mouse.PrimaryDevice, 0, Stylus.CurrentStylusDevice)
{
RoutedEvent = NodifyEditor.ViewportUpdatedEvent // dummy event
};
/// <summary>
/// Initializes a new instance of the <see cref="DragState"/> class.
/// </summary>
/// <param name="stack">The state stack managing this state.</param>
/// <param name="exitGesture">The gesture used to exit the drag state.</param>
/// <param name="cancelGesture">The gesture used to cancel the drag state.</param>
public DragState(InputElementStateStack<TElement> stack, InputGesture exitGesture, InputGesture cancelGesture)
: base(stack.Element, exitGesture, cancelGesture)
{
PositionElement = stack.Element;
Stack = stack;
}
/// <summary>
/// Initializes a new instance of the <see cref="DragState"/> class with an optional cancel gesture.
/// </summary>
/// <param name="stack">The state stack managing this state.</param>
/// <param name="exitGesture">The gesture used to exit the drag state.</param>
public DragState(InputElementStateStack<TElement> stack, InputGesture exitGesture)
: base(stack.Element, exitGesture)
{
PositionElement = stack.Element;
Stack = stack;
}
public void Enter(IInputElementState? from)
=> BeginDrag(_mouseEventArgs);
public void Exit()
{
}
/// <summary>
/// Pushes a new state onto the stack.
/// </summary>
/// <param name="newState">The new state to push.</param>
public void PushState(IInputElementState newState)
=> Stack.PushState(newState);
/// <summary>
/// Pops the current state from the stack.
/// </summary>
public void PopState()
=> Stack.PopState();
protected override void OnCancel(InputEventArgs e)
=> PopState();
protected override void OnEnd(InputEventArgs e)
=> PopState();
}
}
}

View File

@@ -0,0 +1,44 @@
using System.Windows;
namespace Nodify.Interactivity
{
public partial class InputElementStateStack<TElement> where TElement : FrameworkElement
{
/// <summary>
/// Base class for defining input element states.
/// </summary>
public abstract class InputElementState : InputElementState<TElement>, IInputElementState
{
/// <summary>
/// Gets the state stack managing this state.
/// </summary>
protected InputElementStateStack<TElement> Stack { get; }
/// <summary>
/// Initializes a new instance of the <see cref="InputElementState"/> class.
/// </summary>
/// <param name="stack">The state stack managing this state.</param>
public InputElementState(InputElementStateStack<TElement> stack) : base(stack.Element)
{
Stack = stack;
}
public virtual void Enter(IInputElementState? from) { }
public virtual void Exit() { }
/// <summary>
/// Pushes a new state onto the stack.
/// </summary>
/// <param name="newState">The new state to push.</param>
public void PushState(IInputElementState newState)
=> Stack.PushState(newState);
/// <summary>
/// Pops the current state from the stack.
/// </summary>
public void PopState()
=> Stack.PopState();
}
}
}

View File

@@ -0,0 +1,104 @@
using System.Collections.Generic;
using System.Windows;
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <summary>
/// Manages a stack of input states for a UI element, enabling complex input interactions.
/// </summary>
/// <typeparam name="TElement">The type of the associated FrameworkElement.</typeparam>
public partial class InputElementStateStack<TElement> : IInputHandler
where TElement : FrameworkElement
{
private readonly Stack<IInputElementState> _states = new Stack<IInputElementState>();
/// <summary>
/// Gets the associated element for which this state stack is managing input states.
/// </summary>
protected TElement Element { get; }
public bool RequiresInputCapture => State.RequiresInputCapture;
public bool ProcessHandledEvents => State.ProcessHandledEvents;
/// <summary>
/// Initializes a new instance of the <see cref="InputElementStateStack{TElement}"/> class.
/// </summary>
/// <param name="element">The element associated with this state stack.</param>
public InputElementStateStack(TElement element)
{
Element = element;
}
/// <summary>
/// Gets the current state at the top of the stack.
/// </summary>
public IInputElementState State => _states.Peek();
/// <summary>Pushes a new state into the stack.</summary>
/// <param name="newState">The new state.</param>
/// <remarks>Calls <see cref="IInputElementState.Enter"/> on the new state.</remarks>
public void PushState(IInputElementState newState)
{
var prev = _states.Count > 0 ? State : null;
_states.Push(newState);
newState.Enter(prev);
}
/// <summary>Pops the current state from the stack.</summary>
/// <remarks>It doesn't pop the initial state.
/// <br />Calls <see cref="IInputElementState.Exit"/> on the current state.
/// <br />Calls <see cref="IInputElementState.Enter"/> on the new state.</remarks>
public void PopState()
{
// Never remove the default state
if (_states.Count > 1)
{
IInputElementState prev = _states.Pop();
prev.Exit();
State.Enter(prev);
}
}
/// <summary>Pops all states from the stack.</summary>
/// <remarks>It doesn't pop the initial state.
/// <br />Calls <see cref="IInputElementState.Exit"/> on the current state.
/// <br />Calls <see cref="IInputElementState.Enter"/> on the previous state.
/// </remarks>
public void PopAllStates()
{
while (_states.Count > 1)
{
PopState();
}
}
public void HandleEvent(InputEventArgs e)
{
State.HandleEvent(e);
if (e.RoutedEvent == UIElement.LostMouseCaptureEvent)
{
PopAllStates();
}
}
/// <summary>
/// Interface representing a state in the input state stack.
/// </summary>
public interface IInputElementState : IInputHandler
{
/// <summary>
/// Invoked when entering this state from another state.
/// </summary>
/// <param name="from">The state being exited, or null if entering from no prior state.</param>
void Enter(IInputElementState? from);
/// <summary>
/// Invoked when exiting this state.
/// </summary>
void Exit();
}
}
}

View File

@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Input;
namespace Nodify.Interactivity
{
public partial class InputProcessor
{
/// <summary>
/// A shared input processor that allows registering and managing global input handlers for a specific type of UI element.
/// </summary>
/// <typeparam name="TElement">The type of the UI element that the input handlers will be associated with.</typeparam>
public sealed class Shared<TElement> : InputProcessor, IInputHandler
where TElement : FrameworkElement
{
private static readonly List<KeyValuePair<Type, Func<TElement, IInputHandler>>> _handlerFactories = new List<KeyValuePair<Type, Func<TElement, IInputHandler>>>();
bool IInputHandler.ProcessHandledEvents { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Shared{TElement}"/> class for the specified UI element.
/// </summary>
/// <param name="element">The UI element that the shared input handlers will be associated with.</param>
public Shared(TElement element)
{
foreach (var kvp in _handlerFactories)
{
AddHandler(kvp.Value(element));
}
}
/// <summary>
/// Initializes static members of the <see cref="Shared{TElement}"/> class.
/// Ensures default handlers are registered before methods on this class can be used.
/// </summary>
static Shared()
{
if (typeof(TElement) == typeof(NodifyEditor))
{
EditorState.RegisterDefaultHandlers();
}
else if (typeof(TElement) == typeof(ItemContainer))
{
ContainerState.RegisterDefaultHandlers();
}
else if (typeof(TElement) == typeof(Connector))
{
ConnectorState.RegisterDefaultHandlers();
}
else if (typeof(TElement) == typeof(Minimap))
{
MinimapState.RegisterDefaultHandlers();
}
else if (typeof(TElement) == typeof(BaseConnection))
{
ConnectionState.RegisterDefaultHandlers();
}
}
public void HandleEvent(InputEventArgs e)
=> ProcessEvent(e);
/// <summary>
/// Registers a factory method for creating an input handler of the specified type.
/// </summary>
/// <typeparam name="THandler">The type of the input handler to register.</typeparam>
/// <param name="factory">A factory method that creates an instance of the input handler for a given UI element.</param>
/// <exception cref="InvalidOperationException">
/// Thrown if an input handler of the specified type is already registered.
/// </exception>
public static void RegisterHandlerFactory<THandler>(Func<TElement, THandler> factory)
where THandler : IInputHandler
{
if (_handlerFactories.Any(x => x.Key == typeof(THandler)))
{
throw new InvalidOperationException($"An input handler of type '{typeof(THandler)}' has already been registered.");
}
_handlerFactories.Add(new KeyValuePair<Type, Func<TElement, IInputHandler>>(typeof(THandler), elem => factory(elem)));
}
/// <summary>
/// Removes the registered factory method for creating input handlers of the specified type.
/// </summary>
/// <typeparam name="THandler">The type of the input handler to remove.</typeparam>
public static void RemoveHandlerFactory<THandler>()
=> _handlerFactories.RemoveAll(x => x.Key == typeof(THandler));
/// <summary>
/// Replaces the registered factory method with another one of the same type.
/// </summary>
/// <typeparam name="THandler">The type of the input handler to replace.</typeparam>
public static void ReplaceHandlerFactory<THandler>(Func<TElement, THandler> factory)
where THandler : IInputHandler
{
int index = _handlerFactories.FindIndex(x => x.Key == typeof(THandler));
_handlerFactories[index] = new KeyValuePair<Type, Func<TElement, IInputHandler>>(typeof(THandler), elem => factory(elem));
}
/// <summary>
/// Clears all registered handler factories, effectively removing all shared input handlers.
/// </summary>
public static void ClearHandlerFactories()
=> _handlerFactories.Clear();
}
}
/// <summary>
/// Provides extension methods for the <see cref="InputProcessor"/> class.
/// </summary>
public static class InputProcessorExtensions
{
/// <summary>
/// Adds shared input handlers for the specified UI element instance to the input processor.
/// </summary>
/// <typeparam name="TElement">The type of the UI element for which shared input handlers are being added.</typeparam>
/// <param name="inputProcessor">The input processor to which the shared handlers will be added.</param>
/// <param name="instance">The UI element instance associated with the shared handlers.</param>
public static void AddSharedHandlers<TElement>(this InputProcessor inputProcessor, TElement instance)
where TElement : FrameworkElement
{
inputProcessor.AddHandler(new InputProcessor.Shared<TElement>(instance));
}
}
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Generic;
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <summary>
/// Processes input events and delegates them to registered handlers.
/// </summary>
public partial class InputProcessor
{
private readonly List<IInputHandler> _handlers = new List<IInputHandler>();
/// <summary>
/// Gets a value indicating whether the processor has ongoing interactions that require input capture to remain active.
/// </summary>
/// <remarks>
/// This property can be used to determine whether it is safe to release mouse capture, especially during toggled interactions. <br />
/// Toggled interactions usually involve two steps, and it is important to keep the input capture active until the interaction is completed.
/// </remarks>
public bool RequiresInputCapture { get; private set; }
/// <summary>
/// Adds an input handler to the processor.
/// </summary>
/// <param name="handler">The input handler to add.</param>
public void AddHandler(IInputHandler handler)
=> _handlers.Add(handler);
/// <summary>
/// Removes all handlers of the specified type from the processor.
/// </summary>
/// <typeparam name="T">The type of the handler to remove.</typeparam>
public void RemoveHandlers<T>() where T : IInputHandler
=> _handlers.RemoveAll(x => x.GetType() == typeof(T));
/// <summary>
/// Clears all registered handlers.
/// </summary>
public void Clear()
=> _handlers.Clear();
/// <summary>
/// Processes an input event and delegates it to the registered handlers.
/// </summary>
/// <param name="e">The input event arguments to process.</param>
public void ProcessEvent(InputEventArgs e)
{
RequiresInputCapture = false;
for (int i = 0; i < _handlers.Count; i++)
{
IInputHandler handler = _handlers[i];
if (!e.Handled || handler.ProcessHandledEvents)
{
handler.HandleEvent(e);
RequiresInputCapture |= handler.RequiresInputCapture;
}
}
}
}
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Input;
namespace Nodify.Interactivity
{
internal readonly struct DirectionalFocusNavigator<TElement>
where TElement : UIElement, IKeyboardFocusTarget<TElement>
{
private readonly IEnumerable<IKeyboardFocusTarget<TElement>> _availableTargets;
public DirectionalFocusNavigator(IEnumerable<IKeyboardFocusTarget<TElement>> availableTargets)
{
_availableTargets = availableTargets;
}
public readonly IKeyboardFocusTarget<TElement>? FindNextFocusTarget(IKeyboardFocusTarget<TElement> currentContainer, TraversalRequest request)
{
var currentContainerBounds = currentContainer.Bounds;
IEnumerable<IKeyboardFocusTarget<TElement>> candidates = request.FocusNavigationDirection switch
{
FocusNavigationDirection.Left => _availableTargets.Where(c => c.Bounds.Left < currentContainerBounds.Left),
FocusNavigationDirection.Right => _availableTargets.Where(c => c.Bounds.Left > currentContainerBounds.Left),
FocusNavigationDirection.Up => _availableTargets.Where(c => c.Bounds.Top < currentContainerBounds.Top),
FocusNavigationDirection.Down => _availableTargets.Where(c => c.Bounds.Top > currentContainerBounds.Top),
FocusNavigationDirection.Previous => FindCandidatesLinearly(currentContainer, request),
FocusNavigationDirection.Next => FindCandidatesLinearly(currentContainer, request),
FocusNavigationDirection.First => FindCandidatesLinearly(currentContainer, request),
FocusNavigationDirection.Last => FindCandidatesLinearly(currentContainer, request),
_ => Array.Empty<IKeyboardFocusTarget<TElement>>()
};
// Wrap focus if no candidates found in the current direction
if (!candidates.Any())
{
candidates = request.FocusNavigationDirection switch
{
FocusNavigationDirection.Left => _availableTargets.OrderByDescending(c => c.Bounds.Left).Take(1),
FocusNavigationDirection.Right => _availableTargets.OrderBy(c => c.Bounds.Left).Take(1),
FocusNavigationDirection.Up => _availableTargets.OrderByDescending(c => c.Bounds.Top).Take(1),
FocusNavigationDirection.Down => _availableTargets.OrderBy(c => c.Bounds.Top).Take(1),
_ => Array.Empty<IKeyboardFocusTarget<TElement>>()
};
request.Wrapped = true;
}
IKeyboardFocusTarget<TElement>? best = null;
double minDistanceSquared = double.MaxValue;
foreach (var candidate in candidates)
{
double distanceSquared = (candidate.Bounds.TopLeft - currentContainerBounds.TopLeft).LengthSquared;
if (distanceSquared < minDistanceSquared)
{
minDistanceSquared = distanceSquared;
best = candidate;
}
}
return best;
}
private IKeyboardFocusTarget<TElement>[] FindCandidatesLinearly(IKeyboardFocusTarget<TElement> currentContainer, TraversalRequest request)
{
var nextTarget = new LinearFocusNavigator<TElement>(_availableTargets).FindNextFocusTarget(currentContainer, request);
return nextTarget is null ? Array.Empty<IKeyboardFocusTarget<TElement>>() : new[] { nextTarget };
}
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Input;
namespace Nodify.Interactivity
{
/// <summary>
/// Represents a unique identifier for a keyboard navigation layer.
/// </summary>
public class KeyboardNavigationLayerId
{
public static readonly KeyboardNavigationLayerId Nodes = new KeyboardNavigationLayerId();
public static readonly KeyboardNavigationLayerId Connections = new KeyboardNavigationLayerId();
public static readonly KeyboardNavigationLayerId Decorators = new KeyboardNavigationLayerId();
}
/// <summary>
/// Represents a group of keyboard navigation layers that can be activated and navigated through.
/// </summary>
public interface IKeyboardNavigationLayerGroup : IReadOnlyCollection<IKeyboardNavigationLayer>
{
/// <summary>
/// The current active keyboard navigation layer in the group, if any.
/// </summary>
IKeyboardNavigationLayer? ActiveNavigationLayer { get; }
/// <summary>
/// Event that is raised when the active keyboard navigation layer changes.
/// </summary>
event Action<KeyboardNavigationLayerId>? ActiveNavigationLayerChanged;
/// <summary>
/// Activates the next keyboard navigation layer in the group, allowing focus to be restored to the last focused element in that layer.
/// </summary>
/// <returns>Returns true if the navigation layer was activated, false otherwise.</returns>
bool ActivateNextNavigationLayer();
/// <summary>
/// Activates the previous keyboard navigation layer in the group, allowing focus to be restored to the last focused element in that layer.
/// </summary>
/// <returns>Returns true if the navigation layer was activated, false otherwise.</returns>
bool ActivatePreviousNavigationLayer();
/// <summary>
/// Registers a new keyboard navigation layer to the group, allowing it to handle focus movement and restoration.
/// </summary>
/// <param name="layer">The navigation layer.</param>
/// <returns></returns>
bool RegisterNavigationLayer(IKeyboardNavigationLayer layer);
/// <summary>
/// Removes the specified keyboard navigation layer from the group.
/// </summary>
/// <param name="layerId">The navigation layer id.</param>
/// <returns>Returns true if the layer was removed, false otherwise.</returns>
bool RemoveNavigationLayer(KeyboardNavigationLayerId layerId);
/// <summary>
/// Activates the specified keyboard navigation layer, making it the active layer for focus management.
/// </summary>
/// <param name="layerId">The navigation layer id to activate.</param>
/// <returns>Returns true if the navigation layer was activated, false otherwise.</returns>
bool ActivateNavigationLayer(KeyboardNavigationLayerId layerId);
}
/// <summary>
/// Represents a layer of keyboard navigation that can handle focus movement and restoration.
/// </summary>
public interface IKeyboardNavigationLayer
{
/// <summary>
/// Gets the unique identifier for this keyboard navigation layer.
/// </summary>
KeyboardNavigationLayerId Id { get; }
/// <summary>
/// Gets the last focused element within this layer, if any.
/// </summary>
IKeyboardFocusTarget<UIElement>? LastFocusedElement { get; }
/// <summary>
/// Attempts to move focus within this layer based on the provided traversal request.
/// </summary>
/// <param name="request">The traversal request.</param>
/// <returns>Returns true if the focus was moved, false otherwise.</returns>
bool TryMoveFocus(TraversalRequest request);
/// <summary>
/// Attempts to restore focus to the last focused element within this layer.
/// </summary>
/// <returns>Returns true if the focus was restored, false otherwise.</returns>
bool TryRestoreFocus();
/// <summary>
/// Called when the layer is activated, allowing for any necessary setup or focus management.
/// </summary>
void OnActivated();
/// <summary>
/// Called when the layer is deactivated, allowing for any necessary cleanup or focus management.
/// </summary>
void OnDeactivated();
}
/// <summary>
/// Represents a target for keyboard focus within a specific layer, providing bounds and the associated UI element.
/// </summary>
/// <typeparam name="TElement">The associated UI element.</typeparam>
public interface IKeyboardFocusTarget<out TElement>
where TElement : UIElement
{
/// <summary>
/// Gets the bounds of the focus target within the layer.
/// </summary>
Rect Bounds { get; }
/// <summary>
/// Gets the associated UI element for this focus target.
/// </summary>
TElement Element { get; }
}
}

View File

@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Input;
namespace Nodify.Interactivity
{
internal readonly struct LinearFocusNavigator<TElement>
where TElement : UIElement, IKeyboardFocusTarget<TElement>
{
private enum LinearNavigationDirection
{
First,
Last,
Forward,
Backward
}
private readonly IEnumerable<IKeyboardFocusTarget<TElement>> _availableTargets;
public LinearFocusNavigator(IEnumerable<IKeyboardFocusTarget<TElement>> availableTargets)
{
_availableTargets = availableTargets;
}
public readonly IKeyboardFocusTarget<TElement>? FindNextFocusTarget(IKeyboardFocusTarget<TElement> currentContainer, TraversalRequest request)
{
var direction = IsBackward(request.FocusNavigationDirection) ? LinearNavigationDirection.Backward
: IsForward(request.FocusNavigationDirection) ? LinearNavigationDirection.Forward
: request.FocusNavigationDirection == FocusNavigationDirection.First ? LinearNavigationDirection.First : LinearNavigationDirection.Last;
var availableTargets = _availableTargets as List<IKeyboardFocusTarget<TElement>> ?? _availableTargets.ToList();
int currentIndex = availableTargets.IndexOf(currentContainer);
IKeyboardFocusTarget<TElement>? candidate = direction switch
{
LinearNavigationDirection.Forward when currentIndex >= 0 && currentIndex + 1 < availableTargets.Count => availableTargets[currentIndex + 1],
LinearNavigationDirection.Backward when currentIndex > 0 => availableTargets[currentIndex - 1],
LinearNavigationDirection.First when availableTargets.Count > 0 => availableTargets[0],
LinearNavigationDirection.Last when availableTargets.Count > 0 => availableTargets[availableTargets.Count - 1],
_ => null
};
// Wrap focus if no candidates found in the current direction
if (candidate is null)
{
candidate = direction switch
{
LinearNavigationDirection.Forward when availableTargets.Count > 0 => availableTargets[0],
LinearNavigationDirection.Backward when availableTargets.Count > 0 => availableTargets[availableTargets.Count - 1],
_ => null
};
request.Wrapped = candidate != null;
}
return candidate;
}
private static bool IsForward(FocusNavigationDirection dir)
{
return dir == FocusNavigationDirection.Right || dir == FocusNavigationDirection.Up || dir == FocusNavigationDirection.Next;
}
private static bool IsBackward(FocusNavigationDirection dir)
{
return dir == FocusNavigationDirection.Left || dir == FocusNavigationDirection.Down || dir == FocusNavigationDirection.Previous;
}
}
}

View File

@@ -0,0 +1,74 @@
using System;
using System.Windows.Input;
using System.Windows;
namespace Nodify.Interactivity
{
internal class StatefulFocusNavigator<TElement>
where TElement : UIElement, IKeyboardFocusTarget<TElement>
{
public delegate bool FindNextFocusTargetDelegate(TElement? currentElement, TraversalRequest request, out TElement? elementToFocus);
private readonly WeakReference<TElement?> _previousFocusedElement = new WeakReference<TElement?>(null);
private readonly WeakReference<TElement?> _lastFocusedElement = new WeakReference<TElement?>(null);
private FocusNavigationDirection? _previousFocusNavigationDirection;
private readonly Action<IKeyboardFocusTarget<TElement>> _onFocus;
public TElement? LastFocusedElement => _lastFocusedElement.TryGetTarget(out var target) ? target : null;
public StatefulFocusNavigator(Action<IKeyboardFocusTarget<TElement>> onFocus)
{
_onFocus = onFocus;
}
public bool TryMoveFocus(TraversalRequest request, FindNextFocusTargetDelegate findNext)
{
var currentTarget = Keyboard.FocusedElement as TElement;
// If the request is in the opposite direction of the last focus navigation, try to restore the previous focused container
if (_previousFocusedElement.TryGetTarget(out var prevTarget)
&& _previousFocusNavigationDirection.HasValue
&& request.FocusNavigationDirection.IsOppositeOf(_previousFocusNavigationDirection.Value)
&& prevTarget!.Focus())
{
_previousFocusNavigationDirection = request.FocusNavigationDirection;
_previousFocusedElement.SetTarget(currentTarget);
_lastFocusedElement.SetTarget(prevTarget);
_onFocus(prevTarget);
return true;
}
else if (findNext(currentTarget, request, out var nextTarget) && nextTarget!.Element.Focus())
{
_previousFocusNavigationDirection = request.FocusNavigationDirection;
_previousFocusedElement.SetTarget(currentTarget);
_lastFocusedElement.SetTarget(nextTarget);
_onFocus(nextTarget);
return true;
}
return false;
}
public bool TryRestoreFocus()
{
if (_lastFocusedElement.TryGetTarget(out var lastTarget))
{
if (lastTarget!.IsKeyboardFocused)
{
return true;
}
if (lastTarget.Focus())
{
_onFocus.Invoke(lastTarget);
return true;
}
}
return false;
}
}
}