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