From f7d8ff8b1c499e52e9aad09c5804ae6927289329 Mon Sep 17 00:00:00 2001 From: Alberto Ferrari Date: Wed, 26 Aug 2026 05:19:24 +0200 Subject: [PATCH 1/2] Add Shift-constrained straight strokes --- README.md | 1 + .../Geometry/StraightLineSnap.cs | 60 +++++++++ src/SQLBI.Whiteboard/MainWindow.xaml.cs | 23 +++- src/SQLBI.Whiteboard/PenOnlyInkCanvas.cs | 125 ++++++++++++++++++ .../Program.cs | 28 ++++ 5 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 src/SQLBI.Whiteboard.Core/Geometry/StraightLineSnap.cs diff --git a/README.md b/README.md index 9c973e7..4694511 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ Use **Copy settings** after finding a useful combination so the exact values can | Input | Behavior | | --- | --- | | Pen tip | Current tool; Pen is selected at startup | +| Shift + pen tip | Constrain a near-horizontal or near-vertical stroke to that axis | | Pen hover | Show the small red pointer dot and hide the arrow | | Pen contact | Hide both the pointer dot and arrow | | Physical mouse movement | Show the normal arrow | diff --git a/src/SQLBI.Whiteboard.Core/Geometry/StraightLineSnap.cs b/src/SQLBI.Whiteboard.Core/Geometry/StraightLineSnap.cs new file mode 100644 index 0000000..ccd66bd --- /dev/null +++ b/src/SQLBI.Whiteboard.Core/Geometry/StraightLineSnap.cs @@ -0,0 +1,60 @@ +namespace SQLBI.Whiteboard.Core.Geometry; + +public enum StraightLineDirection +{ + None, + Horizontal, + Vertical, +} + +public static class StraightLineSnap +{ + public const double DefaultActivationDistance = 8; + public const double DefaultAngleToleranceDegrees = 15; + + public static bool HasActivationDistance( + PointD anchor, + PointD current, + double activationDistance = DefaultActivationDistance) + { + var deltaX = current.X - anchor.X; + var deltaY = current.Y - anchor.Y; + var minimum = Math.Max(0, activationDistance); + return (deltaX * deltaX) + (deltaY * deltaY) >= minimum * minimum; + } + + public static StraightLineDirection DetectDirection( + PointD anchor, + PointD current, + double angleToleranceDegrees = DefaultAngleToleranceDegrees, + double activationDistance = DefaultActivationDistance) + { + if (!HasActivationDistance(anchor, current, activationDistance)) + { + return StraightLineDirection.None; + } + + var deltaX = Math.Abs(current.X - anchor.X); + var deltaY = Math.Abs(current.Y - anchor.Y); + var tolerance = Math.Clamp(angleToleranceDegrees, 0, 45); + var maximumOffAxisRatio = Math.Tan(tolerance * Math.PI / 180); + if (deltaY <= deltaX * maximumOffAxisRatio) + { + return StraightLineDirection.Horizontal; + } + + return deltaX <= deltaY * maximumOffAxisRatio + ? StraightLineDirection.Vertical + : StraightLineDirection.None; + } + + public static PointD Apply( + PointD point, + PointD anchor, + StraightLineDirection direction) => direction switch + { + StraightLineDirection.Horizontal => new PointD(point.X, anchor.Y), + StraightLineDirection.Vertical => new PointD(anchor.X, point.Y), + _ => point, + }; +} diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml.cs b/src/SQLBI.Whiteboard/MainWindow.xaml.cs index 145d700..72f64a1 100644 --- a/src/SQLBI.Whiteboard/MainWindow.xaml.cs +++ b/src/SQLBI.Whiteboard/MainWindow.xaml.cs @@ -235,6 +235,7 @@ private void History_Changed(object? sender, EventArgs e) private void InkSurface_StrokeCollected(object sender, InkCanvasStrokeCollectedEventArgs e) { + var straightLineDirection = InkSurface.TakeCompletedStraightLineDirection(); if (EffectiveTool == BoardTool.Laser || _stylusAction == PointerAction.Laser || _discardInkStroke) @@ -250,12 +251,22 @@ private void InkSurface_StrokeCollected(object sender, InkCanvasStrokeCollectedE return; } + var screenAnchor = new PointD( + e.Stroke.StylusPoints[0].X, + e.Stroke.StylusPoints[0].Y); var firstTimestamp = Stopwatch.GetTimestamp(); var points = e.Stroke.StylusPoints - .Select((point, index) => new InkPoint( - _camera.ScreenToWorld(new PointD(point.X, point.Y)), - point.PressureFactor, - firstTimestamp + index)) + .Select((point, index) => + { + var screenPoint = StraightLineSnap.Apply( + new PointD(point.X, point.Y), + screenAnchor, + straightLineDirection); + return new InkPoint( + _camera.ScreenToWorld(screenPoint), + point.PressureFactor, + firstTimestamp + index); + }) .ToArray(); var stroke = InkStrokeObject.Create( points, @@ -4371,6 +4382,7 @@ private void Window_PreviewKeyDown(object sender, KeyEventArgs e) var controlDown = modifiers.HasFlag(ModifierKeys.Control); var shiftDown = modifiers.HasFlag(ModifierKeys.Shift); var altDown = modifiers.HasFlag(ModifierKeys.Alt) || e.Key == Key.System; + InkSurface.SetStraightLineMode(shiftDown); if (e.Key == Key.F11 || (e.Key == Key.System && e.SystemKey == Key.F11)) { @@ -4641,6 +4653,8 @@ private void RestoreWindowedBounds() private void Window_PreviewKeyUp(object sender, KeyEventArgs e) { + InkSurface.SetStraightLineMode( + Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)); if (e.Key == Key.Space && _spaceTemporaryPan) { _spaceTemporaryPan = false; @@ -4667,6 +4681,7 @@ private void Window_Deactivated(object? sender, EventArgs e) _mouseAction = PointerAction.None; _penInContact = false; _syntheticLaserContact = false; + InkSurface.SetStraightLineMode(false); ClearTouchNavigation(); InkSurface.Cursor = Cursors.Arrow; HidePointerDot(); diff --git a/src/SQLBI.Whiteboard/PenOnlyInkCanvas.cs b/src/SQLBI.Whiteboard/PenOnlyInkCanvas.cs index 5f56cba..817dfe5 100644 --- a/src/SQLBI.Whiteboard/PenOnlyInkCanvas.cs +++ b/src/SQLBI.Whiteboard/PenOnlyInkCanvas.cs @@ -5,6 +5,7 @@ using System.Windows.Input.StylusPlugIns; using System.Windows.Media; using System.Windows.Threading; +using SQLBI.Whiteboard.Core.Geometry; using SQLBI.Whiteboard.Core.Model; namespace SQLBI.Whiteboard; @@ -41,6 +42,12 @@ public void SetLaserMode(bool laser) => public void SetAllowTouchInk(bool allow) => _penOnlyRenderer.SetAllowTouchInk(allow); + public void SetStraightLineMode(bool active) => + _penOnlyRenderer.SetStraightLineMode(active); + + public StraightLineDirection TakeCompletedStraightLineDirection() => + _penOnlyRenderer.TakeCompletedStraightLineDirection(); + public void AbortWetInk() { _penOnlyRenderer.AbortWetInk(); @@ -208,10 +215,16 @@ internal sealed class PenOnlyDynamicRenderer : DynamicRenderer private volatile PenKind _penKind; private volatile bool _laserMode; private volatile bool _allowTouchInk; + private volatile bool _straightLineMode; private PenKind _strokeKind; private StylusPoint? _lastCalligraphyPoint; private int _lastPacketTimestamp; private double _smoothedCalligraphySpeed; + private bool _straightLineStroke; + private bool _straightLineDecisionMade; + private StraightLineDirection _straightLineDirection; + private StylusPoint? _straightLineAnchor; + private int _completedStraightLineDirection; public PenOnlyDynamicRenderer(IEnumerable touchTabletIds) { @@ -244,8 +257,19 @@ public void SetAllowTouchInk(bool allow) } } + public void SetStraightLineMode(bool active) => _straightLineMode = active; + + public StraightLineDirection TakeCompletedStraightLineDirection() => + (StraightLineDirection)Interlocked.Exchange( + ref _completedStraightLineDirection, + (int)StraightLineDirection.None); + public void AbortWetInk() { + ResetStraightLineStroke(); + Interlocked.Exchange( + ref _completedStraightLineDirection, + (int)StraightLineDirection.None); Enabled = false; Enabled = true; } @@ -264,7 +288,9 @@ protected override void OnStylusDown(RawStylusInput rawStylusInput) _strokeKind = _penKind; ResetCalligraphyDynamics(); + BeginStraightLineStroke(rawStylusInput); ApplyCalligraphyDynamics(rawStylusInput); + ApplyStraightLine(rawStylusInput); base.OnStylusDown(rawStylusInput); } @@ -276,6 +302,7 @@ protected override void OnStylusMove(RawStylusInput rawStylusInput) } ApplyCalligraphyDynamics(rawStylusInput); + ApplyStraightLine(rawStylusInput); base.OnStylusMove(rawStylusInput); } @@ -287,8 +314,15 @@ protected override void OnStylusUp(RawStylusInput rawStylusInput) } ApplyCalligraphyDynamics(rawStylusInput); + ApplyStraightLine(rawStylusInput); + Interlocked.Exchange( + ref _completedStraightLineDirection, + (int)(_straightLineStroke + ? _straightLineDirection + : StraightLineDirection.None)); base.OnStylusUp(rawStylusInput); ResetCalligraphyDynamics(); + ResetStraightLineStroke(); } protected override void OnDraw( @@ -359,6 +393,97 @@ private void ResetCalligraphyDynamics() _smoothedCalligraphySpeed = 0; } + private void BeginStraightLineStroke(RawStylusInput rawStylusInput) + { + ResetStraightLineStroke(); + Interlocked.Exchange( + ref _completedStraightLineDirection, + (int)StraightLineDirection.None); + _straightLineStroke = _straightLineMode && !IsTouchTablet(rawStylusInput); + if (!_straightLineStroke) + { + return; + } + + var points = rawStylusInput.GetStylusPoints(); + if (points.Count > 0) + { + _straightLineAnchor = points[0]; + } + } + + private void ApplyStraightLine(RawStylusInput rawStylusInput) + { + if (!_straightLineStroke) + { + return; + } + + var points = rawStylusInput.GetStylusPoints(); + if (points.Count == 0) + { + return; + } + + _straightLineAnchor ??= points[0]; + var anchor = _straightLineAnchor.Value; + if (!_straightLineDecisionMade) + { + var latest = points[^1]; + var anchorPoint = new PointD(anchor.X, anchor.Y); + var latestPoint = new PointD(latest.X, latest.Y); + if (!StraightLineSnap.HasActivationDistance(anchorPoint, latestPoint)) + { + for (var index = 0; index < points.Count; index++) + { + var point = points[index]; + point.X = anchor.X; + point.Y = anchor.Y; + points[index] = point; + } + + rawStylusInput.SetStylusPoints(points); + return; + } + + _straightLineDirection = StraightLineSnap.DetectDirection( + anchorPoint, + latestPoint); + _straightLineDecisionMade = true; + } + + if (_straightLineDirection == StraightLineDirection.None) + { + return; + } + + var fixedPoint = new PointD(anchor.X, anchor.Y); + for (var index = 0; index < points.Count; index++) + { + var point = points[index]; + var snapped = StraightLineSnap.Apply( + new PointD(point.X, point.Y), + fixedPoint, + _straightLineDirection); + point.X = snapped.X; + point.Y = snapped.Y; + points[index] = point; + } + + rawStylusInput.SetStylusPoints(points); + } + + private void ResetStraightLineStroke() + { + _straightLineStroke = false; + _straightLineDecisionMade = false; + _straightLineDirection = StraightLineDirection.None; + _straightLineAnchor = null; + } + private bool IsTouch(RawStylusInput rawStylusInput) => !_allowTouchInk && _touchTabletIds.ContainsKey(rawStylusInput.TabletDeviceId); + + private bool IsTouchTablet(RawStylusInput rawStylusInput) => + _touchTabletIds.ContainsKey(rawStylusInput.TabletDeviceId); } diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs index 1dbf1be..752a2fe 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs @@ -30,6 +30,34 @@ Assert(framedTopLeft.X >= 49.999999, "Framing must preserve the horizontal margin."); Assert(framedTopLeft.Y >= 49.999999, "Framing must preserve the vertical margin."); +var lineAnchor = new PointD(100, 100); +Assert( + StraightLineSnap.DetectDirection(lineAnchor, new PointD(200, 120)) == + StraightLineDirection.Horizontal, + "A Shift stroke within 15 degrees of horizontal should snap horizontally."); +Assert( + StraightLineSnap.DetectDirection(lineAnchor, new PointD(85, 200)) == + StraightLineDirection.Vertical, + "A Shift stroke within 15 degrees of vertical should snap vertically in either direction."); +Assert( + StraightLineSnap.DetectDirection(lineAnchor, new PointD(200, 130)) == + StraightLineDirection.None, + "A Shift stroke outside the axis tolerance should remain free."); +Assert( + StraightLineSnap.DetectDirection(lineAnchor, new PointD(105, 102)) == + StraightLineDirection.None, + "Straight-line direction should wait for enough movement to establish intent."); +Assert( + StraightLineSnap.Apply( + new PointD(180, 116), + lineAnchor, + StraightLineDirection.Horizontal) == new PointD(180, 100) && + StraightLineSnap.Apply( + new PointD(88, 210), + lineAnchor, + StraightLineDirection.Vertical) == new PointD(100, 210), + "Snapping should preserve travel along the chosen axis and remove only off-axis movement."); + var originalLiveViewBounds = new RectD(100, 50, 400, 200); var reconnectedLiveViewBounds = originalLiveViewBounds.WithCenteredAspectRatio(9d / 16d); AssertNear( From e650a252ce7f70e31a4822d8b5a162feb617522a Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Wed, 26 Aug 2026 19:48:12 +0200 Subject: [PATCH 2/2] Collect pen ink from the pen, and make the barrel button a modifier The barrel switch on a pen that shares its report with the tip switch fabricates a stylus up and a stylus down on every press and every release, and reports the tip as open while it is still pressing. WPF therefore tears the contact in two per click and calls the pen airborne in between, so no stroke the InkCanvas collected could follow it. MainWindow.AppendPenInk now reads the pen packets directly and owns the contact, the straight-line constraint and the calligraphy dynamics, with BoardSurface.PendingStroke drawing the wet stroke. The constraint is one boolean per point, so the barrel button behaves exactly like Shift. TouchInkCanvas keeps the InkCanvas for finger ink only. The pen button is a single configurable barrel button (Laser or Straight line); the reverse end and the upper side button always erase, because Windows reports the two identically. Preferences draws the options for the pen button, toolbar position and toolbar layout instead of naming them. Co-Authored-By: Claude Opus 5 --- Directory.Build.props | 2 +- README.md | 22 +- TODO.md | 48 +- docs/decisions.md | 38 ++ site/guide.html | 6 +- site/shortcuts.html | 7 +- .../Geometry/StraightLineSnap.cs | 35 +- .../Settings/AppSettings.cs | 5 +- .../Settings/PenBarrelButton.cs | 35 ++ .../Settings/PenButtonSettings.cs | 34 ++ src/SQLBI.Whiteboard/BoardSurface.cs | 25 +- src/SQLBI.Whiteboard/MainWindow.xaml | 2 +- src/SQLBI.Whiteboard/MainWindow.xaml.cs | 509 +++++++++++++----- src/SQLBI.Whiteboard/PenTrace.cs | 109 ++++ .../PreferencesWindow.xaml.cs | 278 +++++++++- src/SQLBI.Whiteboard/SettingsCatalog.cs | 36 +- ...{PenOnlyInkCanvas.cs => TouchInkCanvas.cs} | 222 ++------ .../Program.cs | 68 ++- 18 files changed, 1114 insertions(+), 367 deletions(-) create mode 100644 src/SQLBI.Whiteboard.Core/Settings/PenBarrelButton.cs create mode 100644 src/SQLBI.Whiteboard.Core/Settings/PenButtonSettings.cs create mode 100644 src/SQLBI.Whiteboard/PenTrace.cs rename src/SQLBI.Whiteboard/{PenOnlyInkCanvas.cs => TouchInkCanvas.cs} (54%) diff --git a/Directory.Build.props b/Directory.Build.props index d309ca7..d7c1a99 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ scripts/build-installer.ps1 both read it from here, so releasing is a reviewed change to this line rather than an edit in a pipeline variable group. --> - 1.1.0 + 1.1.1 latest enable enable diff --git a/README.md b/README.md index 4694511..b51d7bf 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ How the project is developed and shipped is documented separately: - Markdown `.wimport` recipes that build image and text containers from headings - An intentionally small floating toolbar - A File / Edit / View / Help tab strip. Click a tab for a one-row command strip over the canvas -- Preferences for the startup monitor, full-screen start, finger drawing, snippet format order, laser trail timing and weight, toolbar position and layout, and (except Store installs) a daily new-version check +- Preferences for the startup monitor, full-screen start, finger drawing, the pen button, snippet format order, laser trail timing and weight, toolbar position and layout, and (except Store installs) a daily new-version check - About, with version and channel ## Build and run @@ -174,15 +174,15 @@ Use **Copy settings** after finding a useful combination so the exact values can | Input | Behavior | | --- | --- | | Pen tip | Current tool; Pen is selected at startup | -| Shift + pen tip | Constrain a near-horizontal or near-vertical stroke to that axis | +| Shift + pen tip | Constrain the stroke to horizontal or vertical, whichever is nearer, at a uniform width. Press or release mid-stroke to start or end the constraint from that point. The barrel button does the same when assigned to Straight line | | Pen hover | Show the small red pointer dot and hide the arrow | | Pen contact | Hide both the pointer dot and arrow | | Physical mouse movement | Show the normal arrow | | Left mouse | Temporarily select/move/resize a container; return to the previous drawing tool on release | | Double-click container | Center and fit the image, text, or LiveView to the canvas | | Double-click empty canvas | Center and fit all board content, or reset an empty board | -| Pen eraser | Erase complete strokes | -| Pen barrel | Hold the lower barrel button for the laser; release returns to the previous tool | +| Pen eraser | Erase complete strokes. The upper side button erases too: Windows reports it the same way as a pen turned round | +| Pen barrel | Hold the barrel button for the action assigned in Preferences: Laser (default) or Straight line. Laser returns to the previous tool on release | | One finger | Pan. With Finger drawing on, uses the current tool instead | | Two fingers | Pan and pinch zoom. Cancels an in-progress finger stroke when Finger drawing is on | | Mouse wheel | Zoom at the pointer. Shift+wheel zooms more slowly | @@ -202,7 +202,7 @@ Use **Copy settings** after finding a useful combination so the exact values can | Delete | Delete the selected container and its linked strokes | | Alt+L | Laser pointer | | File / Edit / View / Help | Tab strip. Click a tab for a one-row command strip over the canvas. Click the canvas to hide it | -| Help > Preferences | Searchable settings: startup monitor, full screen, finger drawing, snippet format order, laser trail, toolbar, update checks | +| Help > Preferences | Searchable settings: startup monitor, full screen, finger drawing, pen button, snippet format order, laser trail, toolbar, update checks | | View > Bring to front / Send to back | Reorder the selected image, text, or LiveView (and its linked strokes) | | Help > About | Version, channel, license, the product site, and a download link when a newer release is known | | View > LiveView | Capture, freeze, disconnect, or reconnect a window or display | @@ -247,13 +247,21 @@ is installed. The source is `vscode/sqlbi-whiteboard`. - `SQLBI.Whiteboard.Dax` contains the framework-neutral DAX lexer, parser, classifier, and deterministic formatter adapted from Prompt Assistant. - `SQLBI.Whiteboard.SqlServer` contains the framework-neutral SQL Server 2025 adapter over Microsoft's ScriptDOM parser and script generator. - `vscode/sqlbi-whiteboard` is a VS Code custom editor that shows `preview.png` from a `.wboard` ZIP. It is not part of the desktop installer. -- `SQLBI.Whiteboard` is the WPF shell. `InkCanvas` supplies system-managed wet ink, while `BoardSurface` renders completed ink, images, text, and selection on a white canvas in camera space. A transient AvalonEdit surface is overlaid only while a text container is being edited; language services translate parser classifications into WPF text styles. +- `SQLBI.Whiteboard` is the WPF shell. `TouchInkCanvas` supplies system-managed wet ink for the finger, while `BoardSurface` renders completed ink, images, text, and selection on a white canvas in camera space, plus the pen's own wet stroke. A transient AvalonEdit surface is overlaid only while a text container is being edited; language services translate parser classifications into WPF text styles. - `SQLBI.Whiteboard/LiveView` owns Windows Graphics Capture and the Direct3D-to-WPF bridge. Capture retains one GPU frame per active LiveView; CPU bitmap conversion occurs only when copying or saving a snapshot. - `SQLBI.Whiteboard.Core.SmokeTests` is a package-free executable test harness for camera anchoring, commands, hit testing, and archive round trips. The current document query is deliberately linear. A spatial index can be introduced behind `BoardDocument.Query` when profiling demonstrates a need, without changing input, tools, persistence, or rendering call sites. -Live ink uses a transparent WPF `InkCanvas` above the retained scene. WPF renders wet ink on its dedicated dynamic-rendering thread. On stroke completion, pressure points are transformed from screen space into the unbounded world model; camera movement is suspended while the pen is in contact. +Live ink uses a transparent WPF `TouchInkCanvas` above the retained scene, and it collects +the finger's strokes only: WPF renders those on its dedicated dynamic-rendering thread. +Pen ink is read straight from the pen's packets by `MainWindow.AppendPenInk`, which owns +the contact, the straight-line constraint, and the calligraphy dynamics, and draws the wet +stroke through `BoardSurface.PendingStroke`. A barrel button tears the WPF contact in two +every time it is pressed or released — see [TODO.md](TODO.md) — so no stroke built on that +bookkeeping could behave like the Shift key. In both paths pressure points are transformed +from screen space into the unbounded world model on completion; camera movement is +suspended while the pen is in contact. ## Wacom Cintiq Pro validation diff --git a/TODO.md b/TODO.md index ef85410..598ab01 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,7 @@ The delivery chain works end to end: a merge to `main` builds, signs, and publis pre-release to GitHub Releases, and one approval promotes that same build to a release. reads its download links from the release manifest deployed beside it and needs no edit per release. The current product version is `VersionPrefix` in `Directory.Build.props` -(1.0.3). Identity version for the Store package is `VersionPrefix.0` (`1.0.3.0`). +(1.1.1). Identity version for the Store package is `VersionPrefix.0` (`1.1.1.0`). Declaring that number is decision 20 in [docs/decisions.md](docs/decisions.md). What 1.0 was waiting on shipped during 0.9.x: Preferences, `.wimport`, Explorer and VS Code @@ -34,6 +34,52 @@ walked in full for 1.0.0, which was the last part of the chain that had only eve reasoned about. winget is the one piece still waiting, below. All of it is described in [docs/release-management.md](docs/release-management.md). +## Pen buttons: what was settled, and what is left + +The barrel button is the only assignable one, and it takes Laser or Straight line. Adding +an action means an entry in `PenButtonAction`, a choice in `SettingsCatalog`, and — if it +swaps the tool rather than acting as a modifier — a case in `MainWindow.BarrelToolFor`. +Nothing else needs to know. + +Erasing is not assignable. The reverse end of the pen erases, and so does the upper side +button, because they cannot be told apart: + +- **The upper button and a reversed pen are the same signal.** A trace from the + development pen (`PenTrace`, enabled by pointing `SQLBI_WHITEBOARD_PENTRACE` at a file) + settles what several rounds of inference could not. The device exposes exactly two + buttons, `Tip Switch` and `Barrel Switch` — no eraser button, no secondary tip button. + Clicking the upper side button and turning the pen round produce identical events: + `Inverted` goes true, both switches stay up, pressure stays zero, and when either one + lands the same tip switch closes. So an inversion is the eraser, full stop. A device + that reports a real `SecondaryTipButton` would be distinguishable, and supporting one + would mean re-introducing a second slot — worth doing only if such a device turns up. + +- **The barrel switch masks the tip switch, and the ink there is recovered by hand.** A + barrel press and a barrel release each arrive as a stylus up with `InAir` true. After a + release the pen keeps reporting `Tip Switch=Up` and `InAir` until the button is pressed + again - while the tip is still on the glass, and while the packets still carry its real + pressure (0.54 rising to 0.69 across one such gap in the trace). WPF delivers those as + in-air moves, so the InkCanvas collects nothing and the ink drawn in between was lost. + `AccumulateMaskedTipInk` keeps them instead and commits them as a freehand stroke when + the gap ends. Two consequences worth knowing: a barrel transition splits the line into + separate stroke objects, which shows as a seam where a highlighter overlaps itself and + as several undo steps; and the recovered stretch appears when the gap closes rather + than under the tip, because there is no wet-ink path for points the InkCanvas never + sees. Giving it one means drawing a provisional stroke on the scene surface. + +- **The straight-line constraint cannot start mid-stroke from a button on this pen.** It + can from Shift, and from the barrel button, because both are reported while the tip is + down. Anything reported only through `Inverted` is not, since Invert and Tip are + mutually exclusive on this device. + +- **Two constants stand in for signals the hardware does not give.** `AppendPenInk` calls + four consecutive weightless packets a lift rather than a dropped reading — no digitizer + misses four readings in a row. `DefaultActivationDistance` is 24 px: how far the pen + must travel before the axis is settled. It was 8 px, which let a few milliseconds of + the previous direction pick the axis; a trace of real strokes is the way to revisit it. + Once settled the axis is kept for the whole segment, however far off it the hand + drifts — turning a corner instead was tried and produced a staircase out of a diagonal. + ## Waiting on the first winget submission Not work, but the reason winget is not finished yet. diff --git a/docs/decisions.md b/docs/decisions.md index 6dea65c..11efbdf 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -384,6 +384,44 @@ outbound request. --- +## 22. Pen ink is collected from the pen, not from the InkCanvas + +**Implemented.** + +`InkCanvas` collects a stroke between a stylus down and the matching up. On a pen whose +barrel switch shares the report with the tip switch — the development Cintiq, and the +reason this was found — pressing or releasing that button fabricates a stylus up followed +by a stylus down, and between a release and the next press the driver reports the tip as +open while it is still pressing. WPF therefore reports the pen in the air for as long as +the button is held, and the contact is torn in two on every click. + +Every attempt to repair that inside the InkCanvas moved the fault rather than removing it: +strokes joined across stretches the pen never drew, ink was lost between a release and the +next press, and a modifier held down could not be released. Meanwhile the pen's own packet +stream never breaks — position and pressure arrive continuously, in contact or not, which +a trace of a real session established (`PenTrace`, enabled by pointing +`SQLBI_WHITEBOARD_PENTRACE` at a file). + +So the window reads that stream directly. `MainWindow.AppendPenInk` owns the contact — it +begins at the first pressured packet and ends after a run of weightless ones — and applies +the straight-line constraint and the calligraphy dynamics to each point as it arrives. The +wet stroke is drawn by `BoardSurface.PendingStroke` rather than by WPF's dynamic renderer. +The straight-line constraint is then one boolean read per point, which is what makes the +barrel button behave exactly like the Shift key: neither has any opinion about whether WPF +thinks the pen is down. + +`TouchInkCanvas` keeps the InkCanvas for finger ink, where nothing tears the contact, and +hosts the laser sampler and the hover tracker. It collects no pen ink; strokes the +InkCanvas still opens for a pen are discarded on arrival. + +The cost is that pen wet ink is drawn on the UI thread rather than WPF's dedicated +dynamic-rendering thread. Reverting is not attractive: the machinery this replaced — +a stylus plug-in for the constraint, recovery of ink from in-air packets, splitting a +collected stroke back into contacts, and a second stroke lifecycle inside the renderer — +was several hundred lines and never converged. + +--- + ## Open questions - arm64 is not built; add it if Surface devices matter for a pen application. diff --git a/site/guide.html b/site/guide.html index fc13ec7..4e51e4d 100644 --- a/site/guide.html +++ b/site/guide.html @@ -655,7 +655,7 @@

Ink and tools

Eraser - Removes whole strokes. The eraser end of a pen does the same, and hovering with it draws a dashed square around what a tap would clear. + Removes whole strokes. The eraser end of a pen does the same — and so does the upper side button, because Windows reports the two identically — and hovering with it draws a dashed square around what a tap would clear.
@@ -902,11 +902,11 @@

The command strip

Preferences

-

Help → Preferences is a searchable list: which monitor to open on, start full screen, finger drawing, snippet format order, laser trail timing and weight, toolbar placement, and the daily new-version check. Help → About shows the version. New settings are more rows in Preferences, not a new dialog.

+

Help → Preferences is a searchable list: which monitor to open on, start full screen, finger drawing, what the pen's barrel button does, snippet format order, laser trail timing and weight, toolbar placement and layout, and the daily new-version check. Settings about how something looks — the trail weight, the barrel button, where the toolbar sits and how it is laid out — draw their options rather than naming them, so the choice is made by looking. Help → About shows the version. New settings are more rows in Preferences, not a new dialog.

- + The Preferences dialog diff --git a/site/shortcuts.html b/site/shortcuts.html index be28b21..ca899ae 100644 --- a/site/shortcuts.html +++ b/site/shortcuts.html @@ -80,8 +80,9 @@

Pen hoverShow the small red pointer dot and hide the arrow.
Pen contactHide both the pointer dot and the arrow.
-
Pen eraserErase complete strokes.
-
Barrel buttonHold the lower barrel button for the laser. Release returns to the previous tool.
+
Pen eraserErase complete strokes. The upper side button erases too: Windows reports it the same way as a pen turned round.
+
Barrel buttonHold it for the action set in Preferences: Laser, which returns to the previous tool on release, or Straight line.
+
Straight lineHold Shift, or the barrel button when it is assigned to it, to constrain the stroke to horizontal or vertical at a uniform width. Press or release at any time to start or end the constraint from that point.
@@ -142,7 +143,7 @@