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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -299,7 +310,8 @@ bool Flag(string key, bool fallback) =>
listAlign,
Flag("showRingTrend", false),
NonEmpty(appearance.BackgroundColor),
NonEmpty(appearance.LabelColor)
NonEmpty(appearance.LabelColor),
ringSize
);
}

Expand Down
3 changes: 3 additions & 0 deletions src/DeviceBatteryInfo/Localization/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@
<data name="Widgets.Config.LowThreshold.Label" xml:space="preserve">
<value>Low battery at (%)</value>
</data>
<data name="Widgets.Config.RingSize.Label" xml:space="preserve">
<value>Ring size (%)</value>
</data>

<data name="ConfigFlow.Device.Title" xml:space="preserve">
<value>Add a battery device</value>
Expand Down
14 changes: 14 additions & 0 deletions src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public static UiElement Build(
var showTrend = new UiState<bool>(current.ShowTrend);
var showRingTrend = new UiState<bool>(current.ShowRingTrend);
var lowThreshold = new UiState<double>(current.LowThreshold);
var ringSize = new UiState<double>(current.RingSize);
var sort = new UiState<string>(BatteryWidgetOptions.SortValue(current.Sort));
var title = new UiState<string>(current.Title);
var colors = new UiState<string>(BatteryWidgetOptions.ColorsValue(current.Colors));
Expand Down Expand Up @@ -185,6 +186,19 @@ UiValue<UiVisibleWhen> 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(
[
Expand Down
8 changes: 7 additions & 1 deletion src/DeviceBatteryInfo/Ui/BatteryWidgetModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions src/DeviceBatteryInfo/Ui/BatteryWidgetPreviews.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/DeviceBatteryInfo/Ui/BatteryWidgetSamples.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
{
Expand All @@ -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"],
Expand Down
22 changes: 19 additions & 3 deletions src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -344,13 +344,17 @@ Func<double> diameter
private static UiSize OfDiameter(Func<double> 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<double> diameter,
Func<double> room,
bool showPercent
)
{
var scale = options.RingScale;
Func<double> 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;
Expand Down Expand Up @@ -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 },
};
}
Expand Down Expand Up @@ -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)],
Expand Down
62 changes: 54 additions & 8 deletions tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -255,19 +255,65 @@ model with
);
}

private static Dictionary<string, string?> 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<string>().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<BatteryWidgetModel>(model),
16
)
BatteryWidgetView.Build(widgetId, new UiState<BatteryWidgetModel>(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<string, string?> TextColors(BatteryWidgetModel model)
{
var root = TreeJson(BatteryWidgetTypes.TileId, model);

var colors = new Dictionary<string, string?>
{
Expand Down
Loading