<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Grant Totinov, M. Eng.</title>
    <description>The latest articles on DEV Community by Grant Totinov, M. Eng. (@granttotinov).</description>
    <link>https://dev.to/granttotinov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4189698%2F61866765-f152-43cc-b88f-b6797bacee5f.jpg</url>
      <title>DEV Community: Grant Totinov, M. Eng.</title>
      <link>https://dev.to/granttotinov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/granttotinov"/>
    <language>en</language>
    <item>
      <title>Measure twice, render once: why Align.Width didn't shrink a Spectre.Console panel</title>
      <dc:creator>Grant Totinov, M. Eng.</dc:creator>
      <pubDate>Tue, 27 Oct 2026 02:42:27 +0000</pubDate>
      <link>https://dev.to/granttotinov/measure-twice-render-once-why-alignwidth-didnt-shrink-a-spectreconsole-panel-2358</link>
      <guid>https://dev.to/granttotinov/measure-twice-render-once-why-alignwidth-didnt-shrink-a-spectreconsole-panel-2358</guid>
      <description>&lt;p&gt;This was my first contribution to &lt;a href="https://github.com/spectreconsole/spectre.console" rel="noopener noreferrer"&gt;Spectre.Console&lt;/a&gt;: &lt;a href="https://github.com/spectreconsole/spectre.console/pull/2101" rel="noopener noreferrer"&gt;PR #2101&lt;/a&gt;, merged on 25 April 2026 and released in &lt;a href="https://github.com/spectreconsole/spectre.console/releases/tag/0.56.0" rel="noopener noreferrer"&gt;0.56.0&lt;/a&gt;, whose release notes include the line "&lt;a class="mentioned-user" href="https://dev.to/granttotinov"&gt;@granttotinov&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Issue &lt;a href="https://github.com/spectreconsole/spectre.console/issues/1910" rel="noopener noreferrer"&gt;#1910&lt;/a&gt;, from &lt;a href="https://github.com/LittleLittleCloud" rel="noopener noreferrer"&gt;@LittleLittleCloud&lt;/a&gt;, describes it: put an &lt;code&gt;Align&lt;/code&gt; with an explicit width inside a &lt;code&gt;Panel&lt;/code&gt;, and the panel's border still stretches across the whole console.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Width&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Align&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Hello"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;Width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code, before and after the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.55.0
┌────────────────────────────────────────────────┐
│ Hello                                          │
└────────────────────────────────────────────────┘

0.56.0
┌──────────────────────┐
│ Hello                │
└──────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second panel is 24 columns wide: 20 for the content, plus one column of padding and one of border on each side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two passes: measure, then render
&lt;/h2&gt;

&lt;p&gt;Every Spectre.Console widget is an &lt;code&gt;IRenderable&lt;/code&gt; with two methods:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Measure(options, maxWidth)&lt;/code&gt; returns a &lt;code&gt;Measurement(Min, Max)&lt;/code&gt;: the narrowest the widget can be, and the widest it would like to be, within &lt;code&gt;maxWidth&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Render(options, maxWidth)&lt;/code&gt; produces the actual segments of text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Containers like &lt;code&gt;Panel&lt;/code&gt;, &lt;code&gt;Table&lt;/code&gt; and &lt;code&gt;Columns&lt;/code&gt; call &lt;code&gt;Measure&lt;/code&gt; on their children first, decide how wide each child gets, and only then call &lt;code&gt;Render&lt;/code&gt;. A panel that isn't set to expand sizes itself from its child's measurement.&lt;/p&gt;

&lt;p&gt;The base class has a deliberately simple default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;virtual&lt;/span&gt; &lt;span class="n"&gt;Measurement&lt;/span&gt; &lt;span class="nf"&gt;Measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RenderOptions&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Measurement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"I want all the space you have." That's a safe default for a widget that doesn't know better, and &lt;code&gt;Align&lt;/code&gt; didn't override it. Its &lt;code&gt;Render&lt;/code&gt; method used &lt;code&gt;Width&lt;/code&gt; correctly, so the text was aligned inside a 20-column area. But when the panel asked how wide it should be, &lt;code&gt;Align&lt;/code&gt; answered "50", and the panel drew its border at 50.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="n"&gt;Measurement&lt;/span&gt; &lt;span class="nf"&gt;Measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RenderOptions&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Width&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;measurement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_renderable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Height&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Measurement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;measurement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In words: the width I'll occupy is my explicit &lt;code&gt;Width&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;The issue already sketched an override. The version that got merged differs in that last &lt;code&gt;Math.Min&lt;/code&gt;, and that came from review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What review changed
&lt;/h2&gt;

