From 20597847ee45c4086e60fd9d2c44deeaa33709a3 Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Sun, 23 Aug 2026 15:44:28 +0200 Subject: [PATCH 1/6] Bring the tool palette inside the presenter picture-in-picture The palette was one tool button wider than it needed to be. The dual layout sizes its shared colour column from the pen's six swatches, and the highlighter row carried three tool buttons against the pen row's two, so that row set the width of the whole palette and overflowed the picture-in-picture the default top-right placement exists to hide under. The highlighter has four swatches, leaving two swatch widths of that shared column unused. The select button now sits there, docked to the column's right edge, which drops the palette from 328 to 286 logical pixels without moving anything else. A DockPanel rather than overlapping elements, so a palette that ever gains colours squeezes the swatches instead of drawing over the button. Co-Authored-By: Claude Fable 5 --- src/SQLBI.Whiteboard/MainWindow.xaml | 32 +++++++++++++++--------- src/SQLBI.Whiteboard/Themes/Toolbar.xaml | 4 +-- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml b/src/SQLBI.Whiteboard/MainWindow.xaml index 5b810a3..27a8c9b 100644 --- a/src/SQLBI.Whiteboard/MainWindow.xaml +++ b/src/SQLBI.Whiteboard/MainWindow.xaml @@ -296,29 +296,37 @@ Width="Auto" /> - - - + + + ToolTip="Select image" + Click="SelectToolButton_Click"> + Data="{StaticResource ImageSelectGeometry}" /> - + + + + ToolTip="Highlighter" + Click="DualHighlighterButton_Click"> + Data="{StaticResource HighlightGeometry}" /> From 5db04a7b8fd40bf88894101db9c92f0b24efa728 Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Sun, 23 Aug 2026 15:46:18 +0200 Subject: [PATCH 2/6] Stop the pen barrel button from drawing a laser it never touched Clicking the barrel button over a hovering pen made a short laser trail appear without the pen ever reaching the glass, and the first real tap afterwards drew nothing. The tablet driver synthesises a full stylus down for the button press. It claims everything a real touch claims - not in air, tip switch pressed - so only the pressure gives it away, and nothing is pressing on the tip. That phantom set the contact flag, and the barrel handler then started a trail on the strength of it. The phantom is now rejected in the window and in the ink renderer, which runs on the raw input thread and so never saw the window's guard; left alone it opened a wet stroke that was torn down mid-flight when the tool switched. Two further problems surfaced underneath. The tracker feeding hover positions kept a contact flag of its own, and the phantom's matching up does not arrive until after the next real touch, so every hover in between was dropped. And this tablet reports InAir false while hovering, so three places that read InAir as "hovering" fought the tracker on every sample. Both now defer to the window's contact flag, which is the only copy of that state worth keeping. Finally, WPF raises no stylus down at all for the first landing after a phantom, having never closed the phantom's own down. The laser recovers contact from tip pressure instead, which is the one signal that stayed honest throughout. Co-Authored-By: Claude Fable 5 --- src/SQLBI.Whiteboard/MainWindow.xaml.cs | 110 +++++++++++++++++++---- src/SQLBI.Whiteboard/PenOnlyInkCanvas.cs | 76 ++++++++++++---- 2 files changed, 155 insertions(+), 31 deletions(-) diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml.cs b/src/SQLBI.Whiteboard/MainWindow.xaml.cs index 9c6e771..fca12a2 100644 --- a/src/SQLBI.Whiteboard/MainWindow.xaml.cs +++ b/src/SQLBI.Whiteboard/MainWindow.xaml.cs @@ -108,6 +108,8 @@ private enum SessionChromeMode private AppSettings _settings = new(); private const double ChevronInkOptionsWidth = 240; + + private bool _syntheticLaserContact; private const double SessionTabHeight = 32; private const double ToolPaletteInset = 16; private WindowState _windowStateBeforeFullScreen; @@ -279,6 +281,22 @@ private void InkSurface_PreviewStylusDown(object sender, StylusDownEventArgs e) return; } + if (!IsTouchStylus(e) && !IsPenTipDown(e)) + { + Debug.WriteLine("[Laser] ignoring the barrel button's synthetic stylus down"); + + // The event still reached the InkCanvas, which restores its own ink + // cursor on the way past. Nothing else refreshes it until the pen + // moves again, and a pen resting still after a barrel click does not + // move, so the cursor is put back here rather than left showing. + UsePenCursor(); + + // Handled so the InkCanvas does not open an editing gesture for a + // touch we have just decided never happened. + e.Handled = true; + return; + } + if (IsTouchStylus(e)) { if (!TryBeginFingerTool(e)) @@ -352,6 +370,8 @@ private void InkSurface_PreviewStylusMove(object sender, StylusEventArgs e) TrackTouchPoint(e); } + RecoverLaserContactFromPressure(e); + var position = e.GetPosition(InkSurface); var screen = ToPointD(position); @@ -461,19 +481,13 @@ private void InkSurface_PreviewStylusUp(object sender, StylusEventArgs e) _stylusAction = PointerAction.None; _penInContact = false; + _syntheticLaserContact = false; if (EffectiveTool == BoardTool.Laser) { StopLaserSampling(); LaserTrail.Lift(); - InkSurface.Cursor = Cursors.None; - if (e.StylusDevice.InAir) - { - UpdateHoverPointerDot(e); - } - else - { - HidePointerDot(); - } + UsePenCursor(); + UpdateHoverPointerDot(e); } else if (EffectiveTool == BoardTool.Select) { @@ -482,13 +496,9 @@ private void InkSurface_PreviewStylusUp(object sender, StylusEventArgs e) InkSurface.Cursor = SelectCursorAt(hoverScreen); HidePointerDot(); } - else if (e.StylusDevice.InAir) - { - UpdateHoverPointerDot(e); - } else { - HidePointerDot(); + UpdateHoverPointerDot(e); } Debug.WriteLine("[WpfInk] stylus-up reached WPF"); } @@ -548,6 +558,65 @@ private void Window_PreviewStylusDown(object sender, StylusDownEventArgs e) TryActivatePaletteFromStylus(e); } + private bool IsPenTipDown(StylusEventArgs e) => + !e.StylusDevice.InAir && + !PhantomStylus.IsBarrelDown(e.GetStylusPoints(InkSurface)); + + // The barrel click opens a stylus down that WPF does not close until well + // after the pen has touched and left again, so the first real landing is + // delivered as moves inside it and never gets a down of its own. Tip + // pressure is the only honest signal that the pen is on the glass, so the + // trail starts and ends on that instead of waiting for events that are not + // coming. + private void RecoverLaserContactFromPressure(StylusEventArgs e) + { + if (IsTouchStylus(e) || e.StylusDevice.Inverted) + { + return; + } + + var pressed = HasTipPressure(e); + if (pressed && + !_penInContact && + _stylusAction == PointerAction.None && + EffectiveTool == BoardTool.Laser) + { + _penInContact = true; + _syntheticLaserContact = true; + BeginLaserContact(e); + _stylusAction = PointerAction.Laser; + return; + } + + if (!pressed && _syntheticLaserContact) + { + EndSyntheticLaserContact(); + } + } + + private void EndSyntheticLaserContact() + { + _syntheticLaserContact = false; + _penInContact = false; + _stylusAction = PointerAction.None; + StopLaserSampling(); + LaserTrail.Lift(); + } + + private bool HasTipPressure(StylusEventArgs e) + { + var points = e.GetStylusPoints(InkSurface); + for (var index = 0; index < points.Count; index++) + { + if (points[index].PressureFactor > 0) + { + return true; + } + } + + return false; + } + private void ToolPalette_PreviewStylusDown(object sender, StylusDownEventArgs e) { SessionBar.CollapseIfTransient(); @@ -676,6 +745,11 @@ private void BeginTemporaryLaser(StylusButton button) _discardInkStroke = _penInContact; SetActiveTool(BoardTool.Laser); InkSurface.AbortWetInk(); + + // Engaging the laser from the barrel button is the one tool change that + // can happen without the pen moving, so it cannot wait for the next + // pointer event to settle the cursor. + UsePenCursor(); } private void BeginLaserContact(Point position, float pressure) @@ -4546,6 +4620,7 @@ private void Window_Deactivated(object? sender, EventArgs e) _stylusAction = PointerAction.None; _mouseAction = PointerAction.None; _penInContact = false; + _syntheticLaserContact = false; ClearTouchNavigation(); InkSurface.Cursor = Cursors.Arrow; HidePointerDot(); @@ -4604,11 +4679,16 @@ private void HoverWatch_Tick(object? sender, EventArgs e) } } + // Assigning Cursor only takes effect at the next cursor query, and a pen + // held still raises none - the barrel button can change the tool without any + // pointer movement at all. Without the refresh the previous cursor stays on + // screen until something else moves, which is why a tap appeared to fix it. private void UsePenCursor() { InkSurface.Cursor = EffectiveTool is BoardTool.Select ? Cursors.Arrow : Cursors.None; + Mouse.UpdateCursor(); } // Contact packets arrive as StylusMove; hover is StylusInAirMove. Wacom @@ -4618,7 +4698,7 @@ private void UpdateHoverPointerDot(StylusEventArgs e) { if (IsTouchStylus(e) || e.StylusDevice.Inverted || - !e.StylusDevice.InAir) + _penInContact) { HidePointerDot(); return; diff --git a/src/SQLBI.Whiteboard/PenOnlyInkCanvas.cs b/src/SQLBI.Whiteboard/PenOnlyInkCanvas.cs index f5debfe..5f56cba 100644 --- a/src/SQLBI.Whiteboard/PenOnlyInkCanvas.cs +++ b/src/SQLBI.Whiteboard/PenOnlyInkCanvas.cs @@ -53,6 +53,39 @@ public void DrainLaserSamples(Action consume) } } +// Clicking the barrel button over a hovering pen is delivered as a stylus down +// that claims everything a real touch claims - not in air, tip switch pressed. +// Only the pressure gives it away, because nothing is pressing on the tip. +// Requiring the barrel to be down as well keeps a genuinely light first packet +// from being mistaken for one of these. +internal static class PhantomStylus +{ + public static bool IsBarrelDown(StylusPointCollection points) + { + if (points.Count == 0) + { + return false; + } + + for (var index = 0; index < points.Count; index++) + { + var point = points[index]; + if (point.PressureFactor > 0) + { + return false; + } + + if (!point.HasProperty(StylusPointProperties.BarrelButton) || + point.GetPropertyValue(StylusPointProperties.BarrelButton) == 0) + { + return false; + } + } + + return true; + } +} + public sealed class LaserSamplePlugIn : StylusPlugIn { private readonly ConcurrentQueue<(double X, double Y, float Pressure)> _samples = new(); @@ -107,25 +140,19 @@ public sealed class HoverTrackerPlugIn : StylusPlugIn private readonly object _gate = new(); private Point _latest; private bool _queued; - private bool _contact; public Action? Hovered { get; set; } - protected override void OnStylusDown(RawStylusInput rawStylusInput) => _contact = true; - - protected override void OnStylusUp(RawStylusInput rawStylusInput) - { - _contact = false; + // Contact is deliberately not tracked here. The barrel button opens a stylus + // down whose up does not arrive until after the next real touch, so a flag + // set here stayed set across the whole hover in between and every sample was + // dropped. The window already knows whether the pen is down, and that is the + // only copy of the state worth keeping. + protected override void OnStylusUp(RawStylusInput rawStylusInput) => Enqueue(rawStylusInput); - } - protected override void OnStylusMove(RawStylusInput rawStylusInput) - { - if (!_contact) - { - Enqueue(rawStylusInput); - } - } + protected override void OnStylusMove(RawStylusInput rawStylusInput) => + Enqueue(rawStylusInput); private void Enqueue(RawStylusInput rawStylusInput) { @@ -149,7 +176,19 @@ private void Enqueue(RawStylusInput rawStylusInput) } var dispatcher = Element?.Dispatcher; - dispatcher?.BeginInvoke(() => + if (dispatcher is null) + { + // Nothing will ever clear the queued flag otherwise, and the tracker + // would go quiet for the rest of the session. + lock (_gate) + { + _queued = false; + } + + return; + } + + dispatcher.BeginInvoke(() => { Point consumed; lock (_gate) @@ -213,7 +252,12 @@ public void AbortWetInk() protected override void OnStylusDown(RawStylusInput rawStylusInput) { - if (IsTouch(rawStylusInput) || _laserMode) + // Without this the barrel click opens a wet stroke for a pen that never + // touched the glass, which is then torn down mid-flight when the button + // switches to the laser a moment later. + if (IsTouch(rawStylusInput) || + _laserMode || + PhantomStylus.IsBarrelDown(rawStylusInput.GetStylusPoints())) { return; } From 21c9b8522ca2203f2560338ff26b32428355c050 Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Sun, 23 Aug 2026 15:46:44 +0200 Subject: [PATCH 3/6] Draw the laser as a pointer while the pen hovers Hovering with the laser showed the same flat red dot every tool uses. Microsoft Whiteboard shows the laser itself - a red core inside a soft halo, trailing a short comet as it moves - which reads as a pointer rather than as a cursor. The trail surface now takes hover samples as well as contact ones and keeps every sample from a 110ms window, so the comet is the distance the pen covered in that time: sweep fast and it stretches, stop and it collapses to the dot. A 170px cap keeps a flick from streaking across the board. Hover state is held apart from contact state on purpose: a lifted pen leaves its stroke open for a moment so a resumed line joins the last one, and that window is measured from the contact head being gone, which a hovering pen must not fill. The head is drawn once for both, so the instrument does not change appearance the instant it touches glass. Its centre is now solid red rather than near white, which is what the reference shows and what survives a projector. This also introduces the trail weight the surface reads. A pen reports very little pressure on a quick tap, thin and faint enough to miss in a recording, and the weights raise that floor without touching what a firm stroke looks like. The numbers live beside the enum in Core so the renderer and anything previewing it cannot drift apart. Preferences follows separately. Co-Authored-By: Claude Fable 5 --- .../Settings/LaserSettings.cs | 58 +++++ src/SQLBI.Whiteboard/LaserTrailSurface.cs | 236 ++++++++++++++++-- src/SQLBI.Whiteboard/MainWindow.xaml.cs | 23 +- 3 files changed, 301 insertions(+), 16 deletions(-) diff --git a/src/SQLBI.Whiteboard.Core/Settings/LaserSettings.cs b/src/SQLBI.Whiteboard.Core/Settings/LaserSettings.cs index 2c1d716..9f0f079 100644 --- a/src/SQLBI.Whiteboard.Core/Settings/LaserSettings.cs +++ b/src/SQLBI.Whiteboard.Core/Settings/LaserSettings.cs @@ -6,6 +6,19 @@ public enum LaserHoldMode PerStroke = 1, } +/// +/// How much a light touch is thinned out. The pen reports very little pressure +/// on a quick tap, which at draws a line +/// thin enough to miss on a projector. The heavier settings raise the floor +/// without changing what a firm stroke looks like. +/// +public enum LaserTrailWeight +{ + Light = 0, + Medium = 1, + Bold = 2, +} + public sealed class LaserSettings { public const double DefaultHoldSeconds = 2; @@ -15,12 +28,52 @@ public sealed class LaserSettings public const double MinimumFadeSeconds = 0.05; public const double MaximumFadeSeconds = 10; + /// + /// The widest the trail is drawn, at full pressure. Shared with the width + /// floors below so that a preview of a weight cannot drift from the trail + /// that weight actually produces. + /// + public const double MaximumTrailWidth = 9; + + public const uint TrailArgb = 0xFFE11D48; + + public static byte TrailRed => (byte)((TrailArgb >> 16) & 0xFF); + + public static byte TrailGreen => (byte)((TrailArgb >> 8) & 0xFF); + + public static byte TrailBlue => (byte)(TrailArgb & 0xFF); + public double HoldSeconds { get; set; } = DefaultHoldSeconds; public double FadeSeconds { get; set; } = DefaultFadeSeconds; public LaserHoldMode HoldMode { get; set; } = LaserHoldMode.Shared; + public LaserTrailWeight TrailWeight { get; set; } = LaserTrailWeight.Light; + + /// + /// The narrowest the trail is ever drawn, whatever the pressure. The widest + /// is the same for every weight, so raising this compresses the range from + /// the bottom rather than making a firm stroke heavier. + /// + public static double MinimumTrailWidthFor(LaserTrailWeight weight) => weight switch + { + LaserTrailWeight.Bold => 4, + LaserTrailWeight.Medium => 2.5, + _ => 1.2, + }; + + /// + /// The opacity a trail keeps at the lightest touch, as a fraction of the + /// opacity a full-pressure stroke gets. + /// + public static double MinimumTrailOpacityFor(LaserTrailWeight weight) => weight switch + { + LaserTrailWeight.Bold => 0.85, + LaserTrailWeight.Medium => 0.7, + _ => 0.55, + }; + public static LaserSettings Normalize(LaserSettings? settings) { var result = settings ?? new LaserSettings(); @@ -39,6 +92,11 @@ public static LaserSettings Normalize(LaserSettings? settings) result.HoldMode = LaserHoldMode.Shared; } + if (!Enum.IsDefined(result.TrailWeight)) + { + result.TrailWeight = LaserTrailWeight.Light; + } + return result; } diff --git a/src/SQLBI.Whiteboard/LaserTrailSurface.cs b/src/SQLBI.Whiteboard/LaserTrailSurface.cs index ab53e8a..9f69131 100644 --- a/src/SQLBI.Whiteboard/LaserTrailSurface.cs +++ b/src/SQLBI.Whiteboard/LaserTrailSurface.cs @@ -11,12 +11,29 @@ internal sealed class LaserTrailSurface : FrameworkElement { private const double ResumeSeconds = 0.05; private const double ResumeDistance = 36; - private const double MaximumWidth = 9; - private const double MinimumWidth = 1.2; - private static readonly Color LaserRed = Color.FromRgb(0xE1, 0x1D, 0x48); + private const double MaximumWidth = LaserSettings.MaximumTrailWidth; + + // The hover dot is deliberately independent of the trail weight below: that + // setting is about how hard you have to press, and the pointer is drawn + // without pressing at all. + private const double HoverHeadWidth = 5.1; + + // The hover comet is every sample from this window, so its length is the + // distance the pen covered in that time: a fast hover draws a long tail and + // a slow one barely more than the dot. The cap stops a flick from streaking + // across the board. + private const double HoverTrailSeconds = 0.11; + private const double HoverTrailLimit = 170; + private const float HoverPressure = 0.5f; + private static readonly Color LaserRed = Color.FromRgb( + LaserSettings.TrailRed, + LaserSettings.TrailGreen, + LaserSettings.TrailBlue); private readonly List _strokes = []; private readonly Dictionary _groupClocks = []; + private readonly List _hover = []; + private Point? _hoverHead; private Point? _head; private float _headPressure = 0.5f; private bool _strokeOpen; @@ -30,6 +47,8 @@ internal sealed class LaserTrailSurface : FrameworkElement public LaserHoldMode HoldMode { get; set; } = LaserHoldMode.Shared; + public LaserTrailWeight TrailWeight { get; set; } = LaserTrailWeight.Light; + public LaserTrailSurface() { IsHitTestVisible = false; @@ -64,6 +83,39 @@ public void HideHead() InvalidateVisual(); } + // Hover is deliberately kept clear of the contact state. A lifted pen leaves + // the stroke open for a moment so a resumed line joins the last one, and that + // window is measured from _head being gone; a hovering pen must not fill it. + public void Hover(Point point) + { + var now = Stopwatch.GetTimestamp(); + if (_hover.Count > 0 && Distance(_hover[^1].Point, point) < 0.5) + { + _hover[^1] = new LaserPoint(point, now, HoverPressure); + } + else + { + _hover.Add(new LaserPoint(point, now, HoverPressure)); + } + + _hoverHead = point; + TrimHover(); + EnsureTick(); + InvalidateVisual(); + } + + public void EndHover() + { + if (_hoverHead is null && _hover.Count == 0) + { + return; + } + + _hoverHead = null; + _hover.Clear(); + InvalidateVisual(); + } + public void Clear() { _head = null; @@ -72,6 +124,8 @@ public void Clear() _liftTimestamp = 0; _strokes.Clear(); _groupClocks.Clear(); + _hover.Clear(); + _hoverHead = null; _activeGroupId = 1; StopTick(); InvalidateVisual(); @@ -82,6 +136,7 @@ protected override void OnRender(DrawingContext drawingContext) base.OnRender(drawingContext); CloseStrokeIfLiftExpired(); TrimExpired(); + TrimHover(); foreach (var stroke in _strokes) { @@ -113,32 +168,170 @@ protected override void OnRender(DrawingContext drawingContext) ink.Draw(drawingContext); ink.DrawingAttributes = LaserAttributes( width, - (byte)(230 * life * (0.55 + (0.45 * pressure)))); + (byte)(230 * life * OpacityFor(pressure))); ink.Draw(drawingContext); } - if (_head is not Point head) + if (_hoverHead is Point hoverHead) { - return; + DrawHoverTail(drawingContext); + DrawHead(drawingContext, hoverHead, HoverHeadWidth, HoverPressure); } - var headWidth = WidthFor(_headPressure); - var halo = new RadialGradientBrush( - Color.FromArgb((byte)(80 + (80 * _headPressure)), LaserRed.R, LaserRed.G, LaserRed.B), - Color.FromArgb(0, LaserRed.R, LaserRed.G, LaserRed.B)); + if (_head is Point head) + { + DrawHead(drawingContext, head, WidthFor(_headPressure), _headPressure); + } + } + + // A solid red centre inside a soft shade. The shade is what separates the + // pointer from drawn ink; the centre is what stays legible on a white board + // once a projector and a video encoder have had their turn at it. + private static void DrawHead( + DrawingContext drawingContext, + Point head, + double headWidth, + float pressure) + { + var halo = new RadialGradientBrush + { + GradientStops = + [ + new GradientStop( + Color.FromArgb((byte)(120 + (70 * pressure)), LaserRed.R, LaserRed.G, LaserRed.B), + 0.3), + new GradientStop(Color.FromArgb(0, LaserRed.R, LaserRed.G, LaserRed.B), 1), + ], + }; halo.Freeze(); drawingContext.DrawEllipse(halo, null, head, headWidth + 6, headWidth + 6); + + var core = new SolidColorBrush(LaserRed); + core.Freeze(); + var coreRadius = Math.Max(2.4, headWidth * 0.55); + drawingContext.DrawEllipse(core, null, head, coreRadius, coreRadius); + var hot = new SolidColorBrush(Color.FromArgb( - (byte)(180 + (65 * _headPressure)), + (byte)(150 + (70 * pressure)), 255, 244, 246)); hot.Freeze(); - drawingContext.DrawEllipse(hot, null, head, Math.Max(1.4, headWidth * 0.38), Math.Max(1.4, headWidth * 0.38)); + var hotRadius = Math.Max(0.9, headWidth * 0.2); + drawingContext.DrawEllipse(hot, null, head, hotRadius, hotRadius); + } + + private void DrawHoverTail(DrawingContext drawingContext) + { + if (_hover.Count < 2) + { + return; + } + + var tail = _hover[0].Point; + var head = _hover[^1].Point; + if (Distance(tail, head) < 1.5) + { + return; + } + + var half = HoverHeadWidth * 0.5; + var glow = HoverGeometry(half + 2.5); + var core = HoverGeometry(half); + if (glow is null || core is null) + { + return; + } + + drawingContext.DrawGeometry(HoverBrush(tail, head, 70), null, glow); + drawingContext.DrawGeometry(HoverBrush(tail, head, 205), null, core); + } + + // A wedge that comes to a point at the tail and reaches full width under the + // head, so the comet reads as motion rather than as a drawn line. + private Geometry? HoverGeometry(double halfWidth) + { + var count = _hover.Count; + var left = new Point[count]; + var right = new Point[count]; + for (var index = 0; index < count; index++) + { + var direction = HoverDirection(index); + var taper = halfWidth * index / (count - 1); + var offsetX = -direction.Y * taper; + var offsetY = direction.X * taper; + var point = _hover[index].Point; + left[index] = new Point(point.X + offsetX, point.Y + offsetY); + right[index] = new Point(point.X - offsetX, point.Y - offsetY); + } + + Array.Reverse(right); + var geometry = new StreamGeometry(); + using (var context = geometry.Open()) + { + context.BeginFigure(left[0], isFilled: true, isClosed: true); + context.PolyLineTo(left[1..], isStroked: false, isSmoothJoin: true); + context.PolyLineTo(right, isStroked: false, isSmoothJoin: true); + } + + geometry.Freeze(); + return geometry; + } + + private Vector HoverDirection(int index) + { + var previous = _hover[Math.Max(index - 1, 0)].Point; + var next = _hover[Math.Min(index + 1, _hover.Count - 1)].Point; + var direction = next - previous; + if (direction.Length < 0.001) + { + direction = _hover[^1].Point - _hover[0].Point; + if (direction.Length < 0.001) + { + return new Vector(1, 0); + } + } + + direction.Normalize(); + return direction; + } + + private static Brush HoverBrush(Point tail, Point head, byte alpha) + { + var brush = new LinearGradientBrush + { + StartPoint = tail, + EndPoint = head, + MappingMode = BrushMappingMode.Absolute, + }; + brush.GradientStops.Add( + new GradientStop(Color.FromArgb(0, LaserRed.R, LaserRed.G, LaserRed.B), 0)); + brush.GradientStops.Add( + new GradientStop(Color.FromArgb(alpha, LaserRed.R, LaserRed.G, LaserRed.B), 1)); + brush.Freeze(); + return brush; + } + + private void TrimHover() + { + _hover.RemoveAll(sample => AgeSeconds(sample.Timestamp) > HoverTrailSeconds); + + var length = 0.0; + for (var index = _hover.Count - 1; index > 0; index--) + { + length += Distance(_hover[index].Point, _hover[index - 1].Point); + if (length > HoverTrailLimit) + { + _hover.RemoveRange(0, index); + return; + } + } } private void AcceptSample(Point point, float pressure, bool leaveTrail) { + _hover.Clear(); + _hoverHead = null; var amount = ClampPressure(pressure); if (leaveTrail) { @@ -202,7 +395,11 @@ private void OnRendering(object? sender, EventArgs e) { CloseStrokeIfLiftExpired(); TrimExpired(); - if (_strokes.Count == 0 && _head is null) + TrimHover(); + + // A stationary hover has nothing left to animate once its tail has aged + // out; the head stays on screen and the next move restarts the clock. + if (_strokes.Count == 0 && _head is null && _hover.Count <= 1) { StopTick(); } @@ -345,8 +542,17 @@ private static DrawingAttributes LaserAttributes(double width, byte alpha) }; } - private static double WidthFor(float pressure) => - MinimumWidth + ((MaximumWidth - MinimumWidth) * ClampPressure(pressure)); + private double WidthFor(float pressure) + { + var minimum = LaserSettings.MinimumTrailWidthFor(TrailWeight); + return minimum + ((MaximumWidth - minimum) * ClampPressure(pressure)); + } + + private double OpacityFor(float pressure) + { + var floor = LaserSettings.MinimumTrailOpacityFor(TrailWeight); + return floor + ((1 - floor) * ClampPressure(pressure)); + } private static float ClampPressure(float pressure) => Math.Clamp(pressure, 0.02f, 1f); diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml.cs b/src/SQLBI.Whiteboard/MainWindow.xaml.cs index fca12a2..9a69ff9 100644 --- a/src/SQLBI.Whiteboard/MainWindow.xaml.cs +++ b/src/SQLBI.Whiteboard/MainWindow.xaml.cs @@ -2248,6 +2248,7 @@ private void ApplyLaserSettings() LaserTrail.HoldSeconds = laser.HoldSeconds; LaserTrail.FadeSeconds = laser.FadeSeconds; LaserTrail.HoldMode = laser.HoldMode; + LaserTrail.TrailWeight = laser.TrailWeight; } private void CommitInkStyle(PenStyle style) @@ -4720,7 +4721,23 @@ private void UpdateHoverPointerDotAt(Point rootPosition, bool overBoard) } UsePenCursor(); - ShowPointerDot(rootPosition); + if (EffectiveTool == BoardTool.Laser) + { + // The laser is the same instrument in the air as on the glass, so + // hover drives the trail surface rather than the plain hover dot. + // HidePointerDot is not used here: it ends the hover it is about to + // be handed. + PointerDot.Visibility = Visibility.Collapsed; + LaserTrail.Hover(RootGrid.TranslatePoint(rootPosition, LaserTrail)); + } + else + { + // Switching tools mid-hover has to take the comet with it; the pen + // is still in range, so the hover watchdog would never fire. + LaserTrail.EndHover(); + ShowPointerDot(rootPosition); + } + _lastHoverTimestamp = Stopwatch.GetTimestamp(); if (!_hoverWatch.IsEnabled) { @@ -4747,9 +4764,13 @@ private void ShowPointerDot(Point position) PointerDot.Visibility = Visibility.Visible; } + // Every path that means "the pen is no longer over the board" comes through + // here, including the hover watchdog, so the laser comet is cleared here too + // rather than at each of those call sites. private void HidePointerDot() { PointerDot.Visibility = Visibility.Collapsed; + LaserTrail.EndHover(); _hoverWatch.Stop(); } From f2f06fabb7118f29ed1c772f97e3cceb6910d405 Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Sun, 23 Aug 2026 15:46:55 +0200 Subject: [PATCH 4/6] Offer the laser trail weight in Preferences The trail weight is a setting about how something looks, so a dropdown of three words made the reader imagine the result. The three options are drawn instead, side by side, each showing a light tap above a firm stroke: the firm stroke is identical across all three, so what the setting does and does not change is visible without reading anything. The samples are computed from the same floors the trail itself uses rather than being drawn to look about right, so a preview cannot promise something the laser will not deliver. Light stays the default, matching the behaviour before the weights existed. Co-Authored-By: Claude Fable 5 --- site/guide.html | 4 +- site/shortcuts.html | 2 +- .../PreferencesWindow.xaml.cs | 87 ++++++++++++++++++- src/SQLBI.Whiteboard/SettingsCatalog.cs | 24 +++++ src/SQLBI.Whiteboard/Themes/Settings.xaml | 54 ++++++++++++ .../Program.cs | 22 +++++ 6 files changed, 189 insertions(+), 4 deletions(-) diff --git a/site/guide.html b/site/guide.html index 2b2aad6..8bc0ab5 100644 --- a/site/guide.html +++ b/site/guide.html @@ -354,7 +354,7 @@

