Repository navigation
Releases: TheSevenPens/PenDynamicsLab
Release list
v0.6.0
Six bugs a review found that 229 passing tests could not, a tenth self-test check, and a clean build.
The review
MainWindow.axaml.cs was the largest file here at 1464 lines. Cursor reviewed it as role:code-reviewer, briefed and reporting entirely through #46. Six findings, all six fixed.
Five of the six live in event handlers, which the 229 tests passing at the time never entered.
| Loading a preset did not invalidate stroke caches, so undo replayed a stroke under a stale curve | #53 |
| A recording's metadata was whatever was true at stop, not at capture — switching pen API mid-recording wrote the new device's pressure range over samples scaled to the old one | #55 |
| Renaming a preset onto a name already in use looked like it succeeded | #56 |
| Save-as-PNG failed silently where Copy reported the failure | #57 |
| The App position readout formatted away the precision the conversion goes out of its way to preserve | #54 |
| Dismissing the driver tip was undone by collapsing the ribbon | #58 |
Twelve unit tests were added where a seam existed. The other three fixes were clicked through, two of them against a stashed-fix baseline so the check was known to be able to fail.
Three of the six are the same fault: something shaped like information that cannot carry any. A readout formatted below the precision it exists to show, a save reporting neither success nor failure, and a dismissal recording nothing for the next reader.
--selftest reports 10/10
L3.origin-tracks-window is wired in (#59). It moves the window, converts a fixed desktop point before and after, and fails when the conversion does not follow — the only check that can see a canvas origin read once and cached. No other check can: the replay positions its input relative to the origin the application reports and subtracts the same value back off, so a wrong origin cancels itself exactly.
The conversion handed to it re-reads the window origin on every call, because that is what RenderTimer_Tick does for every pen point. Passing the origin captured for the other checks would have reported a stale value belonging to the self test rather than to the input path.
Verified by reintroducing the fault deliberately: 9/10 passed, reporting the injected displacement exactly, with a process exit code of 1.
This application does not have the fault. That was not known before; nobody had looked, and no check would have said.
Avalonia 12 and a clean build
Avalonia 12.1.2 with its backends named explicitly, and both remaining obsoletions moved off (#60), so the build now carries no warnings.
Bitmap.Save(Stream, int?) was the one worth care rather than a mechanical rename — it is on the export path and the int? was quality. PngBitmapEncoderOptions.Default carries CompressionLevel.Optimal, and a chart exported before and after the change is byte-identical: 3778 bytes, same SHA-256.
The Raw readout
RawX/RawY mean four different things across WinPenKit's backends, and on the Avalonia path they mean nothing at all — as of WinPenKit v0.10.0 those fields are zero rather than a truncated copy of the desktop position. This readout asks the session and shows the unit, or -- where there is none (#61). Printing the pair alone read as a position in the same space as Screen, which it never was.
Requires
WinPenKit v0.10.0 or later, for IPenSession.Conventions.
v0.5.0
The strokes look right now, and there is finally a way to prove it rather than judge it.
Pen positions were being quantized to whole pixels
The complaint that started this: strokes that were not obviously broken, just not as good as they should be. Small bumps along a slow curve, visible at 3–4× zoom, absent from Clip Studio Paint and Krita drawing the same line.
The cause was one line. Converting the pen's desktop position to a canvas position went through TopLevel.PointToClient, which takes a PixelPoint — whose members are integers. Every pen position was being forced onto the whole-pixel grid on the way in.
A digitizer reports much finer than a screen pixel, and that precision is the whole reason to open a digitizer context. At the ~2px steps a tablet reports, an integer grid leaves only a handful of directions a segment can point in, so the path stops following the pen and starts zigzagging. Measured: the median turn between consecutive segments went from about 1.5° to 11.3°.
The conversion is done by hand now. The window origin still goes through the integer API — it is genuinely on a pixel boundary, so nothing is lost — and only the pen's own position keeps its fractional part.
Two supporting changes:
- Wintab's digitizer context is now the default where available, rather than the system context. The system context is quantized by the driver before the application ever sees it.
- Avalonia is upgraded to 11.3.22. Avalonia had this same bug internally; on an earlier version the positions arrive already quantized no matter what this app does.
Strokes taper properly
- Width ramps along a segment instead of stepping at every sample, so pressure changes read as a smooth swell rather than a series of steps.
- A stroke can taper below one pixel. The minimum width was clamped to 1 DIP, which put a floor under every lift-off; it is 0.25 now, so a stroke can thin to a hairline and disappear the way it should.
Tools for looking at what actually happened
Three additions, each built because a question could not otherwise be answered.
A stroke recorder. Draw once, then replay the same pen data as many times as you like with different settings. Recordings go to %LOCALAPPDATA%\PenDynamicsLab\recordings, and replay renders headlessly to a PNG — no window, so comparing ten settings is ten files rather than ten sessions.
A tap test. Stamps a ring of antialiased circles, which separates a surface problem from a stroke problem: if the circles are clean and the strokes are not, the canvas is fine and the fault is downstream.
--selftest. Nine checks covering DPI awareness, window placement, the drawing surface, and the coordinate conversion — with no tablet, no pen input, and no person. Exit code 0 only if everything passes.
SELFTEST PenDynamicsLab
[PASS] L0.dpi-awareness PerMonitorV2
[PASS] L0.window-placement client 3712x1973 at 114,65; work area 3840x2052 at 0,0
[PASS] L0.scale 2.25x
[PASS] L1.surface-physical bitmap 2648x1470, expected 2648x1470 (= ceil(1177x653 logical x 2.25))
[PASS] L1.surface-alignment origin 1178.00,620.00px
[PASS] L1.presentation-1to1 bitmap 2648x1470 presented at 2648.0x1470.0 device px
[PASS] L2.recording-subpixel 0.0% of 480 recorded points are on whole pixels
[PASS] L3.conversion-snap 0.0% of converted points land on whole device pixels
[PASS] L3.conversion-lossless mean turn angle in 0.74 deg, out 0.74 deg (delta 0.00)
RESULT 9/9 passed
The last line is the one worth having. A coordinate conversion is a translation and a uniform scale; both preserve angles exactly, so a correct one reproduces the input's turn angle to the decimal. That is a stronger statement than "the strokes look smooth", it needs no threshold, and it is the first time this app's pipeline has been shown correct by measurement rather than by eye.
The checks and their report format come from WinPenKit, so this app is now held to exactly the same yardstick as the Scribble samples.
The window stays on the screen
The window declared 900 device-independent pixels of height. That is 2025px on a 4K display against a 2052px work area, and 1575px on a 1440p one against 1356px — where it could not fit at all.
Pen input is delivered by absolute screen position, so any part of the window below the work area receives nothing: no points, no telemetry, no error, while the app carries on running and painting. The lower part of the canvas simply stops drawing, which reads as a bug in whatever you were testing.
It is now clamped to the monitor's work area, both at startup and when the window moves to a monitor with a different scale. It only ever shrinks, and only when the declared size does not fit, so on a display where it fits nothing changes.
Also in this release
- Clipboard copy moved to Avalonia's
DataTransferAPI, replacing the deprecated one. - Pull request CI. Nothing verified a PR to this repo before now — only tagged releases were built. Every PR now builds, runs the 229 tests, and must pass
--selftest. - The
Tmds.DBus.Protocoladvisory (CVE-2026-39959) is resolved by the Avalonia upgrade.
Verification
The coordinate fix, the width ramp, the hairline taper and the Wintab digitizer default are confirmed on hardware — drawn with a real tablet and judged by eye.
The work-area clamp is verified by measurement rather than by drawing: the window geometry was read back after moving it between displays. --selftest reports 9/9 on a 4K display at 2.25× and on a 1024×720 CI runner at 1.00×.
v0.4.1
A single fix, on top of v0.4.0 from earlier today.
The first stroke after returning to the app was silently swallowed
Click away to another application, come back, and draw — the first thing you drew was lost, while the second stroke and every one after it drew normally. Wintab only; the Avalonia Pointer path was never affected.
Wintab delivers packets to whichever context is on top of the driver's overlap order, and losing focus drops ours down it with nothing to put it back. The repair is WTEnable followed by WTOverlap, matching what Qt does on window activation — which is why Krita does not have this bug and Clip Studio Paint, on Wintab, does.
That repair lives in WinPenKit and shipped in its release/v0.4.0. What this release adds is the one thing only the application can do: a Wintab context is bound to a hidden message window that never receives WM_ACTIVATE, so the app has to say when it is back.
What's Changed
- Tell the pen session when the window is activated by @TheSevenPens in #30
Full Changelog: v0.4.0...v0.4.1
v0.4.0
What's Changed
- Fix the DrawSurface bitmap leak and the packed-stride assumption by @TheSevenPens in #18
- Skill: check the window is on one monitor before injecting pen input by @TheSevenPens in #19
- Point the code-set chrome at the palette so dark mode reaches it by @TheSevenPens in #20
- Docs, chart export, and the leftover UI contracts by @TheSevenPens in #21
- Extract the live pipeline, channel-shaped, and make the reset rule real by @TheSevenPens in #22
- Make BrushSettings the brush source of truth by @TheSevenPens in #23
- Extract DrawingSession, and stop the shrink from destroying the mark by @TheSevenPens in #24
- Give strokes an identity besides pixels by @TheSevenPens in #25
- Cap the stroke history, without letting undo erase what it evicts by @TheSevenPens in #26
- Ribbon: baselines, spacing, and honest readouts when the pen leaves by @TheSevenPens in #27
- Add the Sakura theme by @TheSevenPens in #28
Full Changelog: v0.3.0...v0.4.0
v0.3.0
What's Changed
- Add quantization as the first pipeline stage by @TheSevenPens in #6
- Fix the smoothing amount slider not appearing when EMA is selected by @TheSevenPens in #7
- Move the processing order into Options, and let it order the cards by @TheSevenPens in #8
- Add a project skill for driving the app visually by @TheSevenPens in #17
New Contributors
- @TheSevenPens made their first contribution in #6
Full Changelog: v0.2.0...v0.3.0
v0.2.0
Two pressure curves in series, with an effective curve chart showing the
pair collapsed into the one mapping the brush obeys. Presets saved before
this load unchanged, as curve 1 with curve 2 bypassed.
Also since v0.1.0:
- Light / dark / follow-system theming, reached from a new Options dialog
behind a gear in the ribbon. The drawing canvas keeps its paper in both
themes so strokes stay readable. - An Inverted curve type.
- Three-state card pills: Off, On, and On with no effect.
- Type-scoped reset, which restores the curve you are on instead of
jumping back to Passthrough. - Basic no longer applies range values it never shows.
- A collapsible telemetry ribbon and tighter chart cards, for presenting.
- HiDPI fixes: strokes render at device resolution and track the pen tip.
- A UI refresh onto one Fluent vocabulary.
127 tests.
v0.1.0
Full Changelog: https://github.com/TheSevenPens/PenDynamicsLab/commits/v0.1.0