diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 62444db..ea0505e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -92,7 +92,7 @@ jobs:
with:
version: ${{ needs.release.outputs.tag }}
source: src/DeviceBatteryInfo
- cli-version: 3.0.0-beta.12
+ cli-version: 3.0.0-beta.15
changelog: ${{ needs.release.outputs.changelog }}
# The release job above already ran the tests on windows-latest
run-tests: false
diff --git a/AGENTS.md b/AGENTS.md
index 4d13288..44fa681 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,7 +38,8 @@ src/DeviceBatteryInfo/
Core/IDeviceDiscovery.cs public: lists present Bluetooth devices and attached Android phones for the
config-flow pickers (HID
enumeration is kept for a future "scan for supported devices" step)
- Ui/ widget rendering: BatteryWidgetView (deck tree), BatteryWidgetConfigView
+ Ui/ widget rendering: BatteryWidgetView (deck tree), DeviceGlyphs (one vector
+ icon per BatterySourceKind, plus the charging bolt), BatteryWidgetConfigView
(config form), UiViewSession (UiView -> IUiSession adapter),
BatteryWidgetModel, BatteryWidgetTypes (descriptors + JSON Schema),
BatteryWidgetSamples (fixed demo models shared by the widget "sample"
@@ -112,27 +113,70 @@ 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
+ 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
+ switch gets its own line and only small segmented controls share a `UiConfigStack` row
+ (`Wrap = false`, `RowWeight = 1` each). Two segmented controls in one row must both be icon-only or
+ both text: the host pads a strip of icon-only options differently, so a mixed pair never lines up. Keep labels and option names short enough not to truncate at that width. Tabs and rows
+ only change rendering: the values stay in the session's `UiState`, and a field's node id stays its bare
+ key, which is what a `VisibleWhen` resolves against (only an object or array input starts a scope).
+ `BatteryWidgetViewTests` checks every condition names a field in the built tree.
**Every `UiLength` is a fraction of the view basis, not a pixel** - a bar needs both `MainSize`
(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, so state is colour + a caption.
- `BatteryWidgetView` sizes the way the host's own Weather widget does: small type, one restrained
- emphasis per row (the percentage, semibold, in the device colour), `UiSize.FromBasis(fraction)` of
- the basis with a `maxOfCross` only as a safety rail for a wide, short widget. An absolute pixel
- ceiling freezes every size a hair above a 1x1 tile and flattens the hierarchy (title, name and
- percent all render the same size), so size relative to the basis instead. Each `Row` hugs its
- content (headline + bar tight together) and the `Fill` body centres the row list with a fixed
- inter-row gap; making the row or its `headline` `Fill` opens slack between the text and its bar and
- reads as top-aligned text, so keep them content-sized. The tile percentage is the one deliberately
- large, bold reading (a tile is one device). A progress bar's `StartColor` and `EndColor` are always
- the same hex - the renderer always paints a `linear-gradient`, and a two-colour battery bar just
- muddies the reading.
-- **A widget press refreshes the batteries, and the host will not run user-bound flows for a plugin
- widget.** `ExecuteActionButtonTriggerRequestMessageHandler` returns "nothing to do" for any widget whose
- type is not built in, so an actions-list editor bound to `flows` saves fine and never fires. A tree that
- declares a `press` event also owns the gesture (`treeClaimsGesture`), so the host skips the tile's own
- triggers. `BatteryWidgetView.Build` therefore takes an optional `onPress`, passed only for a live widget
- session (never for the sample or the previews), which calls `BatteryPollingService.RequestRefresh`.
+ `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
+ 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
+ colour (`BatteryWidgetRow.Color` for the widget's `colors` scheme, all Apple system colours so every
+ scheme has the same saturation; every scheme shows a level at or below the threshold in red even
+ while charging and a stale or unknown one in grey) and the percentage uses the primary text role. A ring is a full-turn `UiGauge`
+ inside a `UiModifier` with `Frame.AspectRatio = 1` and a `UiLayer` for the gauge, the bolt and the
+ face; the gauge is inset by half the bolt's height minus half its stroke, which puts a charging
+ bolt exactly in the gap the gauge leaves at the top (`StartAngle`/`EndAngle`, 0 is up, clockwise).
+ The face (glyph over percentage) must fit the gauge's inner circle, radius about 0.35 of the
+ diameter: the corners of the percentage line are what collide, so check them, not just the height.
+ Every `UiLength` is a fraction of the whole widget's basis, never of a grid cell, so the ring panel
+ estimates its ring diameter (`BatteryWidgetView.Arrange`: the column count that gives the largest
+ ring for the device count and aspect) and sizes each ring's parts reactively from that; a
+ `UiResponsive` picks the aspect bucket. The view builder rejects `Fill`/`MainSize` on a responsive
+ variant's root and on a modifier's child, and `BatteryWidgetViewTests` builds every layout through
+ a real `UiView` to catch that. The list layout sizes the way the host's own Weather widget does:
+ small type, `UiSize.FromBasis(fraction)` with a `maxOfCross` only as a safety rail; each row hugs
+ its content and the `Fill` body centres the rows. Next to a `Fill` sibling a text's measured width
+ is underestimated, so the list percentage has a fixed `MainSize`, sized to its own text so a
+ charging bolt sits beside the number; that width is estimated with `TextWidth` (four character
+ classes fitted to SF Pro Semibold, erring wide). The plugin never sees pixels or fonts, so wherever a
+ layout depends on whether text fits, let the reader measure with `UiFirstFit` (Macro Deck PR 1139)
+ instead of estimating.
+ The host's facts behind that, read from its renderer: the basis is `min(width, height)` of the
+ widget, a `UiResponsive` matches variants against the box its parent gives it (`MinAspect`, or
+ `MinWidth`/`MinHeight` in 120 px cells; min inclusive, max exclusive) and takes the **first** match,
+ so variants must not overlap (`Responsive_variants_never_overlap` checks every built tree), and a
+ text with `MinSize` shrinks toward it to fit its box
+ before it truncates. Texts in one row have no shrink priority, so a caption beside the name
+ truncates both when it does not fit: the list body is a `UiFirstFit` with the rows inline (caption
+ beside the name) first and stacked last. A shrunk text counts as fitting, so the inline layout's
+ 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 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
+ `refresh` action (`RefreshBatteryAction.ActionId`). The host runs that default only while the
+ widget's Short Press flow is missing, empty or fully disabled, so the user's action always wins and
+ a Long Press flow works beside the default refresh. The tree must declare no `press` event: one owns
+ the gesture (`treeClaimsGesture`), and the host then skips both the flows and the default. A host
+ older than beta.15 ignores the default, so a press there does nothing. `SupportsFlows` alone shows no action editor:
+ the host runs the flows under the widget data's top-level `flows` key, so `BatteryWidgetConfigView`
+ serves a `UiActionsListEditor` bound to `flows` in the `UiWidgetConfiguration.Editor` region (seeded
+ from the stored data, and allowed by the `DataSchema`, whose `additionalProperties: false` would
+ otherwise reject it). Without an `Editor` region the desktop draws the properties as one full-width
+ pane, which looks stretched.
- **Widget previews:** `Ui/BatteryWidgetPreviews.cs` has one `static` parameterless method per
scenario, each `[UiPreview(name, View = nameof(BatteryWidgetView), Profile = UiPreviewProfiles.Widget)]`
returning a `UiElement`. `UiPreviewCatalog.Scan` (run by the hosting `ui` capability over the
@@ -153,7 +197,10 @@ Design knowledge that is not obvious from the code alone:
and `PercentPerHour` into a signed rate for automations. History is in-memory only and is lost on
every plugin restart or update by design - it self-heals within `MinWindow`, which is simpler than
persisting it under `MACRO_DECK_PLUGIN_DATA_DIRECTORY`. The widget caption falls back to the trend
- text when there is no time-to-full to show (most sources never report one), and both the `trend`
+ text when there is no time-to-full to show (most sources never report one). The rings layout has no
+ caption; its own `showRingTrend` flag (default off) adds a muted trend line under each ring, below
+ the name, and `Arrange` reserves room for it. It is a separate key rather than `showTrend` because
+ released widgets already store `showTrend: true`, which would shrink every existing ring. Both the `trend`
and `trend-rate` variable suffixes are public API like every other field suffix in
`BatteryVariableCatalog`.
- **Bluetooth battery data lives on a different PnP node than the one the user picks, and is only
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 16b6814..74aeb13 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,12 +4,12 @@
true
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/Makefile b/Makefile
index 3e28738..ddb6c97 100644
--- a/Makefile
+++ b/Makefile
@@ -2,14 +2,24 @@ PROJECT := src/DeviceBatteryInfo
MANIFEST := $(PROJECT)/manifest.json
STATE := $(PROJECT)/.macrodeck-dev-state
-RUN := macrodeck-plugin run --project $(PROJECT) --state-directory $(STATE)
+
+UTF8 := $(if $(filter Windows_NT,$(OS)),chcp.com 65001 >/dev/null &&)
+RUN := $(UTF8) macrodeck-plugin run --project $(PROJECT) --state-directory $(STATE)
+
# The SDK version, for keeping the macrodeck-plugin CLI in step. Read inside recipes rather than with
# $(shell): GnuWin32's make 3.81 sometimes runs $(shell) with an empty command line.
SDK := grep -o 'MacroDeck.Sdk" Version="[^"]*' Directory.Packages.props | cut -d'"' -f3
TESTS := dotnet test DeviceBatteryInfo.slnx --configuration Release --filter "Category!=Hardware"
+RID := $(if $(filter Windows_NT,$(OS)),win-x64,osx-arm64)
+
+# Store images: every [UiPreview] scenario at each deck shape. Override on the command line,
+# e.g. make preview CELLS="--cells 2x2" PREVIEW_ARGS="--theme light".
+CELLS := --cells 1x1 --cells 2x1 --cells 2x2
+PREVIEWS := artifacts/previews
+
.DEFAULT_GOAL := help
-.PHONY: help cli build test test-hardware run watch stub pack conformance update release
+.PHONY: help cli build test test-hardware run watch stub preview pack conformance update release
help:
@echo "make cli install/update the macrodeck-plugin CLI to the SDK version ($$($(SDK)))"
@@ -19,7 +29,8 @@ help:
@echo "make run run the plugin against the running Macro Deck"
@echo "make watch the same, with hot reload / restart on every saved change"
@echo "make stub run the plugin against a disposable stub host (no Macro Deck needed)"
- @echo "make pack build the .macroDeckPlugin into artifacts/ and inspect it"
+ @echo "make preview render the widget previews to PNGs in $(PREVIEWS)/ (store images)"
+ @echo "make pack build this platform's .macroDeckPlugin ($(RID)) into artifacts/ and inspect it"
@echo "make conformance run the conformance suite, report in conformance.md"
@echo "make update bump every package to its newest release (review the diff)"
@echo "make release VERSION=x.y.z"
@@ -44,11 +55,15 @@ watch:
$(RUN) --watch
stub:
- macrodeck-plugin run --project $(PROJECT) --stub-host
+ $(UTF8) macrodeck-plugin run --project $(PROJECT) --stub-host
+
+preview:
+ rm -rf $(PREVIEWS)
+ $(UTF8) macrodeck-plugin preview render --project $(PROJECT) $(CELLS) --output $(PREVIEWS) $(PREVIEW_ARGS)
pack:
rm -f artifacts/*.macroDeckPlugin
- macrodeck-plugin build --source $(PROJECT) --output ./artifacts
+ macrodeck-plugin build --source $(PROJECT) --rid $(RID) --output ./artifacts
macrodeck-plugin inspect --artifact "$$(ls artifacts/*.macroDeckPlugin)"
conformance:
diff --git a/README.md b/README.md
index fcf2ab6..6ce98d4 100644
--- a/README.md
+++ b/README.md
@@ -18,10 +18,18 @@ peripherals, right on your deck.
## Features
**Two deck widgets**, each with a config form to choose which devices it shows and what it displays
-(level bar, percentage, charging indicator, time to full, battery trend, low-battery threshold):
-
-- **Battery panel** shows several devices at once.
-- **Battery tile** shows a single device. Pressing a widget refreshes the levels immediately.
+(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. The colour scheme is a widget setting: by level with cyan while charging (the default), by
+level only, green only, by device type, or a smooth gradient. In every scheme a level at or below the
+low-battery threshold is red, even while charging:
+
+- **Battery panel** shows several devices at once, as a grid of rings that arranges itself to the
+ widget's size (optionally with names), or as a list of rows with bars aligned to the top, centre or
+ bottom.
+- **Battery tile** shows a single device as one large ring, with name, level and state beside it on a
+ wide tile. A short press refreshes the levels unless you give the widget a Short Press action of
+ your own; actions on other triggers (such as a long press) run alongside it like on any other widget.
Widgets update live between polls. The battery trend is shown as a signed change over the window it
covers, for example `-13%/1h` while discharging or `+28%/30m` while charging. It needs a couple of
@@ -152,7 +160,9 @@ Build and tests need no Macro Deck installation.
The [Makefile](Makefile) wraps the everyday commands (`make` lists them): `make run` / `make watch`
launch the plugin against the running Macro Deck through `macrodeck-plugin run` (pairing once, the
credential kept in `src/DeviceBatteryInfo/.macrodeck-dev-state/`), `make stub` against a stub host,
-`make cli` keeps the CLI at the SDK's version, `make pack` builds and inspects the artifact, and
+`make preview` renders the widget previews to PNGs in `artifacts/previews/` (the store images),
+`make cli` keeps the CLI at the SDK's version, `make pack` builds and inspects the artifact for this
+machine's platform (`win-x64` on Windows, `osx-arm64` otherwise; the release workflow builds both), and
`make release VERSION=x.y.z` tests and packs, bumps `manifest.json`, commits, tags `vx.y.z` and pushes -
the tag starts the release workflow. On Windows it needs GNU make and Git Bash's `sh` on `PATH`.
diff --git a/src/DeviceBatteryInfo/Actions/RefreshBatteryAction.cs b/src/DeviceBatteryInfo/Actions/RefreshBatteryAction.cs
index 41403d5..294321d 100644
--- a/src/DeviceBatteryInfo/Actions/RefreshBatteryAction.cs
+++ b/src/DeviceBatteryInfo/Actions/RefreshBatteryAction.cs
@@ -9,7 +9,9 @@ internal sealed class RefreshBatteryAction(BatteryPollingService polling) : IAct
{
private readonly BatteryPollingService _polling = polling;
- public string Id => "refresh";
+ public const string ActionId = "refresh";
+
+ public string Id => ActionId;
public LocalizedText Name => Strings.Actions.Refresh.Name();
diff --git a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs
index 70fb986..27b03b0 100644
--- a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs
+++ b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs
@@ -95,7 +95,8 @@ private static string Preview(JsonElement value)
);
}
- var options = ParseOptions(AttributeJson(surface, UiWidgetSurfaceAttributes.Data));
+ var data = AttributeJson(surface, UiWidgetSurfaceAttributes.Data);
+ var options = ParseOptions(data);
BatteryWidgetModel Compute() => BuildModel(options);
var initial = Compute();
@@ -107,10 +108,7 @@ private static string Preview(JsonElement value)
);
var state = new UiState(initial);
- var view = new UiView(
- surface,
- BatteryWidgetView.Build(localId, state, cornerRadius, _polling.RequestRefresh)
- );
+ var view = new UiView(surface, BatteryWidgetView.Build(localId, state, cornerRadius));
void Refresh() => state.Set(Compute());
void OnRegistryChanged(object? sender, BatterySnapshotChangedEventArgs e) => Refresh();
@@ -141,13 +139,22 @@ private static string Preview(JsonElement value)
}
var widgetType = Attribute(surface, UiConfigSurfaceAttributes.WidgetType) ?? string.Empty;
- if (LocalWidgetId(widgetType) is null)
+ if (LocalWidgetId(widgetType) is not { } localId)
{
return null;
}
- var options = ParseOptions(AttributeJson(surface, UiConfigSurfaceAttributes.WidgetData));
- var view = new UiView(surface, BatteryWidgetConfigView.Build(options, CurrentSlots()));
+ var data = AttributeJson(surface, UiConfigSurfaceAttributes.WidgetData);
+ var options = ParseOptions(data);
+ var view = new UiView(
+ surface,
+ BatteryWidgetConfigView.Build(
+ localId,
+ options,
+ CurrentSlots(),
+ BatteryWidgetTypes.StoredFlows(data)
+ )
+ );
return new UiViewSession(view);
}
@@ -167,7 +174,8 @@ private BatteryWidgetModel BuildModel(BatteryWidgetOptions options)
reading.IsCharging,
snapshot.IsStale,
FormatTimeToFull(reading.TimeToFull),
- BatteryTrendFormatter.FormatText(_trend.GetTrend(snapshot))
+ BatteryTrendFormatter.FormatText(_trend.GetTrend(snapshot)),
+ slot.Kind
);
}
@@ -178,7 +186,8 @@ private BatteryWidgetModel BuildModel(BatteryWidgetOptions options)
BatteryStatus.Unknown,
false,
true,
- null
+ null,
+ Kind: slot.Kind
);
})
.ToArray();
@@ -258,6 +267,24 @@ bool Flag(string key, bool fallback) =>
? titleValue.GetString() ?? string.Empty
: string.Empty;
+ var layout =
+ obj.TryGetProperty("layout", out var layoutValue)
+ && layoutValue.ValueKind == JsonValueKind.String
+ ? BatteryWidgetOptions.ParseLayout(layoutValue.GetString())
+ : BatteryWidgetLayout.Rings;
+
+ var colors =
+ obj.TryGetProperty("colors", out var colorsValue)
+ && colorsValue.ValueKind == JsonValueKind.String
+ ? BatteryWidgetOptions.ParseColors(colorsValue.GetString())
+ : BatteryWidgetOptions.Default.Colors;
+
+ var listAlign =
+ obj.TryGetProperty("listAlign", out var listAlignValue)
+ && listAlignValue.ValueKind == JsonValueKind.String
+ ? BatteryWidgetOptions.ParseListAlign(listAlignValue.GetString())
+ : BatteryWidgetOptions.Default.ListAlign;
+
return new BatteryWidgetOptions(
sourceIds,
Flag("showBar", true),
@@ -267,7 +294,12 @@ bool Flag(string key, bool fallback) =>
Flag("showTrend", true),
low,
sort,
- title
+ title,
+ layout,
+ Flag("showNames", false),
+ colors,
+ listAlign,
+ Flag("showRingTrend", false)
);
}
diff --git a/src/DeviceBatteryInfo/Localization/Strings.resx b/src/DeviceBatteryInfo/Localization/Strings.resx
index 7d965e5..37eb075 100644
--- a/src/DeviceBatteryInfo/Localization/Strings.resx
+++ b/src/DeviceBatteryInfo/Localization/Strings.resx
@@ -122,6 +122,15 @@
no signal
+
+ Devices
+
+
+ Appearance
+
+
+ Show
+
Devices
@@ -129,10 +138,10 @@
Heading
- Shown above the rows. Leave empty for no heading.
+ Shown above the devices. Leave empty for no heading.
- Row order
+ Order
As listed above
@@ -147,22 +156,64 @@
Charging first
- Show level bar
+ Level ring or bar
+
+
+ Layout
+
+
+ Rings
+
+
+ List
+
+
+ Colors
+
+
+ Level, cyan when charging
+
+
+ Level
+
+
+ Green and red
+
+
+ Device type
+
+
+ Gradient
+
+
+ Row alignment
+
+
+ Top
+
+
+ Center
+
+
+ Bottom
+
+
+ Device names
- Show percentage
+ Percentage
- Show charging indicator
+ Charging indicator
- Show time to full
+ Time to full
- Show battery trend
+ Battery trend
- Low battery threshold (%)
+ Low battery at (%)
diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs
index 14b106e..6060570 100644
--- a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs
+++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs
@@ -1,4 +1,7 @@
+using System.Text.Json;
using DeviceBatteryInfo.Core;
+using MacroDeck.Localization;
+using MacroDeck.Ui.Components;
using MacroDeck.Ui.Config;
using MacroDeck.Ui.Config.Options;
using MacroDeck.Ui.Dsl;
@@ -8,41 +11,221 @@ namespace DeviceBatteryInfo.Ui;
internal static class BatteryWidgetConfigView
{
- public static UiElement Build(BatteryWidgetOptions current, IReadOnlyList devices)
+ public static UiElement Build(
+ string widgetLocalId,
+ BatteryWidgetOptions current,
+ IReadOnlyList devices,
+ JsonElement flows = default
+ )
{
+ var isPanel = widgetLocalId != BatteryWidgetTypes.TileId;
var sourceIds = new UiState>(current.SourceIds);
+ var layout = new UiState(BatteryWidgetOptions.LayoutValue(current.Layout));
+ var showNames = new UiState(current.ShowNames);
var showBar = new UiState(current.ShowBar);
var showPercent = new UiState(current.ShowPercent);
var showCharging = new UiState(current.ShowCharging);
var showTimeToFull = new UiState(current.ShowTimeToFull);
var showTrend = new UiState(current.ShowTrend);
+ var showRingTrend = new UiState(current.ShowRingTrend);
var lowThreshold = new UiState(current.LowThreshold);
var sort = new UiState(BatteryWidgetOptions.SortValue(current.Sort));
var title = new UiState(current.Title);
+ var colors = new UiState(BatteryWidgetOptions.ColorsValue(current.Colors));
+ var listAlign = new UiState(
+ BatteryWidgetOptions.ListAlignValue(current.ListAlign)
+ );
+ // A default JsonElement cannot be serialized into the tree.
+ var flowList = new UiState(
+ flows.ValueKind == JsonValueKind.Array ? flows : EmptyFlows
+ );
- var options = devices
- .Select(d => UiOption.Of(d.Id) with { Label = d.DisplayName })
- .ToArray();
+ // Only the panel has a layout to depend on; the tile shows every field.
+ UiValue OnlyFor(string layoutValue) =>
+ isPanel
+ ? UiValue.Of(
+ new UiVisibleWhen
+ {
+ ParameterName = "layout",
+ Values = [layoutValue],
+ SiblingValue = () => layout.Value,
+ }
+ )
+ : UiValue.None();
- var sortOptions = new[]
+ var content = new List();
+ if (isPanel)
{
- UiOption.Of(BatteryWidgetOptions.SortManual) with
+ content.Add(
+ new UiStringInput
+ {
+ Key = "title",
+ Label = Strings.Widgets.Config.Title.Label(),
+ Description = Strings.Widgets.Config.Title.Description(),
+ Binding = Bind.To(title),
+ }
+ );
+ }
+
+ content.Add(
+ new UiMultiSelectInput
{
- Label = Strings.Widgets.Config.Sort.Manual(),
- },
- UiOption.Of(BatteryWidgetOptions.SortLowestFirst) with
+ Key = "sourceIds",
+ Label = Strings.Widgets.Config.Devices.Label(),
+ Options = UiValue.Of>(
+ devices.Select(d => UiOption.Of(d.Id) with { Label = d.DisplayName }).ToArray()
+ ),
+ Binding = Bind.To(sourceIds),
+ Reorderable = UiValue.Of(true),
+ }
+ );
+ content.Add(
+ new UiChoiceInput
{
- Label = Strings.Widgets.Config.Sort.LowestFirst(),
- },
- UiOption.Of(BatteryWidgetOptions.SortChargingFirst) with
+ Key = "sort",
+ Label = Strings.Widgets.Config.Sort.Label(),
+ Options = Options(
+ (BatteryWidgetOptions.SortManual, Strings.Widgets.Config.Sort.Manual()),
+ (BatteryWidgetOptions.SortLowestFirst, Strings.Widgets.Config.Sort.LowestFirst()),
+ (BatteryWidgetOptions.SortChargingFirst, Strings.Widgets.Config.Sort.ChargingFirst()),
+ (BatteryWidgetOptions.SortAlphabetical, Strings.Widgets.Config.Sort.Alphabetical())
+ ),
+ Binding = Bind.To(sort),
+ }
+ );
+
+ var appearance = new List();
+ if (isPanel)
+ {
+ appearance.Add(
+ Row(
+ "layout-row",
+ new UiChoiceInput
+ {
+ Key = "layout",
+ Label = Strings.Widgets.Config.Layout.Label(),
+ Segmented = true,
+ Options = UiValue.Of>(
+ [
+ IconOption(
+ BatteryWidgetOptions.LayoutRings,
+ Strings.Widgets.Config.Layout.Rings(),
+ UiIcons.Disc
+ ),
+ IconOption(
+ BatteryWidgetOptions.LayoutList,
+ Strings.Widgets.Config.Layout.List(),
+ UiIcons.AlignLeft
+ ),
+ ]
+ ),
+ Binding = Bind.To(layout),
+ RowWeight = 1,
+ },
+ new UiChoiceInput
+ {
+ Key = "listAlign",
+ Label = Strings.Widgets.Config.ListAlign.Label(),
+ Segmented = true,
+ Options = UiValue.Of>(
+ [
+ IconOption(
+ BatteryWidgetOptions.ListAlignTop,
+ Strings.Widgets.Config.ListAlign.Top(),
+ UiIcons.AlignTop
+ ),
+ IconOption(
+ BatteryWidgetOptions.ListAlignCenter,
+ Strings.Widgets.Config.ListAlign.Center(),
+ UiIcons.AlignMiddle
+ ),
+ IconOption(
+ BatteryWidgetOptions.ListAlignBottom,
+ Strings.Widgets.Config.ListAlign.Bottom(),
+ UiIcons.AlignBottom
+ ),
+ ]
+ ),
+ Binding = Bind.To(listAlign),
+ VisibleWhen = OnlyFor(BatteryWidgetOptions.LayoutList),
+ RowWeight = 1,
+ }
+ )
+ );
+ }
+
+ appearance.Add(
+ new UiChoiceInput
{
- Label = Strings.Widgets.Config.Sort.ChargingFirst(),
- },
- UiOption.Of(BatteryWidgetOptions.SortAlphabetical) with
+ Key = "colors",
+ Label = Strings.Widgets.Config.Colors.Label(),
+ Options = Options(
+ (
+ BatteryWidgetOptions.ColorsLevelsCharging,
+ Strings.Widgets.Config.Colors.LevelsCharging()
+ ),
+ (BatteryWidgetOptions.ColorsLevels, Strings.Widgets.Config.Colors.Levels()),
+ (BatteryWidgetOptions.ColorsSimple, Strings.Widgets.Config.Colors.Simple()),
+ (BatteryWidgetOptions.ColorsDevice, Strings.Widgets.Config.Colors.Device()),
+ (BatteryWidgetOptions.ColorsGradient, Strings.Widgets.Config.Colors.Gradient())
+ ),
+ Binding = Bind.To(colors),
+ }
+ );
+ appearance.Add(
+ new UiNumberInput
{
- Label = Strings.Widgets.Config.Sort.Alphabetical(),
- },
- };
+ Key = "lowThreshold",
+ Label = Strings.Widgets.Config.LowThreshold.Label(),
+ Min = 1,
+ Max = 99,
+ Step = 1,
+ ShowSlider = true,
+ Binding = Bind.To(lowThreshold),
+ }
+ );
+
+ appearance.AddRange(
+ [
+ new UiHeading { Key = "show-heading", Text = Strings.Widgets.Config.Show.Heading() },
+ Toggle("showBar", Strings.Widgets.Config.ShowBar.Label(), showBar),
+ Toggle("showPercent", Strings.Widgets.Config.ShowPercent.Label(), showPercent),
+ Toggle("showCharging", Strings.Widgets.Config.ShowCharging.Label(), showCharging),
+ // A caption needs the list; the rings layout has room only for its own short trend line.
+ Toggle(
+ "showTimeToFull",
+ Strings.Widgets.Config.ShowTimeToFull.Label(),
+ showTimeToFull,
+ OnlyFor(BatteryWidgetOptions.LayoutList)
+ ),
+ Toggle(
+ "showTrend",
+ Strings.Widgets.Config.ShowTrend.Label(),
+ showTrend,
+ OnlyFor(BatteryWidgetOptions.LayoutList)
+ ),
+ ]
+ );
+
+ if (isPanel)
+ {
+ appearance.Add(
+ Toggle(
+ "showNames",
+ Strings.Widgets.Config.ShowNames.Label(),
+ showNames,
+ OnlyFor(BatteryWidgetOptions.LayoutRings)
+ )
+ );
+ appearance.Add(
+ Toggle(
+ "showRingTrend",
+ Strings.Widgets.Config.ShowTrend.Label(),
+ showRingTrend,
+ OnlyFor(BatteryWidgetOptions.LayoutRings)
+ )
+ );
+ }
return new UiWidgetConfiguration
{
@@ -52,69 +235,78 @@ public static UiElement Build(BatteryWidgetOptions current, IReadOnlyList>(options),
- Binding = Bind.To(sourceIds),
- Reorderable = UiValue.Of(true),
- },
- new UiChoiceInput
- {
- Key = "sort",
- Label = Strings.Widgets.Config.Sort.Label(),
- Options = UiValue.Of>(sortOptions),
- Binding = Bind.To(sort),
- },
- new UiBooleanInput
+ new UiTabs
{
- Key = "showBar",
- Label = Strings.Widgets.Config.ShowBar.Label(),
- Binding = Bind.To(showBar),
+ Key = "sections",
+ Children =
+ [
+ Tab("content-tab", Strings.Widgets.Config.Tabs.Devices(), content),
+ Tab("appearance-tab", Strings.Widgets.Config.Tabs.Appearance(), appearance),
+ ],
},
- new UiBooleanInput
- {
- Key = "showPercent",
- Label = Strings.Widgets.Config.ShowPercent.Label(),
- Binding = Bind.To(showPercent),
- },
- new UiBooleanInput
- {
- Key = "showCharging",
- Label = Strings.Widgets.Config.ShowCharging.Label(),
- Binding = Bind.To(showCharging),
- },
- new UiBooleanInput
- {
- Key = "showTimeToFull",
- Label = Strings.Widgets.Config.ShowTimeToFull.Label(),
- Binding = Bind.To(showTimeToFull),
- },
- new UiBooleanInput
- {
- Key = "showTrend",
- Label = Strings.Widgets.Config.ShowTrend.Label(),
- Binding = Bind.To(showTrend),
- },
- new UiNumberInput
+ ],
+ },
+ // The host reads flows from the top-level "flows" key, and an editor region is what makes
+ // the desktop draw the split layout.
+ Editor = new UiWidgetEditor
+ {
+ Key = "battery-widget-config-editor",
+ Children =
+ [
+ new UiActionsListEditor
{
- Key = "lowThreshold",
- Label = Strings.Widgets.Config.LowThreshold.Label(),
- Min = 1,
- Max = 99,
- Step = 1,
- Binding = Bind.To(lowThreshold),
+ Key = "flows",
+ Binding = Bind.To(flowList),
+ CanRun = true,
},
],
},
};
}
+
+ private static readonly JsonElement EmptyFlows = JsonDocument.Parse("[]").RootElement.Clone();
+
+ private static UiTab Tab(string key, UiText label, IReadOnlyList children) =>
+ new()
+ {
+ Key = key,
+ Label = label,
+ Children = children,
+ };
+
+ private static UiConfigStack Row(string key, params UiElement[] children) =>
+ new()
+ {
+ Key = key,
+ Direction = UiComponentDirections.Horizontal,
+ Wrap = false,
+ Children = children,
+ };
+
+ private static UiBooleanInput Toggle(
+ string key,
+ UiText label,
+ UiState state,
+ UiValue visibleWhen = default
+ ) =>
+ new()
+ {
+ Key = key,
+ Label = label,
+ Binding = Bind.To(state),
+ VisibleWhen = visibleWhen,
+ };
+
+ private static UiValue> Options(
+ params (string Value, LocalizedText Label)[] options
+ ) =>
+ UiValue.Of>(
+ options.Select(o => UiOption.Of(o.Value) with { Label = o.Label }).ToArray()
+ );
+
+ private static UiOption IconOption(
+ string value,
+ LocalizedText label,
+ string icon
+ ) => UiOption.Of(value) with { Label = label, Icon = icon };
}
diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs
index 59e3cc1..58b3e43 100644
--- a/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs
+++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using DeviceBatteryInfo.Core;
namespace DeviceBatteryInfo.Ui;
@@ -15,34 +16,127 @@ internal sealed record BatteryWidgetRow(
bool Charging,
bool Stale,
string? TimeToFull,
- string? Trend = null
+ string? Trend = null,
+ BatterySourceKind Kind = BatterySourceKind.Other
)
{
- public string Color(int lowThreshold)
+ // Apple's system colours.
+ public const string Green = "#34C759";
+ public const string Yellow = "#FFCC00";
+ public const string Orange = "#FF9500";
+ public const string Red = "#FF3B30";
+ public const string Cyan = "#32ADE6";
+ public const string Grey = "#8E8E93";
+
+ private static readonly Dictionary KindColors = new()
+ {
+ [BatterySourceKind.System] = "#5856D6",
+ [BatterySourceKind.Phone] = "#007AFF",
+ [BatterySourceKind.Tablet] = Cyan,
+ [BatterySourceKind.Mouse] = "#AF52DE",
+ [BatterySourceKind.Keyboard] = Orange,
+ [BatterySourceKind.Headset] = "#FF2D55",
+ [BatterySourceKind.Earbuds] = "#00C7BE",
+ [BatterySourceKind.Controller] = Yellow,
+ [BatterySourceKind.Pen] = "#A2845E",
+ [BatterySourceKind.Other] = Green,
+ };
+
+ public string Color(
+ int lowThreshold,
+ BatteryColorScheme scheme = BatteryColorScheme.LevelsCharging
+ )
{
- if (Stale || Percent is null)
+ if (Stale || Percent is not { } percent)
{
- return "#8A8A8A";
+ return Grey;
}
- if (Charging)
+ if (percent <= lowThreshold)
{
- return "#A97BE0";
+ return Red;
}
- return Percent switch
+ return scheme switch
+ {
+ BatteryColorScheme.Simple => Green,
+ BatteryColorScheme.Levels => StepColor(percent),
+ BatteryColorScheme.Device => KindColors.GetValueOrDefault(Kind, Green),
+ BatteryColorScheme.Gradient => GradientColor(percent, lowThreshold),
+ _ => Charging ? Cyan : StepColor(percent),
+ };
+ }
+
+ private static string StepColor(int percent) =>
+ percent switch
+ {
+ <= 40 => Orange,
+ <= 60 => Yellow,
+ _ => Green,
+ };
+
+ // Red at the threshold to green at full.
+ private static string GradientColor(int percent, int lowThreshold)
+ {
+ var t = Math.Clamp(
+ (percent - lowThreshold) / (double)Math.Max(1, 100 - lowThreshold),
+ 0,
+ 1
+ );
+ return Hsv(4 + (131 * t), 0.78, 0.86);
+ }
+
+ private static string Hsv(double hue, double saturation, double value)
+ {
+ var chroma = value * saturation;
+ var x = chroma * (1 - Math.Abs((hue / 60 % 2) - 1));
+ var m = value - chroma;
+ var (r, g, b) = (hue / 60) switch
{
- <= 0 => "#8A8A8A",
- var p when p <= lowThreshold => "#E5533D",
- < 50 => "#E8A13C",
- < 80 => "#3FB669",
- _ => "#4C9BE8",
+ < 1 => (chroma, x, 0.0),
+ < 2 => (x, chroma, 0.0),
+ _ => (0.0, chroma, x),
};
+ return $"#{Channel(r + m)}{Channel(g + m)}{Channel(b + m)}";
}
+ private static string Channel(double value) =>
+ ((int)Math.Round(value * 255)).ToString("X2", CultureInfo.InvariantCulture);
+
+ public double Level => Percent is { } p ? Math.Clamp(p, 0, 100) / 100.0 : 0;
+
public string PercentText() => Percent is { } p ? $"{p}%" : "--";
}
+internal enum BatteryColorScheme
+{
+ LevelsCharging,
+
+ Levels,
+
+ Simple,
+
+ Device,
+
+ Gradient,
+}
+
+internal enum BatteryWidgetLayout
+{
+ Rings,
+
+ List,
+}
+
+internal enum BatteryListAlignment
+{
+ Top,
+
+ Center,
+
+ Bottom,
+}
+
internal enum BatterySortMode
{
Manual,
@@ -63,7 +157,12 @@ internal sealed record BatteryWidgetOptions(
bool ShowTrend,
int LowThreshold,
BatterySortMode Sort = BatterySortMode.Manual,
- string Title = ""
+ string Title = "",
+ BatteryWidgetLayout Layout = BatteryWidgetLayout.Rings,
+ bool ShowNames = false,
+ BatteryColorScheme Colors = BatteryColorScheme.LevelsCharging,
+ BatteryListAlignment ListAlign = BatteryListAlignment.Top,
+ bool ShowRingTrend = false
)
{
public static readonly BatteryWidgetOptions Default = new(
@@ -81,6 +180,61 @@ internal sealed record BatteryWidgetOptions(
public const string SortAlphabetical = "alphabetical";
public const string SortChargingFirst = "charging-first";
+ public const string LayoutRings = "rings";
+ public const string LayoutList = "list";
+
+ public static BatteryWidgetLayout ParseLayout(string? value) =>
+ value == LayoutList ? BatteryWidgetLayout.List : BatteryWidgetLayout.Rings;
+
+ public static string LayoutValue(BatteryWidgetLayout layout) =>
+ layout == BatteryWidgetLayout.List ? LayoutList : LayoutRings;
+
+ public const string ListAlignTop = "top";
+ public const string ListAlignCenter = "center";
+ public const string ListAlignBottom = "bottom";
+
+ public static BatteryListAlignment ParseListAlign(string? value) =>
+ value switch
+ {
+ ListAlignCenter => BatteryListAlignment.Center,
+ ListAlignBottom => BatteryListAlignment.Bottom,
+ _ => BatteryListAlignment.Top,
+ };
+
+ public static string ListAlignValue(BatteryListAlignment align) =>
+ align switch
+ {
+ BatteryListAlignment.Center => ListAlignCenter,
+ BatteryListAlignment.Bottom => ListAlignBottom,
+ _ => ListAlignTop,
+ };
+
+ public const string ColorsLevelsCharging = "levels-charging";
+ public const string ColorsLevels = "levels";
+ public const string ColorsSimple = "simple";
+ public const string ColorsDevice = "device";
+ public const string ColorsGradient = "gradient";
+
+ public static BatteryColorScheme ParseColors(string? value) =>
+ value switch
+ {
+ ColorsLevels => BatteryColorScheme.Levels,
+ ColorsSimple => BatteryColorScheme.Simple,
+ ColorsDevice => BatteryColorScheme.Device,
+ ColorsGradient => BatteryColorScheme.Gradient,
+ _ => BatteryColorScheme.LevelsCharging,
+ };
+
+ public static string ColorsValue(BatteryColorScheme scheme) =>
+ scheme switch
+ {
+ BatteryColorScheme.Levels => ColorsLevels,
+ BatteryColorScheme.Simple => ColorsSimple,
+ BatteryColorScheme.Device => ColorsDevice,
+ BatteryColorScheme.Gradient => ColorsGradient,
+ _ => ColorsLevelsCharging,
+ };
+
public static BatterySortMode ParseSort(string? value) =>
value switch
{
diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs
index a278c55..babf78b 100644
--- a/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs
+++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs
@@ -12,18 +12,42 @@ internal static class BatteryWidgetPreviews
public static UiElement Panel() => PanelOf(BatteryWidgetSamples.Panel());
[UiPreview(
- "Panel - low battery",
+ "Panel - names and heading",
View = nameof(BatteryWidgetView),
Profile = UiPreviewProfiles.Widget
)]
- public static UiElement PanelLow() => PanelOf(BatteryWidgetSamples.PanelLow());
+ public static UiElement PanelNamed() => PanelOf(BatteryWidgetSamples.PanelNamed());
+
+ [UiPreview(
+ "Panel - names and trend",
+ View = nameof(BatteryWidgetView),
+ Profile = UiPreviewProfiles.Widget
+ )]
+ public static UiElement PanelTrend() => PanelOf(BatteryWidgetSamples.PanelTrend());
+
+ [UiPreview("Panel - list", View = nameof(BatteryWidgetView), Profile = UiPreviewProfiles.Widget)]
+ public static UiElement PanelList() => PanelOf(BatteryWidgetSamples.PanelList());
+
+ [UiPreview(
+ "Panel - list with device colours",
+ View = nameof(BatteryWidgetView),
+ Profile = UiPreviewProfiles.Widget
+ )]
+ public static UiElement PanelListDevice() => PanelOf(BatteryWidgetSamples.PanelListDevice());
+
+ [UiPreview(
+ "Panel - gradient colours",
+ View = nameof(BatteryWidgetView),
+ Profile = UiPreviewProfiles.Widget
+ )]
+ public static UiElement PanelGradient() => PanelOf(BatteryWidgetSamples.PanelGradient());
[UiPreview(
- "Panel - charging",
+ "Panel - low battery",
View = nameof(BatteryWidgetView),
Profile = UiPreviewProfiles.Widget
)]
- public static UiElement PanelCharging() => PanelOf(BatteryWidgetSamples.PanelCharging());
+ public static UiElement PanelLow() => PanelOf(BatteryWidgetSamples.PanelLow());
[UiPreview(
"Panel - nothing configured",
@@ -53,6 +77,20 @@ internal static class BatteryWidgetPreviews
)]
public static UiElement TileLow() => TileOf(BatteryWidgetSamples.TileLow());
+ [UiPreview(
+ "Tile - full",
+ View = nameof(BatteryWidgetView),
+ Profile = UiPreviewProfiles.Widget
+ )]
+ public static UiElement TileFull() => TileOf(BatteryWidgetSamples.TileFull());
+
+ [UiPreview(
+ "Tile - no signal",
+ View = nameof(BatteryWidgetView),
+ Profile = UiPreviewProfiles.Widget
+ )]
+ public static UiElement TileNoSignal() => TileOf(BatteryWidgetSamples.TileNoSignal());
+
private static UiElement PanelOf(BatteryWidgetModel model) =>
BatteryWidgetView.Build(
BatteryWidgetTypes.PanelId,
diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs
index ed6377e..3e0b6df 100644
--- a/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs
+++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs
@@ -10,85 +10,244 @@ public static BatteryWidgetModel For(string localId) =>
public static BatteryWidgetModel Panel() =>
new(
[
+ Row("phone", "Phone", 48, BatteryStatus.Discharging, BatterySourceKind.Phone),
+ Row(
+ "earbuds",
+ "Earbuds",
+ 2,
+ BatteryStatus.Charging,
+ BatterySourceKind.Earbuds,
+ charging: true
+ ),
Row(
"mouse",
"Mouse",
- 82,
+ 100,
BatteryStatus.Charging,
- charging: true,
- timeToFull: "0:35"
+ BatterySourceKind.Mouse,
+ charging: true
),
- Row("phone", "Phone", 47, BatteryStatus.Discharging, trend: "-13%/1h"),
- Row("headset", "Headset", 100, BatteryStatus.Full),
+ Row("headset", "Headset", 20, BatteryStatus.Discharging, BatterySourceKind.Headset),
],
- BatteryWidgetOptions.Default with
- {
- Title = "Batteries",
- }
+ BatteryWidgetOptions.Default
);
- public static BatteryWidgetModel PanelLow() =>
+ public static BatteryWidgetModel PanelNamed() =>
new(
[
- Row("mouse", "Mouse", 12, BatteryStatus.Discharging),
- Row("keyboard", "Keyboard", 6, BatteryStatus.Discharging),
- Row("phone", "Phone", 58, BatteryStatus.Discharging),
+ Row("laptop", "Laptop", 76, BatteryStatus.Discharging, BatterySourceKind.System),
+ Row("keyboard", "Keyboard", 58, BatteryStatus.Discharging, BatterySourceKind.Keyboard),
+ Row(
+ "controller",
+ "Controller",
+ 34,
+ BatteryStatus.Charging,
+ BatterySourceKind.Controller,
+ charging: true
+ ),
+ Row("tablet", "Tablet", 100, BatteryStatus.Full, BatterySourceKind.Tablet),
],
BatteryWidgetOptions.Default with
{
Title = "Batteries",
+ ShowNames = true,
}
);
- public static BatteryWidgetModel PanelCharging() =>
+ public static BatteryWidgetModel PanelTrend() =>
new(
[
Row(
"phone",
"Phone",
- 54,
+ 47,
+ BatteryStatus.Discharging,
+ BatterySourceKind.Phone,
+ trend: "-13%/1h"
+ ),
+ Row(
+ "mouse",
+ "Mouse",
+ 82,
BatteryStatus.Charging,
+ BatterySourceKind.Mouse,
charging: true,
trend: "+28%/30m"
),
+ Row("headset", "Headset", 100, BatteryStatus.Full, BatterySourceKind.Headset),
+ Row(
+ "earbuds",
+ "Earbuds",
+ 15,
+ BatteryStatus.Discharging,
+ BatterySourceKind.Earbuds,
+ trend: "-9%/30m"
+ ),
+ ],
+ BatteryWidgetOptions.Default with
+ {
+ ShowNames = true,
+ ShowRingTrend = true,
+ }
+ );
+
+ public static BatteryWidgetModel PanelList() =>
+ new(
+ [
Row(
"mouse",
"Mouse",
- 91,
+ 82,
+ BatteryStatus.Charging,
+ BatterySourceKind.Mouse,
+ charging: true,
+ timeToFull: "0:35"
+ ),
+ Row(
+ "phone",
+ "Phone",
+ 17,
+ BatteryStatus.Discharging,
+ BatterySourceKind.Phone,
+ trend: "-13%/1h"
+ ),
+ Row(
+ "keyboard",
+ "Keyboard",
+ 64,
+ BatteryStatus.Discharging,
+ BatterySourceKind.Keyboard,
+ stale: true
+ ),
+ Row("headset", "Headset", 100, BatteryStatus.Full, BatterySourceKind.Headset),
+ ],
+ BatteryWidgetOptions.Default with
+ {
+ Layout = BatteryWidgetLayout.List,
+ }
+ );
+
+ public static BatteryWidgetModel PanelListDevice() =>
+ new(
+ [
+ Row("laptop", "Laptop", 76, BatteryStatus.Discharging, BatterySourceKind.System),
+ Row(
+ "controller",
+ "Controller",
+ 34,
BatteryStatus.Charging,
+ BatterySourceKind.Controller,
charging: true,
- timeToFull: "0:12"
+ timeToFull: "1:10"
),
- Row("laptop", "Laptop", 100, BatteryStatus.Full),
+ Row("pen", "Pen", 91, BatteryStatus.Discharging, BatterySourceKind.Pen),
+ Row("earbuds", "Earbuds", 8, BatteryStatus.Discharging, BatterySourceKind.Earbuds),
],
BatteryWidgetOptions.Default with
{
- Title = "Charging",
+ Title = "Batteries",
+ Layout = BatteryWidgetLayout.List,
+ Colors = BatteryColorScheme.Device,
+ ListAlign = BatteryListAlignment.Center,
}
);
+ public static BatteryWidgetModel PanelGradient() =>
+ new(
+ [
+ Row("mouse", "Mouse", 92, BatteryStatus.Discharging, BatterySourceKind.Mouse),
+ Row("keyboard", "Keyboard", 63, BatteryStatus.Discharging, BatterySourceKind.Keyboard),
+ Row("headset", "Headset", 38, BatteryStatus.Discharging, BatterySourceKind.Headset),
+ Row("controller", "Controller", 11, BatteryStatus.Discharging, BatterySourceKind.Controller),
+ ],
+ BatteryWidgetOptions.Default with
+ {
+ ShowNames = true,
+ Colors = BatteryColorScheme.Gradient,
+ }
+ );
+
+ public static BatteryWidgetModel PanelLow() =>
+ new(
+ [
+ Row("mouse", "Mouse", 12, BatteryStatus.Discharging, BatterySourceKind.Mouse),
+ Row("keyboard", "Keyboard", 6, BatteryStatus.Discharging, BatterySourceKind.Keyboard),
+ Row("phone", "Phone", 58, BatteryStatus.Discharging, BatterySourceKind.Phone),
+ Row("pen", "Pen", 64, BatteryStatus.Discharging, BatterySourceKind.Pen, stale: true),
+ ],
+ BatteryWidgetOptions.Default
+ );
+
public static BatteryWidgetModel PanelEmpty() =>
new([], BatteryWidgetOptions.Default with { Title = "Batteries" });
public static BatteryWidgetModel TileCharging() =>
new(
- [Row("mouse", "Mouse", 82, BatteryStatus.Charging, charging: true, timeToFull: "0:35")],
+ [
+ Row(
+ "mouse",
+ "Mouse",
+ 82,
+ BatteryStatus.Charging,
+ BatterySourceKind.Mouse,
+ charging: true,
+ timeToFull: "0:35"
+ ),
+ ],
BatteryWidgetOptions.Default
);
public static BatteryWidgetModel TileDischarging() =>
- new([Row("phone", "Phone", 47, BatteryStatus.Discharging)], BatteryWidgetOptions.Default);
+ new(
+ [
+ Row(
+ "phone",
+ "Phone",
+ 47,
+ BatteryStatus.Discharging,
+ BatterySourceKind.Phone,
+ trend: "-13%/1h"
+ ),
+ ],
+ BatteryWidgetOptions.Default
+ );
public static BatteryWidgetModel TileLow() =>
- new([Row("mouse", "Mouse", 9, BatteryStatus.Discharging)], BatteryWidgetOptions.Default);
+ new(
+ [Row("headset", "Headset", 9, BatteryStatus.Discharging, BatterySourceKind.Headset)],
+ BatteryWidgetOptions.Default
+ );
+
+ public static BatteryWidgetModel TileFull() =>
+ new(
+ [Row("tablet", "Tablet", 100, BatteryStatus.Full, BatterySourceKind.Tablet)],
+ BatteryWidgetOptions.Default
+ );
+
+ public static BatteryWidgetModel TileNoSignal() =>
+ new(
+ [
+ Row(
+ "controller",
+ "Controller",
+ 54,
+ BatteryStatus.Discharging,
+ BatterySourceKind.Controller,
+ stale: true
+ ),
+ ],
+ BatteryWidgetOptions.Default
+ );
private static BatteryWidgetRow Row(
string id,
string name,
int percent,
BatteryStatus status,
+ BatterySourceKind kind,
bool charging = false,
string? timeToFull = null,
- string? trend = null
- ) => new(id, name, percent, status, charging, Stale: false, timeToFull, trend);
+ string? trend = null,
+ bool stale = false
+ ) => new(id, name, percent, status, charging, stale, timeToFull, trend, kind);
}
diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs
index 2239561..17ed49a 100644
--- a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs
+++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs
@@ -1,3 +1,5 @@
+using System.Text.Json;
+using DeviceBatteryInfo.Actions;
using MacroDeck.Sdk.Widgets;
namespace DeviceBatteryInfo.Ui;
@@ -20,7 +22,11 @@ public static bool Matches(string widgetTypeAttribute, string localId) =>
DefaultData: DefaultData,
DataSchema: Schema,
HasConfiguration: true
- ),
+ )
+ {
+ SupportsFlows = true,
+ DefaultShortPressAction = RefreshOnPress,
+ },
new WidgetTypeDescriptor(
TileId,
Strings.Widgets.Tile.Name(),
@@ -28,11 +34,24 @@ public static bool Matches(string widgetTypeAttribute, string localId) =>
DefaultData: DefaultData,
DataSchema: Schema,
HasConfiguration: true
- ),
+ )
+ {
+ SupportsFlows = true,
+ DefaultShortPressAction = RefreshOnPress,
+ },
];
+ // Runs until the user gives the widget a Short Press action of their own; their action wins.
+ private static readonly WidgetDefaultAction RefreshOnPress = new(RefreshBatteryAction.ActionId);
+
+ public static JsonElement StoredFlows(JsonElement? data) =>
+ data is { ValueKind: JsonValueKind.Object } stored
+ && stored.TryGetProperty("flows", out var flows)
+ ? flows
+ : default;
+
private const string DefaultData =
- """{"sourceIds":[],"showBar":true,"showPercent":true,"showCharging":true,"showTimeToFull":true,"showTrend":true,"lowThreshold":20,"sort":"manual","title":""}""";
+ """{"sourceIds":[],"showBar":true,"showPercent":true,"showCharging":true,"showTimeToFull":true,"showTrend":true,"lowThreshold":20,"sort":"manual","title":"","layout":"rings","showNames":false,"colors":"levels-charging","listAlign":"top","showRingTrend":false}""";
private const string Schema = """
{
@@ -61,6 +80,38 @@ public static bool Matches(string widgetTypeAttribute, string localId) =>
"default": "manual",
"description": "Row order. 'manual' keeps the order the devices are listed in above."
},
+ "layout": {
+ "type": "string",
+ "enum": ["rings", "list"],
+ "default": "rings",
+ "description": "How the panel draws its devices: a grid of level rings, or a list of rows with bars."
+ },
+ "colors": {
+ "type": "string",
+ "enum": ["levels-charging", "levels", "simple", "device", "gradient"],
+ "default": "levels-charging",
+ "description": "Ring and bar colours. Every scheme shows a level at or below lowThreshold in red."
+ },
+ "listAlign": {
+ "type": "string",
+ "enum": ["top", "center", "bottom"],
+ "default": "top",
+ "description": "Where the list layout places its rows when they do not fill the widget."
+ },
+ "flows": {
+ "type": "array",
+ "description": "The actions a press runs, edited in the widget's action list."
+ },
+ "showNames": {
+ "type": "boolean",
+ "default": false,
+ "description": "Show each device's name under its ring. The list layout and the tile always show names."
+ },
+ "showRingTrend": {
+ "type": "boolean",
+ "default": false,
+ "description": "Show the recent charge/drain rate (for example -13%/1h) under each ring, below the name when names are shown. The list layout uses showTrend instead."
+ },
"title": {
"type": "string",
"default": "",
diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs
index c8fa48a..e60315e 100644
--- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs
+++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs
@@ -12,25 +12,45 @@ internal static class BatteryWidgetView
private const double BreathingRoomPx = 8;
private static readonly double CornerClearance = 1 - (1 / Math.Sqrt(2));
+ // Fractions of the view basis.
+ private const double EdgeInset = 0.06;
+ private const double TitleHeight = 0.12;
+ private const double GridGap = 0.06;
+
+ // Fractions of the ring's diameter. The inset centres the stroke on the bolt, so the bolt fills
+ // the charging gap.
+ private const double RingThickness = 0.085;
+ private const double BoltSize = 0.22;
+ private const double GaugeInset = (BoltSize - RingThickness) / 2;
+ private const double ChargingGapDegrees = 20;
+
+ // Small enough that "100%" clears the ring's inner circle.
+ private const double FaceGlyph = 0.28;
+ private const double FaceGlyphAlone = 0.44;
+ private const double FacePercent = 0.16;
+ private const double NameShare = 0.24;
+ private const double TrendShare = 0.19;
+
+ // An aspect range of the rings' box and the aspect the ring sizes assume for it.
+ private static readonly (double? Min, double? Max, double Aspect)[] AspectBuckets =
+ [
+ (null, 0.7, 0.5),
+ (1.3, 1.8, 1.5),
+ (1.8, 2.6, 2.2),
+ (2.6, 3.6, 3.1),
+ (3.6, null, 4.4),
+ ];
+
+ // The tree declares no press event: one would claim the gesture, and the host would then skip the
+ // widget's flows and its default refresh action.
public static UiElement Build(
string widgetLocalId,
UiState state,
- int cornerRadius,
- Action? onPress = null
- )
- {
- UiStack root =
- widgetLocalId == BatteryWidgetTypes.TileId
- ? Tile(state, cornerRadius)
- : Panel(state, cornerRadius);
-
- return onPress is null
- ? root
- : root with
- {
- Events = [UiEventHandler.On(UiComponentEvents.Press, onPress)],
- };
- }
+ int cornerRadius
+ ) =>
+ widgetLocalId == BatteryWidgetTypes.TileId
+ ? Tile(state, cornerRadius)
+ : Panel(state, cornerRadius);
private static UiSize SafeArea(int cornerRadius)
{
@@ -40,141 +60,482 @@ private static UiSize SafeArea(int cornerRadius)
private static UiStack Panel(UiState state, int cornerRadius)
{
- var body = new UiStack
+ var options = state.Value.Options;
+ var title = options.Title.Trim();
+ var hasTitle = title.Length > 0;
+
+ var children = new List();
+ if (hasTitle)
{
- Key = "body",
+ children.Add(
+ new UiTextRun
+ {
+ Key = "title",
+ Text = title,
+ Size = UiSize.FromBasis(0.072, 0.9),
+ MinSize = 0.045,
+ Weight = UiComponentTextWeights.SemiBold,
+ Role = UiComponentTextRoles.Muted,
+ MaxLines = 1,
+ Wrap = false,
+ }
+ );
+ }
+
+ children.Add(
+ new UiWhen
+ {
+ Key = "filled",
+ Condition = () => state.Value.Rows.Count > 0,
+ Content = () =>
+ options.Layout == BatteryWidgetLayout.List
+ ? ListBody(state)
+ : RingBody(state, hasTitle),
+ }
+ );
+ children.Add(
+ new UiWhen
+ {
+ Key = "empty",
+ Condition = () => state.Value.Rows.Count == 0,
+ Content = () => EmptyText("empty-text", 0.085),
+ }
+ );
+
+ return new UiStack
+ {
+ Key = "battery-panel",
Direction = UiComponentDirections.Vertical,
- Justify = UiComponentJustify.Center,
+ Justify = UiComponentJustify.Start,
Fill = true,
+ Padding = SafeArea(cornerRadius),
Gap = 0.035,
+ Children = children,
+ };
+ }
+
+ private static UiStack EmptyText(string key, double size) =>
+ new()
+ {
+ Key = key + "-wrap",
+ Direction = UiComponentDirections.Vertical,
+ Justify = UiComponentJustify.Center,
+ Fill = true,
+ Children =
+ [
+ new UiTextRun
+ {
+ Key = key,
+ Text = Strings.Widgets.Empty(),
+ Size = UiSize.FromBasis(size, 0.4),
+ MinSize = 0.05,
+ Role = UiComponentTextRoles.Muted,
+ Align = UiComponentAlignments.Center,
+ Wrap = true,
+ MaxLines = 3,
+ },
+ ],
+ };
+
+ private static UiResponsive RingBody(UiState state, bool hasTitle) =>
+ new()
+ {
+ Key = "rings",
+ Fill = true,
+ Default = RingGrid(state, "rings-1", 1, hasTitle),
+ Variants = AspectBuckets
+ .Select(
+ (bucket, index) =>
+ new UiResponsiveVariant
+ {
+ MinAspect = bucket.Min,
+ MaxAspect = bucket.Max,
+ Content = RingGrid(state, $"rings-{index + 2}", bucket.Aspect, hasTitle),
+ }
+ )
+ .ToArray(),
+ };
+
+ private static UiGrid RingGrid(
+ UiState state,
+ string key,
+ double aspect,
+ bool hasTitle
+ )
+ {
+ RingArrangement Arrangement() =>
+ Arrange(
+ state.Value.Rows.Count,
+ aspect,
+ hasTitle,
+ state.Value.Options.ShowNames,
+ state.Value.Options.ShowRingTrend
+ );
+
+ return new UiGrid
+ {
+ Key = key,
+ Columns = UiValue.From(() => Arrangement().Columns),
+ Rows = UiValue.From(() => Arrangement().Rows),
+ Gap = UiSize.FromBasis(GridGap),
Children =
[
new UiRepeat
{
- Key = "rows",
+ Key = "cells",
Items = UiValue.From(() => state.Value.Rows),
KeySelector = row => row.Id,
- Template = (row, key) => Row(row, key, state.Value.Options),
- },
- new UiWhen
- {
- Key = "empty",
- Condition = () => state.Value.Rows.Count == 0,
- Content = () =>
- new UiStack
- {
- Key = "empty-wrap",
- Direction = UiComponentDirections.Vertical,
- Justify = UiComponentJustify.Center,
- Fill = true,
- Children =
- [
- new UiTextRun
- {
- Key = "empty-text",
- Text = Strings.Widgets.Empty(),
- Size = UiSize.FromBasis(0.085, 0.4),
- MinSize = 0.05,
- Role = UiComponentTextRoles.Muted,
- Align = UiComponentAlignments.Center,
- },
- ],
- },
+ Template = (row, rowKey) =>
+ RingCell(row, rowKey, state.Value.Options, () => Arrangement().Diameter),
},
],
};
+ }
- var children = new List();
- var title = state.Value.Options.Title;
- if (!string.IsNullOrWhiteSpace(title))
+ internal readonly record struct RingArrangement(int Columns, int Rows, double Diameter);
+
+ // A tie goes to more columns, so two rings sit side by side.
+ internal static RingArrangement Arrange(
+ int count,
+ double aspect,
+ bool hasTitle,
+ bool showNames,
+ bool showTrend = false
+ )
+ {
+ var shortSide =
+ 1 - (2 * EdgeInset) - (hasTitle && aspect >= 1 ? TitleHeight : 0);
+ var width = Math.Max(aspect, 1) * shortSide;
+ var height = Math.Max(1 / aspect, 1) * shortSide;
+ var labelFactor = 1 + (showNames ? NameShare : 0) + (showTrend ? TrendShare : 0);
+
+ var devices = Math.Max(count, 1);
+ var best = new RingArrangement(1, 1, 0);
+ for (var columns = 1; columns <= devices; columns++)
+ {
+ var rows = (devices + columns - 1) / columns;
+ var cellWidth = (width - ((columns - 1) * GridGap)) / columns;
+ var cellHeight = (height - ((rows - 1) * GridGap)) / rows;
+ var diameter = Math.Min(cellWidth, cellHeight / labelFactor);
+ if (diameter >= best.Diameter - 1e-6)
+ {
+ best = new RingArrangement(columns, rows, diameter);
+ }
+ }
+
+ return best;
+ }
+
+ private static UiStack RingCell(
+ BatteryWidgetRow row,
+ string key,
+ BatteryWidgetOptions options,
+ Func diameter
+ )
+ {
+ var children = new List
+ {
+ Ring(row, options, diameter, showPercent: options.ShowPercent),
+ };
+
+ if (options.ShowNames)
{
children.Add(
new UiTextRun
{
- Key = "title",
- Text = title.Trim(),
- Size = UiSize.FromBasis(0.072, 0.9),
- MinSize = 0.045,
- Weight = UiComponentTextWeights.SemiBold,
- Role = UiComponentTextRoles.Muted,
+ Key = "name",
+ Text = row.Name,
+ Size = OfDiameter(diameter, 0.15),
+ Role = UiComponentTextRoles.Secondary,
+ Weight = UiComponentTextWeights.Medium,
+ Align = UiComponentAlignments.Center,
MaxLines = 1,
Wrap = false,
}
);
}
- children.Add(body);
+ // A placeholder keeps every ring in a grid row at the same height while a trend is withheld.
+ if (options.ShowRingTrend)
+ {
+ children.Add(
+ new UiTextRun
+ {
+ Key = "trend",
+ Text = row.Stale || string.IsNullOrEmpty(row.Trend) ? "–" : row.Trend,
+ Size = OfDiameter(diameter, 0.12),
+ Role = UiComponentTextRoles.Muted,
+ Align = UiComponentAlignments.Center,
+ MaxLines = 1,
+ Wrap = false,
+ }
+ );
+ }
return new UiStack
{
- Key = "battery-panel",
+ Key = key,
Direction = UiComponentDirections.Vertical,
- Justify = UiComponentJustify.Start,
- Fill = true,
- Padding = SafeArea(cornerRadius),
- Gap = 0.035,
+ Justify = UiComponentJustify.Center,
+ Align = UiComponentAlignments.Center,
+ Gap = OfDiameter(diameter, 0.05),
Children = children,
};
}
- private static UiStack Row(BatteryWidgetRow row, string key, BatteryWidgetOptions options)
+ private static UiSize OfDiameter(Func diameter, double fraction) =>
+ UiSize.From(() => UiLength.OfBasis(fraction * diameter()));
+
+ private static UiModifier Ring(
+ BatteryWidgetRow row,
+ BatteryWidgetOptions options,
+ Func diameter,
+ bool showPercent
+ )
{
- var color = row.Color(options.LowThreshold);
- var caption =
- options.ShowCharging || options.ShowTimeToFull || options.ShowTrend
- ? Caption(row, options)
- : null;
+ var color = row.Color(options.LowThreshold, options.Colors);
+ var charging = options.ShowCharging && row.Charging;
+ var gap = charging ? ChargingGapDegrees : 0;
- var nameGroup = new UiStack
+ var layers = new List();
+ if (options.ShowBar)
{
- Key = "namegroup",
- Direction = UiComponentDirections.Horizontal,
- Align = UiComponentAlignments.Baseline,
- Gap = 0.02,
- Children = caption is { } captionText
- ?
- [
- NameText(row.Name),
- new UiTextRun
- {
- Key = "state",
- Text = captionText,
- Size = UiSize.FromBasis(0.058, 0.34),
- MinSize = 0.04,
- Role = UiComponentTextRoles.Muted,
- MaxLines = 1,
- Wrap = false,
- },
- ]
- : [NameText(row.Name)],
+ layers.Add(
+ new UiStack
+ {
+ Key = "gauge-inset",
+ Padding = OfDiameter(diameter, GaugeInset),
+ Children =
+ [
+ new UiGauge
+ {
+ Key = "gauge",
+ Fill = true,
+ Level = row.Level,
+ StartAngle = gap,
+ EndAngle = 360 - gap,
+ LevelColor = color,
+ Thickness = OfDiameter(diameter, RingThickness),
+ },
+ ],
+ }
+ );
+ }
+
+ if (charging)
+ {
+ layers.Add(
+ new UiStack
+ {
+ Key = "bolt-lane",
+ Direction = UiComponentDirections.Vertical,
+ Justify = UiComponentJustify.Start,
+ Align = UiComponentAlignments.Center,
+ Children = [Glyph("bolt", DeviceGlyphs.Bolt, color, diameter, BoltSize)],
+ }
+ );
+ }
+
+ var face = new List
+ {
+ Glyph(
+ "glyph",
+ DeviceGlyphs.For(row.Kind),
+ color,
+ diameter,
+ showPercent ? FaceGlyph : FaceGlyphAlone
+ ),
};
+ if (showPercent)
+ {
+ face.Add(
+ new UiTextRun
+ {
+ Key = "pct",
+ Text = row.PercentText(),
+ Size = OfDiameter(diameter, FacePercent),
+ Weight = UiComponentTextWeights.SemiBold,
+ Role = row.Stale ? UiComponentTextRoles.Muted : UiComponentTextRoles.Primary,
+ Align = UiComponentAlignments.Center,
+ MaxLines = 1,
+ Wrap = false,
+ }
+ );
+ }
- var headline = new UiStack
+ layers.Add(
+ new UiStack
+ {
+ Key = "face",
+ Direction = UiComponentDirections.Vertical,
+ Justify = UiComponentJustify.Center,
+ Align = UiComponentAlignments.Center,
+ Gap = OfDiameter(diameter, 0.02),
+ Children = face,
+ }
+ );
+
+ return new UiModifier
{
- Key = "line",
- Direction = UiComponentDirections.Horizontal,
- Align = UiComponentAlignments.Baseline,
- Justify = UiComponentJustify.SpaceBetween,
- Gap = 0.03,
+ Key = "ring",
+ Fill = true,
+ Frame = new UiFrame { AspectRatio = 1 },
+ Child = new UiLayer { Key = "ring-layers", Children = layers },
+ };
+ }
+
+ private static UiModifier Glyph(
+ string key,
+ string path,
+ string color,
+ Func diameter,
+ double fraction
+ ) =>
+ new()
+ {
+ Key = key,
+ MainSize = OfDiameter(diameter, fraction),
+ Frame = UiValue.From(() =>
+ {
+ var edge = UiLength.OfBasis(fraction * diameter());
+ return new UiFrame { Width = edge, Height = edge };
+ }),
+ Child = new UiShape
+ {
+ Key = key + "-shape",
+ Shape = UiComponentShapes.Path,
+ Path = path,
+ Color = color,
+ },
+ };
+
+ // Fractions of the view basis.
+ private const double ListGlyph = 0.1;
+ private const double ListBolt = 0.075;
+ private const double ListGap = 0.03;
+ private const double NameSize = 0.082;
+ private const double CaptionSize = 0.058;
+ private const double PercentSize = 0.09;
+
+ // The reader measures in the viewer's font and draws the inline rows unless a name or caption
+ // would be cut off. Texts in a row have no shrink priority, and an unsized first-fit would take
+ // the stacked layout's height, so the whole list switches at once.
+ private static UiFirstFit ListBody(UiState state) =>
+ new()
+ {
+ Key = "body",
+ Fill = true,
+ Children = [ListRows(state, "inline", inline: true), ListRows(state, "stacked", inline: false)],
+ };
+
+ // Hugs the text, so the bolt sits next to the number.
+ private static double PercentWidth(BatteryWidgetRow row) =>
+ TextWidth.Of(row.PercentText(), PercentSize) + 0.01;
+
+ private static UiStack ListRows(
+ UiState state,
+ string key,
+ bool inline
+ ) =>
+ new()
+ {
+ Key = key,
+ Direction = UiComponentDirections.Vertical,
+ Justify = state.Value.Options.ListAlign switch
+ {
+ BatteryListAlignment.Center => UiComponentJustify.Center,
+ BatteryListAlignment.Bottom => UiComponentJustify.End,
+ _ => UiComponentJustify.Start,
+ },
+ Gap = 0.045,
Children =
[
- nameGroup,
+ new UiRepeat
+ {
+ Key = "rows",
+ Items = UiValue.From(() => state.Value.Rows),
+ KeySelector = row => row.Id,
+ Template = (row, rowKey) =>
+ ListRow(row, rowKey, state.Value.Options, inline),
+ },
+ ],
+ };
+
+ private static UiStack ListRow(
+ BatteryWidgetRow row,
+ string key,
+ BatteryWidgetOptions options,
+ bool inline
+ )
+ {
+ var color = row.Color(options.LowThreshold, options.Colors);
+ var caption = Caption(row, options);
+
+ // Shrinking would count as fitting, so the inline texts keep their size and truncate instead.
+ var name = NameText(row.Name);
+ var nameGroup = new List { inline ? name : name with { MinSize = 0.048 } };
+ if (caption is { } captionText)
+ {
+ var captionRun = new UiTextRun
+ {
+ Key = "state",
+ Text = captionText,
+ Size = UiSize.FromBasis(CaptionSize, 0.34),
+ Role = UiComponentTextRoles.Muted,
+ MaxLines = 1,
+ Wrap = false,
+ };
+ nameGroup.Add(inline ? captionRun : captionRun with { MinSize = 0.04 });
+ }
+
+ var line = new List
+ {
+ Glyph("glyph", DeviceGlyphs.For(row.Kind), color, () => 1, ListGlyph),
+ new UiStack
+ {
+ Key = "namegroup",
+ Direction = inline
+ ? UiComponentDirections.Horizontal
+ : UiComponentDirections.Vertical,
+ Align = inline ? UiComponentAlignments.Baseline : UiComponentAlignments.Start,
+ Fill = true,
+ Gap = inline ? 0.02 : 0.004,
+ Children = nameGroup,
+ },
+ };
+ if (options.ShowCharging && row.Charging)
+ {
+ line.Add(Glyph("bolt", DeviceGlyphs.Bolt, color, () => 1, ListBolt));
+ }
+
+ if (options.ShowPercent)
+ {
+ line.Add(
new UiTextRun
{
Key = "pct",
- Text = options.ShowPercent ? row.PercentText() : string.Empty,
- Size = UiSize.FromBasis(0.1, 0.62),
+ Text = row.PercentText(),
+ MainSize = PercentWidth(row),
+ Size = UiSize.FromBasis(PercentSize, 0.62),
MinSize = 0.055,
Digits = 4,
Weight = UiComponentTextWeights.SemiBold,
- Color = color,
+ Role = row.Stale ? UiComponentTextRoles.Muted : UiComponentTextRoles.Primary,
Align = UiComponentAlignments.End,
- },
- ],
+ }
+ );
+ }
+
+ var headline = new UiStack
+ {
+ Key = "line",
+ Direction = UiComponentDirections.Horizontal,
+ Align = UiComponentAlignments.Center,
+ Gap = ListGap,
+ Children = line,
};
var children = new List { headline };
-
if (options.ShowBar && row.Percent is not null)
{
children.Add(
@@ -182,8 +543,8 @@ private static UiStack Row(BatteryWidgetRow row, string key, BatteryWidgetOption
{
Key = "bar",
Fill = true,
- MainSize = 0.045,
- Thickness = 0.024,
+ MainSize = 0.03,
+ Thickness = 0.018,
Value = Progress(row.Percent.Value),
StartColor = color,
EndColor = color,
@@ -195,7 +556,7 @@ private static UiStack Row(BatteryWidgetRow row, string key, BatteryWidgetOption
{
Key = key,
Direction = UiComponentDirections.Vertical,
- Gap = 0.016,
+ Gap = 0.018,
Children = children,
};
}
@@ -205,8 +566,7 @@ private static UiTextRun NameText(string name) =>
{
Key = "name",
Text = name,
- Size = UiSize.FromBasis(0.082, 0.44),
- MinSize = 0.048,
+ Size = UiSize.FromBasis(NameSize, 0.44),
Weight = UiComponentTextWeights.Medium,
Role = UiComponentTextRoles.Secondary,
MaxLines = 1,
@@ -222,7 +582,6 @@ private static UiStack Tile(UiState state, int cornerRadius)
Justify = UiComponentJustify.Center,
Fill = true,
Padding = SafeArea(cornerRadius),
- Gap = 0.028,
Children =
[
new UiRepeat
@@ -230,22 +589,27 @@ private static UiStack Tile(UiState state, int cornerRadius)
Key = "tile-row",
Items = UiValue.From(() => FirstRow(state.Value.Rows)),
KeySelector = row => row.Id,
- Template = (row, key) => TileBody(row, key, state.Value.Options),
+ Template = (row, key) =>
+ new UiResponsive
+ {
+ Key = key,
+ Fill = true,
+ Default = TileStacked(row, state.Value.Options),
+ Variants =
+ [
+ new UiResponsiveVariant
+ {
+ MinAspect = 1.6,
+ Content = TileWide(row, state.Value.Options),
+ },
+ ],
+ },
},
new UiWhen
{
Key = "tile-empty",
Condition = () => state.Value.Rows.Count == 0,
- Content = () =>
- new UiTextRun
- {
- Key = "tile-empty-text",
- Text = Strings.Widgets.Empty(),
- Size = UiSize.FromBasis(0.09, 0.5),
- MinSize = 0.05,
- Role = UiComponentTextRoles.Muted,
- Align = UiComponentAlignments.Center,
- },
+ Content = () => EmptyText("tile-empty-text", 0.09),
},
],
};
@@ -253,82 +617,134 @@ private static UiStack Tile(UiState state, int cornerRadius)
private static IReadOnlyList FirstRow(IReadOnlyList rows) =>
rows.Count == 0 ? [] : [rows[0]];
- private static UiStack TileBody(BatteryWidgetRow row, string key, BatteryWidgetOptions options)
+ private static UiStack TileStacked(BatteryWidgetRow row, BatteryWidgetOptions options)
{
- var color = row.Color(options.LowThreshold);
+ var caption = Caption(row, options);
+ // Leaves room below the ring for the name line, and for the caption line when there is one.
+ var diameter = 1 - (2 * EdgeInset) - 0.13 - (caption is null ? 0 : 0.1);
+
var children = new List
{
+ Ring(row, options, () => diameter, options.ShowPercent),
new UiTextRun
{
Key = "name",
Text = row.Name,
- Size = UiSize.FromBasis(0.095, 0.9),
+ Size = UiSize.FromBasis(0.1, 0.9),
MinSize = 0.055,
Role = UiComponentTextRoles.Secondary,
Weight = UiComponentTextWeights.Medium,
MaxLines = 1,
+ Wrap = false,
Align = UiComponentAlignments.Center,
},
+ };
+
+ if (caption is { } captionText)
+ {
+ children.Add(CaptionText(captionText, UiComponentAlignments.Center));
+ }
+
+ return new UiStack
+ {
+ Key = "stacked",
+ Direction = UiComponentDirections.Vertical,
+ Align = UiComponentAlignments.Center,
+ Justify = UiComponentJustify.Center,
+ Gap = 0.02,
+ Children = children,
+ };
+ }
+
+ private static UiStack TileWide(BatteryWidgetRow row, BatteryWidgetOptions options)
+ {
+ const double diameter = 1 - (2 * EdgeInset);
+ var caption = Caption(row, options);
+
+ var details = new List
+ {
new UiTextRun
{
- Key = "pct",
- Text = row.PercentText(),
- Size = UiSize.FromBasis(0.3, 0.66),
- MinSize = 0.14,
- Digits = 4,
- Weight = UiComponentTextWeights.Bold,
- Color = color,
- Align = UiComponentAlignments.Center,
+ Key = "name",
+ Text = row.Name,
+ Size = UiSize.FromBasis(0.12, 0.3),
+ MinSize = 0.055,
+ Role = UiComponentTextRoles.Secondary,
+ Weight = UiComponentTextWeights.Medium,
+ MaxLines = 1,
+ Wrap = false,
},
};
- if (options.ShowBar && row.Percent is not null)
+ if (options.ShowPercent)
{
- children.Add(
- new UiProgressBar
+ details.Add(
+ new UiTextRun
{
- Key = "bar",
- MainSize = 0.07,
- Thickness = 0.04,
- Value = Progress(row.Percent.Value),
- StartColor = color,
- EndColor = color,
+ Key = "pct",
+ Text = row.PercentText(),
+ Size = UiSize.FromBasis(0.3, 0.6),
+ MinSize = 0.12,
+ Weight = UiComponentTextWeights.Bold,
+ Role = row.Stale ? UiComponentTextRoles.Muted : UiComponentTextRoles.Primary,
+ MaxLines = 1,
+ Wrap = false,
}
);
}
- var caption =
- options.ShowCharging || options.ShowTimeToFull || options.ShowTrend
- ? Caption(row, options)
- : null;
if (caption is { } captionText)
{
- children.Add(
- new UiTextRun
+ details.Add(
+ CaptionText(captionText, UiComponentAlignments.Start) with
{
- Key = "caption",
- Text = captionText,
- Size = UiSize.FromBasis(0.072, 0.9),
- MinSize = 0.045,
- Role = UiComponentTextRoles.Muted,
- MaxLines = 1,
- Align = UiComponentAlignments.Center,
+ Size = UiSize.FromBasis(0.1, 0.9),
}
);
}
return new UiStack
{
- Key = key,
- Direction = UiComponentDirections.Vertical,
- Align = UiComponentAlignments.Stretch,
- Justify = UiComponentJustify.Center,
- Gap = 0.028,
- Fill = true,
- Children = children,
+ Key = "wide",
+ Direction = UiComponentDirections.Horizontal,
+ Align = UiComponentAlignments.Center,
+ Gap = 0.1,
+ Children =
+ [
+ new UiStack
+ {
+ Key = "ring-slot",
+ MainSize = diameter,
+ Direction = UiComponentDirections.Vertical,
+ Justify = UiComponentJustify.Center,
+ Children = [Ring(row, options, () => diameter, showPercent: false)],
+ },
+ new UiStack
+ {
+ Key = "details",
+ Direction = UiComponentDirections.Vertical,
+ Justify = UiComponentJustify.Center,
+ Fill = true,
+ Gap = 0.01,
+ Children = details,
+ },
+ ],
};
}
+ private static UiTextRun CaptionText(LocalizedText text, string align) =>
+ new()
+ {
+ Key = "caption",
+ Text = text,
+ Size = UiSize.FromBasis(0.075, 0.9),
+ MinSize = 0.045,
+ Role = UiComponentTextRoles.Muted,
+ MaxLines = 1,
+ Wrap = false,
+ Align = align,
+ };
+
private static UiProgressReference Progress(int percent) =>
UiProgressReference.Halted(Math.Clamp(percent, 0, 100), DateTimeOffset.UtcNow, 100);
@@ -346,9 +762,12 @@ private static UiProgressReference Progress(int percent) =>
return Strings.Widgets.Caption.ChargingEta(row.TimeToFull!);
}
- return options.ShowTrend && !string.IsNullOrEmpty(row.Trend)
- ? row.Trend!
- : Strings.Widgets.Caption.Charging();
+ if (options.ShowTrend && !string.IsNullOrEmpty(row.Trend))
+ {
+ return row.Trend!;
+ }
+
+ return options.ShowCharging ? Strings.Widgets.Caption.Charging() : null;
}
if (row.Status == BatteryStatus.Full)
@@ -356,6 +775,12 @@ private static UiProgressReference Progress(int percent) =>
return Strings.Widgets.Caption.Full();
}
- return options.ShowTrend && !string.IsNullOrEmpty(row.Trend) ? row.Trend! : null;
+ // A null string converts to a non-null LocalizedText, so test the string before returning it.
+ if (options.ShowTrend && !string.IsNullOrEmpty(row.Trend))
+ {
+ return row.Trend;
+ }
+
+ return null;
}
}
diff --git a/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs b/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs
new file mode 100644
index 0000000..a415eaa
--- /dev/null
+++ b/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs
@@ -0,0 +1,268 @@
+using System.Globalization;
+using System.Text;
+using DeviceBatteryInfo.Core;
+
+namespace DeviceBatteryInfo.Ui;
+
+// Plugins ship no images. The renderer takes absolute M/L/H/V/C/Q/A/Z (these glyphs need only
+// M/L/A/Z) and fills nonzero, so a cut-out winds counter-clockwise.
+internal static class DeviceGlyphs
+{
+ private static readonly Dictionary Paths = new()
+ {
+ [BatterySourceKind.System] = Laptop(),
+ [BatterySourceKind.Mouse] = Mouse(),
+ [BatterySourceKind.Keyboard] = Keyboard(),
+ [BatterySourceKind.Headset] = Headset(),
+ [BatterySourceKind.Earbuds] = Earbuds(),
+ [BatterySourceKind.Phone] = Phone(),
+ [BatterySourceKind.Tablet] = Tablet(),
+ [BatterySourceKind.Controller] = Controller(),
+ [BatterySourceKind.Pen] = Pen(),
+ [BatterySourceKind.Other] = Battery(),
+ };
+
+ public static readonly string Bolt = new GlyphPath()
+ .Polygon((0.6, 0.02), (0.18, 0.56), (0.46, 0.56), (0.4, 0.98), (0.82, 0.42), (0.54, 0.42))
+ .ToString();
+
+ public static string For(BatterySourceKind kind) =>
+ Paths.TryGetValue(kind, out var path) ? path : Paths[BatterySourceKind.Other];
+
+ private static string Phone() =>
+ new GlyphPath()
+ .RoundedRect(0.27, 0.06, 0.46, 0.88, 0.1)
+ .RoundedRect(0.33, 0.13, 0.34, 0.74, 0.045, hole: true)
+ .RoundedRect(0.43, 0.8, 0.14, 0.035, 0.0175)
+ .ToString();
+
+ 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)
+ .ToString();
+
+ private static string Laptop() =>
+ new GlyphPath()
+ .RoundedRect(0.17, 0.18, 0.66, 0.48, 0.06)
+ .RoundedRect(0.23, 0.24, 0.54, 0.36, 0.025, hole: true)
+ .RoundedRect(0.05, 0.71, 0.9, 0.08, 0.04)
+ .ToString();
+
+ private static string Mouse() =>
+ new GlyphPath()
+ .RoundedRect(0.28, 0.06, 0.44, 0.88, 0.22)
+ .RoundedRect(0.465, 0.17, 0.07, 0.19, 0.035, hole: true)
+ .ToString();
+
+ private static string Keyboard()
+ {
+ var path = new GlyphPath().RoundedRect(0.04, 0.24, 0.92, 0.52, 0.08);
+ for (var row = 0; row < 2; row++)
+ {
+ for (var key = 0; key < 5; key++)
+ {
+ path.RoundedRect(0.13 + (key * 0.155), 0.33 + (row * 0.12), 0.1, 0.08, 0.02, hole: true);
+ }
+ }
+
+ return path.RoundedRect(0.27, 0.57, 0.46, 0.08, 0.02, hole: true).ToString();
+ }
+
+ private static string Headset() =>
+ new GlyphPath()
+ .Band(0.5, 0.56, 0.4, 0.31)
+ .RoundedRect(0.06, 0.5, 0.22, 0.4, 0.09)
+ .RoundedRect(0.72, 0.5, 0.22, 0.4, 0.09)
+ .ToString();
+
+ private static string Earbuds() =>
+ new GlyphPath()
+ .Circle(0.42, 0.29, 0.22)
+ .RoundedRect(0.48, 0.3, 0.15, 0.64, 0.075)
+ .Circle(0.34, 0.29, 0.055, hole: true)
+ .ToString();
+
+ // One outline, so the button cut-outs never sit where two solid parts overlap.
+ private static string Controller() =>
+ new GlyphPath()
+ .MoveTo(0.3, 0.24)
+ .LineTo(0.7, 0.24)
+ .ArcTo(0.2, 0.9, 0.4)
+ .LineTo(0.97, 0.7)
+ .ArcTo(0.1, 0.78, 0.8)
+ .LineTo(0.65, 0.63)
+ .LineTo(0.35, 0.63)
+ .LineTo(0.22, 0.8)
+ .ArcTo(0.1, 0.03, 0.7)
+ .LineTo(0.1, 0.4)
+ .ArcTo(0.2, 0.3, 0.24)
+ .Close()
+ .RoundedRect(0.235, 0.33, 0.05, 0.17, 0.01, hole: true)
+ .RoundedRect(0.175, 0.39, 0.06, 0.05, 0.01, hole: true)
+ .RoundedRect(0.285, 0.39, 0.06, 0.05, 0.01, hole: true)
+ .Circle(0.69, 0.37, 0.042, hole: true)
+ .Circle(0.78, 0.46, 0.042, hole: true)
+ .ToString();
+
+ private static string Pen() =>
+ new GlyphPath(rotation: 45)
+ .RoundedRect(0.42, 0.04, 0.16, 0.66, 0.06)
+ .Polygon((0.42, 0.72), (0.58, 0.72), (0.5, 0.96))
+ .ToString();
+
+ private static string Battery() =>
+ new GlyphPath()
+ .RoundedRect(0.08, 0.3, 0.76, 0.4, 0.09)
+ .RoundedRect(0.14, 0.36, 0.64, 0.28, 0.045, hole: true)
+ .RoundedRect(0.19, 0.41, 0.34, 0.18, 0.025)
+ .RoundedRect(0.86, 0.42, 0.06, 0.16, 0.03)
+ .ToString();
+
+ // Arcs are circular, so rotating their end points is enough.
+ private sealed class GlyphPath(double rotation = 0)
+ {
+ private readonly StringBuilder _data = new();
+ private readonly double _cos = Math.Cos(rotation * Math.PI / 180);
+ private readonly double _sin = Math.Sin(rotation * Math.PI / 180);
+
+ public GlyphPath RoundedRect(
+ double x,
+ double y,
+ double width,
+ double height,
+ double radius,
+ bool hole = false
+ )
+ {
+ var r = Math.Min(radius, Math.Min(width, height) / 2);
+ var right = x + width;
+ var bottom = y + height;
+ if (hole)
+ {
+ Move(x + r, y);
+ Arc(r, x, y + r, clockwise: false);
+ Line(x, bottom - r);
+ Arc(r, x + r, bottom, clockwise: false);
+ Line(right - r, bottom);
+ Arc(r, right, bottom - r, clockwise: false);
+ Line(right, y + r);
+ Arc(r, right - r, y, clockwise: false);
+ }
+ else
+ {
+ Move(x + r, y);
+ Line(right - r, y);
+ Arc(r, right, y + r, clockwise: true);
+ Line(right, bottom - r);
+ Arc(r, right - r, bottom, clockwise: true);
+ Line(x + r, bottom);
+ Arc(r, x, bottom - r, clockwise: true);
+ Line(x, y + r);
+ Arc(r, x + r, y, clockwise: true);
+ }
+
+ return Close();
+ }
+
+ public GlyphPath Circle(double cx, double cy, double radius, bool hole = false)
+ {
+ Move(cx - radius, cy);
+ Arc(radius, cx + radius, cy, clockwise: !hole, large: true);
+ Arc(radius, cx - radius, cy, clockwise: !hole, large: true);
+ return Close();
+ }
+
+ // The upper half of a ring.
+ public GlyphPath Band(double cx, double cy, double outer, double inner)
+ {
+ Move(cx - outer, cy);
+ Arc(outer, cx + outer, cy, clockwise: true);
+ Line(cx + inner, cy);
+ Arc(inner, cx - inner, cy, clockwise: false);
+ return Close();
+ }
+
+ public GlyphPath Polygon(params (double X, double Y)[] points)
+ {
+ Move(points[0].X, points[0].Y);
+ foreach (var (x, y) in points.Skip(1))
+ {
+ Line(x, y);
+ }
+
+ return Close();
+ }
+
+ public GlyphPath MoveTo(double x, double y)
+ {
+ Move(x, y);
+ return this;
+ }
+
+ public GlyphPath LineTo(double x, double y)
+ {
+ Line(x, y);
+ return this;
+ }
+
+ public GlyphPath ArcTo(double radius, double x, double y)
+ {
+ Arc(radius, x, y, clockwise: true);
+ return this;
+ }
+
+ public override string ToString() => _data.ToString().Trim();
+
+ private void Move(double x, double y) => Append("M", x, y);
+
+ private void Line(double x, double y) => Append("L", x, y);
+
+ private void Arc(double radius, double x, double y, bool clockwise, bool large = false)
+ {
+ var (px, py) = Rotate(x, y);
+ _data
+ .Append("A ")
+ .Append(Number(radius))
+ .Append(' ')
+ .Append(Number(radius))
+ .Append(" 0 ")
+ .Append(large ? '1' : '0')
+ .Append(' ')
+ .Append(clockwise ? '1' : '0')
+ .Append(' ')
+ .Append(Number(px))
+ .Append(' ')
+ .Append(Number(py))
+ .Append(' ');
+ }
+
+ public GlyphPath Close()
+ {
+ _data.Append("Z ");
+ return this;
+ }
+
+ private void Append(string command, double x, double y)
+ {
+ var (px, py) = Rotate(x, y);
+ _data
+ .Append(command)
+ .Append(' ')
+ .Append(Number(px))
+ .Append(' ')
+ .Append(Number(py))
+ .Append(' ');
+ }
+
+ private (double X, double Y) Rotate(double x, double y)
+ {
+ var dx = x - 0.5;
+ var dy = y - 0.5;
+ return (0.5 + (dx * _cos) - (dy * _sin), 0.5 + (dx * _sin) + (dy * _cos));
+ }
+
+ private static string Number(double value) =>
+ Math.Round(value, 4).ToString("0.####", CultureInfo.InvariantCulture);
+ }
+}
diff --git a/src/DeviceBatteryInfo/Ui/TextWidth.cs b/src/DeviceBatteryInfo/Ui/TextWidth.cs
new file mode 100644
index 0000000..95f6538
--- /dev/null
+++ b/src/DeviceBatteryInfo/Ui/TextWidth.cs
@@ -0,0 +1,22 @@
+namespace DeviceBatteryInfo.Ui;
+
+// Text is laid out on the viewing device, so widths are estimated: em widths of SF Pro Semibold,
+// erring wide. Only the list percentage needs it; captions are fitted by the reader (UiFirstFit).
+internal static class TextWidth
+{
+ private const string NarrowChars = " .,:;'!|iljtfrI-/()[]";
+ private const string WideChars = "mwMW%@";
+
+ private const double NarrowEm = 0.36;
+ private const double RegularEm = 0.6;
+ private const double CapitalEm = 0.7;
+ private const double WideEm = 0.95;
+
+ public static double Of(string text, double size) =>
+ text.Sum(c =>
+ NarrowChars.Contains(c) ? NarrowEm
+ : WideChars.Contains(c) ? WideEm
+ : char.IsUpper(c) || char.IsDigit(c) ? CapitalEm
+ : RegularEm
+ ) * size;
+}
diff --git a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs
index 1d9b6b3..8e4eff4 100644
--- a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs
+++ b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using DeviceBatteryInfo.Actions;
using DeviceBatteryInfo.Core;
using DeviceBatteryInfo.Ui;
using MacroDeck.Ui.Model.Events;
@@ -48,35 +49,31 @@ public void View_builds_a_tree(string widgetId)
Assert.That(view.Tree.Root, Is.Not.Null);
}
- [TestCase(BatteryWidgetTypes.PanelId, "battery-panel")]
- [TestCase(BatteryWidgetTypes.TileId, "battery-tile")]
- public void Pressing_the_widget_runs_the_press_callback(string widgetId, string rootId)
+ [TestCase(BatteryWidgetTypes.PanelId)]
+ [TestCase(BatteryWidgetTypes.TileId)]
+ public void The_tree_declares_no_events_so_presses_reach_the_host(string widgetId)
{
- var presses = 0;
var state = new UiState(
new BatteryWidgetModel([Row(72)], BatteryWidgetOptions.Default)
);
- var view = new UiView(
- WidgetSurface(),
- BatteryWidgetView.Build(widgetId, state, 16, () => presses++)
- );
-
- var result = view.Dispatch(new UiEvent { NodeId = rootId, Name = "press" });
+ var view = new UiView(WidgetSurface(), BatteryWidgetView.Build(widgetId, state, 16));
- Assert.That(result.IsAccepted, Is.True);
- Assert.That(presses, Is.EqualTo(1));
+ Assert.That(JsonSerializer.Serialize(view.Tree), Does.Not.Contain("\"events\""));
}
- [TestCase(BatteryWidgetTypes.PanelId)]
- [TestCase(BatteryWidgetTypes.TileId)]
- public void Without_a_press_callback_the_tree_declares_no_events(string widgetId)
+ [Test]
+ public void Every_widget_type_refreshes_on_a_short_press_by_default()
{
- var state = new UiState(
- new BatteryWidgetModel([Row(72)], BatteryWidgetOptions.Default)
+ Assert.That(
+ BatteryWidgetTypes.All.Select(t => t.DefaultShortPressAction?.ActionId),
+ Is.All.EqualTo(RefreshBatteryAction.ActionId)
);
- var view = new UiView(WidgetSurface(), BatteryWidgetView.Build(widgetId, state, 16));
+ }
- Assert.That(JsonSerializer.Serialize(view.Tree), Does.Not.Contain("\"events\""));
+ [Test]
+ public void Every_widget_type_runs_the_users_flows()
+ {
+ Assert.That(BatteryWidgetTypes.All.Select(t => t.SupportsFlows), Is.All.True);
}
[TestCase(BatteryWidgetTypes.PanelId)]
@@ -112,8 +109,9 @@ public void Empty_model_still_builds()
);
}
- [Test]
- public void Config_view_builds()
+ [TestCase(BatteryWidgetTypes.PanelId)]
+ [TestCase(BatteryWidgetTypes.TileId)]
+ public void Config_view_builds(string widgetId)
{
var devices = new[]
{
@@ -129,11 +127,55 @@ public void Config_view_builds()
SessionMode = UiSessionModes.Exclusive,
Attributes = new Dictionary(),
},
- BatteryWidgetConfigView.Build(BatteryWidgetOptions.Default, devices)
+ BatteryWidgetConfigView.Build(widgetId, BatteryWidgetOptions.Default, devices)
).Tree
);
}
+ // The host resolves a condition by the bare field id, so wrapping tabs and rows must not prefix it.
+ [TestCase(BatteryWidgetTypes.PanelId)]
+ [TestCase(BatteryWidgetTypes.TileId)]
+ public void Every_visibility_condition_names_a_field_the_renderer_can_find(string widgetId)
+ {
+ var view = new UiView(
+ new UiSurface
+ {
+ Kind = UiSurfaceKinds.Config,
+ SessionMode = UiSessionModes.Exclusive,
+ Attributes = new Dictionary(),
+ },
+ BatteryWidgetConfigView.Build(widgetId, BatteryWidgetOptions.Default, [])
+ );
+ var root = System.Text.Json.Nodes.JsonNode.Parse(
+ MacroDeck.Ui.Model.Serialization.UiCanonicalJson.Serialize(view.Tree.Root)
+ )!;
+
+ var ids = new HashSet();
+ var conditions = new List();
+ void Walk(System.Text.Json.Nodes.JsonNode node)
+ {
+ ids.Add(node["id"]!.GetValue());
+ if (node["properties"]?["visibleWhen"]?["parameterName"] is { } name)
+ {
+ conditions.Add(name.GetValue());
+ }
+
+ foreach (var child in node["children"]?.AsArray() ?? [])
+ {
+ Walk(child!);
+ }
+ }
+
+ Walk(root);
+
+ Assert.That(ids, Does.Contain("sourceIds").And.Contain("colors").And.Contain("flows"));
+ Assert.That(conditions, Is.All.Matches(ids.Contains));
+ if (widgetId == BatteryWidgetTypes.PanelId)
+ {
+ Assert.That(conditions, Is.Not.Empty);
+ }
+ }
+
[Test]
public async Task Developer_previews_are_discovered_and_build_a_tree()
{
@@ -150,7 +192,7 @@ public async Task Developer_previews_are_discovered_and_build_a_tree()
r.Declaration.Id.Contains(nameof(BatteryWidgetPreviews), StringComparison.Ordinal)
)
.ToArray();
- Assert.That(ours, Has.Length.EqualTo(7));
+ Assert.That(ours, Has.Length.EqualTo(13));
using (Assert.EnterMultipleScope())
{
Assert.That(
@@ -189,6 +231,266 @@ public void Every_widget_type_declares_valid_json_schema()
}
}
+ [TestCase(BatteryWidgetTypes.PanelId, BatteryWidgetOptions.LayoutRings)]
+ [TestCase(BatteryWidgetTypes.PanelId, BatteryWidgetOptions.LayoutList)]
+ [TestCase(BatteryWidgetTypes.TileId, BatteryWidgetOptions.LayoutRings)]
+ public void Every_layout_builds_with_charging_stale_and_unknown_rows(
+ string widgetId,
+ string layout
+ )
+ {
+ var rows = new BatteryWidgetRow[]
+ {
+ new("a", "A", 40, BatteryStatus.Charging, true, false, "0:20", "+28%/30m", BatterySourceKind.Earbuds),
+ new("b", "B", 70, BatteryStatus.Discharging, false, true, null, Kind: BatterySourceKind.Pen),
+ new("c", "C", null, BatteryStatus.Unknown, false, false, null),
+ };
+ var options = BatteryWidgetOptions.Default with
+ {
+ Layout = BatteryWidgetOptions.ParseLayout(layout),
+ ShowNames = true,
+ ShowRingTrend = true,
+ };
+ var state = new UiState(new BatteryWidgetModel(rows, options));
+
+ Assert.DoesNotThrow(
+ () => _ = new UiView(WidgetSurface(), BatteryWidgetView.Build(widgetId, state, 16)).Tree
+ );
+ }
+
+ [TestCase(1, 1.0, 1, 1)]
+ [TestCase(2, 1.0, 2, 1)]
+ [TestCase(4, 1.0, 2, 2)]
+ [TestCase(4, 4.4, 4, 1)]
+ [TestCase(3, 0.5, 1, 3)]
+ [TestCase(6, 1.5, 3, 2)]
+ public void Rings_are_arranged_to_be_as_large_as_the_box_allows(
+ int count,
+ double aspect,
+ int columns,
+ int rows
+ )
+ {
+ var arrangement = BatteryWidgetView.Arrange(count, aspect, hasTitle: false, showNames: false);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(arrangement.Columns, Is.EqualTo(columns));
+ Assert.That(arrangement.Rows, Is.EqualTo(rows));
+ Assert.That(arrangement.Diameter, Is.GreaterThan(0).And.LessThanOrEqualTo(1));
+ }
+ }
+
+ // The host takes the first variant that matches, so an overlap hides the later one.
+ [TestCase(BatteryWidgetTypes.PanelId, BatteryWidgetOptions.LayoutRings)]
+ [TestCase(BatteryWidgetTypes.PanelId, BatteryWidgetOptions.LayoutList)]
+ [TestCase(BatteryWidgetTypes.TileId, BatteryWidgetOptions.LayoutRings)]
+ public void Responsive_variants_never_overlap(string widgetId, string layout)
+ {
+ var model = BatteryWidgetSamples.PanelList() with
+ {
+ Options = BatteryWidgetSamples.PanelList().Options with
+ {
+ Layout = BatteryWidgetOptions.ParseLayout(layout),
+ },
+ };
+ var tree = new UiView(
+ WidgetSurface(),
+ BatteryWidgetView.Build(widgetId, new UiState(model), 16)
+ ).Tree;
+ var json = JsonSerializer.SerializeToElement(tree.Root);
+
+ foreach (var responsive in Descendants(json).Where(n => n.GetProperty("Type").GetString() == "ui.responsive"))
+ {
+ var ranges = responsive
+ .GetProperty("Properties")
+ .GetProperty("variants")
+ .EnumerateArray()
+ .Select(v =>
+ (
+ Min: v.TryGetProperty("minAspect", out var min) ? min.GetDouble() : double.NegativeInfinity,
+ Max: v.TryGetProperty("maxAspect", out var max) ? max.GetDouble() : double.PositiveInfinity
+ )
+ )
+ .OrderBy(r => r.Min)
+ .ToArray();
+
+ for (var i = 1; i < ranges.Length; i++)
+ {
+ Assert.That(ranges[i].Min, Is.GreaterThanOrEqualTo(ranges[i - 1].Max), responsive.GetProperty("Id").GetString());
+ }
+ }
+ }
+
+ private static IEnumerable Descendants(JsonElement node) =>
+ node.TryGetProperty("Children", out var children) && children.ValueKind == JsonValueKind.Array
+ ? children.EnumerateArray().SelectMany(Descendants).Prepend(node)
+ : [node];
+
+ [Test]
+ public void The_list_offers_inline_rows_before_stacked_ones()
+ {
+ var model = BatteryWidgetSamples.PanelList();
+ var tree = new UiView(
+ WidgetSurface(),
+ BatteryWidgetView.Build(BatteryWidgetTypes.PanelId, new UiState(model), 16)
+ ).Tree;
+ var json = JsonSerializer.SerializeToElement(tree.Root);
+
+ var firstFit = Descendants(json).Single(n => n.GetProperty("Type").GetString() == "ui.first-fit");
+ var layouts = firstFit.GetProperty("Children").EnumerateArray().Select(c => c.GetProperty("Id").GetString()).ToArray();
+
+ Assert.That(layouts, Has.Length.EqualTo(2));
+ Assert.That(layouts[0], Does.EndWith("inline"));
+ Assert.That(layouts[1], Does.EndWith("stacked"));
+ }
+
+ [Test]
+ public void Labels_under_the_rings_shrink_them()
+ {
+ var bare = BatteryWidgetView.Arrange(3, 1.0, hasTitle: false, showNames: false);
+ var named = BatteryWidgetView.Arrange(3, 1.0, hasTitle: false, showNames: true);
+ var both = BatteryWidgetView.Arrange(3, 1.0, hasTitle: false, showNames: true, showTrend: true);
+
+ Assert.That(both.Diameter, Is.LessThan(named.Diameter));
+ Assert.That(named.Diameter, Is.LessThan(bare.Diameter));
+ }
+
+ [Test]
+ public void Every_device_glyph_is_a_path_the_renderer_accepts()
+ {
+ var paths = Enum.GetValues()
+ .Select(DeviceGlyphs.For)
+ .Append(DeviceGlyphs.Bolt);
+
+ foreach (var path in paths)
+ {
+ Assert.That(IsShapePathData(path), Is.True, path);
+ }
+ }
+
+ // Mirrors the renderer's isShapePathData.
+ private static bool IsShapePathData(string value)
+ {
+ var arity = new Dictionary
+ {
+ ['M'] = 2,
+ ['L'] = 2,
+ ['H'] = 1,
+ ['V'] = 1,
+ ['C'] = 6,
+ ['Q'] = 4,
+ ['A'] = 7,
+ ['Z'] = 0,
+ };
+ var tokens = System.Text.RegularExpressions.Regex.Matches(
+ value,
+ @"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?"
+ );
+ if (tokens.Count == 0 || tokens[0].Value != "M")
+ {
+ return false;
+ }
+
+ var index = 0;
+ while (index < tokens.Count)
+ {
+ if (tokens[index].Value.Length != 1 || !arity.TryGetValue(tokens[index].Value[0], out var count))
+ {
+ return false;
+ }
+
+ var numbers = 0;
+ index++;
+ while (index < tokens.Count && !char.IsLetter(tokens[index].Value[0]))
+ {
+ numbers++;
+ index++;
+ }
+
+ if (count == 0 ? numbers != 0 : numbers == 0 || numbers % count != 0)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ [Test]
+ public void Every_scheme_shows_a_low_level_in_red_and_a_stale_one_in_grey()
+ {
+ foreach (var scheme in Enum.GetValues())
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(R("a", 15, charging: true).Color(20, scheme), Is.EqualTo(BatteryWidgetRow.Red));
+ Assert.That(R("a", 20).Color(20, scheme), Is.EqualTo(BatteryWidgetRow.Red));
+ Assert.That(R("a", 60, stale: true).Color(20, scheme), Is.EqualTo(BatteryWidgetRow.Grey));
+ Assert.That(R("a", null).Color(20, scheme), Is.EqualTo(BatteryWidgetRow.Grey));
+ Assert.That(R("a", 80).Color(20, scheme), Does.Match("^#[0-9A-F]{6}$"));
+ }
+ }
+ }
+
+ [Test]
+ public void The_default_scheme_steps_with_the_level_and_is_cyan_while_charging()
+ {
+ Assert.That(BatteryWidgetOptions.Default.Colors, Is.EqualTo(BatteryColorScheme.LevelsCharging));
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(R("a", 60, charging: true).Color(20), Is.EqualTo(BatteryWidgetRow.Cyan));
+ Assert.That(R("a", 35).Color(20), Is.EqualTo(BatteryWidgetRow.Orange));
+ Assert.That(R("a", 55).Color(20), Is.EqualTo(BatteryWidgetRow.Yellow));
+ Assert.That(R("a", 61).Color(20), Is.EqualTo(BatteryWidgetRow.Green));
+ }
+ }
+
+ [Test]
+ public void List_alignment_round_trips_through_the_string_form_and_defaults_to_top()
+ {
+ foreach (var align in Enum.GetValues())
+ {
+ Assert.That(
+ BatteryWidgetOptions.ParseListAlign(BatteryWidgetOptions.ListAlignValue(align)),
+ Is.EqualTo(align)
+ );
+ }
+
+ Assert.That(BatteryWidgetOptions.ParseListAlign(null), Is.EqualTo(BatteryListAlignment.Top));
+ }
+
+ [Test]
+ public void Colors_round_trip_through_the_string_form_and_default_to_levels_with_charging()
+ {
+ foreach (var scheme in Enum.GetValues())
+ {
+ Assert.That(
+ BatteryWidgetOptions.ParseColors(BatteryWidgetOptions.ColorsValue(scheme)),
+ Is.EqualTo(scheme)
+ );
+ }
+
+ Assert.That(
+ BatteryWidgetOptions.ParseColors("unknown"),
+ Is.EqualTo(BatteryColorScheme.LevelsCharging)
+ );
+ }
+
+ [Test]
+ public void Layout_round_trips_through_the_string_form_and_defaults_to_rings()
+ {
+ foreach (var layout in Enum.GetValues())
+ {
+ Assert.That(
+ BatteryWidgetOptions.ParseLayout(BatteryWidgetOptions.LayoutValue(layout)),
+ Is.EqualTo(layout)
+ );
+ }
+
+ Assert.That(BatteryWidgetOptions.ParseLayout("unknown"), Is.EqualTo(BatteryWidgetLayout.Rings));
+ }
+
private static BatteryWidgetRow R(
string id,
int? percent,