Measuring the Wrong Thing: Four Qt Bugs Only Pixels Found

DHSeaDev — Chrome Extensions, Windows Tools, & Idle Games

A rendered-pixel assertion is a test that checks what a user actually sees — the drawn height of a widget, the shape of an icon, the colour of a table row — rather than checking that a property was set. Over one build of a five-tab PySide6 desktop utility I shipped four bugs that every syntax check, every type check and every unit test passed. All four were caught by a rendered-pixel assertion, and all four were the same mistake wearing different clothes: I verified that I had changed something instead of measuring what the change did.

The tools themselves I wrote about earlier in the fifteen-second problem — a PDF merger, a bulk file mover and a renamer, folded since into one application. This is what went wrong building it.

Why did making a widget smaller make it bigger?

The request was to shrink a drag-and-drop target by 25%. The widget had setMinimumHeight(180). I changed it to 135, confirmed the new value, and reported it done.

It rendered larger — roughly 390 pixels before, 537 after.

The minimum height never governed the size. The layout added the widget with a stretch factor, so it absorbed every spare pixel available. The minimum was a floor the layout had been far above the entire time, and the window had grown in the same pass, so the target grew with it.

The fix was to express size as a ratio rather than a number: three parts of the spare space for the widget against one part for a spacer below it, giving 75% of what it previously claimed while still scaling with the window. Measured at three window sizes, the result was 400→302, 520→392 and 700→527 pixels — a 24.5% to 24.7% reduction at every size.

The lesson: a property is not a rendered pixel. setMinimumHeight returning the value you set proves nothing about what is drawn.

Does the CSS triangle trick work in Qt stylesheets?

No. Qt draws it as a solid box.

Qt Style Sheets look like CSS, so the familiar triangle trick is tempting — a zero-size element with transparent left and right borders and a solid top border, which browsers collapse into a triangle. Qt does not collapse it. It renders the borders as an actual rectangle.

Counting ink pixels row by row down the arrow region proves it, because a triangle narrows as it descends and a box does not.

StylingInk pixels per row
CSS triangle in QSS10, 10, 10, 10, 10, 10
No stylesheet at all19, 9, 7, 5, 3
The second is a real triangle. The first is a box.

The fix was deleting 85 lines of styling. Qt disables its own native painting for a complex control the moment any sub-control is styled, which means a partial style is worse than no style — the half-styled version measured 22 ink pixels against 64 for the unstyled control. Driven by a QPalette instead of a stylesheet, the control draws itself correctly in all three themes.

The rule: do not partially style a QComboBox or QSpinBox. Palette them.

Why was the live preview lagging inconsistently?

A QTimer.start() overload was silently overwriting the debounce interval.

A live preview updated on a 60-millisecond debounce so it would not rebuild mid-keystroke. The controls were connected directly to the timer’s start method. That method has an overload taking a duration in milliseconds — so every control emitting an integer was overwriting the debounce with its own payload.

A checkbox set the interval to 0 or 2. A dropdown set it to its index. A numeric spinner set it to its own value, so entering 5000 would have produced a five-second delay before the preview moved.

This was found only because a test asserted the interval was still 60 and got 0 back. The symptom would have been lag that varied with whichever control was touched last — close to impossible to reproduce deliberately.

The rule: never connect a signal carrying an integer directly to a slot that has an integer overload. Swallow the arguments in a wrapper.

Why does a .* regex replacement apply itself twice?

Because .* matches the whole string and then the zero-width position at its end.

Python’s re.sub('.*', 'X', 'zebra') returns 'XX', not 'X'. Anyone typing .* in a rename rule to mean “the whole name” gets their replacement twice — Doc_001 becomes Doc_001Doc_001.

This is documented behaviour rather than a defect, but it is not what a person means when they type it. The fix walks finditer and skips zero-length matches, a deliberate divergence from re.sub semantics that is documented in the help text where users will actually see it.

Where does desktop app start-up time actually go?

In this application, 85.3% of a cold start was a single framework import.

The app was slow to open, so I timed every stage rather than guessing.

StageTimeShare
Importing the GUI framework5049 ms85.3%
Importing the PDF library400 ms6.8%
Constructing the main window155 ms2.6%
Everything I wrote~170 ms~3%

Everything under my control totalled under 8%. Deferring the PDF library to first use cut a measured 400 milliseconds and cost nothing, since most sessions never open a PDF.

Beyond that, the honest answer was that this is a packaging problem and not a code problem. Single-file bundling re-extracts the entire framework to a temporary folder on every launch, which makes every launch a cold one. Changing the bundling mode does more than any optimisation I could write.

Saying “I cannot fix this from here, and here is the thing that would” beats a week of micro-optimisation that moves nothing.

What makes a bulk rename safe to undo?

Staging every rename through a temporary name first.

The renamer shows a live preview of every file before touching anything. A rule that would make two files collide blocks the entire batch rather than skipping the two files, because a half-finished rename across hundreds of files is worse than none. Files the rule does not change are greyed out so the ones that will change stand out.

Underneath, every rename goes to a unique temporary name before its final name. That is what makes a swap work: renaming A to B and B to A without staging destroys the first file. It is also what makes a capitalisation-only change work on Windows, where the filesystem does not treat that as a different name.

Undo uses the same staging, which I got wrong initially — I built the guard on the forward path and assumed the reverse was trivial. It is not. Undoing a swap hits exactly the same collision, in reverse. Measured on 3,000 files: rename completes in 0.12 seconds, undo in 0.11 seconds, with zero temporary files left behind.

That refusal to half-finish an irreversible job is the same instinct behind the robot that never presses submit, and behind building a practice environment rather than letting people learn on a live system.

What is the one takeaway?

Every mistake above has the same shape. I checked that I had changed something rather than checking what the change did. A property is not a rendered pixel. A stylesheet rule is not a drawn arrow. A code optimisation is not a faster launch.

None of the measurements were hard. Counting ink pixels across an arrow is a few lines. Timing an import is one line. The hard part was remembering to look at the outcome instead of the edit — especially when the edit looked obviously correct.

It usually did look obviously correct. It usually did not do what I thought.