diff --git a/host/Com/AvnGuids.cs b/host/Com/AvnGuids.cs index 52aa969..492e782 100644 --- a/host/Com/AvnGuids.cs +++ b/host/Com/AvnGuids.cs @@ -37,4 +37,13 @@ internal static class AvnGuids // window-level settings a capture tool needs. Another separately versioned // capability; nothing below is added to a published vtable. public const string IAvnApplication6 = "6B2E8F10-4C91-4E3A-9A77-1F0C2B3A4D80"; + + // Stage 34 Rust-drawn surfaces: bitmap layers Rust fills and the raw + // pointer stream. Another separately versioned capability; nothing below is + // added to a published vtable. + public const string IAvnApplication7 = "6B2E8F10-4C91-4E3A-9A77-1F0C2B3A4D90"; + public const string IAvnPixelSurface = "6B2E8F10-4C91-4E3A-9A77-1F0C2B3A4D91"; + public const string IAvnPixelSurfaceHandler = "6B2E8F10-4C91-4E3A-9A77-1F0C2B3A4D92"; + public const string IAvnShellTray = "6B2E8F10-4C91-4E3A-9A77-1F0C2B3A4D93"; + public const string IAvnShellTrayHandler = "6B2E8F10-4C91-4E3A-9A77-1F0C2B3A4D94"; } diff --git a/host/Com/AvnPixelSurface.cs b/host/Com/AvnPixelSurface.cs new file mode 100644 index 0000000..6a626e2 --- /dev/null +++ b/host/Com/AvnPixelSurface.cs @@ -0,0 +1,367 @@ +using System; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace Avalonia.Host.Com; + +/// Stage 34: creates Rust-drawn pixel surfaces. +public partial class AvnApplication : IAvnApplication7 +{ + public int CreatePixelSurface(IAvnPixelSurfaceHandler? handler, out IAvnPixelSurface? surface) + { + surface = null; + try + { + Dispatcher.UIThread.VerifyAccess(); + surface = new AvnPixelSurface(new RustPixelSurface { Handler = handler }); + return HResults.S_OK; + } + catch (Exception e) + { + return AbiError.Capture(e); + } + } +} + +/// The COM face of a . +[GeneratedComClass] +internal sealed partial class AvnPixelSurface(RustPixelSurface control) : IAvnPixelSurface +{ + public const int MaxLayers = 8; + + public int GetControl(out IAvnControl? value) + { + IAvnControl? found = null; + var hr = Run(() => found = (IAvnControl?)ProjectionRuntime.Wrap(control)); + value = found; + return hr; + } + + public int SetLayer(int layer, int width, int height, int format) + { + if (layer is < 0 or >= MaxLayers || width < 0 || height < 0 || format is < 0 or > 1) + return HResults.E_INVALIDARG; + return Run(() => control.SetLayer(layer, width, height, format == 0 ? PixelFormat.Rgba8888 : PixelFormat.Bgra8888)); + } + + public int WriteLayer(int layer, int x, int y, int width, int height, nint pixels, int stride) + { + if (layer is < 0 or >= MaxLayers || width < 0 || height < 0 || stride < width * 4) + return HResults.E_INVALIDARG; + if (pixels == 0 && width > 0 && height > 0) + return HResults.E_POINTER; + return Run(() => control.WriteLayer(layer, x, y, width, height, pixels, stride)); + } + + public int PlaceLayer(int layer, double x, double y, double width, double height, int fill, int interpolation, double opacity) + { + if (layer is < 0 or >= MaxLayers || interpolation is < 0 or > 4) + return HResults.E_INVALIDARG; + return Run(() => control.PlaceLayer(layer, new Rect(x, y, Math.Max(0, width), Math.Max(0, height)), fill != 0, + (BitmapInterpolationMode)interpolation, Math.Clamp(opacity, 0, 1))); + } + + public int SetBackground(uint argb) => Run(() => + { + control.Backdrop = argb == 0 ? null : new SolidColorBrush(Color.FromUInt32(argb)); + control.InvalidateVisual(); + }); + + public int SetCursor(int cursor) + { + if (cursor != -1 && !Enum.IsDefined((StandardCursorType)cursor)) + return HResults.E_INVALIDARG; + return Run(() => control.Cursor = cursor < 0 ? null : new Cursor((StandardCursorType)cursor)); + } + + public int SetDesiredSize(double width, double height) => Run(() => + { + control.Desired = new Size(Math.Max(0, width), Math.Max(0, height)); + control.InvalidateMeasure(); + }); + + public int Focus() => Run(() => control.Focus()); + + public int GetMetrics(out double width, out double height, out double scaling) + { + Size size = default; + double found = 1; + var hr = Run(() => + { + size = control.Bounds.Size; + found = control.Scaling; + }); + width = size.Width; + height = size.Height; + scaling = found; + return hr; + } + + public int Detach() => Run(() => control.Handler = null); + + private static int Run(Action action) + { + try + { + Dispatcher.UIThread.VerifyAccess(); + action(); + return HResults.S_OK; + } + catch (Exception e) + { + return AbiError.Capture(e); + } + } +} + +/// +/// A control drawing Rust-owned bitmap layers and forwarding its pointer and +/// keyboard input to an . +/// +internal sealed class RustPixelSurface : Control +{ + private sealed class Layer + { + public WriteableBitmap? Bitmap; + public Rect Destination; + public bool Fill = true; + public BitmapInterpolationMode Interpolation; + public double Opacity = 1; + } + + private readonly Layer[] _layers = new Layer[AvnPixelSurface.MaxLayers]; + private TopLevel? _topLevel; + + public RustPixelSurface() + { + ClipToBounds = true; + Focusable = true; + for (var i = 0; i < _layers.Length; i++) + _layers[i] = new Layer(); + } + + public IAvnPixelSurfaceHandler? Handler { get; set; } + + public IBrush? Backdrop { get; set; } + + public Size Desired { get; set; } + + public double Scaling => _topLevel?.RenderScaling ?? 1; + + public void SetLayer(int index, int width, int height, PixelFormat format) + { + var layer = _layers[index]; + if (width == 0 || height == 0) + { + layer.Bitmap?.Dispose(); + layer.Bitmap = null; + } + else if (layer.Bitmap is not { } bitmap || bitmap.PixelSize.Width != width || bitmap.PixelSize.Height != height || bitmap.Format != format) + { + layer.Bitmap?.Dispose(); + // New WriteableBitmap memory is zeroed: transparent in both formats. + layer.Bitmap = new WriteableBitmap(new PixelSize(width, height), new Vector(96, 96), format, AlphaFormat.Premul); + } + InvalidateVisual(); + } + + public unsafe void WriteLayer(int index, int x, int y, int width, int height, nint pixels, int stride) + { + var bitmap = _layers[index].Bitmap ?? throw new InvalidOperationException($"Layer {index} has no bitmap; call SetLayer first."); + var size = bitmap.PixelSize; + var left = Math.Max(0, x); + var top = Math.Max(0, y); + var right = Math.Min(size.Width, x + width); + var bottom = Math.Min(size.Height, y + height); + if (right > left && bottom > top) + { + using var target = bitmap.Lock(); + var rowBytes = (right - left) * 4; + for (var row = top; row < bottom; row++) + { + var source = (byte*)pixels + (long)(row - y) * stride + (long)(left - x) * 4; + var destination = (byte*)target.Address + (long)row * target.RowBytes + (long)left * 4; + Buffer.MemoryCopy(source, destination, rowBytes, rowBytes); + } + } + InvalidateVisual(); + } + + public void PlaceLayer(int index, Rect destination, bool fill, BitmapInterpolationMode interpolation, double opacity) + { + var layer = _layers[index]; + layer.Destination = destination; + layer.Fill = fill; + layer.Interpolation = interpolation; + layer.Opacity = opacity; + InvalidateVisual(); + } + + protected override Size MeasureOverride(Size availableSize) => Desired; + + public override void Render(DrawingContext context) + { + var bounds = new Rect(Bounds.Size); + if (Backdrop is { } backdrop) + context.FillRectangle(backdrop, bounds); + foreach (var layer in _layers) + { + if (layer.Bitmap is not { } bitmap || layer.Opacity <= 0) + continue; + var destination = layer.Fill ? bounds : layer.Destination; + if (destination.Width <= 0 || destination.Height <= 0) + continue; + using var options = context.PushRenderOptions(new RenderOptions { BitmapInterpolationMode = layer.Interpolation }); + using var opacity = context.PushOpacity(layer.Opacity); + context.DrawImage(bitmap, new Rect(bitmap.Size), destination); + } + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _topLevel = TopLevel.GetTopLevel(this); + if (_topLevel is not null) + _topLevel.ScalingChanged += OnScalingChanged; + ReportSize(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + if (_topLevel is not null) + _topLevel.ScalingChanged -= OnScalingChanged; + _topLevel = null; + } + + private void OnScalingChanged(object? sender, EventArgs e) => ReportSize(); + + protected override void OnSizeChanged(SizeChangedEventArgs e) + { + base.OnSizeChanged(e); + ReportSize(); + } + + private void ReportSize() => Handler?.OnResize(Bounds.Width, Bounds.Height, Scaling); + + private static int Buttons(PointerPointProperties p) => + (p.IsLeftButtonPressed ? 1 : 0) | (p.IsRightButtonPressed ? 2 : 0) | (p.IsMiddleButtonPressed ? 4 : 0) | + (p.IsXButton1Pressed ? 8 : 0) | (p.IsXButton2Pressed ? 16 : 0); + + private static int ButtonOf(PointerUpdateKind kind) => kind switch + { + PointerUpdateKind.LeftButtonPressed or PointerUpdateKind.LeftButtonReleased => 1, + PointerUpdateKind.RightButtonPressed or PointerUpdateKind.RightButtonReleased => 2, + PointerUpdateKind.MiddleButtonPressed or PointerUpdateKind.MiddleButtonReleased => 3, + PointerUpdateKind.XButton1Pressed or PointerUpdateKind.XButton1Released => 4, + PointerUpdateKind.XButton2Pressed or PointerUpdateKind.XButton2Released => 5, + _ => 0, + }; + + private bool Send(int kind, PointerPoint point, int button, KeyModifiers modifiers, int clickCount = 0, Vector delta = default) + { + if (Handler is not { } handler) + return false; + var p = point.Position; + var hr = handler.OnPointer(kind, p.X, p.Y, button, Buttons(point.Properties), (int)modifiers, clickCount, delta.X, delta.Y, out var handled); + return hr >= 0 && handled != 0; + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (Handler is null) + return; + Focus(); + var point = e.GetCurrentPoint(this); + e.Pointer.Capture(this); + if (Send(0, point, ButtonOf(point.Properties.PointerUpdateKind), e.KeyModifiers, e.ClickCount)) + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (Handler is null) + return; + var handled = false; + foreach (var point in e.GetIntermediatePoints(this)) + handled |= Send(1, point, 0, e.KeyModifiers); + if (handled) + e.Handled = true; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (Handler is null) + return; + var point = e.GetCurrentPoint(this); + var button = e.InitialPressMouseButton switch + { + MouseButton.Left => 1, + MouseButton.Right => 2, + MouseButton.Middle => 3, + MouseButton.XButton1 => 4, + MouseButton.XButton2 => 5, + _ => 0, + }; + if (Send(2, point, button, e.KeyModifiers)) + e.Handled = true; + if (Buttons(point.Properties) == 0 && e.Pointer.Captured == this) + e.Pointer.Capture(null); + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + if (Send(3, e.GetCurrentPoint(this), 0, e.KeyModifiers, 0, e.Delta)) + e.Handled = true; + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + Send(4, e.GetCurrentPoint(this), 0, e.KeyModifiers); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + Send(5, e.GetCurrentPoint(this), 0, e.KeyModifiers); + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + Handler?.OnPointer(6, 0, 0, 0, 0, 0, 0, 0, 0, out _); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (Handler is { } handler && handler.OnKey(0, (int)e.Key, (int)e.KeyModifiers, out var handled) >= 0 && handled != 0) + e.Handled = true; + } + + protected override void OnKeyUp(KeyEventArgs e) + { + base.OnKeyUp(e); + if (Handler is { } handler && handler.OnKey(1, (int)e.Key, (int)e.KeyModifiers, out var handled) >= 0 && handled != 0) + e.Handled = true; + } + + protected override void OnTextInput(TextInputEventArgs e) + { + base.OnTextInput(e); + if (Handler is { } handler && !string.IsNullOrEmpty(e.Text) && handler.OnTextInput(e.Text, out var handled) >= 0 && handled != 0) + e.Handled = true; + } +} diff --git a/host/Com/AvnShellTray.cs b/host/Com/AvnShellTray.cs new file mode 100644 index 0000000..1939b04 --- /dev/null +++ b/host/Com/AvnShellTray.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices.Marshalling; +using Avalonia.Controls; +using Avalonia.Rust; +using Avalonia.Threading; + +namespace Avalonia.Host.Com; + +/// Stage 34: creates tray icons from Rust. +public partial class AvnApplication +{ + public int CreateTrayIcon(IAvnShellTrayHandler? handler, out IAvnShellTray? tray) + { + tray = null; + try + { + Dispatcher.UIThread.VerifyAccess(); + var application = Application.Current + ?? throw new InvalidOperationException("A tray icon needs a running application."); + var created = new AvnShellTray(application, handler); + _trays.Add(created); + tray = created; + return HResults.S_OK; + } + catch (Exception e) + { + return AbiError.Capture(e); + } + } +} + +/// +/// An Avalonia whose Rust builds +/// item by item. Avalonia only shows icons in the application's +/// , so the icon is added there and removed on +/// (or by the application's shutdown). +/// +[GeneratedComClass] +internal sealed partial class AvnShellTray : IAvnShellTray, IDisposable +{ + private const int KindCommand = 0; + private const int KindCheckBox = 1; + private const int KindSeparator = 2; + private const int KindSubmenu = 3; + private const int FlagChecked = 1; + private const int FlagDisabled = 2; + + private readonly Application _application; + private readonly Dictionary _items = new(); + private readonly NativeMenu _menu = new(); + private IAvnShellTrayHandler? _handler; + private TrayIcon? _icon; + + public AvnShellTray(Application application, IAvnShellTrayHandler? handler) + { + _application = application; + _handler = handler; + _icon = new TrayIcon { Menu = _menu, IsVisible = true }; + _icon.Clicked += (_, _) => _handler?.OnActivate(); + var icons = TrayIcon.GetIcons(application); + if (icons is null) + { + icons = new TrayIcons(); + TrayIcon.SetIcons(application, icons); + } + icons.Add(_icon); + } + + public int SetIcon(string? source) => Run(icon => icon.Icon = string.IsNullOrWhiteSpace(source) ? null : RustTrayAttachment.LoadIcon(source)); + + public int SetToolTip(string? text) => Run(icon => icon.ToolTipText = text); + + public int SetVisible(int visible) => Run(icon => icon.IsVisible = visible != 0); + + public int ClearMenu() => Run(_ => + { + _menu.Items.Clear(); + _items.Clear(); + }); + + public int AddMenuItem(int parent, int id, int kind, string? header, int flags) + { + if (id <= 0 || kind is < KindCommand or > KindSubmenu) + return HResults.E_INVALIDARG; + return Run(_ => + { + if (_items.ContainsKey(id)) + throw new ArgumentException($"Menu item {id} already exists.", nameof(id)); + NativeMenu target; + if (parent == 0) + target = _menu; + else if (_items.TryGetValue(parent, out var owner) && owner is NativeMenuItem { Menu: { } submenu }) + target = submenu; + else + throw new ArgumentException($"Menu item {parent} is not a submenu.", nameof(parent)); + + NativeMenuItemBase item; + if (kind == KindSeparator) + { + item = new NativeMenuItemSeparator(); + } + else + { + var menuItem = new NativeMenuItem(header ?? string.Empty); + if (kind == KindSubmenu) + menuItem.Menu = new NativeMenu(); + else + menuItem.Click += (_, _) => _handler?.OnMenuItem(id); + if (kind == KindCheckBox) + menuItem.ToggleType = MenuItemToggleType.CheckBox; + Apply(menuItem, flags); + item = menuItem; + } + target.Items.Add(item); + _items.Add(id, item); + }); + } + + public int UpdateMenuItem(int id, string? header, int flags) => Run(_ => + { + if (!_items.TryGetValue(id, out var item)) + throw new ArgumentException($"Menu item {id} does not exist.", nameof(id)); + if (item is NativeMenuItem menuItem) + { + if (header is not null) + menuItem.Header = header; + Apply(menuItem, flags); + } + }); + + private static void Apply(NativeMenuItem item, int flags) + { + item.IsChecked = (flags & FlagChecked) != 0; + item.IsEnabled = (flags & FlagDisabled) == 0; + } + + public int Remove() => Run(_ => Dispose()); + + public void Dispose() + { + _handler = null; + if (_icon is not { } icon) + return; + _icon = null; + if (TrayIcon.GetIcons(_application) is { } icons) + icons.Remove(icon); + icon.Dispose(); + } + + private int Run(Action action) + { + try + { + Dispatcher.UIThread.VerifyAccess(); + action(_icon ?? throw new ObjectDisposedException(nameof(IAvnShellTray))); + return HResults.S_OK; + } + catch (Exception e) + { + return AbiError.Capture(e); + } + } +} diff --git a/host/Com/IAvnApplication7.cs b/host/Com/IAvnApplication7.cs new file mode 100644 index 0000000..db34e0b --- /dev/null +++ b/host/Com/IAvnApplication7.cs @@ -0,0 +1,45 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace Avalonia.Host.Com; + +/// +/// Stage 34 code-first desktop pieces. Queried as an optional interface from +/// ; no published vtable changes. +/// +/// +/// +/// The projection covers Avalonia's controls, but an image editor, a selection +/// overlay or a chart needs two things it cannot express: pixels that Rust +/// produced, and the raw pointer stream (press, move, release with positions). +/// A pixel surface is a control that draws Rust-owned bitmap layers and reports +/// its input to a Rust handler; everything else (zoom, hit testing, what to +/// draw) stays on the Rust side. +/// +/// +/// Stage 33's tray icons are declared in a view-model schema compiled into the +/// host, which a code-first application on the prebuilt host does not have. +/// builds one, and its menu, from Rust instead. +/// +/// +[GeneratedComInterface] +[Guid(AvnGuids.IAvnApplication7)] +public partial interface IAvnApplication7 +{ + /// + /// Creates a pixel surface reporting its input to + /// (which may be null for a display-only surface). Place it with + /// . + /// + [PreserveSig] + int CreatePixelSurface(IAvnPixelSurfaceHandler? handler, out IAvnPixelSurface? surface); + + /// + /// Shows a notification-area icon reporting clicks to + /// (null for an icon that does nothing). The icon + /// starts visible with no image or menu, and stays until + /// or the application shuts down. + /// + [PreserveSig] + int CreateTrayIcon(IAvnShellTrayHandler? handler, out IAvnShellTray? tray); +} diff --git a/host/Com/IAvnPixelSurface.cs b/host/Com/IAvnPixelSurface.cs new file mode 100644 index 0000000..09aae60 --- /dev/null +++ b/host/Com/IAvnPixelSurface.cs @@ -0,0 +1,123 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace Avalonia.Host.Com; + +/// +/// A host-owned control drawing up to +/// bitmap layers over a background, in layer order. Every member must be called +/// on the UI thread. +/// +[GeneratedComInterface] +[Guid(AvnGuids.IAvnPixelSurface)] +public partial interface IAvnPixelSurface +{ + /// The control to put in a layout (a plain Control). + [PreserveSig] + int GetControl(out IAvnControl? control); + + /// + /// Allocates layer as a by + /// bitmap, cleared to transparent, or removes it + /// when either is 0. is 0 for premultiplied + /// RGBA8888 and 1 for premultiplied BGRA8888. Reallocating at the same size + /// and format keeps the pixels. + /// + [PreserveSig] + int SetLayer(int layer, int width, int height, int format); + + /// + /// Copies a rectangle of pixels into the layer at (, + /// ), clipped to the layer, and redraws. + /// points at the first row, + /// bytes apart, in the layer's format. + /// + [PreserveSig] + int WriteLayer(int layer, int x, int y, int width, int height, nint pixels, int stride); + + /// + /// Where the layer is drawn, in device-independent pixels relative to the + /// control: the rectangle, or the whole control when + /// is non-zero. is Avalonia's + /// BitmapInterpolationMode (0 unspecified, 1 none, 2 low, 3 medium, + /// 4 high). The default is the whole control at full opacity. + /// + [PreserveSig] + int PlaceLayer(int layer, double x, double y, double width, double height, int fill, int interpolation, double opacity); + + /// Fills the control under the layers with an ARGB color (0 for none). + [PreserveSig] + int SetBackground(uint argb); + + /// + /// Sets the pointer shape: Avalonia's StandardCursorType, or -1 for + /// the inherited cursor. + /// + [PreserveSig] + int SetCursor(int cursor); + + /// + /// The size the surface asks for in layout, in device-independent pixels; + /// 0 asks for nothing and takes what the layout gives (the default). + /// + [PreserveSig] + int SetDesiredSize(double width, double height); + + /// Moves keyboard focus to the surface. + [PreserveSig] + int Focus(); + + /// + /// The control's current size in device-independent pixels and its + /// top-level's render scaling (physical pixels per DIP). + /// + [PreserveSig] + int GetMetrics(out double width, out double height, out double scaling); + + /// + /// Stops reporting input and releases the handler. The control keeps + /// drawing its layers. + /// + [PreserveSig] + int Detach(); +} + +/// +/// Implemented by Rust: receives a pixel surface's input. Positions are in +/// device-independent pixels relative to the control. +/// +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +[Guid(AvnGuids.IAvnPixelSurfaceHandler)] +public partial interface IAvnPixelSurfaceHandler +{ + /// + /// A pointer event. : 0 pressed, 1 moved, 2 released, + /// 3 wheel, 4 entered, 5 exited, 6 capture lost. is + /// the button that changed (0 none, 1 left, 2 right, 3 middle, 4 X1, 5 X2); + /// the ones held after the event (bit 0 left, + /// 1 right, 2 middle, 3 X1, 4 X2). is + /// Avalonia's KeyModifiers. A press captures the pointer until its + /// release. Moves include the coalesced intermediate points, one call each. + /// Setting stops the event from bubbling. + /// + [PreserveSig] + int OnPointer(int kind, double x, double y, int button, int buttons, int modifiers, int clickCount, double deltaX, double deltaY, out int handled); + + /// + /// A key going down ( 0) or up (1) while the surface + /// has focus: Avalonia's Key and KeyModifiers. + /// + [PreserveSig] + int OnKey(int kind, int key, int modifiers, out int handled); + + /// Text typed while the surface has focus. + [PreserveSig] + int OnTextInput(string? text, out int handled); + + /// + /// The control's size (device-independent pixels) or render scaling + /// changed, and when it is first attached to a window. + /// + [PreserveSig] + int OnResize(double width, double height, double scaling); +} diff --git a/host/Com/IAvnShellTray.cs b/host/Com/IAvnShellTray.cs new file mode 100644 index 0000000..3c1bc47 --- /dev/null +++ b/host/Com/IAvnShellTray.cs @@ -0,0 +1,59 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace Avalonia.Host.Com; + +/// +/// A notification-area icon built from Rust (stage 34). Its menu is a tree of +/// items Rust numbers itself; a click on one is reported to the +/// by that number. UI thread only. +/// +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +[Guid(AvnGuids.IAvnShellTray)] +public partial interface IAvnShellTray +{ + /// The image: a file path or an avares:// URI; null removes it. + [PreserveSig] + int SetIcon(string? source); + + [PreserveSig] + int SetToolTip(string? text); + + [PreserveSig] + int SetVisible(int visible); + + /// Removes every menu item. + [PreserveSig] + int ClearMenu(); + + /// + /// Appends item (positive, unique in this menu) under + /// , a submenu's id or 0 for the top level. + /// : 0 command, 1 check box, 2 separator, 3 submenu. + /// : bit 0 checked, bit 1 disabled. + /// + [PreserveSig] + int AddMenuItem(int parent, int id, int kind, string? header, int flags); + + /// Changes an item's header (when not null) and flags. + [PreserveSig] + int UpdateMenuItem(int id, string? header, int flags); + + /// Takes the icon down for good. + [PreserveSig] + int Remove(); +} + +/// Implemented by Rust: what was clicked on a tray icon. +[GeneratedComInterface] +[Guid(AvnGuids.IAvnShellTrayHandler)] +public partial interface IAvnShellTrayHandler +{ + /// The icon itself was clicked. + [PreserveSig] + int OnActivate(); + + /// Menu item was clicked. + [PreserveSig] + int OnMenuItem(int id); +} diff --git a/rust/COMPATIBILITY.md b/rust/COMPATIBILITY.md index ad8adec..97c2dad 100644 --- a/rust/COMPATIBILITY.md +++ b/rust/COMPATIBILITY.md @@ -131,9 +131,12 @@ integration capability, stage 30's `IAvnRustVmSink4` richer data shapes, `IAvnRustVmRangeBatch` windowed range payload, `IAvnRustRangeSource` and `IAvnRustViewModel2`, or stage 31's `IAvnApplication4` clipboard command capability and its host-owned `IAvnClipboardData` payload builder, -`IAvnRustVmSink5` for scalar-number collection elements, or stage 33's +`IAvnRustVmSink5` for scalar-number collection elements, stage 33's `IAvnApplication6` desktop shell capability: shutdown mode, schema-declared -tray icons and window handle/topmost/transparency/capture exclusion), then +tray icons and window handle/topmost/transparency/capture exclusion, or +stage 34's `IAvnApplication7` code-first desktop capability: pixel surfaces +(`IAvnPixelSurface` with its Rust-implemented `IAvnPixelSurfaceHandler`) and +Rust-built tray icons (`IAvnShellTray`, `IAvnShellTrayHandler`)), then negotiate/query it as optional capability. A producer or host that predates an optional capability must report `E_NOINTERFACE` explicitly; silently dropping the affected updates is not an diff --git a/rust/prepare-release.ps1 b/rust/prepare-release.ps1 index 953020e..0133ee4 100644 --- a/rust/prepare-release.ps1 +++ b/rust/prepare-release.ps1 @@ -80,9 +80,11 @@ if ($PSCmdlet.ParameterSetName -eq 'Version') { & cargo update --workspace --manifest-path (Join-Path $root 'rust' 'Cargo.toml') # The in-repository apps use the crates by path with their own lock # files; only the crates' own entries move (CI builds them --locked). + # Lock files without rustolonia (an app's other workspaces) stay. foreach ($lock in @(& git -C $root ls-files 'apps/*Cargo.lock' 'apps/**/Cargo.lock' | Sort-Object -Unique)) { $manifest = Join-Path $root (Split-Path -Parent $lock) 'Cargo.toml' - if (Test-Path -LiteralPath $manifest) { + $usesRustolonia = Select-String -LiteralPath (Join-Path $root $lock) -Pattern '^name = "rustolonia"$' -Quiet + if ($usesRustolonia -and (Test-Path -LiteralPath $manifest)) { & cargo update -p rustolonia -p rustolonia-sys --manifest-path $manifest } } diff --git a/rust/rustolonia-sys/src/lib.rs b/rust/rustolonia-sys/src/lib.rs index 7b98e73..b35aabf 100644 --- a/rust/rustolonia-sys/src/lib.rs +++ b/rust/rustolonia-sys/src/lib.rs @@ -28,6 +28,7 @@ mod event_callback; mod factory; mod filters; mod notification; +mod pixel_surface; mod popup_placement; mod selectors; mod shell; @@ -59,6 +60,13 @@ pub use hresult::{ E_NOTIMPL, E_POINTER, S_OK, }; pub use notification::{notification, Notification, NotificationSpec}; +pub use pixel_surface::{ + pixel_surface_handler, tray_handler, IAvnApplication7, IAvnPixelSurface, + IAvnPixelSurfaceHandler, IAvnShellTray, IAvnShellTrayHandler, PixelSurfaceInput, TrayInput, + IAVN_APPLICATION7_METHOD_COUNT, IAVN_PIXEL_SURFACE_HANDLER_METHOD_COUNT, + IAVN_PIXEL_SURFACE_METHOD_COUNT, IAVN_SHELL_TRAY_HANDLER_METHOD_COUNT, + IAVN_SHELL_TRAY_METHOD_COUNT, +}; pub use popup_placement::{popup_placement, PopupPlacement, PopupPlacementResult}; pub use rust_vm::{ rust_view_model, rust_view_model_with_control, rust_vm_range_batch, rust_vm_update_batch, diff --git a/rust/rustolonia-sys/src/pixel_surface.rs b/rust/rustolonia-sys/src/pixel_surface.rs new file mode 100644 index 0000000..a2057ab --- /dev/null +++ b/rust/rustolonia-sys/src/pixel_surface.rs @@ -0,0 +1,794 @@ +//! Stage 34 code-first desktop ABI. +//! +//! `IAvnApplication7` is a separately versioned capability queried from +//! `IAvnApplication`; nothing here is added to an already published vtable. It +//! creates pixel surfaces: host controls that draw bitmap layers Rust fills +//! (`IAvnPixelSurface`) and report their raw pointer and keyboard input to a +//! Rust-implemented `IAvnPixelSurfaceHandler`; and tray icons whose menu Rust +//! builds (`IAvnShellTray`, reporting to an `IAvnShellTrayHandler`). + +use crate::application::IAvnApplication; +use crate::com::{ComInterface, ComPtr, IUnknown}; +use crate::generated::IAvnControl; +use crate::guid::Guid; +use crate::hresult::{self, Result}; +use std::ffi::c_void; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::atomic::{fence, AtomicU32, Ordering}; +use std::sync::Mutex; + +const fn stage34_iid(last: u8) -> Guid { + Guid { + data1: 0x6B2E8F10, + data2: 0x4C91, + data3: 0x4E3A, + data4: [0x9A, 0x77, 0x1F, 0x0C, 0x2B, 0x3A, 0x4D, last], + } +} + +const IAVN_APPLICATION7_IID: Guid = stage34_iid(0x90); +const IAVN_PIXEL_SURFACE_IID: Guid = stage34_iid(0x91); +const IAVN_PIXEL_SURFACE_HANDLER_IID: Guid = stage34_iid(0x92); +const IAVN_SHELL_TRAY_IID: Guid = stage34_iid(0x93); +const IAVN_SHELL_TRAY_HANDLER_IID: Guid = stage34_iid(0x94); + +type QueryInterface = + unsafe extern "system" fn(*mut IUnknown, *const Guid, *mut *mut c_void) -> i32; +type AddRef = unsafe extern "system" fn(*mut IUnknown) -> u32; + +#[repr(C)] +struct IAvnApplication7Vtbl { + query_interface: QueryInterface, + add_ref: AddRef, + release: AddRef, + create_pixel_surface: unsafe extern "system" fn( + *mut IAvnApplication7, + *mut IAvnPixelSurfaceHandler, + *mut *mut IAvnPixelSurface, + ) -> i32, + create_tray_icon: unsafe extern "system" fn( + *mut IAvnApplication7, + *mut IAvnShellTrayHandler, + *mut *mut IAvnShellTray, + ) -> i32, +} + +#[repr(C)] +pub struct IAvnApplication7 { + vtbl: *const IAvnApplication7Vtbl, +} + +unsafe impl ComInterface for IAvnApplication7 { + const IID: Guid = IAVN_APPLICATION7_IID; +} + +impl ComPtr { + fn vtbl(&self) -> &IAvnApplication7Vtbl { + unsafe { (*self.as_raw()).vtbl.as_ref().unwrap() } + } + + /// Creates a pixel surface reporting its input to `handler`, or a + /// display-only one. + pub fn create_pixel_surface( + &self, + handler: Option<&ComPtr>, + ) -> Result> { + unsafe { + let mut surface = std::ptr::null_mut(); + hresult::check((self.vtbl().create_pixel_surface)( + self.as_raw(), + handler.map_or(std::ptr::null_mut(), |h| h.as_raw()), + &mut surface, + ))?; + ComPtr::from_raw(surface).ok_or(hresult::Error(hresult::E_POINTER)) + } + } + + /// Shows a tray icon reporting clicks to `handler`, or one that does nothing. + pub fn create_tray_icon( + &self, + handler: Option<&ComPtr>, + ) -> Result> { + unsafe { + let mut tray = std::ptr::null_mut(); + hresult::check((self.vtbl().create_tray_icon)( + self.as_raw(), + handler.map_or(std::ptr::null_mut(), |h| h.as_raw()), + &mut tray, + ))?; + ComPtr::from_raw(tray).ok_or(hresult::Error(hresult::E_POINTER)) + } + } +} + +impl ComPtr { + /// Queries the separately versioned Rust-drawn surfaces capability. + pub fn pixel_surfaces(&self) -> Result> { + self.query_interface::() + } +} + +#[repr(C)] +struct IAvnPixelSurfaceVtbl { + query_interface: QueryInterface, + add_ref: AddRef, + release: AddRef, + get_control: unsafe extern "system" fn(*mut IAvnPixelSurface, *mut *mut IAvnControl) -> i32, + set_layer: unsafe extern "system" fn(*mut IAvnPixelSurface, i32, i32, i32, i32) -> i32, + write_layer: unsafe extern "system" fn( + *mut IAvnPixelSurface, + i32, + i32, + i32, + i32, + i32, + *const u8, + i32, + ) -> i32, + place_layer: unsafe extern "system" fn( + *mut IAvnPixelSurface, + i32, + f64, + f64, + f64, + f64, + i32, + i32, + f64, + ) -> i32, + set_background: unsafe extern "system" fn(*mut IAvnPixelSurface, u32) -> i32, + set_cursor: unsafe extern "system" fn(*mut IAvnPixelSurface, i32) -> i32, + set_desired_size: unsafe extern "system" fn(*mut IAvnPixelSurface, f64, f64) -> i32, + focus: unsafe extern "system" fn(*mut IAvnPixelSurface) -> i32, + get_metrics: + unsafe extern "system" fn(*mut IAvnPixelSurface, *mut f64, *mut f64, *mut f64) -> i32, + detach: unsafe extern "system" fn(*mut IAvnPixelSurface) -> i32, +} + +/// A host-owned control drawing bitmap layers. UI thread only. +#[repr(C)] +pub struct IAvnPixelSurface { + vtbl: *const IAvnPixelSurfaceVtbl, +} + +unsafe impl ComInterface for IAvnPixelSurface { + const IID: Guid = IAVN_PIXEL_SURFACE_IID; +} + +impl ComPtr { + fn vtbl(&self) -> &IAvnPixelSurfaceVtbl { + unsafe { (*self.as_raw()).vtbl.as_ref().unwrap() } + } + + pub fn control(&self) -> Result> { + unsafe { + let mut control = std::ptr::null_mut(); + hresult::check((self.vtbl().get_control)(self.as_raw(), &mut control))?; + ComPtr::from_raw(control).ok_or(hresult::Error(hresult::E_POINTER)) + } + } + + /// Allocates (or with a zero size removes) a layer: format 0 is + /// premultiplied RGBA8888, 1 premultiplied BGRA8888. + pub fn set_layer(&self, layer: i32, width: i32, height: i32, format: i32) -> Result<()> { + unsafe { + hresult::check((self.vtbl().set_layer)( + self.as_raw(), + layer, + width, + height, + format, + )) + } + } + + /// Copies `width` by `height` pixels, `stride` bytes per row, into the + /// layer at (`x`, `y`). + /// + /// # Errors + /// + /// `E_INVALIDARG` when `pixels` is too short for the rectangle. + #[allow(clippy::too_many_arguments)] + pub fn write_layer( + &self, + layer: i32, + x: i32, + y: i32, + width: i32, + height: i32, + pixels: &[u8], + stride: i32, + ) -> Result<()> { + if width < 0 || height < 0 || stride < width.saturating_mul(4) { + return Err(hresult::Error(hresult::E_INVALIDARG)); + } + if height > 0 && width > 0 { + let needed = (height as usize - 1) * stride as usize + width as usize * 4; + if pixels.len() < needed { + return Err(hresult::Error(hresult::E_INVALIDARG)); + } + } + unsafe { + hresult::check((self.vtbl().write_layer)( + self.as_raw(), + layer, + x, + y, + width, + height, + pixels.as_ptr(), + stride, + )) + } + } + + #[allow(clippy::too_many_arguments)] + pub fn place_layer( + &self, + layer: i32, + x: f64, + y: f64, + width: f64, + height: f64, + fill: bool, + interpolation: i32, + opacity: f64, + ) -> Result<()> { + unsafe { + hresult::check((self.vtbl().place_layer)( + self.as_raw(), + layer, + x, + y, + width, + height, + fill as i32, + interpolation, + opacity, + )) + } + } + + pub fn set_background(&self, argb: u32) -> Result<()> { + unsafe { hresult::check((self.vtbl().set_background)(self.as_raw(), argb)) } + } + + pub fn set_cursor(&self, cursor: i32) -> Result<()> { + unsafe { hresult::check((self.vtbl().set_cursor)(self.as_raw(), cursor)) } + } + + pub fn set_desired_size(&self, width: f64, height: f64) -> Result<()> { + unsafe { hresult::check((self.vtbl().set_desired_size)(self.as_raw(), width, height)) } + } + + pub fn focus(&self) -> Result<()> { + unsafe { hresult::check((self.vtbl().focus)(self.as_raw())) } + } + + /// The control's size in device-independent pixels and its render scaling. + pub fn metrics(&self) -> Result<(f64, f64, f64)> { + unsafe { + let (mut width, mut height, mut scaling) = (0.0, 0.0, 1.0); + hresult::check((self.vtbl().get_metrics)( + self.as_raw(), + &mut width, + &mut height, + &mut scaling, + ))?; + Ok((width, height, scaling)) + } + } + + pub fn detach(&self) -> Result<()> { + unsafe { hresult::check((self.vtbl().detach)(self.as_raw())) } + } +} + +/// One input notification from a pixel surface, as the host reports it. +#[derive(Clone, Debug, PartialEq)] +pub enum PixelSurfaceInput { + /// `kind`: 0 pressed, 1 moved, 2 released, 3 wheel, 4 entered, 5 exited, + /// 6 capture lost. + Pointer { + kind: i32, + x: f64, + y: f64, + button: i32, + buttons: i32, + modifiers: i32, + click_count: i32, + delta_x: f64, + delta_y: f64, + }, + /// `kind`: 0 down, 1 up. + Key { + kind: i32, + key: i32, + modifiers: i32, + }, + Text(String), + Resize { + width: f64, + height: f64, + scaling: f64, + }, +} + +type HandlerCallback = Box Result + Send>; + +#[repr(C)] +struct IAvnPixelSurfaceHandlerVtbl { + query_interface: QueryInterface, + add_ref: AddRef, + release: AddRef, + on_pointer: unsafe extern "system" fn( + *mut IAvnPixelSurfaceHandler, + i32, + f64, + f64, + i32, + i32, + i32, + i32, + f64, + f64, + *mut i32, + ) -> i32, + on_key: unsafe extern "system" fn(*mut IAvnPixelSurfaceHandler, i32, i32, i32, *mut i32) -> i32, + on_text_input: + unsafe extern "system" fn(*mut IAvnPixelSurfaceHandler, *const u16, *mut i32) -> i32, + on_resize: unsafe extern "system" fn(*mut IAvnPixelSurfaceHandler, f64, f64, f64) -> i32, +} + +/// Implemented by Rust ([`pixel_surface_handler`]); the host calls it on the +/// UI thread. +#[repr(C)] +pub struct IAvnPixelSurfaceHandler { + vtbl: *const IAvnPixelSurfaceHandlerVtbl, +} + +unsafe impl ComInterface for IAvnPixelSurfaceHandler { + const IID: Guid = IAVN_PIXEL_SURFACE_HANDLER_IID; +} + +#[repr(C)] +struct HandlerObject { + vtbl: *const IAvnPixelSurfaceHandlerVtbl, + ref_count: AtomicU32, + callback: Mutex>, +} + +static HANDLER_VTBL: IAvnPixelSurfaceHandlerVtbl = IAvnPixelSurfaceHandlerVtbl { + query_interface: handler_query_interface, + add_ref: handler_add_ref, + release: handler_release, + on_pointer: handler_on_pointer, + on_key: handler_on_key, + on_text_input: handler_on_text_input, + on_resize: handler_on_resize, +}; + +/// Builds an `IAvnPixelSurfaceHandler` from a closure that receives each input +/// notification and returns whether it handled it. A notification raised while +/// the closure is already running (re-entrantly) is reported unhandled. +pub fn pixel_surface_handler( + callback: impl FnMut(&PixelSurfaceInput) -> Result + Send + 'static, +) -> ComPtr { + let object = Box::into_raw(Box::new(HandlerObject { + vtbl: &HANDLER_VTBL, + ref_count: AtomicU32::new(1), + callback: Mutex::new(Some(Box::new(callback))), + })); + unsafe { + ComPtr::from_raw(object.cast()).expect("Box allocation cannot produce a null pointer") + } +} + +unsafe fn dispatch(this: *mut IAvnPixelSurfaceHandler, input: &PixelSurfaceInput) -> Result { + let object = this.cast::(); + let taken = match (*object).callback.lock() { + Ok(mut slot) => slot.take(), + Err(_) => return Err(hresult::Error(hresult::E_FAIL)), + }; + // Re-entered from inside the callback: nothing to call. + let Some(mut callback) = taken else { + return Ok(false); + }; + let outcome = catch_unwind(AssertUnwindSafe(|| callback(input))); + if let Ok(mut slot) = (*object).callback.lock() { + *slot = Some(callback); + } + outcome.unwrap_or(Err(hresult::Error(hresult::E_FAIL))) +} + +unsafe fn report( + this: *mut IAvnPixelSurfaceHandler, + input: PixelSurfaceInput, + handled: *mut i32, +) -> i32 { + match dispatch(this, &input) { + Ok(done) => { + if !handled.is_null() { + *handled = done as i32; + } + hresult::S_OK + } + Err(error) => error.0, + } +} + +unsafe extern "system" fn handler_query_interface( + this: *mut IUnknown, + iid: *const Guid, + result: *mut *mut c_void, +) -> i32 { + if iid.is_null() || result.is_null() { + return hresult::E_POINTER; + } + *result = std::ptr::null_mut(); + if *iid != Guid::IUNKNOWN && *iid != IAvnPixelSurfaceHandler::IID { + return hresult::E_NOINTERFACE; + } + handler_add_ref(this); + *result = this.cast(); + hresult::S_OK +} + +unsafe extern "system" fn handler_add_ref(this: *mut IUnknown) -> u32 { + let object = this.cast::(); + (*object).ref_count.fetch_add(1, Ordering::Relaxed) + 1 +} + +unsafe extern "system" fn handler_release(this: *mut IUnknown) -> u32 { + let object = this.cast::(); + let remaining = (*object).ref_count.fetch_sub(1, Ordering::Release) - 1; + if remaining == 0 { + fence(Ordering::Acquire); + drop(Box::from_raw(object)); + } + remaining +} + +#[allow(clippy::too_many_arguments)] +unsafe extern "system" fn handler_on_pointer( + this: *mut IAvnPixelSurfaceHandler, + kind: i32, + x: f64, + y: f64, + button: i32, + buttons: i32, + modifiers: i32, + click_count: i32, + delta_x: f64, + delta_y: f64, + handled: *mut i32, +) -> i32 { + let input = PixelSurfaceInput::Pointer { + kind, + x, + y, + button, + buttons, + modifiers, + click_count, + delta_x, + delta_y, + }; + report(this, input, handled) +} + +unsafe extern "system" fn handler_on_key( + this: *mut IAvnPixelSurfaceHandler, + kind: i32, + key: i32, + modifiers: i32, + handled: *mut i32, +) -> i32 { + report( + this, + PixelSurfaceInput::Key { + kind, + key, + modifiers, + }, + handled, + ) +} + +unsafe extern "system" fn handler_on_text_input( + this: *mut IAvnPixelSurfaceHandler, + text: *const u16, + handled: *mut i32, +) -> i32 { + let text = crate::clone_utf16(text).unwrap_or_default(); + report(this, PixelSurfaceInput::Text(text), handled) +} + +unsafe extern "system" fn handler_on_resize( + this: *mut IAvnPixelSurfaceHandler, + width: f64, + height: f64, + scaling: f64, +) -> i32 { + let input = PixelSurfaceInput::Resize { + width, + height, + scaling, + }; + report(this, input, std::ptr::null_mut()) +} + +#[repr(C)] +struct IAvnShellTrayVtbl { + query_interface: QueryInterface, + add_ref: AddRef, + release: AddRef, + set_icon: unsafe extern "system" fn(*mut IAvnShellTray, *const u16) -> i32, + set_tool_tip: unsafe extern "system" fn(*mut IAvnShellTray, *const u16) -> i32, + set_visible: unsafe extern "system" fn(*mut IAvnShellTray, i32) -> i32, + clear_menu: unsafe extern "system" fn(*mut IAvnShellTray) -> i32, + add_menu_item: + unsafe extern "system" fn(*mut IAvnShellTray, i32, i32, i32, *const u16, i32) -> i32, + update_menu_item: unsafe extern "system" fn(*mut IAvnShellTray, i32, *const u16, i32) -> i32, + remove: unsafe extern "system" fn(*mut IAvnShellTray) -> i32, +} + +/// A notification-area icon whose menu Rust builds. UI thread only. +#[repr(C)] +pub struct IAvnShellTray { + vtbl: *const IAvnShellTrayVtbl, +} + +unsafe impl ComInterface for IAvnShellTray { + const IID: Guid = IAVN_SHELL_TRAY_IID; +} + +fn utf16(value: Option<&str>) -> Option> { + value.map(|v| v.encode_utf16().chain(Some(0)).collect()) +} + +fn utf16_ptr(value: &Option>) -> *const u16 { + value.as_ref().map_or(std::ptr::null(), |v| v.as_ptr()) +} + +impl ComPtr { + fn vtbl(&self) -> &IAvnShellTrayVtbl { + unsafe { (*self.as_raw()).vtbl.as_ref().unwrap() } + } + + /// A file path or `avares://` URI; `None` removes the image. + pub fn set_icon(&self, source: Option<&str>) -> Result<()> { + let source = utf16(source); + unsafe { hresult::check((self.vtbl().set_icon)(self.as_raw(), utf16_ptr(&source))) } + } + + pub fn set_tool_tip(&self, text: Option<&str>) -> Result<()> { + let text = utf16(text); + unsafe { hresult::check((self.vtbl().set_tool_tip)(self.as_raw(), utf16_ptr(&text))) } + } + + pub fn set_visible(&self, visible: bool) -> Result<()> { + unsafe { hresult::check((self.vtbl().set_visible)(self.as_raw(), visible as i32)) } + } + + pub fn clear_menu(&self) -> Result<()> { + unsafe { hresult::check((self.vtbl().clear_menu)(self.as_raw())) } + } + + /// `kind`: 0 command, 1 check box, 2 separator, 3 submenu; `flags`: bit 0 + /// checked, bit 1 disabled; `parent` 0 for the top level. + pub fn add_menu_item( + &self, + parent: i32, + id: i32, + kind: i32, + header: Option<&str>, + flags: i32, + ) -> Result<()> { + let header = utf16(header); + unsafe { + hresult::check((self.vtbl().add_menu_item)( + self.as_raw(), + parent, + id, + kind, + utf16_ptr(&header), + flags, + )) + } + } + + /// Changes an item's header (unless `None`) and flags. + pub fn update_menu_item(&self, id: i32, header: Option<&str>, flags: i32) -> Result<()> { + let header = utf16(header); + unsafe { + hresult::check((self.vtbl().update_menu_item)( + self.as_raw(), + id, + utf16_ptr(&header), + flags, + )) + } + } + + pub fn remove(&self) -> Result<()> { + unsafe { hresult::check((self.vtbl().remove)(self.as_raw())) } + } +} + +/// What was clicked on a tray icon: the icon itself, or menu item `id`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrayInput { + Activate, + MenuItem(i32), +} + +#[repr(C)] +struct IAvnShellTrayHandlerVtbl { + query_interface: QueryInterface, + add_ref: AddRef, + release: AddRef, + on_activate: unsafe extern "system" fn(*mut IAvnShellTrayHandler) -> i32, + on_menu_item: unsafe extern "system" fn(*mut IAvnShellTrayHandler, i32) -> i32, +} + +/// Implemented by Rust ([`tray_handler`]); the host calls it on the UI thread. +#[repr(C)] +pub struct IAvnShellTrayHandler { + vtbl: *const IAvnShellTrayHandlerVtbl, +} + +unsafe impl ComInterface for IAvnShellTrayHandler { + const IID: Guid = IAVN_SHELL_TRAY_HANDLER_IID; +} + +static TRAY_HANDLER_VTBL: IAvnShellTrayHandlerVtbl = IAvnShellTrayHandlerVtbl { + query_interface: tray_query_interface, + add_ref: tray_add_ref, + release: tray_release, + on_activate: tray_on_activate, + on_menu_item: tray_on_menu_item, +}; + +unsafe extern "system" fn tray_query_interface( + this: *mut IUnknown, + iid: *const Guid, + result: *mut *mut c_void, +) -> i32 { + crate::event_callback::query_interface::(this, iid, result) +} + +unsafe extern "system" fn tray_add_ref(this: *mut IUnknown) -> u32 { + crate::event_callback::add_ref::(this) +} + +unsafe extern "system" fn tray_release(this: *mut IUnknown) -> u32 { + crate::event_callback::release::(this) +} + +/// Builds an `IAvnShellTrayHandler` from a closure receiving each click. +pub fn tray_handler( + mut callback: impl FnMut(TrayInput) -> Result<()> + Send + 'static, +) -> ComPtr { + crate::event_callback::create( + IAvnShellTrayHandler { + vtbl: &TRAY_HANDLER_VTBL, + }, + move |input: &mut TrayInput| callback(*input), + ) +} + +unsafe extern "system" fn tray_on_activate(this: *mut IAvnShellTrayHandler) -> i32 { + crate::event_callback::invoke::(this, &mut TrayInput::Activate) +} + +unsafe extern "system" fn tray_on_menu_item(this: *mut IAvnShellTrayHandler, id: i32) -> i32 { + crate::event_callback::invoke::( + this, + &mut TrayInput::MenuItem(id), + ) +} + +/// Number of methods `IAvnApplication7` publishes after `IUnknown`. +pub const IAVN_APPLICATION7_METHOD_COUNT: usize = 2; +/// Number of methods `IAvnShellTray` publishes after `IUnknown`. +pub const IAVN_SHELL_TRAY_METHOD_COUNT: usize = 7; +/// Number of methods `IAvnShellTrayHandler` publishes after `IUnknown`. +pub const IAVN_SHELL_TRAY_HANDLER_METHOD_COUNT: usize = 2; +/// Number of methods `IAvnPixelSurface` publishes after `IUnknown`. +pub const IAVN_PIXEL_SURFACE_METHOD_COUNT: usize = 10; +/// Number of methods `IAvnPixelSurfaceHandler` publishes after `IUnknown`. +pub const IAVN_PIXEL_SURFACE_HANDLER_METHOD_COUNT: usize = 4; + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::size_of; + + fn slots() -> usize { + size_of::() / size_of::() + } + + #[test] + fn vtables_have_expected_slot_counts() { + assert_eq!( + 3 + IAVN_APPLICATION7_METHOD_COUNT, + slots::() + ); + assert_eq!( + 3 + IAVN_PIXEL_SURFACE_METHOD_COUNT, + slots::() + ); + assert_eq!( + 3 + IAVN_PIXEL_SURFACE_HANDLER_METHOD_COUNT, + slots::() + ); + assert_eq!( + 3 + IAVN_SHELL_TRAY_METHOD_COUNT, + slots::() + ); + assert_eq!( + 3 + IAVN_SHELL_TRAY_HANDLER_METHOD_COUNT, + slots::() + ); + } + + #[test] + fn tray_handler_reports_clicks() { + let seen = std::sync::Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + let handler = tray_handler(move |input| { + log.lock().unwrap().push(input); + Ok(()) + }); + let raw = handler.as_raw(); + unsafe { + let vtbl = &*(*raw).vtbl; + assert_eq!(0, (vtbl.on_activate)(raw)); + assert_eq!(0, (vtbl.on_menu_item)(raw, 7)); + } + assert_eq!( + vec![TrayInput::Activate, TrayInput::MenuItem(7)], + *seen.lock().unwrap() + ); + } + + #[test] + fn iids_are_in_the_stage_34_slot() { + assert_eq!(0x90, IAVN_APPLICATION7_IID.data4[7]); + assert_ne!(crate::shell::IAvnApplication6::IID, IAVN_APPLICATION7_IID); + } + + #[test] + fn handler_reports_input_and_handled() { + let seen = std::sync::Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + let handler = pixel_surface_handler(move |input| { + log.lock().unwrap().push(input.clone()); + Ok(matches!(input, PixelSurfaceInput::Key { .. })) + }); + let raw = handler.as_raw(); + unsafe { + let vtbl = &*(*raw).vtbl; + let mut handled = -1; + assert_eq!(0, (vtbl.on_key)(raw, 0, 44, 2, &mut handled)); + assert_eq!(1, handled); + assert_eq!( + 0, + (vtbl.on_pointer)(raw, 0, 1.5, 2.5, 1, 1, 0, 1, 0.0, 0.0, &mut handled) + ); + assert_eq!(0, handled); + assert_eq!(0, (vtbl.on_resize)(raw, 10.0, 20.0, 1.5)); + } + let seen = seen.lock().unwrap(); + assert_eq!(3, seen.len()); + assert_eq!( + PixelSurfaceInput::Resize { + width: 10.0, + height: 20.0, + scaling: 1.5 + }, + seen[2] + ); + } +} diff --git a/rust/rustolonia-sys/tests/nativeaot_fixture.rs b/rust/rustolonia-sys/tests/nativeaot_fixture.rs index a0941dd..ee7313c 100644 --- a/rust/rustolonia-sys/tests/nativeaot_fixture.rs +++ b/rust/rustolonia-sys/tests/nativeaot_fixture.rs @@ -181,6 +181,40 @@ fn application_exposes_the_shell_capability() { shell.set_shutdown_mode(0).unwrap(); } +#[test] +fn application_exposes_the_code_first_desktop_capability() { + let host = load(); + let factory = host.activation_factory().unwrap(); + let app = factory.create_application().unwrap(); + let surfaces = app.pixel_surfaces().unwrap(); + // Both need Avalonia's UI thread, which is whichever test thread touched + // the dispatcher first: elsewhere they fail cleanly instead of crashing. + let handler = rustolonia_sys::pixel_surface_handler(|_| Ok(false)); + if let Ok(surface) = surfaces.create_pixel_surface(Some(&handler)) { + let _ = surface.control().unwrap(); + // Bitmaps need the platform Run initializes; arguments are checked first. + assert!(surface.set_layer(8, 2, 2, 0).is_err()); + assert!(surface.set_layer(0, 2, 2, 5).is_err()); + assert!(surface.write_layer(0, 0, 0, 2, 2, &[255; 8], 8).is_err()); + assert!(surface + .place_layer(0, 0.0, 0.0, 1.0, 1.0, false, 9, 1.0) + .is_err()); + assert!(surface.set_cursor(999).is_err()); + surface + .place_layer(0, 0.0, 0.0, 10.0, 10.0, false, 1, 0.5) + .unwrap(); + assert_eq!((0.0, 0.0, 1.0), surface.metrics().unwrap()); + surface.detach().unwrap(); + } + let tray = rustolonia_sys::tray_handler(|_| Ok(())); + if let Ok(icon) = surfaces.create_tray_icon(Some(&tray)) { + icon.add_menu_item(0, 1, 0, Some("Item"), 0).unwrap(); + assert!(icon.add_menu_item(0, 1, 0, Some("Again"), 0).is_err()); + icon.update_menu_item(1, None, 1).unwrap(); + icon.remove().unwrap(); + } +} + #[test] fn factory_unknown_qi_succeeds() { let host = load(); diff --git a/rust/rustolonia/README.md b/rust/rustolonia/README.md index 45e614c..2e9e965 100644 --- a/rust/rustolonia/README.md +++ b/rust/rustolonia/README.md @@ -66,6 +66,14 @@ The prebuilt release covers code-first applications. Applications with compiled AXAML and view-model IR still build their own host from a source checkout. +Two things the projected controls cannot express are covered from Rust +directly: `pixel_surface::PixelSurface` is a control that draws bitmap layers +Rust fills and reports the raw pointer stream (press, move, release, wheel) +and keys to a Rust closure, for canvases, overlays and custom charts (see +`examples/pixel_surface.rs`); `ShellTray` is a notification-area icon whose +menu Rust builds, with no schema compiled into the host. Both need a host with +the stage 34 capability. + Application view-model schemas are owned by the consuming crate. In this repository the flagship sample is [`../avalonia-sample`](../avalonia-sample), and [`../templates/avalonia-app`](../templates/avalonia-app) (or diff --git a/rust/rustolonia/examples/pixel_surface.rs b/rust/rustolonia/examples/pixel_surface.rs new file mode 100644 index 0000000..ad1935b --- /dev/null +++ b/rust/rustolonia/examples/pixel_surface.rs @@ -0,0 +1,161 @@ +//! A paint surface: drag with the left button to draw, right-click to clear. +//! Rust owns the pixels; the host only shows them and reports the pointer. +//! +//! `RUSTOLONIA_EXAMPLE_EXIT_AFTER_MS` closes it after a delay (for smoke runs). + +use rustolonia::pixel_surface::{Cursor, MouseButton, PixelFormat, PointerKind, SurfaceInput}; +use rustolonia::{App, Dock, DockPanel, PixelSurface, Result, TextBlock, Window}; +use std::sync::{Arc, Mutex}; + +struct Canvas { + width: u32, + height: u32, + scaling: f64, + pixels: Vec, + drawing: bool, +} + +impl Canvas { + fn resize(&mut self, width: u32, height: u32) { + self.width = width; + self.height = height; + self.pixels = vec![0; width as usize * height as usize * 4]; + for (i, px) in self.pixels.chunks_exact_mut(4).enumerate() { + let x = (i as u32 % width.max(1)) as f32 / width.max(1) as f32; + px.copy_from_slice(&[(40.0 + 60.0 * x) as u8, 44, 52, 255]); + } + } + + /// Paints a dot around (`x`, `y`) in physical pixels; returns the damage. + fn dot(&mut self, x: f64, y: f64) -> Option<(i32, i32, u32, u32)> { + let r = (6.0 * self.scaling) as i32; + let (cx, cy) = (x as i32, y as i32); + let x0 = (cx - r).max(0); + let y0 = (cy - r).max(0); + let x1 = (cx + r).min(self.width as i32); + let y1 = (cy + r).min(self.height as i32); + if x1 <= x0 || y1 <= y0 { + return None; + } + for py in y0..y1 { + for px in x0..x1 { + if (px - cx).pow(2) + (py - cy).pow(2) <= r * r { + let i = (py as usize * self.width as usize + px as usize) * 4; + self.pixels[i..i + 4].copy_from_slice(&[255, 196, 0, 255]); + } + } + } + Some((x0, y0, (x1 - x0) as u32, (y1 - y0) as u32)) + } +} + +fn flush(surface: &PixelSurface, canvas: &Canvas, damage: (i32, i32, u32, u32)) -> Result<()> { + let (x, y, w, h) = damage; + let stride = canvas.width as usize * 4; + let start = y as usize * stride + x as usize * 4; + surface.write_layer(0, x, y, w, h, &canvas.pixels[start..], stride) +} + +fn main() -> Result<()> { + App::load_from_env()?.run(|scope| { + let canvas = Arc::new(Mutex::new(Canvas { + width: 0, + height: 0, + scaling: 1.0, + pixels: Vec::new(), + drawing: false, + })); + let slot: Arc>> = Arc::default(); + let surface = { + let canvas = canvas.clone(); + let slot = slot.clone(); + scope.create_pixel_surface(move |input| { + let Some(surface) = slot.lock().unwrap().clone() else { + return Ok(false); + }; + let mut canvas = canvas.lock().unwrap(); + match input { + SurfaceInput::Resize(metrics) => { + let (w, h) = metrics.physical_size(); + canvas.scaling = metrics.scaling; + canvas.resize(w, h); + surface.set_layer_pixels(0, w, h, PixelFormat::Rgba8, &canvas.pixels)?; + } + SurfaceInput::Pointer(p) => { + let (x, y) = (p.x * canvas.scaling, p.y * canvas.scaling); + match p.kind { + PointerKind::Pressed if p.button == MouseButton::Right => { + let (w, h) = (canvas.width, canvas.height); + canvas.resize(w, h); + surface.write_layer( + 0, + 0, + 0, + w, + h, + &canvas.pixels, + w as usize * 4, + )?; + } + PointerKind::Pressed => { + canvas.drawing = true; + if let Some(d) = canvas.dot(x, y) { + flush(&surface, &canvas, d)?; + } + } + PointerKind::Moved if canvas.drawing => { + if let Some(d) = canvas.dot(x, y) { + flush(&surface, &canvas, d)?; + } + } + PointerKind::Released | PointerKind::CaptureLost => { + canvas.drawing = false + } + _ => return Ok(false), + } + } + _ => return Ok(false), + } + Ok(true) + })? + }; + surface.set_cursor(Cursor::Cross)?; + *slot.lock().unwrap() = Some(surface.clone()); + + let hint = TextBlock::new()? + .text("Drag to paint, right-click to clear")? + .margin(8.0)?; + DockPanel::set_dock(&hint, Dock::Top)?; + let window = Window::new()? + .title("Pixel surface")? + .width(640.0)? + .height(420.0)? + .content(Some( + &DockPanel::new()? + .child(hint)? + .child(surface.control().clone())?, + ))?; + scope.mount(window)?; + + if let Some(ms) = std::env::var("RUSTOLONIA_EXAMPLE_EXIT_AFTER_MS") + .ok() + .and_then(|v| v.parse().ok()) + { + let scope = scope.clone(); + let probe = surface.clone(); + scope.clone().spawn(async move { + if let Ok(delay) = scope.delay(std::time::Duration::from_millis(ms)) { + let _ = delay.await; + } + let s = scope.clone(); + let _ = scope.post(move || { + if let Ok(m) = probe.metrics() { + println!("surface {}x{} @{}", m.width, m.height, m.scaling); + } + let _ = s.shutdown(); + }); + })?; + } + Ok(()) + }) +} diff --git a/rust/rustolonia/src/lib.rs b/rust/rustolonia/src/lib.rs index 25fb0cc..570d1cd 100644 --- a/rust/rustolonia/src/lib.rs +++ b/rust/rustolonia/src/lib.rs @@ -4,6 +4,7 @@ mod async_runtime; pub mod clipboard; pub mod data_shapes; pub mod menu; +pub mod pixel_surface; pub mod reactive; mod runtime; pub mod shell; @@ -17,6 +18,7 @@ pub use clipboard::{ClipboardCapabilities, ClipboardData, ClipboardFilesOperatio pub use data_shapes::{CancellationToken, MapKey, RangeBatch, RangeRequest}; pub use generated::*; pub use menu::{RecentFileList, RecentFilesChange}; +pub use pixel_surface::{PixelSurface, SurfaceInput}; pub use reactive::{ provide_context, use_context, Effect, Memo, NodeRef, ReactiveScope, Resource, ResourceState, Signal, TwoWay, @@ -25,7 +27,7 @@ pub use runtime::{ discover_host_path, App, AppContext, AppScope, AsControl, Downcast, EventSubscription, NonOwningRef, ResourceValue, ThemeVariant, Upcast, HOST_NATIVE_LIB_ENV_VAR, }; -pub use shell::ShutdownMode; +pub use shell::{ShellTray, ShutdownMode, TrayEvent, TrayMenuItem}; pub use storage::{ ActivationEvent, DragDropEffects, FileDropEvent, FileTypeFilter, FolderPickerOptions, OpenFilePickerOptions, PickerOutcome, SaveFilePickerOptions, StorageCapabilities, StorageItem, diff --git a/rust/rustolonia/src/pixel_surface.rs b/rust/rustolonia/src/pixel_surface.rs new file mode 100644 index 0000000..1f7ef40 --- /dev/null +++ b/rust/rustolonia/src/pixel_surface.rs @@ -0,0 +1,509 @@ +//! Rust-drawn surfaces: the separately versioned stage 34 `IAvnApplication7` +//! capability. +//! +//! A [`PixelSurface`] is a control that shows pixels Rust produced and reports +//! the raw pointer stream (press, move, release, wheel) and keyboard input to a +//! Rust closure. It is what an image editor canvas, a selection overlay or a +//! custom chart is built on: Rust decides what to draw and how input maps to +//! it, the host only blits bitmaps. +//! +//! A surface draws up to [`PixelSurface::MAX_LAYERS`] bitmap layers in order +//! over an optional background. Each layer is placed at a rectangle in +//! device-independent pixels, or stretched over the whole control, so a large +//! document can be scaled by the compositor while a control-sized overlay is +//! drawn 1:1 in physical pixels (control size × [`SurfaceMetrics::scaling`]). +//! +//! ```no_run +//! # fn demo(scope: &rustolonia::AppScope) -> rustolonia::Result<()> { +//! use rustolonia::pixel_surface::{PixelFormat, SurfaceInput}; +//! let surface = scope.create_pixel_surface(|input| { +//! if let SurfaceInput::Pointer(p) = input { +//! println!("{:?} at {}, {}", p.kind, p.x, p.y); +//! } +//! Ok(true) +//! })?; +//! surface.set_layer(0, 2, 1, PixelFormat::Rgba8)?; +//! surface.write_layer(0, 0, 0, 2, 1, &[255, 0, 0, 255, 0, 0, 255, 255], 8)?; +//! # Ok(()) } +//! ``` + +use crate::{AppContext, Control, Key, Result}; +use rustolonia_sys as sys; + +/// The pixel layout of a layer; both are premultiplied, 4 bytes per pixel. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PixelFormat { + /// Red, green, blue, alpha (what `tiny-skia` and `image` produce). + #[default] + Rgba8, + /// Blue, green, red, alpha (what Windows GDI captures produce). + Bgra8, +} + +/// How a scaled layer is filtered (Avalonia's `BitmapInterpolationMode`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Interpolation { + #[default] + Default, + /// Nearest neighbor: crisp pixels when zoomed in. + None, + Low, + Medium, + High, +} + +/// Where a layer is drawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Placement { + /// Stretched over the whole control (the default). + Fill, + /// At a rectangle in device-independent pixels relative to the control. + Rect { + x: f64, + y: f64, + width: f64, + height: f64, + }, +} + +/// A standard pointer shape (Avalonia's `StandardCursorType`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(i32)] +pub enum Cursor { + /// Whatever the parent shows. + Inherit = -1, + Arrow = 0, + Ibeam, + Wait, + Cross, + UpArrow, + SizeWestEast, + SizeNorthSouth, + SizeAll, + No, + Hand, + AppStarting, + Help, + TopSide, + BottomSide, + LeftSide, + RightSide, + TopLeftCorner, + TopRightCorner, + BottomLeftCorner, + BottomRightCorner, + DragMove, + DragCopy, + DragLink, + None, +} + +/// What happened to the pointer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PointerKind { + /// A button went down; the surface captures the pointer until it is released. + Pressed, + Moved, + Released, + Wheel, + Entered, + Exited, + /// The capture ended without a release (another window took it). + CaptureLost, +} + +/// A pointer button. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MouseButton { + None, + Left, + Right, + Middle, + X1, + X2, +} + +impl MouseButton { + fn from_abi(value: i32) -> Self { + match value { + 1 => Self::Left, + 2 => Self::Right, + 3 => Self::Middle, + 4 => Self::X1, + 5 => Self::X2, + _ => Self::None, + } + } + + fn bit(self) -> i32 { + match self { + Self::None => 0, + Self::Left => 1, + Self::Right => 2, + Self::Middle => 4, + Self::X1 => 8, + Self::X2 => 16, + } + } +} + +/// Keyboard modifiers held during an event (Avalonia's `KeyModifiers`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Modifiers(pub i32); + +impl Modifiers { + pub fn alt(self) -> bool { + self.0 & crate::key_modifiers::ALT != 0 + } + + pub fn control(self) -> bool { + self.0 & crate::key_modifiers::CONTROL != 0 + } + + pub fn shift(self) -> bool { + self.0 & crate::key_modifiers::SHIFT != 0 + } + + pub fn meta(self) -> bool { + self.0 & crate::key_modifiers::META != 0 + } +} + +/// A pointer event; positions are device-independent pixels relative to the +/// surface. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PointerInput { + pub kind: PointerKind, + pub x: f64, + pub y: f64, + /// The button that went down or up; [`MouseButton::None`] otherwise. + pub button: MouseButton, + buttons: i32, + pub modifiers: Modifiers, + /// 2 for the second press of a double click. + pub click_count: i32, + /// Wheel movement, in notches. + pub delta_x: f64, + pub delta_y: f64, +} + +impl PointerInput { + /// Whether `button` is held after this event. + pub fn is_pressed(&self, button: MouseButton) -> bool { + self.buttons & button.bit() != 0 + } +} + +/// One input notification from a surface. +#[derive(Clone, Debug, PartialEq)] +pub enum SurfaceInput { + Pointer(PointerInput), + /// A key went down or up while the surface had focus. `key` is `None` for + /// a key the projection does not know. + Key { + down: bool, + key: Option, + modifiers: Modifiers, + }, + /// Text typed while the surface had focus. + Text(String), + /// The surface was attached to a window, resized, or its scaling changed. + Resize(SurfaceMetrics), +} + +/// A surface's size and scaling. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SurfaceMetrics { + /// Device-independent pixels. + pub width: f64, + pub height: f64, + /// Physical pixels per device-independent pixel. + pub scaling: f64, +} + +impl SurfaceMetrics { + /// The size in physical pixels, rounded up: what a 1:1 layer needs. + pub fn physical_size(&self) -> (u32, u32) { + let pixels = |dips: f64| (dips * self.scaling).ceil().max(0.0) as u32; + (pixels(self.width), pixels(self.height)) + } +} + +impl SurfaceInput { + fn from_abi(input: &sys::PixelSurfaceInput) -> Self { + match *input { + sys::PixelSurfaceInput::Pointer { + kind, + x, + y, + button, + buttons, + modifiers, + click_count, + delta_x, + delta_y, + } => SurfaceInput::Pointer(PointerInput { + kind: match kind { + 0 => PointerKind::Pressed, + 1 => PointerKind::Moved, + 2 => PointerKind::Released, + 3 => PointerKind::Wheel, + 4 => PointerKind::Entered, + 5 => PointerKind::Exited, + _ => PointerKind::CaptureLost, + }, + x, + y, + button: MouseButton::from_abi(button), + buttons, + modifiers: Modifiers(modifiers), + click_count, + delta_x, + delta_y, + }), + sys::PixelSurfaceInput::Key { + kind, + key, + modifiers, + } => SurfaceInput::Key { + down: kind == 0, + key: Key::try_from(key).ok(), + modifiers: Modifiers(modifiers), + }, + sys::PixelSurfaceInput::Text(ref text) => SurfaceInput::Text(text.clone()), + sys::PixelSurfaceInput::Resize { + width, + height, + scaling, + } => SurfaceInput::Resize(SurfaceMetrics { + width, + height, + scaling, + }), + } + } +} + +/// A control drawing Rust-owned bitmap layers. Cheap to clone; every method +/// must be called on the UI thread. +#[derive(Clone)] +pub struct PixelSurface { + raw: sys::ComPtr, + control: Control, +} + +impl PixelSurface { + /// How many layers a surface draws. + pub const MAX_LAYERS: usize = 8; + + /// The control to place in a layout. + pub fn control(&self) -> &Control { + &self.control + } + + /// Allocates layer `layer` as `width` by `height` transparent pixels, or + /// removes it when either is 0. Keeps the pixels when the size and format + /// are unchanged. + pub fn set_layer( + &self, + layer: usize, + width: u32, + height: u32, + format: PixelFormat, + ) -> Result<()> { + let format = match format { + PixelFormat::Rgba8 => 0, + PixelFormat::Bgra8 => 1, + }; + Ok(self + .raw + .set_layer(layer as i32, abi_len(width)?, abi_len(height)?, format)?) + } + + /// Copies `width` by `height` pixels into the layer at (`x`, `y`) and + /// redraws. `pixels` holds rows `stride` bytes apart, in the layer's + /// format; the rectangle is clipped to the layer. + #[allow(clippy::too_many_arguments)] + pub fn write_layer( + &self, + layer: usize, + x: i32, + y: i32, + width: u32, + height: u32, + pixels: &[u8], + stride: usize, + ) -> Result<()> { + Ok(self.raw.write_layer( + layer as i32, + x, + y, + abi_len(width)?, + abi_len(height)?, + pixels, + i32::try_from(stride).map_err(|_| sys::Error(sys::E_INVALIDARG))?, + )?) + } + + /// Allocates the layer at `width` by `height` and fills it with `pixels` + /// (tightly packed rows). + pub fn set_layer_pixels( + &self, + layer: usize, + width: u32, + height: u32, + format: PixelFormat, + pixels: &[u8], + ) -> Result<()> { + self.set_layer(layer, width, height, format)?; + self.write_layer(layer, 0, 0, width, height, pixels, width as usize * 4) + } + + /// Where and how the layer is drawn. + pub fn place_layer( + &self, + layer: usize, + placement: Placement, + interpolation: Interpolation, + opacity: f64, + ) -> Result<()> { + let (x, y, width, height, fill) = match placement { + Placement::Fill => (0.0, 0.0, 0.0, 0.0, true), + Placement::Rect { + x, + y, + width, + height, + } => (x, y, width, height, false), + }; + let interpolation = match interpolation { + Interpolation::Default => 0, + Interpolation::None => 1, + Interpolation::Low => 2, + Interpolation::Medium => 3, + Interpolation::High => 4, + }; + Ok(self.raw.place_layer( + layer as i32, + x, + y, + width, + height, + fill, + interpolation, + opacity, + )?) + } + + /// Fills the control under the layers; `None` leaves it transparent. + pub fn set_background(&self, argb: Option) -> Result<()> { + Ok(self.raw.set_background(argb.unwrap_or(0))?) + } + + pub fn set_cursor(&self, cursor: Cursor) -> Result<()> { + Ok(self.raw.set_cursor(cursor as i32)?) + } + + /// The size the surface asks for in layout; `(0, 0)` (the default) takes + /// whatever the layout gives. + pub fn set_desired_size(&self, width: f64, height: f64) -> Result<()> { + Ok(self.raw.set_desired_size(width, height)?) + } + + /// Moves keyboard focus to the surface. + pub fn focus(&self) -> Result<()> { + Ok(self.raw.focus()?) + } + + pub fn metrics(&self) -> Result { + let (width, height, scaling) = self.raw.metrics()?; + Ok(SurfaceMetrics { + width, + height, + scaling, + }) + } + + /// Stops reporting input and drops the handler closure (and whatever it + /// captured). The surface keeps drawing. + pub fn detach(&self) -> Result<()> { + Ok(self.raw.detach()?) + } +} + +fn abi_len(value: u32) -> Result { + Ok(i32::try_from(value).map_err(|_| sys::Error(sys::E_INVALIDARG))?) +} + +impl AppContext { + fn surfaces(&self) -> Result> { + Ok(self.application().pixel_surfaces()?) + } + + /// Creates a surface reporting its input to `handler`, which returns + /// whether it handled the event (a handled event does not bubble). The + /// handler runs on the UI thread; an input raised while it is already + /// running (from something it called) is not delivered. + pub fn create_pixel_surface( + &self, + mut handler: impl FnMut(&SurfaceInput) -> Result + Send + 'static, + ) -> Result { + let sys_handler = sys::pixel_surface_handler(move |input| { + handler(&SurfaceInput::from_abi(input)).map_err(|error| match error { + crate::Error::Abi(error) => error, + _ => sys::Error(sys::E_FAIL), + }) + }); + self.wrap(self.surfaces()?.create_pixel_surface(Some(&sys_handler))?) + } + + /// Creates a surface that only draws: it reports no input. + pub fn create_display_surface(&self) -> Result { + self.wrap(self.surfaces()?.create_pixel_surface(None)?) + } + + fn wrap(&self, raw: sys::ComPtr) -> Result { + let control = Control { + raw: raw.control()?, + }; + Ok(PixelSurface { raw, control }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pointer_input_maps_kinds_buttons_and_modifiers() { + let input = SurfaceInput::from_abi(&sys::PixelSurfaceInput::Pointer { + kind: 2, + x: 3.0, + y: 4.0, + button: 1, + buttons: 4, + modifiers: crate::key_modifiers::SHIFT | crate::key_modifiers::CONTROL, + click_count: 1, + delta_x: 0.0, + delta_y: 0.0, + }); + let SurfaceInput::Pointer(p) = input else { + panic!("not a pointer event") + }; + assert_eq!(PointerKind::Released, p.kind); + assert_eq!(MouseButton::Left, p.button); + assert!(!p.is_pressed(MouseButton::Left)); + assert!(p.is_pressed(MouseButton::Middle)); + assert!(p.modifiers.shift() && p.modifiers.control() && !p.modifiers.alt()); + } + + #[test] + fn physical_size_rounds_up() { + let m = SurfaceMetrics { + width: 100.5, + height: 10.0, + scaling: 1.5, + }; + assert_eq!((151, 15), m.physical_size()); + } +} diff --git a/rust/rustolonia/src/shell.rs b/rust/rustolonia/src/shell.rs index e56104f..7584c6e 100644 --- a/rust/rustolonia/src/shell.rs +++ b/rust/rustolonia/src/shell.rs @@ -7,7 +7,8 @@ //! ([`AppScope::mount_dynamic_view_model_hidden`], [`Window::show`]), //! notification-area icons whose menu comes from the view-model schema //! ([`AppScope::mount_dynamic_tray`], usually through a generated -//! `mount_`), and window-level settings: the native handle, topmost, +//! `mount_`, or [`AppContext::create_tray`] with no schema at all), and +//! window-level settings: the native handle, topmost, //! per-pixel transparency and exclusion from screen capture. use crate::{AppContext, AppScope, Result, Window}; @@ -91,3 +92,196 @@ impl Window { Ok(self.raw.show()?) } } + +/// One entry of a [`ShellTray`] menu. Ids are the application's own: a click +/// is reported as [`TrayEvent::MenuItem`] with the id, and +/// [`ShellTray::update_item`] finds an entry by it. Ids must be unique and +/// below 2^30. +#[derive(Clone, Debug, PartialEq)] +pub enum TrayMenuItem { + Command { + id: u32, + header: String, + enabled: bool, + }, + /// A check box item. Clicking it does not toggle it: the application flips + /// its state and calls [`ShellTray::update_item`]. + Check { + id: u32, + header: String, + checked: bool, + enabled: bool, + }, + Separator, + Submenu { + header: String, + items: Vec, + }, +} + +impl TrayMenuItem { + pub fn command(id: u32, header: impl Into) -> Self { + Self::Command { + id, + header: header.into(), + enabled: true, + } + } + + pub fn check(id: u32, header: impl Into, checked: bool) -> Self { + Self::Check { + id, + header: header.into(), + checked, + enabled: true, + } + } + + pub fn submenu(header: impl Into, items: Vec) -> Self { + Self::Submenu { + header: header.into(), + items, + } + } +} + +/// What was clicked on a [`ShellTray`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrayEvent { + /// The icon itself. + Activate, + /// The menu entry with this id. + MenuItem(u32), +} + +/// A notification-area icon built from Rust (stage 34): unlike a schema +/// tray ([`AppScope::mount_dynamic_tray`]) it needs nothing compiled into the +/// host, so a code-first application on the prebuilt host can have one. It is +/// removed by [`ShellTray::remove`] or when the application shuts down. +#[derive(Clone)] +pub struct ShellTray { + raw: sys::ComPtr, +} + +const FLAG_CHECKED: i32 = 1; +const FLAG_DISABLED: i32 = 2; +/// Submenus get ids from here up, out of the application's range. +const FIRST_SUBMENU_ID: i32 = 1 << 30; + +fn flags(checked: bool, enabled: bool) -> i32 { + (if checked { FLAG_CHECKED } else { 0 }) | (if enabled { 0 } else { FLAG_DISABLED }) +} + +fn item_id(id: u32) -> Result { + match i32::try_from(id) { + Ok(id) if id > 0 && id < FIRST_SUBMENU_ID => Ok(id), + _ => Err(sys::Error(sys::E_INVALIDARG).into()), + } +} + +impl ShellTray { + /// The image: a file path or an `avares://` URI (`.ico` on Windows). + pub fn set_icon(&self, source: &str) -> Result<()> { + Ok(self.raw.set_icon(Some(source))?) + } + + pub fn set_tool_tip(&self, text: &str) -> Result<()> { + Ok(self.raw.set_tool_tip(Some(text))?) + } + + pub fn set_visible(&self, visible: bool) -> Result<()> { + Ok(self.raw.set_visible(visible)?) + } + + /// Replaces the whole menu. + pub fn set_menu(&self, items: &[TrayMenuItem]) -> Result<()> { + self.raw.clear_menu()?; + let mut next_submenu = FIRST_SUBMENU_ID; + self.add_items(0, items, &mut next_submenu) + } + + fn add_items(&self, parent: i32, items: &[TrayMenuItem], next_submenu: &mut i32) -> Result<()> { + for item in items { + match item { + TrayMenuItem::Command { + id, + header, + enabled, + } => self.raw.add_menu_item( + parent, + item_id(*id)?, + 0, + Some(header), + flags(false, *enabled), + )?, + TrayMenuItem::Check { + id, + header, + checked, + enabled, + } => self.raw.add_menu_item( + parent, + item_id(*id)?, + 1, + Some(header), + flags(*checked, *enabled), + )?, + TrayMenuItem::Separator => { + let id = *next_submenu; + *next_submenu += 1; + self.raw.add_menu_item(parent, id, 2, None, 0)? + } + TrayMenuItem::Submenu { header, items } => { + let id = *next_submenu; + *next_submenu += 1; + self.raw.add_menu_item(parent, id, 3, Some(header), 0)?; + self.add_items(id, items, next_submenu)?; + } + } + } + Ok(()) + } + + /// Changes an entry's state, and its header unless `None`. + pub fn update_item( + &self, + id: u32, + header: Option<&str>, + checked: bool, + enabled: bool, + ) -> Result<()> { + Ok(self + .raw + .update_menu_item(item_id(id)?, header, flags(checked, enabled))?) + } + + /// Takes the icon down for good. + pub fn remove(&self) -> Result<()> { + Ok(self.raw.remove()?) + } +} + +impl AppContext { + /// Shows a tray icon (no image or menu yet) reporting clicks to `handler`, + /// on the UI thread. + pub fn create_tray( + &self, + mut handler: impl FnMut(TrayEvent) -> Result<()> + Send + 'static, + ) -> Result { + let sys_handler = sys::tray_handler(move |input| { + let event = match input { + sys::TrayInput::Activate => TrayEvent::Activate, + sys::TrayInput::MenuItem(id) => TrayEvent::MenuItem(id as u32), + }; + handler(event).map_err(|error| match error { + crate::Error::Abi(error) => error, + _ => sys::Error(sys::E_FAIL), + }) + }); + let raw = self + .application() + .pixel_surfaces()? + .create_tray_icon(Some(&sys_handler))?; + Ok(ShellTray { raw }) + } +}