diff --git a/AGENTS.md b/AGENTS.md index 99c6255..125e026 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,6 +162,9 @@ Design knowledge that is not obvious from the code alone: 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. + The ring fills its slot and the diameter only sizes its parts, so `ringSize` (50-100 %, default + 100) shrinks a ring by capping its frame (`MaxWidth`/`MaxHeight`) at the scaled diameter and sizing + the parts from that; at 100 % the frame stays uncapped, so existing widgets render as before. Every `UiLength` is a fraction of the whole widget's basis, never of a grid cell, so the ring panel estimates its ring diameter (`BatteryWidgetView.Arrange`: the column count that gives the largest ring for the device count and aspect) and sizes each ring's parts reactively from that; a diff --git a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs index 722c8c8..1aad0d3 100644 --- a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs +++ b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs @@ -281,6 +281,17 @@ bool Flag(string key, bool fallback) => ? BatteryWidgetOptions.ParseListAlign(listAlignValue.GetString()) : BatteryWidgetOptions.Default.ListAlign; + var ringSize = + obj.TryGetProperty("ringSize", out var ringSizeValue) + && ringSizeValue.ValueKind == JsonValueKind.Number + && ringSizeValue.TryGetInt32(out var parsedRingSize) + ? Math.Clamp( + parsedRingSize, + BatteryWidgetOptions.MinRingSize, + BatteryWidgetOptions.MaxRingSize + ) + : BatteryWidgetOptions.MaxRingSize; + var appearance = UiWidgetAppearance.Read(obj); return new BatteryWidgetOptions( @@ -299,7 +310,8 @@ bool Flag(string key, bool fallback) => listAlign, Flag("showRingTrend", false), NonEmpty(appearance.BackgroundColor), - NonEmpty(appearance.LabelColor) + NonEmpty(appearance.LabelColor), + ringSize ); } diff --git a/src/DeviceBatteryInfo/Localization/Strings.resx b/src/DeviceBatteryInfo/Localization/Strings.resx index 47933a5..835c143 100644 --- a/src/DeviceBatteryInfo/Localization/Strings.resx +++ b/src/DeviceBatteryInfo/Localization/Strings.resx @@ -215,6 +215,9 @@ Low battery at (%) + + Ring size (%) + Add a battery device diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs index 76f5241..69db47a 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs @@ -30,6 +30,7 @@ public static UiElement Build( var showTrend = new UiState(current.ShowTrend); var showRingTrend = new UiState(current.ShowRingTrend); var lowThreshold = new UiState(current.LowThreshold); + var ringSize = new UiState(current.RingSize); var sort = new UiState(BatteryWidgetOptions.SortValue(current.Sort)); var title = new UiState(current.Title); var colors = new UiState(BatteryWidgetOptions.ColorsValue(current.Colors)); @@ -185,6 +186,19 @@ UiValue OnlyFor(string layoutValue) => Binding = Bind.To(lowThreshold), } ); + appearance.Add( + new UiNumberInput + { + Key = "ringSize", + Label = Strings.Widgets.Config.RingSize.Label(), + Min = BatteryWidgetOptions.MinRingSize, + Max = BatteryWidgetOptions.MaxRingSize, + Step = 5, + ShowSlider = true, + Binding = Bind.To(ringSize), + VisibleWhen = OnlyFor(BatteryWidgetOptions.LayoutRings), + } + ); appearance.AddRange( [ diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs index 09a3bfc..38df6ec 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs @@ -164,9 +164,15 @@ internal sealed record BatteryWidgetOptions( BatteryListAlignment ListAlign = BatteryListAlignment.Top, bool ShowRingTrend = false, string? BackgroundColor = null, - string? TextColor = null + string? TextColor = null, + int RingSize = BatteryWidgetOptions.MaxRingSize ) { + public const int MinRingSize = 50; + public const int MaxRingSize = 100; + + public double RingScale => Math.Clamp(RingSize, MinRingSize, MaxRingSize) / 100.0; + public static readonly BatteryWidgetOptions Default = new( [], ShowBar: true, diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs index 90b433e..44b604b 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs @@ -113,6 +113,20 @@ internal static class BatteryWidgetPreviews public static UiElement PanelListCustomColors() => PanelOf(BatteryWidgetSamples.PanelListCustomColors()); + [UiPreview( + "Tile - smaller ring", + View = nameof(BatteryWidgetView), + Profile = UiPreviewProfiles.Widget + )] + public static UiElement TileSmallRing() => TileOf(BatteryWidgetSamples.TileSmallRing()); + + [UiPreview( + "Panel - smaller rings", + View = nameof(BatteryWidgetView), + Profile = UiPreviewProfiles.Widget + )] + public static UiElement PanelSmallRings() => PanelOf(BatteryWidgetSamples.PanelSmallRings()); + 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 fc70afc..78ceb45 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs @@ -266,6 +266,18 @@ public static BatteryWidgetModel PanelListCustomColors() => }, }; + public static BatteryWidgetModel TileSmallRing() => + TileDischarging() with + { + Options = BatteryWidgetOptions.Default with { RingSize = 60 }, + }; + + public static BatteryWidgetModel PanelSmallRings() => + PanelNamed() with + { + Options = PanelNamed().Options with { RingSize = 70 }, + }; + private static BatteryWidgetRow Row( string id, string name, diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs index 81005a3..543834d 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs @@ -60,7 +60,7 @@ public static JsonElement StoredFlows(JsonElement? data) => : default; private const string DefaultData = - """{"sourceIds":[],"showBar":true,"showPercent":true,"showCharging":true,"showTimeToFull":true,"showTrend":true,"lowThreshold":20,"sort":"manual","title":"","layout":"rings","showNames":false,"colors":"levels-charging","listAlign":"top","showRingTrend":false}"""; + """{"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,"ringSize":100}"""; private const string Schema = """ { @@ -83,6 +83,13 @@ public static JsonElement StoredFlows(JsonElement? data) => "description": "Show the recent charge/drain rate (for example -13%/1h) when there is no time-to-full to show instead." }, "lowThreshold": { "type": "integer", "minimum": 1, "maximum": 99, "default": 20 }, + "ringSize": { + "type": "integer", + "minimum": 50, + "maximum": 100, + "default": 100, + "description": "The ring's size in percent of the room it has. The list layout has no ring." + }, "sort": { "type": "string", "enum": ["manual", "lowest-first", "alphabetical", "charging-first"], diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs index 168edf9..07e5fcb 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs @@ -344,13 +344,17 @@ Func diameter private static UiSize OfDiameter(Func diameter, double fraction) => UiSize.From(() => UiLength.OfBasis(fraction * diameter())); + // The ring fills its slot and `room` only estimates that slot, so a smaller ring caps its frame + // and sizes its parts from the capped diameter. private static UiModifier Ring( BatteryWidgetRow row, BatteryWidgetOptions options, - Func diameter, + Func room, bool showPercent ) { + var scale = options.RingScale; + Func diameter = scale < 1 ? () => room() * scale : room; var color = row.Color(options.LowThreshold, options.Colors); var charging = options.ShowCharging && row.Charging; var gap = charging ? ChargingGapDegrees : 0; @@ -438,7 +442,19 @@ bool showPercent { Key = "ring", Fill = true, - Frame = new UiFrame { AspectRatio = 1 }, + Frame = + scale < 1 + ? UiValue.From(() => + { + var edge = UiLength.OfBasis(diameter()); + return new UiFrame + { + AspectRatio = 1, + MaxWidth = edge, + MaxHeight = edge, + }; + }) + : new UiFrame { AspectRatio = 1 }, Child = new UiLayer { Key = "ring-layers", Children = layers }, }; } @@ -779,7 +795,7 @@ private static UiStack TileWide(BatteryWidgetRow row, BatteryWidgetOptions optio new UiStack { Key = "ring-slot", - MainSize = diameter, + MainSize = diameter * options.RingScale, Direction = UiComponentDirections.Vertical, Justify = UiComponentJustify.Center, Children = [Ring(row, options, () => diameter, showPercent: false)], diff --git a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs index 0c198cd..c56942c 100644 --- a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs @@ -200,7 +200,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(16)); + Assert.That(ours, Has.Length.EqualTo(18)); using (Assert.EnterMultipleScope()) { Assert.That( @@ -255,19 +255,65 @@ model with ); } - private static Dictionary TextColors(BatteryWidgetModel model) + // The ring fills its slot, so only a capped frame makes it smaller; full size keeps the old tree. + [TestCase(BatteryWidgetTypes.TileId)] + [TestCase(BatteryWidgetTypes.PanelId)] + public void A_smaller_ring_caps_its_frame_and_full_size_does_not(string widgetId) + { + var model = + widgetId == BatteryWidgetTypes.TileId + ? BatteryWidgetSamples.TileDischarging() + : BatteryWidgetSamples.PanelNamed(); + + System.Text.Json.Nodes.JsonNode? RingFrame(int ringSize) + { + var root = TreeJson( + widgetId, + model with + { + Options = model.Options with { RingSize = ringSize }, + } + ); + System.Text.Json.Nodes.JsonNode? Find(System.Text.Json.Nodes.JsonNode node) + { + if (node["id"]!.GetValue().EndsWith(".ring", StringComparison.Ordinal)) + { + return node["properties"]?["frame"]; + } + + return (node["children"]?.AsArray() ?? []) + .Select(child => Find(child!)) + .FirstOrDefault(frame => frame is not null); + } + + return Find(root); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(RingFrame(100)?["maxWidth"], Is.Null); + Assert.That(RingFrame(60)?["maxWidth"], Is.Not.Null); + Assert.That(RingFrame(60)?["maxHeight"], Is.Not.Null); + } + } + + private static System.Text.Json.Nodes.JsonNode TreeJson( + string widgetId, + BatteryWidgetModel model + ) { var view = new UiView( WidgetSurface(), - BatteryWidgetView.Build( - BatteryWidgetTypes.TileId, - new UiState(model), - 16 - ) + BatteryWidgetView.Build(widgetId, new UiState(model), 16) ); - var root = System.Text.Json.Nodes.JsonNode.Parse( + return System.Text.Json.Nodes.JsonNode.Parse( MacroDeck.Ui.Model.Serialization.UiCanonicalJson.Serialize(view.Tree.Root) )!; + } + + private static Dictionary TextColors(BatteryWidgetModel model) + { + var root = TreeJson(BatteryWidgetTypes.TileId, model); var colors = new Dictionary {