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
{