Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.<row id> = { "type": "icon-pack", "reference": "<icon id>" }`;
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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 92 additions & 16 deletions src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Guid, UiResource>? icons = null;
BatteryWidgetModel Compute() => BuildModel(options, icons);

var initial = Compute();
_logger.Information(
Expand All @@ -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;
Expand Down Expand Up @@ -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<Guid, UiResource>? icons = null
)
{
var own = CurrentSlots();
IReadOnlyList<BatteryWidgetRow> rows;
Expand All @@ -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<IReadOnlyDictionary<Guid, UiResource>> ResolveIconsAsync(
BatteryWidgetOptions options,
CancellationToken cancellationToken
)
{
var resolved = new Dictionary<Guid, UiResource>();
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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -380,7 +455,8 @@ bool Flag(string key, bool fallback) =>
namePosition,
ringColor,
Flag("lowInRed", true),
thresholds
thresholds,
icons
);
}

Expand Down
13 changes: 9 additions & 4 deletions src/DeviceBatteryInfo/Core/HostCallRetry.cs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
)
);
}
6 changes: 6 additions & 0 deletions src/DeviceBatteryInfo/Localization/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@
<data name="Widgets.Config.Tabs.Devices" xml:space="preserve">
<value>Devices</value>
</data>
<data name="Widgets.Config.Tabs.Icons" xml:space="preserve">
<value>Icons</value>
</data>
<data name="Widgets.Config.Tabs.Appearance" xml:space="preserve">
<value>Appearance</value>
</data>
Expand All @@ -138,6 +141,9 @@
<value>{device} - {provider}</value>
<comment>A device another plugin reports to Macro Deck, followed by that plugin's name.</comment>
</data>
<data name="Widgets.Config.Icons.Description" xml:space="preserve">
<value>Optional. Pick an icon from your icon packs to show instead of the built-in symbol.</value>
</data>
<data name="Widgets.Config.Title.Label" xml:space="preserve">
<value>Heading</value>
</data>
Expand Down
Loading
Loading