&lt;p&gt;The first review was automated, from Copilot, and it made two points that both changed the PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The minimum has to be clamped too.&lt;/strong&gt; My first version returned &lt;code&gt;new Measurement(measurement.Min, width)&lt;/code&gt;. The review pointed out that a child's &lt;code&gt;Min&lt;/code&gt; can be larger than &lt;code&gt;width&lt;/code&gt;. &lt;code&gt;Paragraph&lt;/code&gt;, 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 &lt;code&gt;Min &amp;gt; Max&lt;/code&gt; makes no sense, and a &lt;code&gt;Table&lt;/code&gt; or &lt;code&gt;Columns&lt;/code&gt; reading it would reserve more space than the explicit &lt;code&gt;Width&lt;/code&gt; allows, which defeats the point of setting it. Clamping &lt;code&gt;Min&lt;/code&gt; keeps the measurement consistent: &lt;code&gt;Min &amp;lt;= Max == width&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test has to exercise &lt;code&gt;Measure&lt;/code&gt;, not &lt;code&gt;Render&lt;/code&gt;.&lt;/strong&gt; My first test rendered &lt;code&gt;Align.Left(new Panel("Hello World!")).Width(20)&lt;/code&gt; and checked the output. The review noted that this would pass without the fix, because &lt;code&gt;Align.Render&lt;/code&gt; already respected &lt;code&gt;Width&lt;/code&gt;. The bug only shows up when a parent measures the &lt;code&gt;Align&lt;/code&gt;. The final test calls &lt;code&gt;Measure&lt;/code&gt; directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Should_Return_Explicit_Width_When_Set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Given&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;console&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TestConsole&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;align&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Align&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Hello World!"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;Width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// When&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;measurement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;IRenderable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;align&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RenderOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;40&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Then&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;measurement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Max&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public API snapshot
&lt;/h2&gt;

&lt;p&gt;Spectre.Console keeps a verified snapshot of its entire public API surface (&lt;code&gt;Public_API.Output.verified.txt&lt;/code&gt;), and the build fails if the surface changes without the snapshot being updated. A new &lt;code&gt;protected override&lt;/code&gt; counts as a public API change, because subclasses can see it. I hadn't updated the snapshot, so &lt;a href="https://github.com/patriksvensson" rel="noopener noreferrer"&gt;@patriksvensson&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone writing a Spectre.Console widget
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If your widget can be narrower than the space it's offered, override &lt;code&gt;Measure&lt;/code&gt;. The default claims everything.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;Measure&lt;/code&gt; and &lt;code&gt;Render&lt;/code&gt; in agreement. If &lt;code&gt;Render&lt;/code&gt; uses a width, &lt;code&gt;Measure&lt;/code&gt; should report the same width.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;Min &amp;lt;= Max&lt;/code&gt;, and clamp anything you get from a child.&lt;/li&gt;
&lt;li&gt;Test measurement directly, or through a parent that depends on it, like &lt;code&gt;Panel&lt;/code&gt;, &lt;code&gt;Table&lt;/code&gt; or &lt;code&gt;Columns&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;spectreconsole/spectre.console issue &lt;a href="https://github.com/spectreconsole/spectre.console/issues/1910" rel="noopener noreferrer"&gt;#1910&lt;/a&gt;: Align Measure always return maxWidth even when I explicitly set Width.&lt;/li&gt;
&lt;li&gt;spectreconsole/spectre.console PR &lt;a href="https://github.com/spectreconsole/spectre.console/pull/2101" rel="noopener noreferrer"&gt;#2101&lt;/a&gt;: Fix Align measure to respect explicitly set width.&lt;/li&gt;
&lt;li&gt;Spectre.Console &lt;a href="https://github.com/spectreconsole/spectre.console/releases/tag/0.56.0" rel="noopener noreferrer"&gt;0.56.0 release notes&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Spectre.Console docs, &lt;a href="https://spectreconsole.net/console/widgets/align/" rel="noopener noreferrer"&gt;Align widget&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>dotnet</category>
      <category>spectreconsole</category>
      <category>csharp</category>
    </item>
  </channel>
</rss>
