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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
87 changes: 67 additions & 20 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -112,27 +113,70 @@ Design knowledge that is not obvious from the code alone:
which conformance does not catch, so the `BatteryWidgetViewTests` build each tree through a real
`UiView`. A widget `config` surface is served by this plugin's own `IUiProvider.CreateSessionAsync`
(kind `"config"`, `entryPoint == "widget-config"`), not by the hosting config-flow path.
`BatteryWidgetConfigView` follows the host's own Action Button form: `UiTabs` (Devices, and Appearance
with the display switches under a "Show" heading) and `Segmented` choices whose `UiOption.Icon` names a `UiIcons` value for short icon choices.
The properties pane beside the actions editor is narrow (about 280 px): a switch in a row wraps its
label and stacks it above the switch, and one whose row partner is hidden jumps to the right, so every
switch gets its own line and only small segmented controls share a `UiConfigStack` row
(`Wrap = false`, `RowWeight = 1` each). Two segmented controls in one row must both be icon-only or
both text: the host pads a strip of icon-only options differently, so a mixed pair never lines up. Keep labels and option names short enough not to truncate at that width. Tabs and rows
only change rendering: the values stay in the session's `UiState`, and a field's node id stays its bare
key, which is what a `VisibleWhen` resolves against (only an object or array input starts a scope).
`BatteryWidgetViewTests` checks every condition names a field in the built tree.
**Every `UiLength` is a fraction of the view basis, not a pixel** - a bar needs both `MainSize`
(its box) and `Thickness` (its track), texts beside a `Fill` sibling need a `MainSize`, and
`Padding` is the corner-radius safe area (`BatteryWidgetView.SafeArea`, radius from the
`cornerRadius` surface attribute). Plugins ship no images, so state is colour + a caption.
`BatteryWidgetView` sizes the way the host's own Weather widget does: small type, one restrained
emphasis per row (the percentage, semibold, in the device colour), `UiSize.FromBasis(fraction)` of
the basis with a `maxOfCross` only as a safety rail for a wide, short widget. An absolute pixel
ceiling freezes every size a hair above a 1x1 tile and flattens the hierarchy (title, name and
percent all render the same size), so size relative to the basis instead. Each `Row` hugs its
content (headline + bar tight together) and the `Fill` body centres the row list with a fixed
inter-row gap; making the row or its `headline` `Fill` opens slack between the text and its bar and
reads as top-aligned text, so keep them content-sized. The tile percentage is the one deliberately
large, bold reading (a tile is one device). A progress bar's `StartColor` and `EndColor` are always
the same hex - the renderer always paints a `linear-gradient`, and a two-colour battery bar just
muddies the reading.
- **A widget press refreshes the batteries, and the host will not run user-bound flows for a plugin
widget.** `ExecuteActionButtonTriggerRequestMessageHandler` returns "nothing to do" for any widget whose
type is not built in, so an actions-list editor bound to `flows` saves fine and never fires. A tree that
declares a `press` event also owns the gesture (`treeClaimsGesture`), so the host skips the tile's own
triggers. `BatteryWidgetView.Build` therefore takes an optional `onPress`, passed only for a live widget
session (never for the sample or the previews), which calls `BatteryPollingService.RequestRefresh`.
`cornerRadius` surface attribute). Plugins ship no images: device icons are `UiShape` paths in
`DeviceGlyphs`, drawn in the unit square the renderer scales to the shape's box (so give the box a
square `UiFrame`), absolute `M L H V C Q A Z` only, filled nonzero - a solid part clockwise, a
cut-out counter-clockwise, and a cut-out never where two solid parts overlap (the winding sums to
1 there and it disappears). `BatteryWidgetViewTests` checks every path against the renderer's
grammar. A shape's `Color` takes a hex only, not a theme role, so glyphs and rings carry the state
colour (`BatteryWidgetRow.Color` for the widget's `colors` scheme, all Apple system colours so every
scheme has the same saturation; every scheme shows a level at or below the threshold in red even
while charging and a stale or unknown one in grey) and the percentage uses the primary text role. A ring is a full-turn `UiGauge`
inside a `UiModifier` with `Frame.AspectRatio = 1` and a `UiLayer` for the gauge, the bolt and the
face; the gauge is inset by half the bolt's height minus half its stroke, which puts a charging
bolt exactly in the gap the gauge leaves at the top (`StartAngle`/`EndAngle`, 0 is up, clockwise).
The face (glyph over percentage) must fit the gauge's inner circle, radius about 0.35 of the
diameter: the corners of the percentage line are what collide, so check them, not just the height.
Every `UiLength` is a fraction of the whole widget's basis, never of a grid cell, so the ring panel
estimates its ring diameter (`BatteryWidgetView.Arrange`: the column count that gives the largest
ring for the device count and aspect) and sizes each ring's parts reactively from that; a
`UiResponsive` picks the aspect bucket. The view builder rejects `Fill`/`MainSize` on a responsive
variant's root and on a modifier's child, and `BatteryWidgetViewTests` builds every layout through
a real `UiView` to catch that. The list layout sizes the way the host's own Weather widget does:
small type, `UiSize.FromBasis(fraction)` with a `maxOfCross` only as a safety rail; each row hugs
its content and the `Fill` body centres the rows. Next to a `Fill` sibling a text's measured width
is underestimated, so the list percentage has a fixed `MainSize`, sized to its own text so a
charging bolt sits beside the number; that width is estimated with `TextWidth` (four character
classes fitted to SF Pro Semibold, erring wide). The plugin never sees pixels or fonts, so wherever a
layout depends on whether text fits, let the reader measure with `UiFirstFit` (Macro Deck PR 1139)
instead of estimating.
The host's facts behind that, read from its renderer: the basis is `min(width, height)` of the
widget, a `UiResponsive` matches variants against the box its parent gives it (`MinAspect`, or
`MinWidth`/`MinHeight` in 120 px cells; min inclusive, max exclusive) and takes the **first** match,
so variants must not overlap (`Responsive_variants_never_overlap` checks every built tree), and a
text with `MinSize` shrinks toward it to fit its box
before it truncates. Texts in one row have no shrink priority, so a caption beside the name
truncates both when it does not fit: the list body is a `UiFirstFit` with the rows inline (caption
beside the name) first and stacked last. A shrunk text counts as fitting, so the inline layout's
name and caption have no `MinSize`. It switches the whole list, not each row, because an unsized
first-fit takes its last layout's size and every row would be as tall as a stacked one. A progress bar's `StartColor`
and `EndColor` are always the same hex - the renderer always paints a `linear-gradient`.
- **A short press refreshes until the user sets their own Short Press action.** Both widget types set
`SupportsFlows` (Macro Deck PR 960), so the host runs the actions the user bound to the widget, like a
built-in one, and `DefaultShortPressAction` (PR 1111, host and SDK beta.15) names this plugin's own
`refresh` action (`RefreshBatteryAction.ActionId`). The host runs that default only while the
widget's Short Press flow is missing, empty or fully disabled, so the user's action always wins and
a Long Press flow works beside the default refresh. The tree must declare no `press` event: one owns
the gesture (`treeClaimsGesture`), and the host then skips both the flows and the default. A host
older than beta.15 ignores the default, so a press there does nothing. `SupportsFlows` alone shows no action editor:
the host runs the flows under the widget data's top-level `flows` key, so `BatteryWidgetConfigView`
serves a `UiActionsListEditor` bound to `flows` in the `UiWidgetConfiguration.Editor` region (seeded
from the stored data, and allowed by the `DataSchema`, whose `additionalProperties: false` would
otherwise reject it). Without an `Editor` region the desktop draws the properties as one full-width
pane, which looks stretched.
- **Widget previews:** `Ui/BatteryWidgetPreviews.cs` has one `static` parameterless method per
scenario, each `[UiPreview(name, View = nameof(BatteryWidgetView), Profile = UiPreviewProfiles.Widget)]`
returning a `UiElement`. `UiPreviewCatalog.Scan` (run by the hosting `ui` capability over the
Expand All @@ -153,7 +197,10 @@ Design knowledge that is not obvious from the code alone:
and `PercentPerHour` into a signed rate for automations. History is in-memory only and is lost on
every plugin restart or update by design - it self-heals within `MinWindow`, which is simpler than
persisting it under `MACRO_DECK_PLUGIN_DATA_DIRECTORY`. The widget caption falls back to the trend
text when there is no time-to-full to show (most sources never report one), and both the `trend`
text when there is no time-to-full to show (most sources never report one). The rings layout has no
caption; its own `showRingTrend` flag (default off) adds a muted trend line under each ring, below
the name, and `Arrange` reserves room for it. It is a separate key rather than `showTrend` because
released widgets already store `showTrend: true`, which would shrink every existing ring. Both the `trend`
and `trend-rate` variable suffixes are public API like every other field suffix in
`BatteryVariableCatalog`.
- **Bluetooth battery data lives on a different PnP node than the one the user picks, and is only
Expand Down
12 changes: 6 additions & 6 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="MacroDeck.Localization" Version="3.0.0-beta.13" />
<PackageVersion Include="MacroDeck.Plugin.Analyzers" Version="3.0.0-beta.13" />
<PackageVersion Include="MacroDeck.Plugin.Hosting" Version="3.0.0-beta.13" />
<PackageVersion Include="MacroDeck.Plugin.Serilog" Version="3.0.0-beta.13" />
<PackageVersion Include="MacroDeck.Plugin.Testing" Version="3.0.0-beta.13" />
<PackageVersion Include="MacroDeck.Sdk" Version="3.0.0-beta.13" />
<PackageVersion Include="MacroDeck.Localization" Version="3.0.0-beta.15" />
<PackageVersion Include="MacroDeck.Plugin.Analyzers" Version="3.0.0-beta.15" />
<PackageVersion Include="MacroDeck.Plugin.Hosting" Version="3.0.0-beta.15" />
<PackageVersion Include="MacroDeck.Plugin.Serilog" Version="3.0.0-beta.15" />
<PackageVersion Include="MacroDeck.Plugin.Testing" Version="3.0.0-beta.15" />
<PackageVersion Include="MacroDeck.Sdk" Version="3.0.0-beta.15" />
</ItemGroup>
<ItemGroup>
<!-- Cross-platform HID access for feature-report battery reads (e.g. Razer wireless mice). -->
Expand Down
25 changes: 20 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@ PROJECT := src/DeviceBatteryInfo
MANIFEST := $(PROJECT)/manifest.json

STATE := $(PROJECT)/.macrodeck-dev-state
RUN := macrodeck-plugin run --project $(PROJECT) --state-directory $(STATE)

UTF8 := $(if $(filter Windows_NT,$(OS)),chcp.com 65001 >/dev/null &&)
RUN := $(UTF8) macrodeck-plugin run --project $(PROJECT) --state-directory $(STATE)

# The SDK version, for keeping the macrodeck-plugin CLI in step. Read inside recipes rather than with
# $(shell): GnuWin32's make 3.81 sometimes runs $(shell) with an empty command line.
SDK := grep -o 'MacroDeck.Sdk" Version="[^"]*' Directory.Packages.props | cut -d'"' -f3
TESTS := dotnet test DeviceBatteryInfo.slnx --configuration Release --filter "Category!=Hardware"

RID := $(if $(filter Windows_NT,$(OS)),win-x64,osx-arm64)

# Store images: every [UiPreview] scenario at each deck shape. Override on the command line,
# e.g. make preview CELLS="--cells 2x2" PREVIEW_ARGS="--theme light".
CELLS := --cells 1x1 --cells 2x1 --cells 2x2
PREVIEWS := artifacts/previews

.DEFAULT_GOAL := help
.PHONY: help cli build test test-hardware run watch stub pack conformance update release
.PHONY: help cli build test test-hardware run watch stub preview pack conformance update release

help:
@echo "make cli install/update the macrodeck-plugin CLI to the SDK version ($$($(SDK)))"
Expand All @@ -19,7 +29,8 @@ help:
@echo "make run run the plugin against the running Macro Deck"
@echo "make watch the same, with hot reload / restart on every saved change"
@echo "make stub run the plugin against a disposable stub host (no Macro Deck needed)"
@echo "make pack build the .macroDeckPlugin into artifacts/ and inspect it"
@echo "make preview render the widget previews to PNGs in $(PREVIEWS)/ (store images)"
@echo "make pack build this platform's .macroDeckPlugin ($(RID)) into artifacts/ and inspect it"
@echo "make conformance run the conformance suite, report in conformance.md"
@echo "make update bump every package to its newest release (review the diff)"
@echo "make release VERSION=x.y.z"
Expand All @@ -44,11 +55,15 @@ watch:
$(RUN) --watch

stub:
macrodeck-plugin run --project $(PROJECT) --stub-host
$(UTF8) macrodeck-plugin run --project $(PROJECT) --stub-host

preview:
rm -rf $(PREVIEWS)
$(UTF8) macrodeck-plugin preview render --project $(PROJECT) $(CELLS) --output $(PREVIEWS) $(PREVIEW_ARGS)

pack:
rm -f artifacts/*.macroDeckPlugin
macrodeck-plugin build --source $(PROJECT) --output ./artifacts
macrodeck-plugin build --source $(PROJECT) --rid $(RID) --output ./artifacts
macrodeck-plugin inspect --artifact "$$(ls artifacts/*.macroDeckPlugin)"

conformance:
Expand Down
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,18 @@ peripherals, right on your deck.
## Features

**Two deck widgets**, each with a config form to choose which devices it shows and what it displays
(level bar, percentage, charging indicator, time to full, battery trend, low-battery threshold):

- **Battery panel** shows several devices at once.
- **Battery tile** shows a single device. Pressing a widget refreshes the levels immediately.
(level ring or bar, percentage, charging indicator, time to full, battery trend, low-battery threshold).
Each device is drawn with an icon for its kind and a coloured ring, with a bolt in the ring's gap while it
charges. The colour scheme is a widget setting: by level with cyan while charging (the default), by
level only, green only, by device type, or a smooth gradient. In every scheme a level at or below the
low-battery threshold is red, even while charging:

- **Battery panel** shows several devices at once, as a grid of rings that arranges itself to the
widget's size (optionally with names), or as a list of rows with bars aligned to the top, centre or
bottom.
- **Battery tile** shows a single device as one large ring, with name, level and state beside it on a
wide tile. A short press refreshes the levels unless you give the widget a Short Press action of
your own; actions on other triggers (such as a long press) run alongside it like on any other widget.

Widgets update live between polls. The battery trend is shown as a signed change over the window it
covers, for example `-13%/1h` while discharging or `+28%/30m` while charging. It needs a couple of
Expand Down Expand Up @@ -152,7 +160,9 @@ Build and tests need no Macro Deck installation.
The [Makefile](Makefile) wraps the everyday commands (`make` lists them): `make run` / `make watch`
launch the plugin against the running Macro Deck through `macrodeck-plugin run` (pairing once, the
credential kept in `src/DeviceBatteryInfo/.macrodeck-dev-state/`), `make stub` against a stub host,
`make cli` keeps the CLI at the SDK's version, `make pack` builds and inspects the artifact, and
`make preview` renders the widget previews to PNGs in `artifacts/previews/` (the store images),
`make cli` keeps the CLI at the SDK's version, `make pack` builds and inspects the artifact for this
machine's platform (`win-x64` on Windows, `osx-arm64` otherwise; the release workflow builds both), and
`make release VERSION=x.y.z` tests and packs, bumps `manifest.json`, commits, tags `vx.y.z` and pushes -
the tag starts the release workflow. On Windows it needs GNU make and Git Bash's `sh` on `PATH`.

Expand Down
4 changes: 3 additions & 1 deletion src/DeviceBatteryInfo/Actions/RefreshBatteryAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading
Loading