From cfff419b62a1476493d9d56de4911e46d1ddee2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 1 Oct 2026 08:40:22 +0200 Subject: [PATCH 01/11] chore: force UTF-8 console output on Windows and pack for the current platform --- Makefile | 13 +++++++++---- README.md | 3 ++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 3e28738..8c72f63 100644 --- a/Makefile +++ b/Makefile @@ -2,12 +2,17 @@ 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) + .DEFAULT_GOAL := help .PHONY: help cli build test test-hardware run watch stub pack conformance update release @@ -19,7 +24,7 @@ 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 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 +49,11 @@ watch: $(RUN) --watch stub: - macrodeck-plugin run --project $(PROJECT) --stub-host + $(UTF8) macrodeck-plugin run --project $(PROJECT) --stub-host 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..9e89f5a 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,8 @@ 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 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`. From e90b9585a6972c0d2b0c864b49e9679216b8201f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 1 Oct 2026 08:43:46 +0200 Subject: [PATCH 02/11] feat: run user-bound actions on a widget press and refresh until any are set --- AGENTS.md | 22 +++++--- README.md | 4 +- .../BatteryIntegration.Widgets.cs | 19 +++++-- .../Ui/BatteryWidgetConfigView.cs | 28 ++++++++++- .../Ui/BatteryWidgetTypes.cs | 50 ++++++++++++++++++- src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs | 2 + .../BatteryWidgetViewTests.cs | 41 ++++++++++++++- 7 files changed, 151 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4d13288..f35206f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,12 +127,22 @@ Design knowledge that is not obvious from the code alone: 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`. +- **A widget press refreshes until the user binds flows.** 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. + A tree that declares a `press` event owns the gesture (`treeClaimsGesture`) and the host then skips + every flow, so `CreateWidgetSession` passes `BatteryWidgetView.Build` the refresh as `onPress` only + while `BatteryWidgetTypes.HasPressFlows` is false (no enabled action on a non-`onEvent` trigger, + mirroring the host's `hasRunnableFlow`). Saving flows changes the widget data, which makes the host + reopen the session, so the tree drops the press as soon as the user binds an action; to keep + refreshing they add "Refresh battery levels" to their flow. The press and flows cannot be mixed + (a tap refresh plus a long-press flow is impossible). `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. Once a host with PR 1111 ships (beta.15), a widget type can declare a + default Short Press action that runs until the user sets their own; that replaces this conditional + press with the same behaviour. - **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 diff --git a/README.md b/README.md index 9e89f5a..eb45331 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,9 @@ peripherals, right on your deck. (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. +- **Battery tile** shows a single device. Pressing a widget refreshes the levels until you assign + actions to it; from then on it runs your actions like any other widget (add the "Refresh battery + levels" action to keep refreshing). 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 diff --git a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs index 70fb986..2593f11 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,9 +108,15 @@ private static string Preview(JsonElement value) ); var state = new UiState(initial); + // Saving flows reopens the session, so the refresh stays the press only until the user binds one. var view = new UiView( surface, - BatteryWidgetView.Build(localId, state, cornerRadius, _polling.RequestRefresh) + BatteryWidgetView.Build( + localId, + state, + cornerRadius, + BatteryWidgetTypes.HasPressFlows(data) ? null : _polling.RequestRefresh + ) ); void Refresh() => state.Set(Compute()); @@ -146,8 +153,12 @@ private static string Preview(JsonElement value) 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(options, CurrentSlots(), BatteryWidgetTypes.StoredFlows(data)) + ); return new UiViewSession(view); } diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs index 14b106e..7e09b2e 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using DeviceBatteryInfo.Core; using MacroDeck.Ui.Config; using MacroDeck.Ui.Config.Options; @@ -8,7 +9,11 @@ namespace DeviceBatteryInfo.Ui; internal static class BatteryWidgetConfigView { - public static UiElement Build(BatteryWidgetOptions current, IReadOnlyList devices) + public static UiElement Build( + BatteryWidgetOptions current, + IReadOnlyList devices, + JsonElement flows = default + ) { var sourceIds = new UiState>(current.SourceIds); var showBar = new UiState(current.ShowBar); @@ -19,6 +24,10 @@ public static UiElement Build(BatteryWidgetOptions current, IReadOnlyList(current.LowThreshold); var sort = new UiState(BatteryWidgetOptions.SortValue(current.Sort)); var title = new UiState(current.Title); + // 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 }) @@ -115,6 +124,23 @@ public static UiElement Build(BatteryWidgetOptions current, IReadOnlyList DefaultData: DefaultData, DataSchema: Schema, HasConfiguration: true - ), + ) + { + SupportsFlows = true, + }, new WidgetTypeDescriptor( TileId, Strings.Widgets.Tile.Name(), @@ -28,9 +32,47 @@ public static bool Matches(string widgetTypeAttribute, string localId) => DefaultData: DefaultData, DataSchema: Schema, HasConfiguration: true - ), + ) + { + SupportsFlows = true, + }, ]; + public static JsonElement StoredFlows(JsonElement? data) => + data is { ValueKind: JsonValueKind.Object } stored + && stored.TryGetProperty("flows", out var flows) + ? flows + : default; + + // Mirrors the host's hasRunnableFlow: an onEvent flow never runs on a press, and a flow whose + // actions are all disabled runs nothing. + public static bool HasPressFlows(JsonElement? data) + { + var flows = StoredFlows(data); + return flows.ValueKind == JsonValueKind.Array + && flows.EnumerateArray().Any(flow => !IsEventFlow(flow) && HasEnabledAction(flow)); + } + + private static bool IsEventFlow(JsonElement flow) => + flow.ValueKind == JsonValueKind.Object + && flow.TryGetProperty("triggerType", out var trigger) + && trigger.ValueKind == JsonValueKind.String + && string.Equals(trigger.GetString(), "onEvent", StringComparison.OrdinalIgnoreCase); + + private static bool HasEnabledAction(JsonElement flow) => + flow.ValueKind == JsonValueKind.Object + && flow.TryGetProperty("children", out var children) + && children.ValueKind == JsonValueKind.Array + && children + .EnumerateArray() + .Any(block => + block.ValueKind == JsonValueKind.Object + && !( + block.TryGetProperty("disabled", out var disabled) + && disabled.ValueKind == JsonValueKind.True + ) + ); + private const string DefaultData = """{"sourceIds":[],"showBar":true,"showPercent":true,"showCharging":true,"showTimeToFull":true,"showTrend":true,"lowThreshold":20,"sort":"manual","title":""}"""; @@ -61,6 +103,10 @@ public static bool Matches(string widgetTypeAttribute, string localId) => "default": "manual", "description": "Row order. 'manual' keeps the order the devices are listed in above." }, + "flows": { + "type": "array", + "description": "The actions a press runs, edited in the widget's action list." + }, "title": { "type": "string", "default": "", diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs index c8fa48a..4e57d46 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs @@ -12,6 +12,8 @@ internal static class BatteryWidgetView private const double BreathingRoomPx = 8; private static readonly double CornerClearance = 1 - (1 / Math.Sqrt(2)); + // A press event claims the gesture and the host then skips the widget's flows, so pass onPress + // only while the widget has none. public static UiElement Build( string widgetLocalId, UiState state, diff --git a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs index 1d9b6b3..fb0909a 100644 --- a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs @@ -69,7 +69,9 @@ public void Pressing_the_widget_runs_the_press_callback(string widgetId, string [TestCase(BatteryWidgetTypes.PanelId)] [TestCase(BatteryWidgetTypes.TileId)] - public void Without_a_press_callback_the_tree_declares_no_events(string widgetId) + public void Without_a_press_handler_the_tree_declares_no_events_so_presses_run_the_flows( + string widgetId + ) { var state = new UiState( new BatteryWidgetModel([Row(72)], BatteryWidgetOptions.Default) @@ -79,6 +81,43 @@ public void Without_a_press_callback_the_tree_declares_no_events(string widgetId Assert.That(JsonSerializer.Serialize(view.Tree), Does.Not.Contain("\"events\"")); } + [TestCase("""{}""", ExpectedResult = false)] + [TestCase("""{"flows":[]}""", ExpectedResult = false)] + [TestCase( + """{"flows":[{"triggerType":"onShortPress","children":[]}]}""", + ExpectedResult = false + )] + [TestCase( + """{"flows":[{"triggerType":"onShortPress","children":[{"disabled":true}]}]}""", + ExpectedResult = false + )] + [TestCase( + """{"flows":[{"triggerType":"onEvent","children":[{"actionId":"x"}]}]}""", + ExpectedResult = false + )] + [TestCase( + """{"flows":[{"triggerType":"onShortPress","children":[{"actionId":"x"}]}]}""", + ExpectedResult = true + )] + [TestCase( + """{"flows":[{"triggerType":"onLongPress","children":[{"actionId":"x"}]}]}""", + ExpectedResult = true + )] + public bool Only_an_enabled_action_on_a_press_trigger_counts_as_a_press_flow(string data) => + BatteryWidgetTypes.HasPressFlows(JsonDocument.Parse(data).RootElement); + + [Test] + public void Missing_widget_data_has_no_press_flows() + { + Assert.That(BatteryWidgetTypes.HasPressFlows(null), Is.False); + } + + [Test] + public void Every_widget_type_runs_the_users_flows() + { + Assert.That(BatteryWidgetTypes.All.Select(t => t.SupportsFlows), Is.All.True); + } + [TestCase(BatteryWidgetTypes.PanelId)] [TestCase(BatteryWidgetTypes.TileId)] public async Task Pushing_a_new_model_emits_patches(string widgetId) From cec3c0f06d92e0ce2cbdd1a4614fcb21040b3166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 1 Oct 2026 08:44:06 +0200 Subject: [PATCH 03/11] feat: redesign the battery widgets around device rings and colour schemes --- AGENTS.md | 47 +- README.md | 18 +- .../BatteryIntegration.Widgets.cs | 39 +- .../Localization/Strings.resx | 67 +- .../Ui/BatteryWidgetConfigView.cs | 300 ++++++-- .../Ui/BatteryWidgetModel.cs | 179 ++++- .../Ui/BatteryWidgetPreviews.cs | 11 +- .../Ui/BatteryWidgetSamples.cs | 120 +++- .../Ui/BatteryWidgetTypes.cs | 25 +- src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs | 647 ++++++++++++++---- src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs | 268 ++++++++ .../BatteryWidgetViewTests.cs | 265 ++++++- 12 files changed, 1666 insertions(+), 320 deletions(-) create mode 100644 src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs diff --git a/AGENTS.md b/AGENTS.md index f35206f..68cacce 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,21 +113,41 @@ 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. + `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). + 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`. A progress bar's `StartColor` + and `EndColor` are always the same hex - the renderer always paints a `linear-gradient`. - **A widget press refreshes until the user binds flows.** 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. A tree that declares a `press` event owns the gesture (`treeClaimsGesture`) and the host then skips diff --git a/README.md b/README.md index eb45331..e31a14a 100644 --- a/README.md +++ b/README.md @@ -18,12 +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 until you assign - actions to it; from then on it runs your actions like any other widget (add the "Refresh battery - levels" action to keep refreshing). +(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. Pressing a widget refreshes the levels until you assign actions to it; from then on it + runs your actions like any other widget (add the "Refresh battery levels" action to keep refreshing). 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 diff --git a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs index 2593f11..aed96a8 100644 --- a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs +++ b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs @@ -148,7 +148,7 @@ 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; } @@ -157,7 +157,12 @@ private static string Preview(JsonElement value) var options = ParseOptions(data); var view = new UiView( surface, - BatteryWidgetConfigView.Build(options, CurrentSlots(), BatteryWidgetTypes.StoredFlows(data)) + BatteryWidgetConfigView.Build( + localId, + options, + CurrentSlots(), + BatteryWidgetTypes.StoredFlows(data) + ) ); return new UiViewSession(view); } @@ -178,7 +183,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 ); } @@ -189,7 +195,8 @@ private BatteryWidgetModel BuildModel(BatteryWidgetOptions options) BatteryStatus.Unknown, false, true, - null + null, + Kind: slot.Kind ); }) .ToArray(); @@ -269,6 +276,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), @@ -278,7 +303,11 @@ bool Flag(string key, bool fallback) => Flag("showTrend", true), low, sort, - title + title, + layout, + Flag("showNames", false), + colors, + listAlign ); } 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 7e09b2e..ebcbc3f 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs @@ -1,5 +1,6 @@ using System.Text.Json; using DeviceBatteryInfo.Core; +using MacroDeck.Ui.Components; using MacroDeck.Ui.Config; using MacroDeck.Ui.Config.Options; using MacroDeck.Ui.Dsl; @@ -10,12 +11,16 @@ namespace DeviceBatteryInfo.Ui; internal static class BatteryWidgetConfigView { 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); @@ -24,34 +29,185 @@ public static UiElement Build( 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(); + // The rings layout has no room for a caption. + 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( + Toggle( + "showNames", + Strings.Widgets.Config.ShowNames.Label(), + showNames, + OnlyFor(BatteryWidgetOptions.LayoutRings) + ) + ); + } + + 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), + Toggle( + "showTimeToFull", + Strings.Widgets.Config.ShowTimeToFull.Label(), + showTimeToFull, + OnlyFor(BatteryWidgetOptions.LayoutList) + ), + Toggle( + "showTrend", + Strings.Widgets.Config.ShowTrend.Label(), + showTrend, + OnlyFor(BatteryWidgetOptions.LayoutList) + ), + ] + ); return new UiWidgetConfiguration { @@ -61,66 +217,14 @@ public static UiElement Build( Key = "battery-widget-config-properties", Children = [ - new UiStringInput - { - Key = "title", - Label = Strings.Widgets.Config.Title.Label(), - Description = Strings.Widgets.Config.Title.Description(), - Binding = Bind.To(title), - }, - new UiMultiSelectInput + new UiTabs { - Key = "sourceIds", - Label = Strings.Widgets.Config.Devices.Label(), - Options = UiValue.Of>(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 - { - Key = "showBar", - Label = Strings.Widgets.Config.ShowBar.Label(), - Binding = Bind.To(showBar), - }, - 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 - { - Key = "lowThreshold", - Label = Strings.Widgets.Config.LowThreshold.Label(), - Min = 1, - Max = 99, - Step = 1, - Binding = Bind.To(lowThreshold), + Key = "sections", + Children = + [ + Tab("content-tab", Strings.Widgets.Config.Tabs.Devices(), content), + Tab("appearance-tab", Strings.Widgets.Config.Tabs.Appearance(), appearance), + ], }, ], }, @@ -143,4 +247,48 @@ public static UiElement Build( } 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, MacroDeck.Localization.LocalizedText Label)[] options + ) => + UiValue.Of>( + options.Select(o => UiOption.Of(o.Value) with { Label = o.Label }).ToArray() + ); + + private static UiOption IconOption( + string value, + MacroDeck.Localization.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..c27129b 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,11 @@ 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 ) { public static readonly BatteryWidgetOptions Default = new( @@ -81,6 +179,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..5a66a7a 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs @@ -12,18 +12,21 @@ 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 - list", View = nameof(BatteryWidgetView), Profile = UiPreviewProfiles.Widget)] + public static UiElement PanelList() => PanelOf(BatteryWidgetSamples.PanelList()); [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", diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs index ed6377e..a0c0a68 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs @@ -10,85 +10,145 @@ 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 + ), ], BatteryWidgetOptions.Default with { Title = "Batteries", + ShowNames = true, } ); - public static BatteryWidgetModel PanelCharging() => + public static BatteryWidgetModel PanelList() => new( [ - Row( - "phone", - "Phone", - 54, - BatteryStatus.Charging, - charging: true, - trend: "+28%/30m" - ), Row( "mouse", "Mouse", - 91, + 82, BatteryStatus.Charging, + BatterySourceKind.Mouse, charging: true, - timeToFull: "0:12" + timeToFull: "0:35" ), - Row("laptop", "Laptop", 100, BatteryStatus.Full), + Row( + "phone", + "Phone", + 47, + BatteryStatus.Discharging, + BatterySourceKind.Phone, + trend: "-13%/1h" + ), + Row("headset", "Headset", 100, BatteryStatus.Full, BatterySourceKind.Headset), ], BatteryWidgetOptions.Default with { - Title = "Charging", + Layout = BatteryWidgetLayout.List, } ); + 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), + new( + "pen", + "Pen", + 64, + BatteryStatus.Discharging, + Charging: false, + Stale: true, + TimeToFull: null, + Kind: BatterySourceKind.Pen + ), + ], + 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 + ); 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); + ) => new(id, name, percent, status, charging, Stale: false, timeToFull, trend, kind); } diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs index 1d44fc8..d65fd69 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs @@ -74,7 +74,7 @@ private static bool HasEnabledAction(JsonElement flow) => ); 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"}"""; private const string Schema = """ { @@ -103,10 +103,33 @@ private static bool HasEnabledAction(JsonElement flow) => "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." + }, "title": { "type": "string", "default": "", diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs index 4e57d46..9cc93f8 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs @@ -12,6 +12,29 @@ 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; + private const double NameShare = 0.24; + + // 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), + ]; + // A press event claims the gesture and the host then skips the widget's flows, so pass onPress // only while the widget has none. public static UiElement Build( @@ -42,141 +65,420 @@ 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); + + 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) + { + 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 = showNames ? 1 + NameShare : 1; + + 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, percentSize: 0.19), + }; + + 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); - 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, + double percentSize + ) { - 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 ? 0.34 : 0.44 + ), }; + if (showPercent) + { + face.Add( + new UiTextRun + { + Key = "pct", + Text = row.PercentText(), + Size = OfDiameter(diameter, percentSize), + 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, + }, + }; + + private static UiStack ListBody(UiState state) => + new() + { + Key = "body", + Direction = UiComponentDirections.Vertical, + Justify = state.Value.Options.ListAlign switch + { + BatteryListAlignment.Center => UiComponentJustify.Center, + BatteryListAlignment.Bottom => UiComponentJustify.End, + _ => UiComponentJustify.Start, + }, + Fill = true, + Gap = 0.045, Children = [ - nameGroup, + new UiRepeat + { + Key = "rows", + Items = UiValue.From(() => state.Value.Rows), + KeySelector = row => row.Id, + Template = (row, key) => ListRow(row, key, state.Value.Options), + }, + ], + }; + + private static UiStack ListRow(BatteryWidgetRow row, string key, BatteryWidgetOptions options) + { + var color = row.Color(options.LowThreshold, options.Colors); + var caption = Caption(row, options); + + var nameGroup = new List { NameText(row.Name) }; + if (caption is { } captionText) + { + nameGroup.Add( + new UiTextRun + { + Key = "state", + Text = captionText, + Size = UiSize.FromBasis(0.058, 0.34), + MinSize = 0.04, + Role = UiComponentTextRoles.Muted, + MaxLines = 1, + Wrap = false, + } + ); + } + + var line = new List + { + Glyph("glyph", DeviceGlyphs.For(row.Kind), color, () => 1, 0.1), + new UiStack + { + Key = "namegroup", + Direction = UiComponentDirections.Horizontal, + Align = UiComponentAlignments.Baseline, + Fill = true, + Gap = 0.02, + Children = nameGroup, + }, + }; + if (options.ShowCharging && row.Charging) + { + line.Add(Glyph("bolt", DeviceGlyphs.Bolt, color, () => 1, 0.075)); + } + + if (options.ShowPercent) + { + line.Add( new UiTextRun { Key = "pct", - Text = options.ShowPercent ? row.PercentText() : string.Empty, + Text = row.PercentText(), + MainSize = 0.25, Size = UiSize.FromBasis(0.1, 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 = 0.03, + Children = line, }; var children = new List { headline }; - if (options.ShowBar && row.Percent is not null) { children.Add( @@ -184,8 +486,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, @@ -197,7 +499,7 @@ private static UiStack Row(BatteryWidgetRow row, string key, BatteryWidgetOption { Key = key, Direction = UiComponentDirections.Vertical, - Gap = 0.016, + Gap = 0.018, Children = children, }; } @@ -224,7 +526,6 @@ private static UiStack Tile(UiState state, int cornerRadius) Justify = UiComponentJustify.Center, Fill = true, Padding = SafeArea(cornerRadius), - Gap = 0.028, Children = [ new UiRepeat @@ -232,22 +533,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), }, ], }; @@ -255,82 +561,128 @@ 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); + var diameter = 1 - (2 * EdgeInset) - 0.13 - (caption is null ? 0 : 0.1); + var children = new List { + Ring(row, options, () => diameter, options.ShowPercent, percentSize: 0.22), 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 - { - Key = "caption", - Text = captionText, - Size = UiSize.FromBasis(0.072, 0.9), - MinSize = 0.045, - Role = UiComponentTextRoles.Muted, - MaxLines = 1, - Align = UiComponentAlignments.Center, - } - ); + details.Add(CaptionText(captionText, UiComponentAlignments.Start) with { 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, percentSize: 0)], + }, + 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); @@ -348,9 +700,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) diff --git a/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs b/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs new file mode 100644 index 0000000..9bfcd1a --- /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/A/Z only 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/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs index fb0909a..2e6dbca 100644 --- a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs @@ -48,6 +48,20 @@ public void View_builds_a_tree(string widgetId) Assert.That(view.Tree.Root, Is.Not.Null); } + [TestCase(BatteryWidgetTypes.PanelId)] + [TestCase(BatteryWidgetTypes.TileId)] + public void Without_a_press_handler_the_tree_declares_no_events_so_presses_run_the_flows( + string widgetId + ) + { + var state = new UiState( + new BatteryWidgetModel([Row(72)], BatteryWidgetOptions.Default) + ); + var view = new UiView(WidgetSurface(), BatteryWidgetView.Build(widgetId, state, 16)); + + Assert.That(JsonSerializer.Serialize(view.Tree), Does.Not.Contain("\"events\"")); + } + [TestCase(BatteryWidgetTypes.PanelId, "battery-panel")] [TestCase(BatteryWidgetTypes.TileId, "battery-tile")] public void Pressing_the_widget_runs_the_press_callback(string widgetId, string rootId) @@ -67,20 +81,6 @@ public void Pressing_the_widget_runs_the_press_callback(string widgetId, string Assert.That(presses, Is.EqualTo(1)); } - [TestCase(BatteryWidgetTypes.PanelId)] - [TestCase(BatteryWidgetTypes.TileId)] - public void Without_a_press_handler_the_tree_declares_no_events_so_presses_run_the_flows( - string widgetId - ) - { - var state = new UiState( - new BatteryWidgetModel([Row(72)], BatteryWidgetOptions.Default) - ); - var view = new UiView(WidgetSurface(), BatteryWidgetView.Build(widgetId, state, 16)); - - Assert.That(JsonSerializer.Serialize(view.Tree), Does.Not.Contain("\"events\"")); - } - [TestCase("""{}""", ExpectedResult = false)] [TestCase("""{"flows":[]}""", ExpectedResult = false)] [TestCase( @@ -151,8 +151,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[] { @@ -168,11 +169,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() { @@ -189,7 +234,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(8)); using (Assert.EnterMultipleScope()) { Assert.That( @@ -228,6 +273,190 @@ 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", Kind: 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, + }; + 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)); + } + } + + [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, From 90ae890c6a8e2d89cfdc74c2c7fc40444ef307c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 1 Oct 2026 10:08:22 +0200 Subject: [PATCH 04/11] feat: add battery trend option to ring layout --- AGENTS.md | 5 ++- .../BatteryIntegration.Widgets.cs | 3 +- .../Ui/BatteryWidgetConfigView.cs | 31 +++++++++++----- .../Ui/BatteryWidgetModel.cs | 3 +- .../Ui/BatteryWidgetPreviews.cs | 7 ++++ .../Ui/BatteryWidgetSamples.cs | 29 +++++++++++++++ .../Ui/BatteryWidgetTypes.cs | 7 +++- src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs | 36 +++++++++++++++++-- .../BatteryWidgetViewTests.cs | 16 +++++++-- 9 files changed, 119 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 68cacce..0769732 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,7 +184,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/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs index aed96a8..cd0bc26 100644 --- a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs +++ b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs @@ -307,7 +307,8 @@ bool Flag(string key, bool fallback) => layout, Flag("showNames", false), colors, - listAlign + listAlign, + Flag("showRingTrend", false) ); } diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs index ebcbc3f..82ee469 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs @@ -26,6 +26,7 @@ public static UiElement Build( 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); @@ -38,7 +39,7 @@ public static UiElement Build( flows.ValueKind == JsonValueKind.Array ? flows : EmptyFlows ); - // The rings layout has no room for a caption. + // The rings layout has no room for a caption, only a short trend line. UiValue OnlyFor(string layoutValue) => isPanel ? UiValue.Of( @@ -150,14 +151,6 @@ UiValue OnlyFor(string layoutValue) => } ) ); - appearance.Add( - Toggle( - "showNames", - Strings.Widgets.Config.ShowNames.Label(), - showNames, - OnlyFor(BatteryWidgetOptions.LayoutRings) - ) - ); } appearance.Add( @@ -209,6 +202,26 @@ UiValue OnlyFor(string layoutValue) => ] ); + 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 { Key = "battery-widget-config", diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs index c27129b..58b3e43 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs @@ -161,7 +161,8 @@ internal sealed record BatteryWidgetOptions( BatteryWidgetLayout Layout = BatteryWidgetLayout.Rings, bool ShowNames = false, BatteryColorScheme Colors = BatteryColorScheme.LevelsCharging, - BatteryListAlignment ListAlign = BatteryListAlignment.Top + BatteryListAlignment ListAlign = BatteryListAlignment.Top, + bool ShowRingTrend = false ) { public static readonly BatteryWidgetOptions Default = new( diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs index 5a66a7a..6d24fad 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs @@ -18,6 +18,13 @@ internal static class BatteryWidgetPreviews )] 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()); diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs index a0c0a68..9b73ff7 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs @@ -53,6 +53,35 @@ BatteryWidgetOptions.Default with } ); + public static BatteryWidgetModel PanelTrend() => + new( + [ + Row( + "phone", + "Phone", + 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), + ], + BatteryWidgetOptions.Default with + { + ShowNames = true, + ShowRingTrend = true, + } + ); + public static BatteryWidgetModel PanelList() => new( [ diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs index d65fd69..439d9fa 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs @@ -74,7 +74,7 @@ private static bool HasEnabledAction(JsonElement flow) => ); private const string DefaultData = - """{"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"}"""; + """{"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 = """ { @@ -130,6 +130,11 @@ private static bool HasEnabledAction(JsonElement flow) => "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 9cc93f8..b6a800e 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs @@ -24,6 +24,7 @@ internal static class BatteryWidgetView private const double GaugeInset = (BoltSize - RingThickness) / 2; private const double ChargingGapDegrees = 20; 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 = @@ -169,7 +170,13 @@ bool hasTitle ) { RingArrangement Arrangement() => - Arrange(state.Value.Rows.Count, aspect, hasTitle, state.Value.Options.ShowNames); + Arrange( + state.Value.Rows.Count, + aspect, + hasTitle, + state.Value.Options.ShowNames, + state.Value.Options.ShowRingTrend + ); return new UiGrid { @@ -194,13 +201,19 @@ RingArrangement Arrangement() => 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) + 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 = showNames ? 1 + NameShare : 1; + var labelFactor = 1 + (showNames ? NameShare : 0) + (showTrend ? TrendShare : 0); var devices = Math.Max(count, 1); var best = new RingArrangement(1, 1, 0); @@ -248,6 +261,23 @@ Func diameter ); } + // 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 = key, diff --git a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs index 2e6dbca..c8be481 100644 --- a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs @@ -234,7 +234,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(8)); + Assert.That(ours, Has.Length.EqualTo(9)); using (Assert.EnterMultipleScope()) { Assert.That( @@ -283,7 +283,7 @@ string layout { var rows = new BatteryWidgetRow[] { - new("a", "A", 40, BatteryStatus.Charging, true, false, "0:20", Kind: BatterySourceKind.Earbuds), + 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), }; @@ -291,6 +291,7 @@ string layout { Layout = BatteryWidgetOptions.ParseLayout(layout), ShowNames = true, + ShowRingTrend = true, }; var state = new UiState(new BatteryWidgetModel(rows, options)); @@ -322,6 +323,17 @@ int rows } } + [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() { From 50ce15f07f1dbfbe0fcfb8a7d56279ddbf5d97af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 1 Oct 2026 10:12:33 +0200 Subject: [PATCH 05/11] fix: keep ring percentage inside the ring --- AGENTS.md | 2 ++ src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0769732..4ee213e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,8 @@ Design knowledge that is not obvious from the code alone: 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 diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs index b6a800e..95d381b 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs @@ -23,6 +23,12 @@ internal static class BatteryWidgetView private const double BoltSize = 0.22; private const double GaugeInset = (BoltSize - RingThickness) / 2; private const double ChargingGapDegrees = 20; + + // The face sits inside the ring's inner circle (radius 0.35): with the percentage, the corners of + // a "100%" line under the glyph stay about 0.32 from the centre. + 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; @@ -241,7 +247,7 @@ Func diameter { var children = new List { - Ring(row, options, diameter, showPercent: options.ShowPercent, percentSize: 0.19), + Ring(row, options, diameter, showPercent: options.ShowPercent), }; if (options.ShowNames) @@ -296,8 +302,7 @@ private static UiModifier Ring( BatteryWidgetRow row, BatteryWidgetOptions options, Func diameter, - bool showPercent, - double percentSize + bool showPercent ) { var color = row.Color(options.LowThreshold, options.Colors); @@ -350,7 +355,7 @@ double percentSize DeviceGlyphs.For(row.Kind), color, diameter, - showPercent ? 0.34 : 0.44 + showPercent ? FaceGlyph : FaceGlyphAlone ), }; if (showPercent) @@ -360,7 +365,7 @@ double percentSize { Key = "pct", Text = row.PercentText(), - Size = OfDiameter(diameter, percentSize), + Size = OfDiameter(diameter, FacePercent), Weight = UiComponentTextWeights.SemiBold, Role = row.Stale ? UiComponentTextRoles.Muted : UiComponentTextRoles.Primary, Align = UiComponentAlignments.Center, @@ -598,7 +603,7 @@ private static UiStack TileStacked(BatteryWidgetRow row, BatteryWidgetOptions op var children = new List { - Ring(row, options, () => diameter, options.ShowPercent, percentSize: 0.22), + Ring(row, options, () => diameter, options.ShowPercent), new UiTextRun { Key = "name", @@ -685,7 +690,7 @@ private static UiStack TileWide(BatteryWidgetRow row, BatteryWidgetOptions optio MainSize = diameter, Direction = UiComponentDirections.Vertical, Justify = UiComponentJustify.Center, - Children = [Ring(row, options, () => diameter, showPercent: false, percentSize: 0)], + Children = [Ring(row, options, () => diameter, showPercent: false)], }, new UiStack { From 8de76ed3b3b9977472a0280822adf852e829b14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 1 Oct 2026 10:57:07 +0200 Subject: [PATCH 06/11] fix: fit list captions and percentages to the estimated text width --- AGENTS.md | 16 ++- src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs | 117 +++++++++++++++--- src/DeviceBatteryInfo/Ui/TextWidth.cs | 53 ++++++++ .../BatteryWidgetViewTests.cs | 81 ++++++++++++ 4 files changed, 248 insertions(+), 19 deletions(-) create mode 100644 src/DeviceBatteryInfo/Ui/TextWidth.cs diff --git a/AGENTS.md b/AGENTS.md index 4ee213e..739a718 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,21 @@ Design knowledge that is not obvious from the code alone: 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`. A progress bar's `StartColor` + is underestimated, so the list percentage has a fixed `MainSize`, sized to its own text so a + charging bolt sits beside the number. The plugin never sees pixels or fonts, so a layout that + depends on text width estimates it with `TextWidth`: four character classes (narrow, regular, + capital/digit, wide) fitted to SF Pro Semibold, the widest case of the host's system font, within a + few percent and erring wide; a localized text counts as its widest culture. Text is laid out on the + viewing device, so reading font files on the plugin's machine would not be more accurate. + 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 `UiResponsive` over aspect buckets, and each + row puts its caption beside the name only where `CaptionFitsInline` says it fits at that bucket's + lowest aspect, under the name otherwise. A progress bar's `StartColor` and `EndColor` are always the same hex - the renderer always paints a `linear-gradient`. - **A widget press refreshes until the user binds flows.** 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. diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs index 95d381b..c4d1367 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs @@ -24,8 +24,7 @@ internal static class BatteryWidgetView private const double GaugeInset = (BoltSize - RingThickness) / 2; private const double ChargingGapDegrees = 20; - // The face sits inside the ring's inner circle (radius 0.35): with the percentage, the corners of - // a "100%" line under the glyph stay about 0.32 from the centre. + // 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; @@ -101,7 +100,7 @@ private static UiStack Panel(UiState state, int cornerRadius Condition = () => state.Value.Rows.Count > 0, Content = () => options.Layout == BatteryWidgetLayout.List - ? ListBody(state) + ? ListBody(state, hasTitle) : RingBody(state, hasTitle), } ); @@ -421,10 +420,78 @@ double fraction }, }; - private static UiStack ListBody(UiState state) => + // 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 CaptionGap = 0.02; + private const double PercentSize = 0.09; + // The real padding can exceed EdgeInset. + private const double FitSlack = 0.02; + + // The host takes the first matching variant, so each bucket ends where the next begins. + private static readonly double[] ListAspects = [1.15, 1.3, 1.5, 1.75, 2.0, 2.4, 2.8, 3.4]; + + private static UiResponsive ListBody(UiState state, bool hasTitle) => new() { Key = "body", + Fill = true, + Default = ListRows(state, "rows-1", ListWidth(1, hasTitle)), + Variants = ListAspects + .Select( + (aspect, index) => + new UiResponsiveVariant + { + MinAspect = aspect, + MaxAspect = index + 1 < ListAspects.Length ? ListAspects[index + 1] : null, + Content = ListRows(state, $"rows-{index + 2}", ListWidth(aspect, hasTitle)), + } + ) + .ToArray(), + }; + + // The basis is the widget's short side. + internal static double ListWidth(double aspect, bool hasTitle) + { + var bodyHeight = 1 - (2 * EdgeInset) - (hasTitle ? TitleHeight : 0); + return Math.Max(1 - (2 * EdgeInset), aspect * bodyHeight); + } + + // Texts in a row have no shrink priority, so an overlong caption truncates the name too. + internal static bool CaptionFitsInline( + BatteryWidgetRow row, + BatteryWidgetOptions options, + double width + ) + { + if (Caption(row, options) is not { } caption) + { + return true; + } + + var bolt = options.ShowCharging && row.Charging ? ListBolt + ListGap : 0; + var percent = options.ShowPercent ? PercentWidth(row) + ListGap : 0; + var available = width - ListGlyph - ListGap - bolt - percent; + var needed = + TextWidth.Of(row.Name, NameSize) + CaptionGap + TextWidth.Of(caption, CaptionSize); + return needed + FitSlack <= available; + } + + // 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, + double width + ) => + new() + { + Key = key, Direction = UiComponentDirections.Vertical, Justify = state.Value.Options.ListAlign switch { @@ -432,7 +499,6 @@ private static UiStack ListBody(UiState state) => BatteryListAlignment.Bottom => UiComponentJustify.End, _ => UiComponentJustify.Start, }, - Fill = true, Gap = 0.045, Children = [ @@ -441,15 +507,22 @@ private static UiStack ListBody(UiState state) => Key = "rows", Items = UiValue.From(() => state.Value.Rows), KeySelector = row => row.Id, - Template = (row, key) => ListRow(row, key, state.Value.Options), + Template = (row, rowKey) => + ListRow(row, rowKey, state.Value.Options, width), }, ], }; - private static UiStack ListRow(BatteryWidgetRow row, string key, BatteryWidgetOptions options) + private static UiStack ListRow( + BatteryWidgetRow row, + string key, + BatteryWidgetOptions options, + double width + ) { var color = row.Color(options.LowThreshold, options.Colors); var caption = Caption(row, options); + var inlineCaption = CaptionFitsInline(row, options, width); var nameGroup = new List { NameText(row.Name) }; if (caption is { } captionText) @@ -459,7 +532,7 @@ private static UiStack ListRow(BatteryWidgetRow row, string key, BatteryWidgetOp { Key = "state", Text = captionText, - Size = UiSize.FromBasis(0.058, 0.34), + Size = UiSize.FromBasis(CaptionSize, 0.34), MinSize = 0.04, Role = UiComponentTextRoles.Muted, MaxLines = 1, @@ -470,20 +543,22 @@ private static UiStack ListRow(BatteryWidgetRow row, string key, BatteryWidgetOp var line = new List { - Glyph("glyph", DeviceGlyphs.For(row.Kind), color, () => 1, 0.1), + Glyph("glyph", DeviceGlyphs.For(row.Kind), color, () => 1, ListGlyph), new UiStack { Key = "namegroup", - Direction = UiComponentDirections.Horizontal, - Align = UiComponentAlignments.Baseline, + Direction = inlineCaption + ? UiComponentDirections.Horizontal + : UiComponentDirections.Vertical, + Align = inlineCaption ? UiComponentAlignments.Baseline : UiComponentAlignments.Start, Fill = true, - Gap = 0.02, + Gap = inlineCaption ? 0.02 : 0.004, Children = nameGroup, }, }; if (options.ShowCharging && row.Charging) { - line.Add(Glyph("bolt", DeviceGlyphs.Bolt, color, () => 1, 0.075)); + line.Add(Glyph("bolt", DeviceGlyphs.Bolt, color, () => 1, ListBolt)); } if (options.ShowPercent) @@ -493,8 +568,8 @@ private static UiStack ListRow(BatteryWidgetRow row, string key, BatteryWidgetOp { Key = "pct", Text = row.PercentText(), - MainSize = 0.25, - Size = UiSize.FromBasis(0.1, 0.62), + MainSize = PercentWidth(row), + Size = UiSize.FromBasis(PercentSize, 0.62), MinSize = 0.055, Digits = 4, Weight = UiComponentTextWeights.SemiBold, @@ -509,7 +584,7 @@ private static UiStack ListRow(BatteryWidgetRow row, string key, BatteryWidgetOp Key = "line", Direction = UiComponentDirections.Horizontal, Align = UiComponentAlignments.Center, - Gap = 0.03, + Gap = ListGap, Children = line, }; @@ -544,7 +619,7 @@ private static UiTextRun NameText(string name) => { Key = "name", Text = name, - Size = UiSize.FromBasis(0.082, 0.44), + Size = UiSize.FromBasis(NameSize, 0.44), MinSize = 0.048, Weight = UiComponentTextWeights.Medium, Role = UiComponentTextRoles.Secondary, @@ -748,6 +823,12 @@ private static UiProgressReference Progress(int percent) => return Strings.Widgets.Caption.Full(); } - return options.ShowTrend && !string.IsNullOrEmpty(row.Trend) ? row.Trend! : null; + // `? row.Trend : null` would turn a null string into a non-null caption. + if (options.ShowTrend && !string.IsNullOrEmpty(row.Trend)) + { + return row.Trend; + } + + return null; } } diff --git a/src/DeviceBatteryInfo/Ui/TextWidth.cs b/src/DeviceBatteryInfo/Ui/TextWidth.cs new file mode 100644 index 0000000..c624d46 --- /dev/null +++ b/src/DeviceBatteryInfo/Ui/TextWidth.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using System.Text; +using MacroDeck.Localization; + +namespace DeviceBatteryInfo.Ui; + +// Text is laid out on the viewing device, so widths are estimated: em widths of SF Pro Semibold, +// erring wide. +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; + + // The viewer's culture is unknown, so the widest translation counts. + public static double Of(LocalizedText text, double size) => + text.Localized is { } localized + ? Strings.LocalizationCatalog.Cultures + .Select(culture => Resolve(localized, culture)) + .OfType() + .Select(resolved => Of(resolved, size)) + .DefaultIfEmpty(0) + .Max() + : Of(text.Literal, size); + + private static string? Resolve(LocalizedString text, string culture) + { + if (!Strings.LocalizationCatalog.TryGetTemplate(culture, text.Key.Name, out var template)) + { + return null; + } + + var resolved = new StringBuilder(template); + foreach (var (name, value) in text.Arguments) + { + resolved.Replace("{" + name + "}", Convert.ToString(value, CultureInfo.InvariantCulture)); + } + + return resolved.ToString(); + } +} diff --git a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs index c8be481..eff88f2 100644 --- a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs @@ -323,6 +323,87 @@ int rows } } + // 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]; + + [TestCase(1.0, false)] + [TestCase(1.3, true)] + [TestCase(2.0, true)] + public void A_caption_goes_beside_the_name_once_it_fits(double aspect, bool inline) + { + var mouse = new BatteryWidgetRow( + "mouse", + "Mouse", + 82, + BatteryStatus.Charging, + Charging: true, + Stale: false, + TimeToFull: "0:35", + Kind: BatterySourceKind.Mouse + ); + var options = BatteryWidgetOptions.Default with { Layout = BatteryWidgetLayout.List }; + + Assert.That( + BatteryWidgetView.CaptionFitsInline( + mouse, + options, + BatteryWidgetView.ListWidth(aspect, hasTitle: false) + ), + Is.EqualTo(inline) + ); + } + + [Test] + public void Text_width_resolves_a_localized_caption() + { + var eta = TextWidth.Of(Strings.Widgets.Caption.ChargingEta("0:35"), 1); + + Assert.That(eta, Is.EqualTo(TextWidth.Of("0:35 to full", 1)).Within(1e-9)); + } + [Test] public void Labels_under_the_rings_shrink_them() { From f6acb6960eb7bc5fb8ecd70e561d6d3d1b62df8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 2 Oct 2026 21:26:43 +0200 Subject: [PATCH 07/11] feat: add make preview to render the widget previews to PNG --- Makefile | 12 +++++++++++- README.md | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8c72f63..ddb6c97 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,13 @@ TESTS := dotnet test DeviceBatteryInfo.slnx --configuration Release --filter 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)))" @@ -24,6 +29,7 @@ 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 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)" @@ -51,6 +57,10 @@ watch: stub: $(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) --rid $(RID) --output ./artifacts diff --git a/README.md b/README.md index e31a14a..23ea565 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ 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 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 - From c2b8abbd1e2c3c3af63b9e9eac348bcc603649d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 2 Oct 2026 21:43:35 +0200 Subject: [PATCH 08/11] feat: let the reader choose inline or stacked list rows with UiFirstFit --- AGENTS.md | 16 ++-- Directory.Packages.props | 10 +- src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs | 93 +++++-------------- src/DeviceBatteryInfo/Ui/TextWidth.cs | 33 +------ .../BatteryWidgetViewTests.cs | 43 +++------ 5 files changed, 52 insertions(+), 143 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 739a718..4cd4c18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,20 +149,20 @@ Design knowledge that is not obvious from the code alone: 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. The plugin never sees pixels or fonts, so a layout that - depends on text width estimates it with `TextWidth`: four character classes (narrow, regular, - capital/digit, wide) fitted to SF Pro Semibold, the widest case of the host's system font, within a - few percent and erring wide; a localized text counts as its widest culture. Text is laid out on the - viewing device, so reading font files on the plugin's machine would not be more accurate. + 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 `UiResponsive` over aspect buckets, and each - row puts its caption beside the name only where `CaptionFitsInline` says it fits at that bucket's - lowest aspect, under the name otherwise. A progress bar's `StartColor` + 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 widget press refreshes until the user binds flows.** 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. diff --git a/Directory.Packages.props b/Directory.Packages.props index 16b6814..1bf8f16 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,12 +4,12 @@ true - - - - + + + + - + diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs index c4d1367..b57efad 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs @@ -100,7 +100,7 @@ private static UiStack Panel(UiState state, int cornerRadius Condition = () => state.Value.Rows.Count > 0, Content = () => options.Layout == BatteryWidgetLayout.List - ? ListBody(state, hasTitle) + ? ListBody(state) : RingBody(state, hasTitle), } ); @@ -426,60 +426,19 @@ double fraction private const double ListGap = 0.03; private const double NameSize = 0.082; private const double CaptionSize = 0.058; - private const double CaptionGap = 0.02; private const double PercentSize = 0.09; - // The real padding can exceed EdgeInset. - private const double FitSlack = 0.02; - // The host takes the first matching variant, so each bucket ends where the next begins. - private static readonly double[] ListAspects = [1.15, 1.3, 1.5, 1.75, 2.0, 2.4, 2.8, 3.4]; - - private static UiResponsive ListBody(UiState state, bool hasTitle) => + // 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, - Default = ListRows(state, "rows-1", ListWidth(1, hasTitle)), - Variants = ListAspects - .Select( - (aspect, index) => - new UiResponsiveVariant - { - MinAspect = aspect, - MaxAspect = index + 1 < ListAspects.Length ? ListAspects[index + 1] : null, - Content = ListRows(state, $"rows-{index + 2}", ListWidth(aspect, hasTitle)), - } - ) - .ToArray(), + Children = [ListRows(state, "inline", inline: true), ListRows(state, "stacked", inline: false)], }; - // The basis is the widget's short side. - internal static double ListWidth(double aspect, bool hasTitle) - { - var bodyHeight = 1 - (2 * EdgeInset) - (hasTitle ? TitleHeight : 0); - return Math.Max(1 - (2 * EdgeInset), aspect * bodyHeight); - } - - // Texts in a row have no shrink priority, so an overlong caption truncates the name too. - internal static bool CaptionFitsInline( - BatteryWidgetRow row, - BatteryWidgetOptions options, - double width - ) - { - if (Caption(row, options) is not { } caption) - { - return true; - } - - var bolt = options.ShowCharging && row.Charging ? ListBolt + ListGap : 0; - var percent = options.ShowPercent ? PercentWidth(row) + ListGap : 0; - var available = width - ListGlyph - ListGap - bolt - percent; - var needed = - TextWidth.Of(row.Name, NameSize) + CaptionGap + TextWidth.Of(caption, CaptionSize); - return needed + FitSlack <= available; - } - // Hugs the text, so the bolt sits next to the number. private static double PercentWidth(BatteryWidgetRow row) => TextWidth.Of(row.PercentText(), PercentSize) + 0.01; @@ -487,7 +446,7 @@ private static double PercentWidth(BatteryWidgetRow row) => private static UiStack ListRows( UiState state, string key, - double width + bool inline ) => new() { @@ -508,7 +467,7 @@ double width Items = UiValue.From(() => state.Value.Rows), KeySelector = row => row.Id, Template = (row, rowKey) => - ListRow(row, rowKey, state.Value.Options, width), + ListRow(row, rowKey, state.Value.Options, inline), }, ], }; @@ -517,28 +476,27 @@ private static UiStack ListRow( BatteryWidgetRow row, string key, BatteryWidgetOptions options, - double width + bool inline ) { var color = row.Color(options.LowThreshold, options.Colors); var caption = Caption(row, options); - var inlineCaption = CaptionFitsInline(row, options, width); - var nameGroup = new List { NameText(row.Name) }; + // 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) { - nameGroup.Add( - new UiTextRun - { - Key = "state", - Text = captionText, - Size = UiSize.FromBasis(CaptionSize, 0.34), - MinSize = 0.04, - Role = UiComponentTextRoles.Muted, - MaxLines = 1, - Wrap = false, - } - ); + 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 @@ -547,12 +505,12 @@ double width new UiStack { Key = "namegroup", - Direction = inlineCaption + Direction = inline ? UiComponentDirections.Horizontal : UiComponentDirections.Vertical, - Align = inlineCaption ? UiComponentAlignments.Baseline : UiComponentAlignments.Start, + Align = inline ? UiComponentAlignments.Baseline : UiComponentAlignments.Start, Fill = true, - Gap = inlineCaption ? 0.02 : 0.004, + Gap = inline ? 0.02 : 0.004, Children = nameGroup, }, }; @@ -620,7 +578,6 @@ private static UiTextRun NameText(string name) => Key = "name", Text = name, Size = UiSize.FromBasis(NameSize, 0.44), - MinSize = 0.048, Weight = UiComponentTextWeights.Medium, Role = UiComponentTextRoles.Secondary, MaxLines = 1, diff --git a/src/DeviceBatteryInfo/Ui/TextWidth.cs b/src/DeviceBatteryInfo/Ui/TextWidth.cs index c624d46..95f6538 100644 --- a/src/DeviceBatteryInfo/Ui/TextWidth.cs +++ b/src/DeviceBatteryInfo/Ui/TextWidth.cs @@ -1,11 +1,7 @@ -using System.Globalization; -using System.Text; -using MacroDeck.Localization; - namespace DeviceBatteryInfo.Ui; // Text is laid out on the viewing device, so widths are estimated: em widths of SF Pro Semibold, -// erring wide. +// erring wide. Only the list percentage needs it; captions are fitted by the reader (UiFirstFit). internal static class TextWidth { private const string NarrowChars = " .,:;'!|iljtfrI-/()[]"; @@ -23,31 +19,4 @@ public static double Of(string text, double size) => : char.IsUpper(c) || char.IsDigit(c) ? CapitalEm : RegularEm ) * size; - - // The viewer's culture is unknown, so the widest translation counts. - public static double Of(LocalizedText text, double size) => - text.Localized is { } localized - ? Strings.LocalizationCatalog.Cultures - .Select(culture => Resolve(localized, culture)) - .OfType() - .Select(resolved => Of(resolved, size)) - .DefaultIfEmpty(0) - .Max() - : Of(text.Literal, size); - - private static string? Resolve(LocalizedString text, string culture) - { - if (!Strings.LocalizationCatalog.TryGetTemplate(culture, text.Key.Name, out var template)) - { - return null; - } - - var resolved = new StringBuilder(template); - foreach (var (name, value) in text.Arguments) - { - resolved.Replace("{" + name + "}", Convert.ToString(value, CultureInfo.InvariantCulture)); - } - - return resolved.ToString(); - } } diff --git a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs index eff88f2..f01cf9d 100644 --- a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs @@ -369,39 +369,22 @@ private static IEnumerable Descendants(JsonElement node) => ? children.EnumerateArray().SelectMany(Descendants).Prepend(node) : [node]; - [TestCase(1.0, false)] - [TestCase(1.3, true)] - [TestCase(2.0, true)] - public void A_caption_goes_beside_the_name_once_it_fits(double aspect, bool inline) - { - var mouse = new BatteryWidgetRow( - "mouse", - "Mouse", - 82, - BatteryStatus.Charging, - Charging: true, - Stale: false, - TimeToFull: "0:35", - Kind: BatterySourceKind.Mouse - ); - var options = BatteryWidgetOptions.Default with { Layout = BatteryWidgetLayout.List }; - - Assert.That( - BatteryWidgetView.CaptionFitsInline( - mouse, - options, - BatteryWidgetView.ListWidth(aspect, hasTitle: false) - ), - Is.EqualTo(inline) - ); - } - [Test] - public void Text_width_resolves_a_localized_caption() + public void The_list_offers_inline_rows_before_stacked_ones() { - var eta = TextWidth.Of(Strings.Widgets.Caption.ChargingEta("0:35"), 1); + 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(eta, Is.EqualTo(TextWidth.Of("0:35 to full", 1)).Within(1e-9)); + Assert.That(layouts, Has.Length.EqualTo(2)); + Assert.That(layouts[0], Does.EndWith("inline")); + Assert.That(layouts[1], Does.EndWith("stacked")); } [Test] From 3c16ac21fcdfe3da51e86910a596ee6a63ac5d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 2 Oct 2026 22:04:20 +0200 Subject: [PATCH 09/11] feat: show four devices and more states in the widget previews --- .../Ui/BatteryWidgetPreviews.cs | 28 ++++++ .../Ui/BatteryWidgetSamples.cs | 96 ++++++++++++++++--- .../BatteryWidgetViewTests.cs | 2 +- 3 files changed, 112 insertions(+), 14 deletions(-) diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs index 6d24fad..babf78b 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs @@ -28,6 +28,20 @@ internal static class BatteryWidgetPreviews [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 - low battery", View = nameof(BatteryWidgetView), @@ -63,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 9b73ff7..3e0b6df 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs @@ -45,6 +45,7 @@ public static BatteryWidgetModel PanelNamed() => BatterySourceKind.Controller, charging: true ), + Row("tablet", "Tablet", 100, BatteryStatus.Full, BatterySourceKind.Tablet), ], BatteryWidgetOptions.Default with { @@ -74,6 +75,14 @@ public static BatteryWidgetModel PanelTrend() => 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 { @@ -97,11 +106,19 @@ public static BatteryWidgetModel PanelList() => Row( "phone", "Phone", - 47, + 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 @@ -110,22 +127,53 @@ BatteryWidgetOptions.Default with } ); + public static BatteryWidgetModel PanelListDevice() => + new( + [ + Row("laptop", "Laptop", 76, BatteryStatus.Discharging, BatterySourceKind.System), + Row( + "controller", + "Controller", + 34, + BatteryStatus.Charging, + BatterySourceKind.Controller, + charging: true, + timeToFull: "1:10" + ), + Row("pen", "Pen", 91, BatteryStatus.Discharging, BatterySourceKind.Pen), + Row("earbuds", "Earbuds", 8, BatteryStatus.Discharging, BatterySourceKind.Earbuds), + ], + BatteryWidgetOptions.Default with + { + 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), - new( - "pen", - "Pen", - 64, - BatteryStatus.Discharging, - Charging: false, - Stale: true, - TimeToFull: null, - Kind: BatterySourceKind.Pen - ), + Row("pen", "Pen", 64, BatteryStatus.Discharging, BatterySourceKind.Pen, stale: true), ], BatteryWidgetOptions.Default ); @@ -170,6 +218,27 @@ public static BatteryWidgetModel TileLow() => 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, @@ -178,6 +247,7 @@ private static BatteryWidgetRow Row( BatterySourceKind kind, bool charging = false, string? timeToFull = null, - string? trend = null - ) => new(id, name, percent, status, charging, Stale: false, timeToFull, trend, kind); + string? trend = null, + bool stale = false + ) => new(id, name, percent, status, charging, stale, timeToFull, trend, kind); } diff --git a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs index f01cf9d..e0eb9b2 100644 --- a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs @@ -234,7 +234,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(9)); + Assert.That(ours, Has.Length.EqualTo(13)); using (Assert.EnterMultipleScope()) { Assert.That( From 9bc566c0a07c09e0735e42f346d48d051f05f032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 10:34:03 +0200 Subject: [PATCH 10/11] chore: align test package and release CLI with SDK beta.15, tidy widget comments --- .github/workflows/release.yml | 2 +- Directory.Packages.props | 2 +- src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs | 13 +++++++++---- src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs | 10 ++++++++-- src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs | 4 ++-- 5 files changed, 21 insertions(+), 10 deletions(-) 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/Directory.Packages.props b/Directory.Packages.props index 1bf8f16..74aeb13 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs index 82ee469..6060570 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs @@ -1,5 +1,6 @@ using System.Text.Json; using DeviceBatteryInfo.Core; +using MacroDeck.Localization; using MacroDeck.Ui.Components; using MacroDeck.Ui.Config; using MacroDeck.Ui.Config.Options; @@ -39,7 +40,7 @@ public static UiElement Build( flows.ValueKind == JsonValueKind.Array ? flows : EmptyFlows ); - // The rings layout has no room for a caption, only a short trend line. + // Only the panel has a layout to depend on; the tile shows every field. UiValue OnlyFor(string layoutValue) => isPanel ? UiValue.Of( @@ -159,7 +160,10 @@ UiValue OnlyFor(string layoutValue) => Key = "colors", Label = Strings.Widgets.Config.Colors.Label(), Options = Options( - (BatteryWidgetOptions.ColorsLevelsCharging, Strings.Widgets.Config.Colors.LevelsCharging()), + ( + 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()), @@ -187,6 +191,7 @@ UiValue OnlyFor(string layoutValue) => 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(), @@ -293,7 +298,7 @@ private static UiBooleanInput Toggle( }; private static UiValue> Options( - params (string Value, MacroDeck.Localization.LocalizedText Label)[] options + params (string Value, LocalizedText Label)[] options ) => UiValue.Of>( options.Select(o => UiOption.Of(o.Value) with { Label = o.Label }).ToArray() @@ -301,7 +306,7 @@ private static UiValue> Options( private static UiOption IconOption( string value, - MacroDeck.Localization.LocalizedText label, + LocalizedText label, string icon ) => UiOption.Of(value) with { Label = label, Icon = icon }; } diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs index b57efad..fbdd88c 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs @@ -631,6 +631,7 @@ private static IReadOnlyList FirstRow(IReadOnlyList @@ -705,7 +706,12 @@ private static UiStack TileWide(BatteryWidgetRow row, BatteryWidgetOptions optio if (caption is { } captionText) { - details.Add(CaptionText(captionText, UiComponentAlignments.Start) with { Size = UiSize.FromBasis(0.1, 0.9) }); + details.Add( + CaptionText(captionText, UiComponentAlignments.Start) with + { + Size = UiSize.FromBasis(0.1, 0.9), + } + ); } return new UiStack @@ -780,7 +786,7 @@ private static UiProgressReference Progress(int percent) => return Strings.Widgets.Caption.Full(); } - // `? row.Trend : null` would turn a null string into a non-null caption. + // 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; diff --git a/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs b/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs index 9bfcd1a..a415eaa 100644 --- a/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs +++ b/src/DeviceBatteryInfo/Ui/DeviceGlyphs.cs @@ -4,8 +4,8 @@ namespace DeviceBatteryInfo.Ui; -// Plugins ship no images. The renderer takes absolute M/L/H/V/A/Z only and fills nonzero, so a -// cut-out winds counter-clockwise. +// 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() From cc81fd73594ace71160af19e26f72ee86fefacd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 10:34:03 +0200 Subject: [PATCH 11/11] feat: refresh on a short press through the widget types' default action --- AGENTS.md | 21 +++---- README.md | 4 +- .../Actions/RefreshBatteryAction.cs | 4 +- .../BatteryIntegration.Widgets.cs | 11 +--- .../Ui/BatteryWidgetTypes.cs | 35 ++---------- src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs | 25 +++------ .../BatteryWidgetViewTests.cs | 56 +++---------------- 7 files changed, 35 insertions(+), 121 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4cd4c18..44fa681 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,22 +164,19 @@ Design knowledge that is not obvious from the code alone: name and caption have no `MinSize`. It switches the whole list, not each row, because an unsized first-fit takes its last layout's size and every row would be as tall as a stacked one. A progress bar's `StartColor` and `EndColor` are always the same hex - the renderer always paints a `linear-gradient`. -- **A widget press refreshes until the user binds flows.** 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. - A tree that declares a `press` event owns the gesture (`treeClaimsGesture`) and the host then skips - every flow, so `CreateWidgetSession` passes `BatteryWidgetView.Build` the refresh as `onPress` only - while `BatteryWidgetTypes.HasPressFlows` is false (no enabled action on a non-`onEvent` trigger, - mirroring the host's `hasRunnableFlow`). Saving flows changes the widget data, which makes the host - reopen the session, so the tree drops the press as soon as the user binds an action; to keep - refreshing they add "Refresh battery levels" to their flow. The press and flows cannot be mixed - (a tap refresh plus a long-press flow is impossible). `SupportsFlows` alone shows no action editor: +- **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. Once a host with PR 1111 ships (beta.15), a widget type can declare a - default Short Press action that runs until the user sets their own; that replaces this conditional - press with the same behaviour. + 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 diff --git a/README.md b/README.md index 23ea565..6ce98d4 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ low-battery threshold is red, even while charging: 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. Pressing a widget refreshes the levels until you assign actions to it; from then on it - runs your actions like any other widget (add the "Refresh battery levels" action to keep refreshing). + 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 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 cd0bc26..27b03b0 100644 --- a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs +++ b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs @@ -108,16 +108,7 @@ private static string Preview(JsonElement value) ); var state = new UiState(initial); - // Saving flows reopens the session, so the refresh stays the press only until the user binds one. - var view = new UiView( - surface, - BatteryWidgetView.Build( - localId, - state, - cornerRadius, - BatteryWidgetTypes.HasPressFlows(data) ? null : _polling.RequestRefresh - ) - ); + var view = new UiView(surface, BatteryWidgetView.Build(localId, state, cornerRadius)); void Refresh() => state.Set(Compute()); void OnRegistryChanged(object? sender, BatterySnapshotChangedEventArgs e) => Refresh(); diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs index 439d9fa..17ed49a 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using DeviceBatteryInfo.Actions; using MacroDeck.Sdk.Widgets; namespace DeviceBatteryInfo.Ui; @@ -24,6 +25,7 @@ public static bool Matches(string widgetTypeAttribute, string localId) => ) { SupportsFlows = true, + DefaultShortPressAction = RefreshOnPress, }, new WidgetTypeDescriptor( TileId, @@ -35,44 +37,19 @@ public static bool Matches(string widgetTypeAttribute, string localId) => ) { 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; - // Mirrors the host's hasRunnableFlow: an onEvent flow never runs on a press, and a flow whose - // actions are all disabled runs nothing. - public static bool HasPressFlows(JsonElement? data) - { - var flows = StoredFlows(data); - return flows.ValueKind == JsonValueKind.Array - && flows.EnumerateArray().Any(flow => !IsEventFlow(flow) && HasEnabledAction(flow)); - } - - private static bool IsEventFlow(JsonElement flow) => - flow.ValueKind == JsonValueKind.Object - && flow.TryGetProperty("triggerType", out var trigger) - && trigger.ValueKind == JsonValueKind.String - && string.Equals(trigger.GetString(), "onEvent", StringComparison.OrdinalIgnoreCase); - - private static bool HasEnabledAction(JsonElement flow) => - flow.ValueKind == JsonValueKind.Object - && flow.TryGetProperty("children", out var children) - && children.ValueKind == JsonValueKind.Array - && children - .EnumerateArray() - .Any(block => - block.ValueKind == JsonValueKind.Object - && !( - block.TryGetProperty("disabled", out var disabled) - && disabled.ValueKind == JsonValueKind.True - ) - ); - private const string DefaultData = """{"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}"""; diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs index fbdd88c..e60315e 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs @@ -41,27 +41,16 @@ private static readonly (double? Min, double? Max, double Aspect)[] AspectBucket (3.6, null, 4.4), ]; - // A press event claims the gesture and the host then skips the widget's flows, so pass onPress - // only while the widget has none. + // 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) { diff --git a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs index e0eb9b2..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; @@ -50,9 +51,7 @@ public void View_builds_a_tree(string widgetId) [TestCase(BatteryWidgetTypes.PanelId)] [TestCase(BatteryWidgetTypes.TileId)] - public void Without_a_press_handler_the_tree_declares_no_events_so_presses_run_the_flows( - string widgetId - ) + public void The_tree_declares_no_events_so_presses_reach_the_host(string widgetId) { var state = new UiState( new BatteryWidgetModel([Row(72)], BatteryWidgetOptions.Default) @@ -62,54 +61,13 @@ string widgetId Assert.That(JsonSerializer.Serialize(view.Tree), Does.Not.Contain("\"events\"")); } - [TestCase(BatteryWidgetTypes.PanelId, "battery-panel")] - [TestCase(BatteryWidgetTypes.TileId, "battery-tile")] - public void Pressing_the_widget_runs_the_press_callback(string widgetId, string rootId) - { - 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" }); - - Assert.That(result.IsAccepted, Is.True); - Assert.That(presses, Is.EqualTo(1)); - } - - [TestCase("""{}""", ExpectedResult = false)] - [TestCase("""{"flows":[]}""", ExpectedResult = false)] - [TestCase( - """{"flows":[{"triggerType":"onShortPress","children":[]}]}""", - ExpectedResult = false - )] - [TestCase( - """{"flows":[{"triggerType":"onShortPress","children":[{"disabled":true}]}]}""", - ExpectedResult = false - )] - [TestCase( - """{"flows":[{"triggerType":"onEvent","children":[{"actionId":"x"}]}]}""", - ExpectedResult = false - )] - [TestCase( - """{"flows":[{"triggerType":"onShortPress","children":[{"actionId":"x"}]}]}""", - ExpectedResult = true - )] - [TestCase( - """{"flows":[{"triggerType":"onLongPress","children":[{"actionId":"x"}]}]}""", - ExpectedResult = true - )] - public bool Only_an_enabled_action_on_a_press_trigger_counts_as_a_press_flow(string data) => - BatteryWidgetTypes.HasPressFlows(JsonDocument.Parse(data).RootElement); - [Test] - public void Missing_widget_data_has_no_press_flows() + public void Every_widget_type_refreshes_on_a_short_press_by_default() { - Assert.That(BatteryWidgetTypes.HasPressFlows(null), Is.False); + Assert.That( + BatteryWidgetTypes.All.Select(t => t.DefaultShortPressAction?.ActionId), + Is.All.EqualTo(RefreshBatteryAction.ActionId) + ); } [Test]