Explain code

Talk to the room

-

The laser pointer is for the projector, not for the file. Preferences control the timing, not the shape: how long the trail stays fully visible after you lift, and how long it then takes to fade away. A pen in the air shows a small high-contrast hover dot; the dot disappears on contact so it never sits under the nib. A physical mouse keeps a normal arrow.

+

The laser pointer is for the projector, not for the file. Preferences control how long the trail stays fully visible after you lift, how long it then takes to fade away, and how much a light touch is thinned out — a pen reports very little pressure on a quick tap, and the heavier trail weights keep that tap as readable as a firm stroke. A pen in the air shows a small high-contrast hover dot; the dot disappears on contact so it never sits under the nib. With the laser selected that hover dot is the laser itself, halo and all, and it draws a short comet as you move: the faster you sweep, the longer the comet, so the room can follow the pointer before you have touched anything. A physical mouse keeps a normal arrow.

@@ -901,7 +901,7 @@

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, 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, 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.

diff --git a/site/shortcuts.html b/site/shortcuts.html index dcb3ce9..be28b21 100644 --- a/site/shortcuts.html +++ b/site/shortcuts.html @@ -149,7 +149,7 @@

View → Bring to frontPut the selected image, text, or LiveView above everything else, with its linked strokes. Letter B when the View strip is open.
View → Send to backPut the selected container behind everything else. Letter S when the View strip is open. Strokes cannot be reordered on their own.
-
Help → PreferencesSearchable settings: startup monitor, full screen, finger drawing, snippet format order, laser trail, toolbar, update checks (not on a Microsoft Store install).
+
Help → PreferencesSearchable settings: startup monitor, full screen, finger drawing, snippet format order, laser trail timing and weight, toolbar, update checks (not on a Microsoft Store install).
View → LiveViewCapture an application window or display as a container.
View → FreezeFreeze or resume the selected live feed. Resume uses a play icon.
View → DisconnectRelease the capture target. The last frame stays. Reconnect is then the only way back to a live feed.
diff --git a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs index 2fdb857..6373a72 100644 --- a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs +++ b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs @@ -3,6 +3,7 @@ using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; +using System.Windows.Media; using SQLBI.Whiteboard.Core.Model; using SQLBI.Whiteboard.Core.Settings; @@ -172,7 +173,7 @@ private Border CreateRow(SettingDescriptor setting) body.Children.Add(value); body.Children.Add(slider); } - else if (setting.Editor == SettingEditorKind.OrderedList) + else if (setting.Editor is SettingEditorKind.OrderedList or SettingEditorKind.LaserWeightChoice) { body.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); body.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); @@ -206,9 +207,88 @@ private FrameworkElement CreateEditor(SettingDescriptor setting) => SettingEditorKind.DoubleRange => CreateSlider(setting), SettingEditorKind.MonitorChoice => CreateMonitorCombo(), SettingEditorKind.OrderedList => CreateOrderedList(setting), + SettingEditorKind.LaserWeightChoice => CreateLaserWeightChoice(setting), _ => CreateEnumCombo(setting), }; + // Each option is drawn as the strokes it produces, at the same width and + // opacity the trail itself would use, so the choice is made by looking + // rather than by imagining what a word means. + private FrameworkElement CreateLaserWeightChoice(SettingDescriptor setting) + { + const float LightTouch = 0.08f; + const float FirmTouch = 0.6f; + + var host = new UniformGrid + { + Rows = 1, + Columns = setting.Choices.Count, + }; + + var segments = new List(); + foreach (var choice in setting.Choices) + { + if (!Enum.TryParse(choice.Id, out var weight)) + { + continue; + } + + var samples = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; + samples.Children.Add(CreateLaserSample(weight, LightTouch)); + samples.Children.Add(CreateLaserSample(weight, FirmTouch)); + samples.Children.Add(new TextBlock + { + Style = (Style)FindResource("SettingsValueLabel"), + Text = choice.Title, + Margin = new Thickness(0, 8, 0, 0), + HorizontalAlignment = HorizontalAlignment.Center, + }); + + var segment = new ToggleButton + { + Style = (Style)FindResource("SettingsSampleSegment"), + Content = samples, + IsChecked = choice.Id == CurrentEnumId(setting), + Tag = choice.Id, + }; + segment.Click += (_, _) => + { + foreach (var other in segments) + { + other.IsChecked = ReferenceEquals(other, segment); + } + + SetEnum(setting, choice.Id); + }; + segments.Add(segment); + host.Children.Add(segment); + } + + return host; + } + + private Border CreateLaserSample(LaserTrailWeight weight, float pressure) + { + var minimumWidth = LaserSettings.MinimumTrailWidthFor(weight); + var height = minimumWidth + ((LaserSettings.MaximumTrailWidth - minimumWidth) * pressure); + var floor = LaserSettings.MinimumTrailOpacityFor(weight); + var opacity = floor + ((1 - floor) * pressure); + var brush = new SolidColorBrush(Color.FromArgb( + (byte)(230 * opacity), + LaserSettings.TrailRed, + LaserSettings.TrailGreen, + LaserSettings.TrailBlue)); + brush.Freeze(); + return new Border + { + Height = height, + Width = 76, + Margin = new Thickness(0, 4, 0, 4), + CornerRadius = new CornerRadius(height / 2), + Background = brush, + }; + } + private FrameworkElement CreateOrderedList(SettingDescriptor setting) { var host = new StackPanel(); @@ -463,6 +543,7 @@ private string CurrentEnumId(SettingDescriptor setting) => setting.Id switch { SettingsCatalog.Ids.LaserHoldMode => _settings.Laser.HoldMode.ToString(), + SettingsCatalog.Ids.LaserTrailWeight => _settings.Laser.TrailWeight.ToString(), SettingsCatalog.Ids.ToolbarPlacement => _settings.ToolbarPlacement.ToString(), SettingsCatalog.Ids.ToolbarLayout => _settings.CalligraphyAccess.ToString(), SettingsCatalog.Ids.FingerMode => _settings.FingerMode.ToString(), @@ -525,6 +606,10 @@ private void SetEnum(SettingDescriptor setting, string id) when Enum.TryParse(id, out var holdMode): _settings.Laser.HoldMode = holdMode; break; + case SettingsCatalog.Ids.LaserTrailWeight + when Enum.TryParse(id, out var trailWeight): + _settings.Laser.TrailWeight = trailWeight; + break; case SettingsCatalog.Ids.ToolbarPlacement when Enum.TryParse(id, out var placement): _settings.ToolbarPlacement = placement; diff --git a/src/SQLBI.Whiteboard/SettingsCatalog.cs b/src/SQLBI.Whiteboard/SettingsCatalog.cs index 3fc5742..9d5789c 100644 --- a/src/SQLBI.Whiteboard/SettingsCatalog.cs +++ b/src/SQLBI.Whiteboard/SettingsCatalog.cs @@ -9,6 +9,13 @@ internal enum SettingEditorKind DoubleRange, MonitorChoice, OrderedList, + + /// + /// The laser trail weights, drawn side by side as the strokes they produce. + /// Naming the options tells you nothing about what they look like, and this + /// is a setting about how something looks. + /// + LaserWeightChoice, } internal sealed class SettingChoice @@ -54,6 +61,8 @@ public static class Ids public const string LaserHoldSeconds = "laser.holdSeconds"; public const string LaserFadeSeconds = "laser.fadeSeconds"; public const string LaserHoldMode = "laser.holdMode"; + + public const string LaserTrailWeight = "laser.trailWeight"; public const string ToolbarPlacement = "toolbar.placement"; public const string ToolbarLayout = "toolbar.layout"; public const string FingerMode = "input.fingerMode"; @@ -151,6 +160,21 @@ public static class Ids ], }, new() + { + Id = Ids.LaserTrailWeight, + Category = Laser, + Title = "Trail weight", + Description = "A pen reports little pressure on a quick tap. Each option shows that tap above a firm stroke: the firm stroke never changes, only how much the light one is thinned out.", + Keywords = ["laser", "weight", "thickness", "width", "pressure", "trail"], + Editor = SettingEditorKind.LaserWeightChoice, + Choices = + [ + new() { Id = nameof(LaserTrailWeight.Light), Title = "Light" }, + new() { Id = nameof(LaserTrailWeight.Medium), Title = "Medium" }, + new() { Id = nameof(LaserTrailWeight.Bold), Title = "Bold" }, + ], + }, + new() { Id = Ids.ToolbarPlacement, Category = Toolbar, diff --git a/src/SQLBI.Whiteboard/Themes/Settings.xaml b/src/SQLBI.Whiteboard/Themes/Settings.xaml index 6dd2f3c..f6921f0 100644 --- a/src/SQLBI.Whiteboard/Themes/Settings.xaml +++ b/src/SQLBI.Whiteboard/Themes/Settings.xaml @@ -292,6 +292,60 @@ + + +