From 4ec7a571322c36469f60e7bca35f46a9c7df63f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sun, 18 Oct 2026 22:51:42 +0200 Subject: [PATCH] feat: let widgets replace device icons with icons from the user's icon packs --- AGENTS.md | 19 +- README.md | 4 +- .../BatteryIntegration.Widgets.cs | 108 +++++- src/DeviceBatteryInfo/Core/HostCallRetry.cs | 13 +- .../Localization/Strings.resx | 6 + .../Ui/BatteryWidgetConfigView.cs | 91 ++++- .../Ui/BatteryWidgetModel.cs | 59 +++- .../Ui/BatteryWidgetTypes.cs | 5 + src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs | 50 ++- src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs | 4 +- .../BatteryWidgetViewTests.cs | 2 +- .../ExternalBatteryDevicesTests.cs | 10 +- .../WidgetIconTests.cs | 333 ++++++++++++++++++ 13 files changed, 659 insertions(+), 45 deletions(-) create mode 100644 tests/DeviceBatteryInfo.Tests/WidgetIconTests.cs diff --git a/AGENTS.md b/AGENTS.md index 47f39f2..6c6724f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,8 @@ tests/DeviceBatteryInfo.Tests/ out-of-reach devices, the kind mapping ExternalBatteryDevicesTests.cs other providers' devices in the picker and as widget rows, our own filtered out, a live widget session, the reverse kind and state mapping + WidgetIconTests.cs per-widget icons: stored shapes, the image in place of the glyph, the + pickers, a live session loading an icon from the fake icon store HidTransportPlatformTests.cs macOS entry ids and descriptors (a captured G Pro X Wireless one), Windows interface and unit keys, the bounded exchange on a fake channel, vendor-collection matching @@ -150,7 +152,7 @@ Design knowledge that is not obvious from the code alone: which conformance does not catch, so the `BatteryWidgetViewTests` build each tree through a real `UiView`. A widget `config` surface is served by this plugin's own `IUiProvider.CreateSessionAsync` (kind `"config"`, `entryPoint == "widget-config"`), not by the hosting config-flow path. - `BatteryWidgetConfigView` follows the host's own Action Button form: `UiTabs` (Devices, and Appearance + `BatteryWidgetConfigView` follows the host's own Action Button form: `UiTabs` (Devices, Icons, and Appearance with the display switches under a "Show" heading) and `Segmented` choices whose `UiOption.Icon` names a `UiIcons` value for short icon choices. The properties pane beside the actions editor is narrow (about 280 px): a switch in a row wraps its label and stacks it above the switch, and one whose row partner is hidden jumps to the right, so every @@ -164,8 +166,8 @@ Design knowledge that is not obvious from the code alone: (its box) and `Thickness` (its track), texts beside a `Fill` sibling need a `MainSize`, and `Padding` is the corner-radius safe area (`BatteryWidgetView.SafeArea`, radius from the `cornerRadius` surface attribute). Plugins ship no images: device icons are `UiShape` paths in - `DeviceGlyphs`, drawn in the unit square the renderer scales to the shape's box (so give the box a - square `UiFrame`), absolute `M L H V C Q A Z` only, filled nonzero - a solid part clockwise, a + `DeviceGlyphs` (unless the user picks their own, see the per-widget icons below), drawn in the unit + square the renderer scales to the shape's box (so give the box a square `UiFrame`), absolute `M L H V C Q A Z` only, filled nonzero - a solid part clockwise, a cut-out counter-clockwise, and a cut-out never where two solid parts overlap (the winding sums to 1 there and it disappears). `BatteryWidgetViewTests` checks every path against the renderer's grammar. A shape's `Color` takes a hex only, not a theme role, so glyphs and rings carry the state @@ -225,6 +227,17 @@ Design knowledge that is not obvious from the code alone: name and caption have no `MinSize`. It switches the whole list, not each row, because an unsized first-fit takes its last layout's size and every row would be as tall as a stacked one. A progress bar's `StartColor` and `EndColor` are always the same hex - the renderer always paints a `linear-gradient`. +- **A widget can replace any device's glyph with an icon from the user's icon packs.** The Icons tab + has a `UiObjectInput` keyed `icons` holding a `UiRepeat` of one `UiIconReferenceInput` per device the + widget shows, following the `sourceIds` state live (empty means this plugin's devices, as in the + widget); a repeat opens no id scope. Each picker is keyed by row id (`WidgetDevice.IconKey`: the slot + id, or `ExternalBatteryDevices.RowKey` for another provider's device, because an input key takes no + colon), and every device keeps its state, so a deselected device keeps its icon. The data holds `icons. = { "type": "icon-pack", "reference": "" }`; + a bare id is accepted too. Each widget session resolves the ids through `UiResources.GetIconAsync` in + the background (per session, because a replaced icon keeps its id and gets a new hash) and + re-renders; a missing icon is logged and the glyph stays. The image is untinted and needs an explicit + `Size` (`OfParent(1)` inside the glyph's box), or the reader draws it at its pixel size. The config + flow cannot offer a per-device default yet: its dialog renders an `ActionParameter.Icon` as a text box. - **A short press refreshes until the user sets their own Short Press action.** Both widget types set `SupportsFlows` (Macro Deck PR 960), so the host runs the actions the user bound to the widget, like a built-in one, and `DefaultShortPressAction` (PR 1111, host and SDK beta.15) names this plugin's own diff --git a/README.md b/README.md index ae7bbea..377e776 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,9 @@ peripherals, right on your deck. (level ring or bar, percentage, charging indicator, time to full, battery trend, low-battery threshold). Each device is drawn with an icon for its kind and a coloured ring, with a bolt in the ring's gap while it charges. For adb and Bluetooth devices you pick the icon when adding the device (phone, tablet, VR -headset, speaker and more). The colour scheme is a widget setting: by level with cyan while charging (the default), by +headset, speaker and more). On a widget's **Icons** tab you can replace the icon of any device it shows +with one from your Macro Deck icon packs, including packs of your own images; it is drawn in its own +colours. The colour scheme is a widget setting: by level with cyan while charging (the default), by level only, green only, by device type, a smooth gradient, or one custom colour of your choice (for a black and white board, say). The level schemes have a colour threshold bar where you move, recolour, add and remove the colour bands (by default red up to the low-battery threshold, then orange, yellow diff --git a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs index d9daacb..0e382ac 100644 --- a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs +++ b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs @@ -6,7 +6,7 @@ using MacroDeck.Sdk.Ui; using MacroDeck.Sdk.Widgets; using MacroDeck.Ui.Config; -using MacroDeck.Ui.Config.Options; +using MacroDeck.Ui.Model.Resources; using MacroDeck.Ui.Model.Surfaces; using MacroDeck.Ui.Runtime; @@ -105,7 +105,8 @@ private static string Preview(JsonElement value) var data = AttributeJson(surface, UiWidgetSurfaceAttributes.Data); var options = ParseOptions(data); - BatteryWidgetModel Compute() => BuildModel(options); + IReadOnlyDictionary? icons = null; + BatteryWidgetModel Compute() => BuildModel(options, icons); var initial = Compute(); _logger.Information( @@ -127,10 +128,36 @@ private static string Preview(JsonElement value) _polling.RequestRefresh(); + var closing = new CancellationTokenSource(); + if (options.Icons is { Count: > 0 }) + { + _ = LoadIconsAsync(); + } + + async Task LoadIconsAsync() + { + try + { + var token = closing.Token; + icons = await ResolveIconsAsync(options, token); + if (!token.IsCancellationRequested) + { + Refresh(); + } + } + catch (OperationCanceledException) { } + catch (Exception exception) + { + _logger.Warning(exception, "Loading the icons of a battery widget failed."); + } + } + return new UiViewSession( view, onDispose: () => { + closing.Cancel(); + closing.Dispose(); _registry.Changed -= OnRegistryChanged; _catalog.Changed -= OnCatalogChanged; BatteryCatalogChanged -= OnCatalogChanged; @@ -158,12 +185,15 @@ private static string Preview(JsonElement value) var options = ParseOptions(data); var view = new UiView( surface, - BatteryWidgetConfigView.Build(localId, options, DeviceChoices(), data ?? default) + BatteryWidgetConfigView.Build(localId, options, WidgetDevices(), data ?? default) ); return new UiViewSession(view); } - internal BatteryWidgetModel BuildModel(BatteryWidgetOptions options) + internal BatteryWidgetModel BuildModel( + BatteryWidgetOptions options, + IReadOnlyDictionary? icons = null + ) { var own = CurrentSlots(); IReadOnlyList rows; @@ -185,9 +215,53 @@ internal BatteryWidgetModel BuildModel(BatteryWidgetOptions options) .ToArray(); } + if (icons is { Count: > 0 }) + { + rows = rows.Select(row => + options.IconFor(row.Id) is { } id && icons.TryGetValue(id, out var icon) + ? row with { Icon = icon } + : row + ) + .ToArray(); + } + return new BatteryWidgetModel(options.Order(rows), options); } + // Per session: a replaced icon keeps its id but gets a new hash. + internal async Task> ResolveIconsAsync( + BatteryWidgetOptions options, + CancellationToken cancellationToken + ) + { + var resolved = new Dictionary(); + if (_context?.UiResources is not { } resources || options.Icons is not { Count: > 0 } icons) + { + return resolved; + } + + foreach (var id in icons.Values.Distinct()) + { + try + { + resolved[id] = await HostCallRetry.RunAsync( + () => resources.GetIconAsync(id, cancellationToken), + cancellationToken + ); + } + catch (UiResourceException exception) + { + _logger.Warning( + "Could not load icon {IconId} for a battery widget ({ErrorCode}).", + id, + exception.ErrorCode + ); + } + } + + return resolved; + } + private BatteryWidgetRow OwnRow(BatterySlot slot) { if (_registry.TryGet(slot.Id, out var snapshot)) @@ -233,20 +307,17 @@ private static BatteryWidgetRow ExternalRow(BatteryDeviceSnapshot device) ); } - internal UiOption[] DeviceChoices() => + internal WidgetDevice[] WidgetDevices() => [ .. CurrentSlots() - .Select(slot => UiOption.Of(slot.Id) with { Label = slot.DisplayName }), + .Select(slot => new WidgetDevice(slot.Id, slot.Id, slot.DisplayName, IsOwn: true)), .. ExternalDevices() - .Select(device => - UiOption.Of(device.Id) with - { - Label = Strings.Widgets.Config.Devices.External( - device.Name, - device.ProviderName - ), - } - ), + .Select(device => new WidgetDevice( + device.Id, + ExternalBatteryDevices.RowKey(device.Id), + Strings.Widgets.Config.Devices.External(device.Name, device.ProviderName), + IsOwn: false + )), ]; private string? LocalWidgetId(string widgetTypeAttribute) @@ -357,6 +428,10 @@ bool Flag(string key, bool fallback) => ? parsedThresholds : null; + var icons = obj.TryGetProperty("icons", out var iconsValue) + ? BatteryWidgetOptions.ParseIcons(iconsValue) + : null; + var appearance = UiWidgetAppearance.Read(obj); return new BatteryWidgetOptions( @@ -380,7 +455,8 @@ bool Flag(string key, bool fallback) => namePosition, ringColor, Flag("lowInRed", true), - thresholds + thresholds, + icons ); } diff --git a/src/DeviceBatteryInfo/Core/HostCallRetry.cs b/src/DeviceBatteryInfo/Core/HostCallRetry.cs index 892fbc9..aae41ae 100644 --- a/src/DeviceBatteryInfo/Core/HostCallRetry.cs +++ b/src/DeviceBatteryInfo/Core/HostCallRetry.cs @@ -1,3 +1,5 @@ +using MacroDeck.Sdk.Ui; + namespace DeviceBatteryInfo.Core; // The host throttles all of a plugin's callbacks together, adb included, so any call can be refused @@ -25,9 +27,12 @@ await Task.Delay(FirstDelay * Math.Pow(2, attempt - 1), cancellationToken) // HostInvocationException is internal to the hosting package, so it is matched by name. private static bool IsTransient(Exception exception) => - exception.GetType().Name == "HostInvocationException" - && ( - exception.Message.Contains("too quickly", StringComparison.OrdinalIgnoreCase) - || exception.Message.Contains("connection", StringComparison.OrdinalIgnoreCase) + exception is UiResourceException { ErrorCode: UiResourceErrorCode.RateLimited } + || ( + exception.GetType().Name == "HostInvocationException" + && ( + exception.Message.Contains("too quickly", StringComparison.OrdinalIgnoreCase) + || exception.Message.Contains("connection", StringComparison.OrdinalIgnoreCase) + ) ); } diff --git a/src/DeviceBatteryInfo/Localization/Strings.resx b/src/DeviceBatteryInfo/Localization/Strings.resx index 93314ab..204c95b 100644 --- a/src/DeviceBatteryInfo/Localization/Strings.resx +++ b/src/DeviceBatteryInfo/Localization/Strings.resx @@ -125,6 +125,9 @@ Devices + + Icons + Appearance @@ -138,6 +141,9 @@ {device} - {provider} A device another plugin reports to Macro Deck, followed by that plugin's name. + + Optional. Pick an icon from your icon packs to show instead of the built-in symbol. + Heading diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs index e4b946c..cddc439 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using DeviceBatteryInfo.Core; using MacroDeck.Localization; using MacroDeck.Ui.Components; @@ -14,7 +15,7 @@ internal static class BatteryWidgetConfigView public static UiElement Build( string widgetLocalId, BatteryWidgetOptions current, - IReadOnlyList devices, + IReadOnlyList devices, JsonElement data = default ) { @@ -80,7 +81,9 @@ UiValue OnlyFor(string layoutValue) => { Key = "sourceIds", Label = Strings.Widgets.Config.Devices.Label(), - Options = UiValue.Of(devices), + Options = UiValue.Of>( + devices.Select(d => UiOption.Of(d.SourceId) with { Label = d.Label }).ToArray() + ), Binding = Bind.To(sourceIds), Reorderable = UiValue.Of(true), } @@ -343,6 +346,11 @@ UiValue ColorsIn(params string[] values) => Children = [ Tab("content-tab", Strings.Widgets.Config.Tabs.Devices(), content), + Tab( + "icons-tab", + Strings.Widgets.Config.Tabs.Icons(), + [IconsField(devices, current, sourceIds)] + ), Tab("appearance-tab", Strings.Widgets.Config.Tabs.Appearance(), appearance), ], }, @@ -368,6 +376,85 @@ UiValue ColorsIn(params string[] values) => private static readonly JsonElement EmptyFlows = JsonDocument.Parse("[]").RootElement.Clone(); + // Null is "no icon picked". + private static UiIconReference IconReference(Guid? id) => + id is { } value + ? new UiIconReference(BatteryWidgetOptions.IconPackType, value.ToString()) + : null!; + + // Follows the device selection; an empty one shows this plugin's devices, like the widget. + private static UiObjectInput IconsField( + IReadOnlyList devices, + BatteryWidgetOptions current, + UiState> sourceIds + ) + { + var states = devices.ToDictionary( + device => device.IconKey, + device => new UiState(IconReference(current.IconFor(device.IconKey))), + StringComparer.Ordinal + ); + var bySource = devices.ToDictionary(device => device.SourceId, StringComparer.Ordinal); + + IReadOnlyList Shown() => + sourceIds.Value.Count == 0 + ? devices.Where(device => device.IsOwn).ToArray() + : sourceIds + .Value.Select(id => bySource.GetValueOrDefault(id)) + .OfType() + .ToArray(); + + JsonElement Compose() + { + var icons = new JsonObject(); + foreach (var (key, state) in states) + { + if (state.Value is { } reference) + { + icons[key] = new JsonObject + { + ["type"] = reference.Type, + ["reference"] = reference.Reference, + }; + } + } + + return JsonSerializer.SerializeToElement(icons); + } + + void Apply(JsonElement value) + { + var parsed = BatteryWidgetOptions.ParseIcons(value); + foreach (var (key, state) in states) + { + state.Value = IconReference(parsed.TryGetValue(key, out var id) ? id : null); + } + } + + return new UiObjectInput + { + Key = "icons", + Description = Strings.Widgets.Config.Icons.Description(), + Binding = Bind.Custom(Compose, Apply), + Children = + [ + new UiRepeat + { + Key = "icon-items", + Items = UiValue.From(Shown), + KeySelector = device => device.IconKey, + Template = (device, _) => + new UiIconReferenceInput + { + Key = device.IconKey, + Label = device.Label, + Binding = Bind.To(states[device.IconKey]), + }, + }, + ], + }; + } + private static UiTab Tab(string key, UiText label, IReadOnlyList children) => new() { diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs index 0278d0c..3acd2ec 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs @@ -1,6 +1,9 @@ using System.Globalization; +using System.Text.Json; using DeviceBatteryInfo.Core; +using MacroDeck.Localization; using MacroDeck.Ui.Config; +using MacroDeck.Ui.Model.Resources; namespace DeviceBatteryInfo.Ui; @@ -9,6 +12,14 @@ internal sealed record BatteryWidgetModel( BatteryWidgetOptions Options ); +// IconKey is the row id: a source id may hold a colon, which no input key takes. +internal sealed record WidgetDevice( + string SourceId, + string IconKey, + LocalizedText Label, + bool IsOwn +); + internal sealed record BatteryWidgetRow( string Id, string Name, @@ -18,7 +29,8 @@ internal sealed record BatteryWidgetRow( bool Stale, string? TimeToFull, string? Trend = null, - BatterySourceKind Kind = BatterySourceKind.Other + BatterySourceKind Kind = BatterySourceKind.Other, + UiResource? Icon = null ) { // Apple's system colours. @@ -200,9 +212,52 @@ internal sealed record BatteryWidgetOptions( BatteryNamePosition NamePosition = BatteryNamePosition.Below, string RingColor = BatteryWidgetRow.White, bool LowInRed = true, - UiThresholds? Thresholds = null + UiThresholds? Thresholds = null, + IReadOnlyDictionary? Icons = null ) { + public const string IconPackType = "icon-pack"; + + public static IReadOnlyDictionary ParseIcons(JsonElement icons) + { + var parsed = new Dictionary(StringComparer.Ordinal); + if (icons.ValueKind != JsonValueKind.Object) + { + return parsed; + } + + foreach (var entry in icons.EnumerateObject()) + { + if (IconId(entry.Value) is { } id) + { + parsed[entry.Name] = id; + } + } + + return parsed; + } + + // The picker writes a bare id instead of a reference over a stored string. + public static Guid? IconId(JsonElement value) + { + var reference = value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Object + when ( + !value.TryGetProperty("type", out var type) + || type.ValueEquals(IconPackType) + ) + && value.TryGetProperty("reference", out var inner) + && inner.ValueKind == JsonValueKind.String => inner.GetString(), + _ => null, + }; + return Guid.TryParse(reference, out var id) ? id : null; + } + + public Guid? IconFor(string rowId) => + Icons is not null && Icons.TryGetValue(rowId, out var id) ? id : null; + private const int OrangeFrom = 1; private const int YellowFrom = 41; private const int GreenFrom = 61; diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs index 47524e2..60305b2 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs @@ -150,6 +150,11 @@ public static JsonElement StoredFlows(JsonElement? data) => "default": "top", "description": "Where the list layout places its rows when they do not fill the widget." }, + "icons": { + "type": "object", + "additionalProperties": { "type": ["object", "string", "null"] }, + "description": "Per-device icons that replace the built-in symbol, keyed by device id (a hashed key for other plugins' devices): an icon-pack reference { \"type\": \"icon-pack\", \"reference\": \"\" } or the bare icon id. The icon is drawn in its own colours." + }, "flows": { "type": "array", "description": "The actions a press runs, edited in the widget's action list." diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs index d95d6bc..0df94d7 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs @@ -399,9 +399,9 @@ private static UiModifier Ring( var nameInside = innerName is not null; var face = new List { - Glyph( + DeviceIcon( "glyph", - DeviceGlyphs.For(row.Kind), + row, color, RingPart( diameter, @@ -497,6 +497,42 @@ private static UiModifier Ring( } private static UiModifier Glyph(string key, string path, string color, Func edge) => + Square( + key, + edge, + new UiShape + { + Key = key + "-shape", + Shape = UiComponentShapes.Path, + Path = path, + Color = color, + } + ); + + private static UiModifier DeviceIcon( + string key, + BatteryWidgetRow row, + string color, + Func edge + ) => + row.Icon is { } icon + ? Square( + key, + edge, + new UiImage + { + Key = key + "-image", + Source = UiValue.Of(icon), + Size = UiSize.From(() => FillBox(edge())), + } + ) + : Glyph(key, DeviceGlyphs.For(row.Kind), color, edge); + + // An unsized image draws at its pixel size. + private static UiLength FillBox(UiLength edge) => + edge.ParentFraction is null ? edge : UiLength.OfParent(1, edge.Basis); + + private static UiModifier Square(string key, Func edge, UiElement child) => new() { Key = key, @@ -506,13 +542,7 @@ private static UiModifier Glyph(string key, string path, string color, Func { - Glyph("glyph", DeviceGlyphs.For(row.Kind), color, () => UiLength.OfBasis(ListGlyph)), + DeviceIcon("glyph", row, color, () => UiLength.OfBasis(ListGlyph)), new UiStack { Key = "namegroup", diff --git a/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs b/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs index f6e5f66..0d17d41 100644 --- a/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs +++ b/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs @@ -41,7 +41,9 @@ private static string Phone() => private static string Tablet() => new GlyphPath() .RoundedRect(0.14, 0.1, 0.72, 0.8, 0.09) - .RoundedRect(0.2, 0.16, 0.6, 0.68, 0.04, hole: true) + .RoundedRect(0.2, 0.17, 0.6, 0.66, 0.04, hole: true) + .Circle(0.5, 0.135, 0.017, hole: true) + .RoundedRect(0.4, 0.765, 0.2, 0.035, 0.0175) .ToString(); private static string Laptop() => diff --git a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs index 8454f2a..1a9284c 100644 --- a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs @@ -114,7 +114,7 @@ public void Empty_model_still_builds() [TestCase(BatteryWidgetTypes.TileId)] public void Config_view_builds(string widgetId) { - UiOption[] devices = [UiOption.Of("system") with { Label = "This PC" }]; + WidgetDevice[] devices = [new("system", "system", "This PC", IsOwn: true)]; Assert.DoesNotThrow( () => diff --git a/tests/DeviceBatteryInfo.Tests/ExternalBatteryDevicesTests.cs b/tests/DeviceBatteryInfo.Tests/ExternalBatteryDevicesTests.cs index 95b629c..4476921 100644 --- a/tests/DeviceBatteryInfo.Tests/ExternalBatteryDevicesTests.cs +++ b/tests/DeviceBatteryInfo.Tests/ExternalBatteryDevicesTests.cs @@ -16,7 +16,7 @@ namespace DeviceBatteryInfo.Tests; [TestFixture] public sealed class ExternalBatteryDevicesTests { - private const string PhoneId = "com.example.phones:pixel"; + internal const string PhoneId = "com.example.phones:pixel"; private static readonly Guid OwnEntry = Guid.Parse("5d3c8f2a-1b7e-4c90-a6d4-93e0f1b2c7a8"); private static PluginTestHarness CreateHarness() => @@ -59,7 +59,7 @@ private static BatteryDeviceSnapshot Snapshot( UpdatedAt = DateTimeOffset.UnixEpoch, }; - private static async Task InitializedWithCatalogAsync() + internal static async Task InitializedWithCatalogAsync() { var harness = CreateHarness(); await harness.InitializeIntegrationsAsync(); @@ -90,7 +90,7 @@ private static async Task InitializedWithCatalogAsync() return harness; } - private static BatteryIntegration Integration(PluginTestHarness harness) => + internal static BatteryIntegration Integration(PluginTestHarness harness) => harness.Services.GetRequiredService(); [Test] @@ -98,11 +98,11 @@ public async Task The_picker_lists_other_providers_devices_with_their_provider_b { await using var harness = await InitializedWithCatalogAsync(); - var choices = Integration(harness).DeviceChoices(); + var choices = Integration(harness).WidgetDevices(); using (Assert.EnterMultipleScope()) { - Assert.That(choices.Select(c => c.Value), Is.EqualTo(new[] { "mouse", PhoneId })); + Assert.That(choices.Select(c => c.SourceId), Is.EqualTo(new[] { "mouse", PhoneId })); Assert.That( choices[1].Label.ToString(), Does.Contain("Widgets.Config.Devices.External") diff --git a/tests/DeviceBatteryInfo.Tests/WidgetIconTests.cs b/tests/DeviceBatteryInfo.Tests/WidgetIconTests.cs new file mode 100644 index 0000000..004f01f --- /dev/null +++ b/tests/DeviceBatteryInfo.Tests/WidgetIconTests.cs @@ -0,0 +1,333 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using DeviceBatteryInfo.Core; +using DeviceBatteryInfo.HostBatteries; +using DeviceBatteryInfo.Ui; +using MacroDeck.Plugin.Testing; +using MacroDeck.Sdk.Ui; +using MacroDeck.Ui.Config; +using MacroDeck.Ui.Model.Events; +using MacroDeck.Ui.Model.Resources; +using MacroDeck.Ui.Model.Serialization; +using MacroDeck.Ui.Model.Surfaces; +using MacroDeck.Ui.Model.Versioning; +using MacroDeck.Ui.Runtime; +using NUnit.Framework; + +namespace DeviceBatteryInfo.Tests; + +[TestFixture] +public sealed class WidgetIconTests +{ + private static readonly Guid IconId = Guid.Parse("0f8a3c52-6d1e-4b7a-9c20-5e4f7a1b2c3d"); + + private static readonly byte[] OnePixelPng = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + ); + + private static readonly UiResource Resource = new() + { + ResourceId = $"app.macro-deck.plugin-icon.{IconId:D}", + ContentHash = "sha256-test", + MediaType = "image/png", + }; + + private static BatteryWidgetOptions Parse(string json) => + BatteryIntegration.ParseOptions(JsonDocument.Parse(json).RootElement); + + [Test] + public void Stored_icons_are_read_as_references_or_bare_ids_and_anything_else_is_ignored() + { + var options = Parse( + $$$""" + {"icons":{ + "mouse":{"type":"icon-pack","reference":"{{{IconId}}}"}, + "phone":"{{{IconId}}}", + "tablet":{"type":"plugin-icon","reference":"pack/name"}, + "pen":{"type":"icon-pack","reference":"not-a-guid"}, + "watch":null + }} + """ + ); + + using (Assert.EnterMultipleScope()) + { + Assert.That(options.IconFor("mouse"), Is.EqualTo(IconId)); + Assert.That(options.IconFor("phone"), Is.EqualTo(IconId)); + Assert.That(options.IconFor("tablet"), Is.Null); + Assert.That(options.IconFor("pen"), Is.Null); + Assert.That(options.IconFor("watch"), Is.Null); + Assert.That(Parse("{}").IconFor("mouse"), Is.Null); + } + } + + [TestCase(BatteryWidgetTypes.PanelId, BatteryWidgetOptions.LayoutRings)] + [TestCase(BatteryWidgetTypes.PanelId, BatteryWidgetOptions.LayoutList)] + [TestCase(BatteryWidgetTypes.TileId, BatteryWidgetOptions.LayoutRings)] + public void A_row_with_an_icon_draws_the_image_in_its_own_colours_instead_of_the_glyph( + string widgetId, + string layout + ) + { + var options = BatteryWidgetOptions.Default with { Layout = BatteryWidgetOptions.ParseLayout(layout) }; + var withIcon = Nodes(Tree(widgetId, Row() with { Icon = Resource }, options)).ToArray(); + var withoutIcon = Nodes(Tree(widgetId, Row(), options)).ToArray(); + + var images = withIcon.Where(n => Type(n) == "ui.image").ToArray(); + using (Assert.EnterMultipleScope()) + { + Assert.That(images, Is.Not.Empty); + Assert.That( + images.Select(n => n["properties"]?["source"]?["resourceId"]?.GetValue()), + Is.All.EqualTo(Resource.ResourceId) + ); + Assert.That(images.Select(n => n["properties"]?["tint"]), Is.All.Null); + Assert.That(images.Select(n => n["properties"]?["size"]), Is.All.Not.Null); + Assert.That(withIcon.Where(IsGlyphShape), Is.Empty); + Assert.That(withoutIcon.Where(n => Type(n) == "ui.image"), Is.Empty); + Assert.That(withoutIcon.Where(IsGlyphShape), Is.Not.Empty); + } + } + + private static readonly WidgetDevice[] FormDevices = + [ + new("mouse", "mouse", "Mouse", IsOwn: true), + new("headset", "headset", "Headset", IsOwn: true), + new(ExternalBatteryDevicesTests.PhoneId, "ext-0123456789abcdef", "Pixel 9 - Phones", IsOwn: false), + ]; + + [Test] + public void The_icons_tab_offers_a_picker_for_each_shown_device_in_the_icons_object() + { + var options = BatteryWidgetOptions.Default with + { + Icons = new Dictionary { ["mouse"] = IconId }, + }; + + var pickers = Pickers(options); + + using (Assert.EnterMultipleScope()) + { + Assert.That(pickers.Select(Id), Is.EqualTo(new[] { "icons.mouse", "icons.headset" })); + Assert.That(pickers[0].ToJsonString(), Does.Contain(IconId.ToString())); + Assert.That(pickers[1].ToJsonString(), Does.Not.Contain(IconId.ToString())); + } + } + + [Test] + public void The_icons_tab_follows_the_picked_devices_and_their_order() + { + var options = BatteryWidgetOptions.Default with + { + SourceIds = [ExternalBatteryDevicesTests.PhoneId, "mouse"], + }; + + Assert.That( + Pickers(options).Select(Id), + Is.EqualTo(new[] { "icons.ext-0123456789abcdef", "icons.mouse" }) + ); + } + + [Test] + public async Task Picked_icons_reach_this_plugins_and_other_providers_rows() + { + await using var harness = await ExternalBatteryDevicesTests.InitializedWithCatalogAsync(); + var phoneKey = ExternalBatteryDevices.RowKey(ExternalBatteryDevicesTests.PhoneId); + var integration = ExternalBatteryDevicesTests.Integration(harness); + var options = BatteryWidgetOptions.Default with + { + SourceIds = ["mouse", ExternalBatteryDevicesTests.PhoneId], + Icons = new Dictionary { [phoneKey] = IconId }, + }; + + var model = integration.BuildModel( + options, + new Dictionary { [IconId] = Resource } + ); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + integration.WidgetDevices().Select(d => d.IconKey), + Is.EqualTo(new[] { "mouse", phoneKey }) + ); + Assert.That(model.Rows.Select(r => r.Icon), Is.EqualTo(new[] { null, Resource })); + } + } + + [Test] + public async Task A_widget_session_loads_its_icons_from_the_users_icon_packs() + { + await using var harness = await ExternalBatteryDevicesTests.InitializedWithCatalogAsync(); + var resource = harness.Context.UiResources.AddIcon(IconId, OnePixelPng, "image/png"); + + await using var session = await OpenWidgetAsync(harness, IconId); + + Assert.That(await EventuallyContainsAsync(session, resource.ResourceId), Is.True); + } + + [Test] + public async Task An_icon_that_is_gone_leaves_the_built_in_glyph() + { + await using var harness = await ExternalBatteryDevicesTests.InitializedWithCatalogAsync(); + + await using var session = await OpenWidgetAsync(harness, IconId); + await Task.Delay(100); + + var nodes = Nodes(Parse(session)).ToArray(); + using (Assert.EnterMultipleScope()) + { + Assert.That(nodes.Where(n => Type(n) == "ui.image"), Is.Empty); + Assert.That(nodes.Where(IsGlyphShape), Is.Not.Empty); + } + } + + [Test] + public async Task A_rate_limited_icon_lookup_is_retried() + { + var calls = 0; + + var result = await HostCallRetry.RunAsync( + () => + ++calls < 3 + ? Task.FromException( + new UiResourceException(UiResourceErrorCode.RateLimited, "slow down") + ) + : Task.FromResult(calls), + CancellationToken.None + ); + + Assert.That(result, Is.EqualTo(3)); + } + + private static async Task OpenWidgetAsync(PluginTestHarness harness, Guid iconId) + { + var data = JsonSerializer.SerializeToElement( + new + { + icons = new Dictionary + { + ["mouse"] = new { type = "icon-pack", reference = iconId.ToString() }, + }, + } + ); + var session = await ExternalBatteryDevicesTests + .Integration(harness) + .CreateSessionAsync( + new UiSessionRequest + { + UiModelVersion = UiModelVersions.Current, + Surface = new UiSurface + { + Kind = UiSurfaceKinds.Widget, + SessionMode = UiSessionModes.Shared, + Attributes = new Dictionary + { + [UiWidgetSurfaceAttributes.WidgetType] = + JsonSerializer.SerializeToElement(BatteryWidgetTypes.PanelId), + [UiWidgetSurfaceAttributes.Data] = data, + }, + }, + }, + CancellationToken.None + ); + Assert.That(session, Is.Not.Null); + return session!; + } + + private static async Task EventuallyContainsAsync(IUiSession session, string text) + { + for (var attempt = 0; attempt < 100; attempt++) + { + if (Parse(session).ToJsonString().Contains(text, StringComparison.Ordinal)) + { + return true; + } + + await Task.Delay(20); + } + + return false; + } + + private static BatteryWidgetRow Row() => + new( + "mouse", + "Mouse", + 72, + BatteryStatus.Discharging, + false, + Stale: false, + TimeToFull: null, + Kind: BatterySourceKind.Mouse + ); + + private static JsonNode Parse(IUiSession session) => + JsonNode.Parse(UiCanonicalJson.Serialize(session.BuildTree().Root))!; + + private static JsonNode Tree(string widgetId, BatteryWidgetRow row, BatteryWidgetOptions options) + { + var state = new UiState(new BatteryWidgetModel([row], options)); + var view = new UiView( + new UiSurface + { + Kind = UiSurfaceKinds.Widget, + SessionMode = UiSessionModes.Shared, + Attributes = new Dictionary(), + }, + BatteryWidgetView.Build(widgetId, state, 16) + ); + return JsonNode.Parse(UiCanonicalJson.Serialize(view.Tree.Root))!; + } + + [Test] + public async Task Ticking_a_device_updates_the_icons_tab_live() + { + var view = ConfigView(BatteryWidgetOptions.Default); + _ = view.Tree; + + view.Dispatch( + new UiEvent + { + NodeId = "sourceIds", + Name = UiConfigEvents.Change, + Data = JsonSerializer.SerializeToElement(new[] { "headset" }), + } + ); + await view.WhenIdleAsync(); + + Assert.That( + Nodes(JsonNode.Parse(UiCanonicalJson.Serialize(view.Tree.Root))!) + .Where(n => Type(n) == "icon") + .Select(Id), + Is.EqualTo(new[] { "icons.headset" }) + ); + } + + private static JsonNode[] Pickers(BatteryWidgetOptions options) => + Nodes(ConfigTree(options)).Where(n => Type(n) == "icon").ToArray(); + + private static JsonNode ConfigTree(BatteryWidgetOptions options) => + JsonNode.Parse(UiCanonicalJson.Serialize(ConfigView(options).Tree.Root))!; + + private static UiView ConfigView(BatteryWidgetOptions options) => + new( + new UiSurface + { + Kind = UiSurfaceKinds.Config, + SessionMode = UiSessionModes.Exclusive, + Attributes = new Dictionary(), + }, + BatteryWidgetConfigView.Build(BatteryWidgetTypes.PanelId, options, FormDevices) + ); + + private static IEnumerable Nodes(JsonNode node) => + new[] { node }.Concat((node["children"]?.AsArray() ?? []).SelectMany(child => Nodes(child!))); + + private static string Id(JsonNode node) => node["id"]!.GetValue(); + + private static string? Type(JsonNode node) => node["type"]?.GetValue(); + + private static bool IsGlyphShape(JsonNode node) => + Id(node).EndsWith("glyph-shape", StringComparison.Ordinal); +}