Spectre.Console 的 Align.Width 為何未能縮窄 Panel?
Measure twice, render once: why Align.Width didn't shrink a Spectre.Console panel
Spectre.Console 在 0.56.0 修正了 Align 設定明確 Width 時,Panel 邊框仍延伸至整個主控台的問題。原因是 Align.Render 會遵守 Width,但 Measure 沒有覆寫預設測量方式,因而回報可用的最大寬度。修正後會按指定寬度測量並限制子元件的最小寬度,測試則直接檢查 Measure 的結果。
This was my first contribution to Spectre.Console: PR #2101, merged on 25 April 2026 and released in 0.56.0, whose release notes include the line "@granttotinov made their first contribution". The fix is eight lines. Understanding why those eight lines were needed meant learning how Spectre.Console lays out widgets, and that's what this post is mostly about.
The symptom
Issue #1910, from @LittleLittleCloud, describes it: put an Align with an explicit width inside a Panel, and the panel's border still stretches across the whole console.
console.Profile.Width = 50;
console.Write(new Panel(Align.Left(new Text("Hello")).Width(20)));
Same code, before and after the fix:
0.55.0
┌────────────────────────────────────────────────┐
│ Hello │
└────────────────────────────────────────────────┘
0.56.0
┌──────────────────────┐
│ Hello │
└──────────────────────┘
The second panel is 24 columns wide: 20 for the content, plus one column of padding and one of border on each side.
Two passes: measure, then render
Every Spectre.Console widget is an IRenderable with two methods:
-
Measure(options, maxWidth)returns aMeasurement(Min, Max): the narrowest the widget can be, and the widest it would like to be, withinmaxWidth. -
Render(options, maxWidth)produces the actual segments of text.
Containers like Panel, Table and Columns call Measure on their children first, decide how wide each child gets, and only then call Render. A panel that isn't set to expand sizes itself from its child's measurement.
The base class has a deliberately simple default:
protected virtual Measurement Measure(RenderOptions options, int maxWidth)
{
return new Measurement(maxWidth, maxWidth);
}
"I want all the space you have." That's a safe default for a widget that doesn't know better, and Align didn't override it. Its Render method used Width correctly, so the text was aligned inside a 20-column area. But when the panel asked how wide it should be, Align answered "50", and the panel drew its border at 50.
The fix
protected override Measurement Measure(RenderOptions options, int maxWidth)
{
var width = Math.Min(Width ?? maxWidth, maxWidth);
var measurement = _renderable.Measure(options with { Height = null }, width);
return new Measurement(Math.Min(measurement.Min, width), width);
}
In words: the width I'll occupy is my explicit Width, if there is one, capped at what the parent can offer. Ask the child how it measures within that width. Report that width as my maximum, and the child's minimum as my minimum, but never more than my width.
The issue already sketched an override. The version that got merged differs in that last Math.Min, and that came from review.
What review changed
The first review was automated, from Copilot, and it made two points that both changed the PR.
The minimum has to be clamped too. My first version returned new Measurement(measurement.Min, width). The review pointed out that a child's Min can be larger than width. Paragraph, for example, doesn't clamp its minimum for unbreakable content, so a long word reports a minimum wider than the space it's given. A measurement where Min > Max makes no sense, and a Table or Columns reading it would reserve more space than the explicit Width allows, which defeats the point of setting it. Clamping Min keeps the measurement consistent: Min <= Max == width.
The test has to exercise Measure, not Render. My first test rendered Align.Left(new Panel("Hello World!")).Width(20) and checked the output. The review noted that this would pass without the fix, because Align.Render already respected Width. The bug only shows up when a parent measures the Align. The final test calls Measure directly:
[Fact]
public void Should_Return_Explicit_Width_When_Set()
{
// Given
var console = new TestConsole().Size(new Size(40, 15));
var align = Align.Left(new Panel("Hello World!")).Width(20);
// When
var measurement = ((IRenderable)align)
.Measure(RenderOptions.Create(console), 40);
// Then
Assert.Equal(20, measurement.Max);
}
A test that passes with and without the fix doesn't test the fix. The cheapest check is to run a new test against the old code and watch it fail.
The public API snapshot
Spectre.Console keeps a verified snapshot of its entire public API surface (Public_API.Output.verified.txt), and the build fails if the surface changes without the snapshot being updated. A new protected override counts as a public API change, because subclasses can see it. I hadn't updated the snapshot, so @patriksvensson pushed a commit that did, then merged it with "Thank you for your contribution. Much appreciated! 👍". For a first PR to a project, that's about as smooth as it gets. It's still worth looking for snapshot tests like this before opening one.
What I'd tell someone writing a Spectre.Console widget
- If your widget can be narrower than the space it's offered, override
Measure. The default claims everything. - Keep
MeasureandRenderin agreement. IfRenderuses a width,Measureshould report the same width. - Keep
Min <= Max, and clamp anything you get from a child. - Test measurement directly, or through a parent that depends on it, like
Panel,TableorColumns.
References
- spectreconsole/spectre.console issue #1910: Align Measure always return maxWidth even when I explicitly set Width.
- spectreconsole/spectre.console PR #2101: Fix Align measure to respect explicitly set width.
- Spectre.Console 0.56.0 release notes.
- Spectre.Console docs, Align widget.
來源:DEV Community · dev.to