diff --git a/Directory.Build.props b/Directory.Build.props
index 0f979b2..ae4a22d 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.
-->
- 0.9.5
+ 1.0.0latestenableenable
diff --git a/TODO.md b/TODO.md
index 88c7d78..21fdae7 100644
--- a/TODO.md
+++ b/TODO.md
@@ -16,10 +16,10 @@ 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`
-(0.9.5). Identity version for the Store package is `VersionPrefix.0` (`0.9.5.0`).
+(1.0.0). Identity version for the Store package is `VersionPrefix.0` (`1.0.0.0`).
-What 1.0 was waiting on is in the product: Preferences, `.wimport`, Explorer and VS Code
-previews, the public documentation site, and Finger drawing (default when no pen is
+What 1.0 was waiting on shipped during 0.9.x: Preferences, `.wimport`, Explorer and VS
+Code previews, the public documentation site, and Finger drawing (default when no pen is
detected). The Store listing was submitted by hand on 20 August 2026 for 0.9.2.
No numbered work remains. The video teaser is recorded and served from the landing page
diff --git a/docs/decisions.md b/docs/decisions.md
index f8574c5..caad828 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -328,11 +328,26 @@ rendition, so the limit is far away at this site's traffic.
---
+## 20. The version is 1.0.0
+
+**Implemented.**
+
+The open question was never the number, it was what had to be true before it stopped
+being 0.x. That turned out to be product rather than pipeline: Preferences, `.wimport`,
+the Explorer and VS Code previews, the documentation site, and Finger drawing. All of
+them shipped during 0.9.x, and no numbered work was left behind them.
+
+Nothing about delivery changes with it. A merge still publishes a pre-release, promotion
+is still an approval on that same run, and the Store still takes the MSIX from it — so
+1.0.0 reaches people by the path 0.9.5 already proved, which is the reason the number
+could be treated as a statement about the product rather than an event in the pipeline.
+
+The Store carries `1.0.0.0` as its identity version, and winget continues from whatever
+version its first submission settles on.
+
+---
+
## Open questions
-- Whether 1.0.0 is declared, and on what. `VersionPrefix` in `Directory.Build.props`
- reads `0.9.5`, the released channel has been published since `0.9.0`, and `0.9.2` is
- the version live in the Store. Nothing is blocked on the number; what is open is
- what has to be true before it stops being 0.x.
- arm64 is not built; add it if Surface devices matter for a pen application.
- The brand mark is placeholder-grade (decision 12).
diff --git a/site/guide.html b/site/guide.html
index 2b2aad6..edd7762 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.
@@ -655,7 +655,7 @@
Ink and tools
Eraser
- Removes whole strokes. The eraser end of a pen does the same.
+ 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.
@@ -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.
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.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 b/src/SQLBI.Whiteboard/MainWindow.xaml
index 5b810a3..0239be4 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}" />
+
+
+
+
+
+
+
+ !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 +755,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)
@@ -1324,7 +1408,7 @@ private void BeginErase(PointD screen)
private void EraseAt(PointD worldPoint)
{
- var radius = 12 / _camera.Zoom;
+ var radius = EraserScreenRadius / _camera.Zoom;
var hits = _document.Objects
.OfType()
.Where(stroke => _erasedObjects.All(item => item.Id != stroke.Id))
@@ -2174,6 +2258,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)
@@ -4546,6 +4631,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 +4690,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
@@ -4616,9 +4707,16 @@ private void UsePenCursor()
// not the same as leaving the InkCanvas hit-test bounds.
private void UpdateHoverPointerDot(StylusEventArgs e)
{
- if (IsTouchStylus(e) ||
- e.StylusDevice.Inverted ||
- !e.StylusDevice.InAir)
+ if (IsTouchStylus(e))
+ {
+ HidePointerDot();
+ return;
+ }
+
+ // A reversed pen used to be dropped here. It erases on contact, so it
+ // has more to show while hovering than any other pose, not less.
+ _penInverted = e.StylusDevice.Inverted;
+ if (_penInContact)
{
HidePointerDot();
return;
@@ -4640,7 +4738,32 @@ private void UpdateHoverPointerDotAt(Point rootPosition, bool overBoard)
}
UsePenCursor();
- ShowPointerDot(rootPosition);
+ if (IsErasing)
+ {
+ // What a tap would erase is a patch of board, not a point, so the
+ // pointer shows the patch. A dot would say nothing about reach.
+ PointerDot.Visibility = Visibility.Collapsed;
+ LaserTrail.EndHover();
+ ShowEraserHint(rootPosition);
+ }
+ else 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();
+ HideEraserHint();
+ ShowPointerDot(rootPosition);
+ }
+
_lastHoverTimestamp = Stopwatch.GetTimestamp();
if (!_hoverWatch.IsEnabled)
{
@@ -4667,12 +4790,33 @@ 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();
+ HideEraserHint();
_hoverWatch.Stop();
}
+ // Reversing the pen erases without changing the selected tool, so the tool
+ // alone does not answer what a tap would do here.
+ private bool IsErasing => _penInverted || EffectiveTool == BoardTool.Eraser;
+
+ private void ShowEraserHint(Point position)
+ {
+ var side = EraserScreenRadius * 2;
+ EraserHint.Width = side;
+ EraserHint.Height = side;
+ EraserHintTransform.X = position.X - (side / 2);
+ EraserHintTransform.Y = position.Y - (side / 2);
+ EraserHint.Visibility = Visibility.Visible;
+ }
+
+ private void HideEraserHint() => EraserHint.Visibility = Visibility.Collapsed;
+
private void ShowError(string context, Exception exception)
{
MessageBox.Show(
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;
}
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 @@
+
+
+