using System;
using System.Globalization;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;
namespace IFSRenderer.AvaloniaShell.Controls;
/// <summary>
/// The numeric control the whole UI is built from: <c>- value +</c> with drag-to-scrub,
/// typed entry, reset, and the keyframe diamond.
/// </summary>
/// <remarks>
/// <para>
/// A port of <c>WpfDisplay/Views/ValueSlider.xaml(.cs)</c>, which is instantiated ~40 times
/// across the Windows app and is therefore the visual texture of every panel. Behaviour is
/// matched deliberately, including the parts that are easy to miss:
/// </para>
/// <list type="bullet">
/// <item>Ctrl multiplies the increment by 10, Shift by 0.1 — on the buttons and on the drag.</item>
/// <item>The display format is <c>N0</c> when <see cref="Increment"/> is a whole number and
/// <c>N4</c> otherwise, so integers do not show ".0000".</item>
/// <item>Reset sets the value to <see cref="DefaultValue"/>, or to 0 if it is already there.
/// That is Apophysis' behaviour and the original copies it.</item>
/// <item>The diamond has three states — see <see cref="UpdateAnimatedSymbol"/>.</item>
/// </list>
/// <para>
/// The scrub WRAPS the pointer at the edge of the monitor, so it never runs out of desk — what
/// Blender calls continuous grab. Avalonia has no cursor-warp API, but X11 does
/// (<c>XWarpPointer</c>), so it is called directly. Where that is unavailable — a Wayland
/// session with no XWayland — the cursor simply travels and the scrub stops at the screen edge.
/// </para>
/// </remarks>
public partial class ValueSlider : UserControl
{
// ------------------------------------------------------------------ cursor warp
// DllImport rather than LibraryImport: every signature here is blittable, so the source
// generator would buy nothing, and it demands AllowUnsafeBlocks across the whole project.
[DllImport("libX11.so.6")]
private static extern IntPtr XOpenDisplay(IntPtr name);
[DllImport("libX11.so.6")]
private static extern IntPtr XDefaultRootWindow(IntPtr display);
[DllImport("libX11.so.6")]
private static extern int XWarpPointer(IntPtr display, IntPtr srcWindow, IntPtr destWindow,
int srcX, int srcY, uint srcWidth, uint srcHeight, int destX, int destY);
[DllImport("libX11.so.6")]
private static extern int XFlush(IntPtr display);
private static IntPtr _display;
private static bool _displayResolved;
/// <summary>
/// How the pointer is moved. Swappable so the wrap arithmetic can be tested without an X
/// server, and so the no-warp path (Wayland, or a headless run) is exercisable too.
/// </summary>
internal static Func<PixelPoint, bool> PointerWarp { get; set; } = WarpPointer;
/// <summary>
/// Moves the system pointer to a screen position. False when no X11 display can be
/// reached, in which case the caller must cope with a pointer that travels.
/// </summary>
/// <remarks>
/// The display handle is opened once and kept: it is only ever used to warp, and closing
/// it per drag would cost a round trip to the X server on every pointer move.
/// </remarks>
private static bool WarpPointer(PixelPoint screen)
{
if (!_displayResolved)
{
_displayResolved = true;
// DllNotFoundException when libX11 is absent, e.g. a Wayland-only container.
try { _display = XOpenDisplay(IntPtr.Zero); }
catch { _display = IntPtr.Zero; }
}
if (_display == IntPtr.Zero)
return false;
try
{
XWarpPointer(_display, IntPtr.Zero, XDefaultRootWindow(_display),
0, 0, 0, 0, screen.X, screen.Y);
XFlush(_display);
return true;
}
catch
{
_display = IntPtr.Zero;
return false;
}
}
/// <summary>
/// Reports a channel's state for <paramref name="path"/>: null when no channel exists,
/// false when one exists, true when a keyframe sits at the current time.
/// </summary>
/// <remarks>
/// A hook rather than a lookup so the control stays independent of the host. WPF reaches
/// through <c>Application.Current.MainWindow.DataContext</c> here, and says "ugh" in a
/// comment while doing it.
/// </remarks>
public static Func<string, bool?> ChannelStateProvider { get; set; }
/// <summary>True when the live value differs from what the channel evaluates to.</summary>
public static Func<string, double, bool> ChannelModifiedProvider { get; set; }
/// <summary>Raised by the host when the dopesheet or the playhead changed.</summary>
public static event Action AnimationStateChanged;
/// <summary>Asks the host to key this parameter: (label, channel path, value).</summary>
public static Action<string, string, double> AnimateRequested { get; set; }
public static void NotifyAnimationStateChanged() => AnimationStateChanged?.Invoke();
// ------------------------------------------------------------------ properties
public static readonly StyledProperty<double> ValueProperty =
AvaloniaProperty.Register<ValueSlider, double>(nameof(Value),
defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public double Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public static readonly StyledProperty<string> LabelProperty =
AvaloniaProperty.Register<ValueSlider, string>(nameof(Label));
public string Label
{
get => GetValue(LabelProperty);
set => SetValue(LabelProperty, value);
}
public static readonly StyledProperty<bool> IsLabelShownProperty =
AvaloniaProperty.Register<ValueSlider, bool>(nameof(IsLabelShown), true);
public bool IsLabelShown
{
get => GetValue(IsLabelShownProperty);
set => SetValue(IsLabelShownProperty, value);
}
public static readonly StyledProperty<double> DefaultValueProperty =
AvaloniaProperty.Register<ValueSlider, double>(nameof(DefaultValue));
public double DefaultValue
{
get => GetValue(DefaultValueProperty);
set => SetValue(DefaultValueProperty, value);
}
public static readonly StyledProperty<double> IncrementProperty =
AvaloniaProperty.Register<ValueSlider, double>(nameof(Increment), 0.1);
public double Increment
{
get => GetValue(IncrementProperty);
set => SetValue(IncrementProperty, value);
}
public static readonly StyledProperty<double?> MinValueProperty =
AvaloniaProperty.Register<ValueSlider, double?>(nameof(MinValue));
public double? MinValue
{
get => GetValue(MinValueProperty);
set => SetValue(MinValueProperty, value);
}
public static readonly StyledProperty<double?> MaxValueProperty =
AvaloniaProperty.Register<ValueSlider, double?>(nameof(MaxValue));
public double? MaxValue
{
get => GetValue(MaxValueProperty);
set => SetValue(MaxValueProperty, value);
}
public static readonly StyledProperty<string> AnimationPathProperty =
AvaloniaProperty.Register<ValueSlider, string>(nameof(AnimationPath));
public string AnimationPath
{
get => GetValue(AnimationPathProperty);
set => SetValue(AnimationPathProperty, value);
}
public static readonly StyledProperty<string> HelpTextProperty =
AvaloniaProperty.Register<ValueSlider, string>(nameof(HelpText));
/// <summary>The parameter's explanatory text, shown on hover and in the context menu.</summary>
public string HelpText
{
get => GetValue(HelpTextProperty);
set => SetValue(HelpTextProperty, value);
}
public static readonly StyledProperty<ValueSliderSettings> SliderSettingsProperty =
AvaloniaProperty.Register<ValueSlider, ValueSliderSettings>(nameof(SliderSettings));
public ValueSliderSettings SliderSettings
{
get => GetValue(SliderSettingsProperty);
set => SetValue(SliderSettingsProperty, value);
}
// --------------------------------------------------------------------- state
private TextBlock _labelText;
private TextBlock _valueText;
private TextBox _valueEditor;
private Border _valueField;
private Grid _labelRow;
private Button _down, _up, _diamond1, _diamond2;
private Action _valueWillChange;
private Action<double> _valueChanged;
private Action _draggingStarted;
private Action<double> _draggingCompleted;
/// <summary>Modifiers seen on the most recent pointer press, for the step multiplier.</summary>
private KeyModifiers _modifiers;
private bool _pressed, _dragging, _editing;
private Point _dragOrigin;
private double _dragStartValue;
/// <summary>Pointer X in screen pixels at the previous move, for delta accumulation.</summary>
private int _lastScreenX;
/// <summary>Value before an edit began, restored on Escape.</summary>
private double _lastValue;
/// <summary>Set while writing the value into the editor, so its handlers no-op.</summary>
private bool _syncing;
private double IncrementMultiplier => 1.0
* (_modifiers.HasFlag(KeyModifiers.Control) ? 10 : 1)
* (_modifiers.HasFlag(KeyModifiers.Shift) ? 0.1 : 1);
public ValueSlider()
{
InitializeComponent();
_labelRow = this.FindControl<Grid>("LabelRow");
_labelText = this.FindControl<TextBlock>("LabelText");
_valueText = this.FindControl<TextBlock>("ValueText");
_valueEditor = this.FindControl<TextBox>("ValueEditor");
_valueField = this.FindControl<Border>("ValueField");
_down = this.FindControl<Button>("DownButton");
_up = this.FindControl<Button>("UpButton");
_diamond1 = this.FindControl<Button>("Diamond1");
_diamond2 = this.FindControl<Button>("Diamond2");
// Click carries no modifier state, so grab it from the press that precedes it.
foreach (Button b in new[] { _down, _up })
b.AddHandler(PointerPressedEvent, (_, e) => _modifiers = e.KeyModifiers,
RoutingStrategies.Tunnel);
_down.Click += (_, _) => Step(-1);
_up.Click += (_, _) => Step(+1);
_diamond1.Click += (_, _) => Animate();
_diamond2.Click += (_, _) => Animate();
_valueField.PointerPressed += OnFieldPressed;
_valueField.PointerMoved += OnFieldMoved;
_valueField.PointerReleased += OnFieldReleased;
_valueEditor.KeyDown += OnEditorKeyDown;
_valueEditor.LostFocus += (_, _) => CommitEdit();
// valueEditor_MouseDoubleClick: reset to the default, then select the whole value so
// it can be typed straight over.
//
// NOT DoubleTapped. Avalonia's Gestures raises that only when both presses hit the
// SAME element — which here they never can, because the first click lands on the value
// field and that field swaps itself for this editor, so the second lands on a
// different control. The handler simply never ran: double-click neither reset the
// value nor corrected the selection. The editor's own press does carry
// ClickCount == 2, so that is what to hang it on.
//
// Tunnel, so this runs before TextBox's class handler; the select-all is then posted
// to land after that handler has done its own word selection, which otherwise leaves a
// single run highlighted ("55" of "14.55").
_valueEditor.AddHandler(PointerPressedEvent, (_, e) =>
{
if (e.ClickCount < 2)
return;
ResetValue();
StartEditing();
Dispatcher.UIThread.Post(() =>
{
if (_editing)
_valueEditor.SelectAll();
}, DispatcherPriority.Input);
}, RoutingStrategies.Tunnel);
ContextMenu = BuildContextMenu();
AnimationStateChanged += UpdateAnimatedSymbol;
DetachedFromVisualTree += (_, _) => AnimationStateChanged -= UpdateAnimatedSymbol;
RefreshText();
UpdateAnimatedSymbol();
UpdateLayoutMode();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == SliderSettingsProperty)
ApplySettings(change.GetNewValue<ValueSliderSettings>());
else if (change.Property == ValueProperty)
{
RefreshText();
UpdateAnimatedSymbol();
}
else if (change.Property == IncrementProperty)
RefreshText();
else if (change.Property == LabelProperty)
_labelText.Text = Label;
else if (change.Property == IsLabelShownProperty)
UpdateLayoutMode();
else if (change.Property == AnimationPathProperty)
UpdateAnimatedSymbol();
else if (change.Property == HelpTextProperty)
ToolTip.SetTip(this, HelpText);
}
/// <summary>
/// Copies the supplied settings onto the control. Only non-null members are applied, so
/// "unspecified" leaves whatever the call site set directly — the same contract the WPF
/// version implements via <c>BindingOperations.IsDataBound</c>.
/// </summary>
private void ApplySettings(ValueSliderSettings s)
{
if (s is null)
return;
if (s.Label is not null)
Label = s.Label;
if (s.IsLabelShown is bool shown)
IsLabelShown = shown;
if (s.DefaultValue is double d)
DefaultValue = d;
if (s.MinValue is double min)
MinValue = min;
if (s.MaxValue is double max)
MaxValue = max;
if (s.Increment is double inc)
Increment = inc;
if (s.AnimationPath is not null)
AnimationPath = s.AnimationPath;
if (s.ToolTip is not null)
HelpText = s.ToolTip;
_valueWillChange = s.ValueWillChange ?? _valueWillChange;
_valueChanged = s.ValueChanged ?? _valueChanged;
_draggingStarted = s.ValueDraggingStarted ?? _draggingStarted;
_draggingCompleted = s.ValueDraggingCompleted ?? _draggingCompleted;
RefreshText();
UpdateAnimatedSymbol();
UpdateLayoutMode();
}
private void UpdateLayoutMode()
{
if (_labelRow is null)
return;
_labelRow.IsVisible = IsLabelShown;
// The diamond lives in the label row when there is one, inline otherwise.
_diamond1.IsVisible = IsLabelShown;
_diamond2.IsVisible = !IsLabelShown;
}
// ---------------------------------------------------------------- formatting
/// <summary>Integers show no decimals; everything else shows four, as on Windows.</summary>
private string Format(double v) =>
v.ToString(Increment % 1 == 0 ? "N0" : "N4", CultureInfo.CurrentCulture);
private void RefreshText()
{
if (_valueText is not null)
_valueText.Text = Format(Value);
}
/// <summary>
/// Paints the keyframe diamond. Three states, matching the original and the wiki:
/// <c>◇</c> the parameter is animatable but has no channel; <c>◆</c> a channel exists;
/// <c>◈</c> a keyframe sits at the current time. Grey when there is no channel, orange
/// when the live value has drifted from the channel's evaluated value, white otherwise.
/// </summary>
private void UpdateAnimatedSymbol()
{
if (_diamond1 is null)
return;
string symbol = " ";
IBrush colour = Brushes.Gray;
if (!string.IsNullOrEmpty(AnimationPath))
{
bool? state = ChannelStateProvider?.Invoke(AnimationPath);
symbol = state switch
{
null => "◇",
false => "◆",
true => "◈",
};
if (state is not null)
{
bool modified = ChannelModifiedProvider?.Invoke(AnimationPath, Value) ?? false;
colour = modified ? Brushes.Orange : Brushes.White;
}
}
foreach (Button b in new[] { _diamond1, _diamond2 })
{
// A TextBlock, NOT a bare string. Assigning a string makes ContentPresenter wrap it
// in an AccessText, which sets ClipToBounds and so clips the glyph to its measured
// ADVANCE width. ◇ ◆ ◈ are not in Inter, so they resolve through font fallback, and
// the fallback face inks them wider than the advance it reports — which sliced the
// right vertex off every keyframe diamond with a clean vertical cut. Measured:
// button 26x27 with correct 8px padding, AccessText 10x11 and clipping. A TextBlock
// hosts the same glyph without the clip.
if (b.Content is TextBlock existing)
{
existing.Text = symbol;
existing.Foreground = colour;
}
else
{
b.Content = new TextBlock
{
Text = symbol,
Foreground = colour,
ClipToBounds = false,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
};
}
b.Foreground = colour;
b.IsEnabled = !string.IsNullOrEmpty(AnimationPath);
}
}
// -------------------------------------------------------------------- editing
private double Constrain(double v)
{
if (MinValue is double min)
v = Math.Max(min, v);
if (MaxValue is double max)
v = Math.Min(max, v);
return v;
}
private void Step(int direction)
{
_valueWillChange?.Invoke();
Value = Constrain(Value + direction * Increment * IncrementMultiplier);
_valueChanged?.Invoke(Value);
}
private void ResetValue()
{
_valueWillChange?.Invoke();
// As in Apophysis: back to the default, or to zero if already at the default.
Value = Value == DefaultValue ? 0.0 : DefaultValue;
_valueChanged?.Invoke(Value);
}
private void Animate()
{
if (string.IsNullOrEmpty(AnimationPath))
return;
AnimateRequested?.Invoke(Label, AnimationPath, Value);
UpdateAnimatedSymbol();
}
private void StartEditing()
{
_lastValue = Value;
_editing = true;
_syncing = true;
try
{
_valueEditor.Text = Value.ToString("0.####", CultureInfo.CurrentCulture);
}
finally { _syncing = false; }
_valueEditor.IsVisible = true;
_valueField.IsVisible = false;
_valueEditor.Focus();
_valueEditor.SelectAll();
}
private void CommitEdit()
{
if (!_editing)
return;
_editing = false;
_valueEditor.IsVisible = false;
_valueField.IsVisible = true;
if (_syncing)
return;
if (double.TryParse(_valueEditor.Text, NumberStyles.Float, CultureInfo.CurrentCulture, out double parsed)
|| double.TryParse(_valueEditor.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out parsed))
{
_valueWillChange?.Invoke();
Value = Constrain(parsed);
_valueChanged?.Invoke(Value);
}
}
private void OnEditorKeyDown(object sender, KeyEventArgs e)
{
_modifiers = e.KeyModifiers;
switch (e.Key)
{
case Key.Enter:
e.Handled = true;
CommitEdit();
break;
case Key.Up:
e.Handled = true;
Step(+1);
_syncing = true;
try { _valueEditor.Text = Value.ToString("0.####", CultureInfo.CurrentCulture); }
finally { _syncing = false; }
break;
case Key.Down:
e.Handled = true;
Step(-1);
_syncing = true;
try { _valueEditor.Text = Value.ToString("0.####", CultureInfo.CurrentCulture); }
finally { _syncing = false; }
break;
case Key.Escape:
e.Handled = true;
_editing = false;
_valueEditor.IsVisible = false;
_valueField.IsVisible = true;
Value = _lastValue;
_valueChanged?.Invoke(Value);
break;
}
}
// ------------------------------------------------------------------ drag-scrub
private void OnFieldPressed(object sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(_valueField).Properties.IsLeftButtonPressed)
return;
_modifiers = e.KeyModifiers;
_pressed = true;
_dragging = false;
_dragOrigin = e.GetPosition(this);
_dragStartValue = Value;
_lastScreenX = ScreenX(e);
e.Pointer.Capture(_valueField);
e.Handled = true;
}
private void OnFieldMoved(object sender, PointerEventArgs e)
{
if (!_pressed)
return;
_modifiers = e.KeyModifiers;
if (!_dragging)
{
// A press that never moves is a click, which opens the editor instead.
if (Math.Abs(e.GetPosition(this).X - _dragOrigin.X) < 2)
return;
_dragging = true;
_valueWillChange?.Invoke();
_draggingStarted?.Invoke();
Cursor = new Cursor(StandardCursorType.None);
_lastScreenX = ScreenX(e);
return;
}
int screenX = ScreenX(e);
int delta = screenX - _lastScreenX;
_lastScreenX = screenX;
// A jump of more than half the monitor is the wrap below, not the user's hand. Ignoring
// it by MAGNITUDE is what makes this robust: it does not matter how late the warp's own
// move event arrives, or how many queued events precede it.
PixelRect screen = MonitorBounds();
if (screen.Width > 0 && Math.Abs(delta) > screen.Width / 2)
return;
double target = Constrain(Value + delta * Increment * IncrementMultiplier);
if (target != Value)
{
Value = target;
_valueChanged?.Invoke(Value);
}
WrapPointerAtScreenEdge(e, screen);
}
/// <summary>
/// Teleports the pointer to the opposite edge when it reaches one, so a scrub never runs out
/// of desk — the behaviour Blender calls continuous grab.
/// </summary>
/// <remarks>
/// <para>
/// This replaces an earlier attempt that warped the pointer back to the drag origin on EVERY
/// move. That fought pointer-event latency: each queued move re-applied a stale offset which
/// the rebase then banked, so values crept in one direction on their own and bounded ones
/// pinned themselves to a limit. Wrapping only AT THE EDGE makes warps rare, and the
/// magnitude guard above makes the induced event harmless whenever it lands.
/// </para>
/// <para>
/// Wrapping is per-MONITOR, not across the whole virtual desktop: on a multi-head setup the
/// pointer should stay on the screen holding the window rather than traversing all of them
/// before coming back.
/// </para>
/// </remarks>
private void WrapPointerAtScreenEdge(PointerEventArgs e, PixelRect screen)
{
if (screen.Width <= 0)
return;
const int margin = 2;
int x = _lastScreenX;
int? destination = null;
if (x <= screen.X + margin)
destination = screen.X + screen.Width - margin - 1;
else if (x >= screen.X + screen.Width - margin - 1)
destination = screen.X + margin;
if (destination is not int target)
return;
int y = ScreenY(e);
if (!PointerWarp(new PixelPoint(target, y)))
return;
// Adopt the warped position immediately, so the move event the warp raises measures a
// delta of roughly zero rather than the width of the screen.
_lastScreenX = target;
}
/// <summary>Bounds of the monitor showing this control, or empty if unknown.</summary>
private PixelRect MonitorBounds()
{
try
{
if (TopLevel.GetTopLevel(this) is not Window window)
return default;
Avalonia.Platform.Screen screen = window.Screens?.ScreenFromVisual(this);
return screen?.Bounds ?? default;
}
catch
{
return default;
}
}
private int ScreenX(PointerEventArgs e) => ScreenPoint(e).X;
private int ScreenY(PointerEventArgs e) => ScreenPoint(e).Y;
private PixelPoint ScreenPoint(PointerEventArgs e)
{
try { return this.PointToScreen(e.GetPosition(this)); }
catch { return default; }
}
/// <summary>Puts the pointer back at the drag origin. False if the platform cannot warp.</summary>
private bool PinPointerToDragOrigin()
{
try
{
return PointerWarp(this.PointToScreen(_dragOrigin));
}
catch
{
// PointToScreen throws if the control left the visual tree mid-drag.
return false;
}
}
private void OnFieldReleased(object sender, PointerReleasedEventArgs e)
{
if (!_pressed)
return;
_pressed = false;
e.Pointer.Capture(null);
Cursor = Cursor.Default;
if (_dragging)
{
_dragging = false;
// WPF also warps home on mouse-up; the pointer is normally already there.
PinPointerToDragOrigin();
_draggingCompleted?.Invoke(Value);
}
else
{
StartEditing();
}
}
// ---------------------------------------------------------------- context menu
private ContextMenu BuildContextMenu()
{
var reset = new MenuItem { Header = "Reset" };
reset.Click += (_, _) => ResetValue();
var animate = new MenuItem { Header = "Animate" };
animate.Click += (_, _) => Animate();
var menu = new ContextMenu();
var items = new Avalonia.Controls.Controls { reset, animate };
menu.ItemsSource = items;
// The help text and the channel path are shown on open, so they track the
// parameter the slider is currently bound to.
menu.Opening += (_, _) =>
{
animate.IsEnabled = !string.IsNullOrEmpty(AnimationPath);
while (items.Count > 2)
items.RemoveAt(items.Count - 1);
if (!string.IsNullOrWhiteSpace(HelpText))
{
items.Add(new Separator());
items.Add(new MenuItem
{
Header = new TextBlock
{
Text = HelpText,
MaxWidth = 300,
TextWrapping = TextWrapping.Wrap,
},
IsHitTestVisible = false,
});
}
if (!string.IsNullOrEmpty(AnimationPath))
{
items.Add(new Separator());
items.Add(new MenuItem
{
Header = new TextBlock
{
Text = AnimationPath,
FontFamily = new FontFamily("monospace"),
FontSize = 11,
Foreground = Brushes.Gray,
},
IsHitTestVisible = false,
});
}
};
return menu;
}
}