diff --git a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs
index ca68408..5b7cfe8 100644
--- a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs
+++ b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs
@@ -14,6 +14,15 @@ public partial class DasherCanvas
private long _lastGazeTicks;
private static readonly TimeSpan GazeTimeout = TimeSpan.FromMilliseconds(500);
+ /// True while the engine is parked on the crosshair because
+ /// gaze was lost; the next valid sample clears it.
+ private volatile bool _gazePausedForLoss;
+
+ /// Serialises GazeFilter use between the gaze callback
+ /// thread (Process) and the UI thread (Reset in PauseForGazeLoss,
+ /// ConfigureGazeFilter swaps).
+ private readonly object _gazeFilterLock = new();
+
public bool IsEyeTracking => _useEyeGazeInput;
public bool IsEyeTrackActive => _eyeTrackActive;
@@ -43,11 +52,31 @@ public async Task InitializeEyeGazeAsync(EyeGazeIntegration.TrackerType tr
{
_useEyeGazeInput = true;
_eyeGazeIntegration.GazePositionChanged += OnEyeGazePositionChanged;
+ _eyeGazeIntegration.Warning += OnEyeTrackerWarning;
+ _gazePausedForLoss = false;
+ _lastGazeTicks = 0;
+ _crosshairCached = false;
+ _crosshairQueryFailedLogged = false;
+ // Cache the crosshair now (not first-Render) so the
+ // hold-before-first-paint window is empty.
+ CacheCrosshairScreenPosition();
EyeGazeLogger.Log("Eye gaze input active — indicator should appear (green/red dot top-right)");
}
return ok;
}
+ /// Tracker-side degradation notice (thread-pool thread):
+ /// log + one toast per session, marshalled to the UI thread.
+ private bool _gazeWarned;
+ private void OnEyeTrackerWarning(string message)
+ {
+ EyeGazeLogger.Log($"Tracker warning: {message}");
+ if (_gazeWarned) return;
+ _gazeWarned = true;
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ Services.ToastNotifier.Show("Eye tracker notice", message, isWarning: true));
+ }
+
public void DisableEyeGaze()
{
DisableEyeGaze(blocking: true);
@@ -62,9 +91,12 @@ private void DisableEyeGaze(bool blocking)
{
_useEyeGazeInput = false;
_eyeTrackActive = false;
+ _gazePausedForLoss = false;
+ _gazeWarned = false;
if (_eyeGazeIntegration != null)
{
_eyeGazeIntegration.GazePositionChanged -= OnEyeGazePositionChanged;
+ _eyeGazeIntegration.Warning -= OnEyeTrackerWarning;
_eyeGazeIntegration.Shutdown(blocking);
_eyeGazeIntegration = null;
}
@@ -75,12 +107,52 @@ private void DisableEyeGaze(bool blocking)
private float _cachedOriginX;
private float _cachedOriginY;
+ private GazeFilter? _gazeFilter;
+ private int _gazeLogInterval; // log every Nth sample (throttles the log)
+
+ ///
+ /// Configure the gaze filter (dead zone, velocity gate, settle window).
+ /// Null disables all filtering (raw passthrough — pre-Juan behaviour).
+ ///
+ public void ConfigureGazeFilter(double? deadZonePx = null, double? maxVelocity = null, double? settleMs = null)
+ {
+ lock (_gazeFilterLock)
+ {
+ if (deadZonePx == null && maxVelocity == null && settleMs == null)
+ {
+ _gazeFilter = null; // disable
+ EyeGazeLogger.Log("Gaze filter DISABLED (raw passthrough)");
+ return;
+ }
+
+ _gazeFilter ??= new GazeFilter();
+ if (deadZonePx.HasValue) _gazeFilter.DeadZonePx = deadZonePx.Value;
+ if (maxVelocity.HasValue) _gazeFilter.MaxVelocityPxPerMs = maxVelocity.Value;
+ if (settleMs.HasValue) _gazeFilter.SettleMs = settleMs.Value;
+ EyeGazeLogger.Log($"Gaze filter: deadZone={_gazeFilter.DeadZonePx}px vel={_gazeFilter.MaxVelocityPxPerMs}px/ms settle={_gazeFilter.SettleMs}ms");
+ }
+ }
+
private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint)
{
if (!_useEyeGazeInput || _handle == IntPtr.Zero) return;
+ if (!gazePoint.IsValid)
+ {
+ // Tracker says eyes not found: hold immediately. Dropping
+ // invalid samples silently left the last gaze position
+ // driving the zoom (the eyegaze.log field session).
+ PauseForGazeLoss("invalid sample");
+ return;
+ }
+
_lastGazeTicks = DateTimeOffset.UtcNow.Ticks;
_eyeTrackActive = true;
+ if (_gazePausedForLoss)
+ {
+ _gazePausedForLoss = false;
+ EyeGazeLogger.Log("Gaze resumed — zoom continues");
+ }
float x = gazePoint.X;
float y = gazePoint.Y;
@@ -91,9 +163,148 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint)
y = (float)(gazePoint.Y - _cachedOriginY);
}
+ // Apply the gaze filter (velocity gate → dead zone → settle).
+ // Local capture: ConfigureGazeFilter runs on the UI thread and
+ // can swap the field to null between check and call.
+ var filter = _gazeFilter;
+ if (filter != null)
+ {
+ var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ double filteredX = x, filteredY = y;
+ bool send;
+ lock (_gazeFilterLock) send = filter.Process(x, y, nowMs, out filteredX, out filteredY);
+ if (!send)
+ return; // filtered out (in transit, jitter, or not settled)
+
+ // Diagnostic: log every 30th sample (≈once per 500ms at 60Hz)
+ if (EyeGazeLogger.Verbose)
+ {
+ _gazeLogInterval++;
+ if (_gazeLogInterval >= 30)
+ {
+ _gazeLogInterval = 0;
+ EyeGazeLogger.Log($"gaze filter: sent=({filteredX:F0},{filteredY:F0}) vel={filter.LastVelocity:F1}px/ms " +
+ $"[total={filter.TotalSamples} sent={filter.SentSamples} dz={filter.DeadZoneBlocks} vel_blk={filter.VelocityBlocks} settle_blk={filter.SettleBlocks}]");
+ }
+ }
+
+ x = (float)filteredX;
+ y = (float)filteredY;
+ }
+
NativeBridge.dasher_mouse_move(_handle, x, y);
}
+ /// Screen X of the crosshair in engine screen space — the
+ /// zero-velocity point. Cached by Render (UI thread); the hold reads
+ /// it from the gaze callback thread.
+ private volatile float _cachedCrosshairScreenX;
+
+ ///
+ /// Screen Y of the crosshair. BOTH axes matter: parking on the X
+ /// alone freezes the zoom but keeps steering the vertical pan (the
+ /// engine pans toward the pointer's Y at a constant rate — the
+ /// root-independent screen→Dasher mapping never self-corrects). The
+ /// full (X, Y) park is a TRUE freeze: dasher (2048, 2048) yields
+ /// zero zoom AND zero pan.
+ ///
+ /// Geometry assumption: valid for the default/squish geometries
+ /// (integer-exact screen↔Dasher round-trip). NOT maintained for
+ /// LP_GEOMETRY=square_no_xhair (crosshair offscreen + reverse
+ /// boost): the hold would reverse-zoom. Gaze users don't reach that
+ /// advanced engine setting via the Dasher UI.
+ ///
+ private volatile float _cachedCrosshairScreenY;
+ private volatile bool _crosshairCached;
+ private bool _crosshairQueryFailedLogged;
+
+ ///
+ /// Query the engine for the crosshair's screen position. UI thread
+ /// (Render) or once at gaze init; retried per frame until it
+ /// succeeds so the uncached window is empty.
+ ///
+ private void CacheCrosshairScreenPosition()
+ {
+ try
+ {
+ var vp = new NativeBridge.DasherViewport
+ {
+ struct_size = System.Runtime.InteropServices.Marshal.SizeOf()
+ };
+ if (NativeBridge.dasher_get_viewport(_handle, ref vp) == 0 &&
+ NativeBridge.dasher_dasher_to_screen(_handle, vp.crosshair_x, vp.crosshair_y, out var chX, out var chY) == 0)
+ {
+ _cachedCrosshairScreenX = chX;
+ _cachedCrosshairScreenY = chY;
+ _crosshairCached = true;
+ }
+ else if (!_crosshairQueryFailedLogged)
+ {
+ _crosshairQueryFailedLogged = true;
+ EyeGazeLogger.Log("Gaze hold: viewport query failed — will retry each frame (park unavailable until it succeeds)");
+ }
+ }
+ catch (Exception ex)
+ {
+ if (!_crosshairQueryFailedLogged)
+ {
+ _crosshairQueryFailedLogged = true;
+ EyeGazeLogger.Log($"Gaze hold: viewport query threw: {ex.Message} — will retry each frame");
+ }
+ }
+ }
+
+ ///
+ /// Park the pointer on the crosshair: the crosshair is the engine's
+ /// zero-velocity point, so the zoom AND pan freeze WITHOUT stopping
+ /// the engine — no resume click is needed and the next valid sample
+ /// simply moves again. (The stop-outside idiom was rejected in
+ /// review: BP_STOP_OUTSIDE performs a full engine stop that needs a
+ /// Primary_Input click to resume — a click gaze users can't make;
+ /// every blink would have halted typing.)
+ ///
+ private void PauseForGazeLoss(string reason)
+ {
+ _eyeTrackActive = false;
+ var filter = _gazeFilter;
+ lock (_gazeFilterLock) filter?.Reset();
+ if (_gazePausedForLoss) return; // already holding
+ _gazePausedForLoss = true;
+ if (!_crosshairCached)
+ {
+ // Not cached yet (gaze lost before the first Render): retry
+ // now. If it still fails, skip the park this instant — the
+ // next Render caches and subsequent losses hold properly.
+ CacheCrosshairScreenPosition();
+ if (!_crosshairCached)
+ {
+ EyeGazeLogger.Log($"Gaze lost ({reason}) — crosshair not cached yet, holding skipped this once");
+ return;
+ }
+ }
+ EyeGazeLogger.Log($"Gaze lost ({reason}) — holding on crosshair");
+ try
+ {
+ NativeBridge.dasher_mouse_move(_handle, _cachedCrosshairScreenX, _cachedCrosshairScreenY);
+ }
+ catch (Exception ex)
+ {
+ EyeGazeLogger.Log($"PauseForGazeLoss: mouse_move failed: {ex.Message}");
+ }
+ }
+
+ /// Staleness backstop: called from Render (UI thread, RAF
+ /// cadence) — if no valid sample arrived within GazeTimeout (e.g.
+ /// the tracker stopped delivering entirely), pause. The indicator's
+ /// red dot rides the same check.
+ private void CheckGazeStaleness()
+ {
+ if (!_useEyeGazeInput) return;
+ if (_lastGazeTicks == 0 || DateTimeOffset.UtcNow - new DateTimeOffset(_lastGazeTicks, TimeSpan.Zero) <= GazeTimeout)
+ return;
+ PauseForGazeLoss("no gaze data for " + GazeTimeout.TotalMilliseconds + "ms");
+ }
+
private void DrawEyeTrackIndicator(DrawingContext context)
{
if (!_useEyeGazeInput) return;
@@ -111,7 +322,8 @@ private void DrawEyeTrackIndicator(DrawingContext context)
var color = _eyeTrackActive
? Color.FromRgb(80, 200, 80) // green
- : Color.FromRgb(200, 60, 60); // red
+ : Color.FromRgb(200, 60, 60) // red
+ ;
context.DrawEllipse(
new SolidColorBrush(color),
diff --git a/src/Dasher.Windows/Controls/DasherCanvas.cs b/src/Dasher.Windows/Controls/DasherCanvas.cs
index ed9368d..86f0cfa 100644
--- a/src/Dasher.Windows/Controls/DasherCanvas.cs
+++ b/src/Dasher.Windows/Controls/DasherCanvas.cs
@@ -233,6 +233,18 @@ public override void Render(DrawingContext context)
var scaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1.0;
_cachedOriginX = (float)(origin.X / scaling);
_cachedOriginY = (float)(origin.Y / scaling);
+
+ // Crosshair screen position — the zero-velocity point used to
+ // hold the zoom when gaze is lost (PauseForGazeLoss). Zoom-
+ // invariant but RESIZE-dependent: TrySetScreenSize invalidates
+ // the cache when the canvas dims change; until then the cached
+ // point is reused (2 P/Invokes saved per frame).
+ if (!_crosshairCached)
+ CacheCrosshairScreenPosition();
+
+ // Staleness backstop: catches a tracker that stops delivering
+ // entirely (no invalid samples either).
+ CheckGazeStaleness();
}
DrawEyeTrackIndicator(context);
@@ -383,6 +395,11 @@ private void TrySetScreenSize()
NativeBridge.dasher_set_screen_size(_handle, w, h);
_lastScreenWidth = w;
_lastScreenHeight = h;
+ // The crosshair's screen position (the gaze hold's zero-velocity
+ // point) is resize-dependent — invalidate so the next Render
+ // recaches it (review loop 3: a stale park silently reintroduced
+ // the pan-during-blink bug after any resize/DPI change).
+ _crosshairCached = false;
}
private void Tick(long timeMs)
diff --git a/src/Dasher.Windows/Controls/SettingsPanel.cs b/src/Dasher.Windows/Controls/SettingsPanel.cs
index 98fc9a7..7465f37 100644
--- a/src/Dasher.Windows/Controls/SettingsPanel.cs
+++ b/src/Dasher.Windows/Controls/SettingsPanel.cs
@@ -623,6 +623,130 @@ void PopulateSelections(AccessMethod method)
Margin = new Thickness(0, 2, 0, 0),
};
+ // ── Gaze filter controls (transit zoom fix; tune for eye tracker latency) ──
+ var filterHeader = new TextBlock
+ {
+ Text = "Gaze Smoothing",
+ FontSize = 12,
+ FontWeight = FontWeight.Medium,
+ Foreground = BrushLabel,
+ Margin = new Thickness(0, 10, 0, 0),
+ };
+
+ var filterToggle = new ToggleSwitch
+ {
+ IsChecked = config.GazeFilterEnabled,
+ OnContent = "", OffContent = "", MinWidth = 0, Height = 24,
+ };
+ var filterToggleLabel = new TextBlock
+ {
+ Text = "Filter gaze (reduces zoom toward wrong positions during eye transit)",
+ FontSize = 12,
+ Foreground = BrushValue,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ var filterTogglePanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
+ filterTogglePanel.Children.Add(filterToggle);
+ filterTogglePanel.Children.Add(filterToggleLabel);
+
+ var gazeFilterHelp = new TextBlock
+ {
+ Text = "Reduces zoom toward wrong positions while your eyes move to a target",
+ FontSize = 11,
+ Foreground = BrushMuted,
+ TextWrapping = TextWrapping.Wrap,
+ };
+
+ // Eye-tracker diagnostics toggle: detailed per-sample logging to
+ // %APPDATA%\Dasher\eyegaze.log (off by default; lifecycle events
+ // always logged). The file to attach to bug reports.
+ var diagToggle = new ToggleSwitch
+ {
+ IsChecked = config.GazeDiagnostics,
+ OnContent = "", OffContent = "", MinWidth = 0, Height = 24,
+ };
+ var diagToggleLabel = new TextBlock
+ {
+ Text = "Save eye-tracker diagnostics",
+ FontSize = 12,
+ Foreground = BrushValue,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ var diagTogglePanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
+ diagTogglePanel.Children.Add(diagToggle);
+ diagTogglePanel.Children.Add(diagToggleLabel);
+ var diagHelp = new TextBlock
+ {
+ Text = "Detailed tracking log for bug reports (per-sample lines, written to eyegaze.log in the Dasher folder). Basic connection events are always logged.",
+ FontSize = 11,
+ Foreground = BrushMuted,
+ TextWrapping = TextWrapping.Wrap,
+ };
+ diagToggle.PropertyChanged += (s, e) =>
+ {
+ if (e.Property.Name != "IsChecked") return;
+ var cfg = AccessConfiguration.Load();
+ cfg.GazeDiagnostics = diagToggle.IsChecked == true;
+ cfg.Save();
+ EyeGaze.EyeGazeLogger.Verbose = cfg.GazeDiagnostics;
+ };
+
+ var deadZoneSlider = new Slider
+ {
+ Minimum = 0, Maximum = 30, Value = config.GazeDeadZonePx,
+ Width = 180, FontSize = 12,
+ };
+ var deadZoneLabel = new TextBlock { Text = $"Dead zone: {config.GazeDeadZonePx:F0} px", FontSize = 11, Foreground = BrushValue };
+ deadZoneSlider.PropertyChanged += (s, e) =>
+ {
+ if (e.Property.Name == "Value")
+ deadZoneLabel.Text = $"Dead zone: {deadZoneSlider.Value:F0} px";
+ };
+
+ var settleSlider = new Slider
+ {
+ Minimum = 0, Maximum = 300, Value = config.GazeSettleMs,
+ Width = 180, FontSize = 12,
+ };
+ var settleLabel = new TextBlock { Text = $"Settle time: {config.GazeSettleMs:F0} ms", FontSize = 11, Foreground = BrushValue };
+ settleSlider.PropertyChanged += (s, e) =>
+ {
+ if (e.Property.Name == "Value")
+ settleLabel.Text = $"Settle time: {settleSlider.Value:F0} ms";
+ };
+
+ void SaveFilterConfig()
+ {
+ var cfg = AccessConfiguration.Load();
+ cfg.GazeFilterEnabled = filterToggle.IsChecked ?? true;
+ cfg.GazeDeadZonePx = deadZoneSlider.Value;
+ cfg.GazeSettleMs = settleSlider.Value;
+ cfg.Save();
+ // Apply live if canvas is available
+ var canvas = this.FindControl("DasherCanvas");
+ if (canvas != null)
+ {
+ if (cfg.GazeFilterEnabled)
+ canvas.ConfigureGazeFilter(cfg.GazeDeadZonePx, null, cfg.GazeSettleMs);
+ else
+ canvas.ConfigureGazeFilter(); // disable
+ }
+ }
+
+ // Debounced save on slider change
+ Avalonia.Threading.DispatcherTimer? filterSaveTimer = null;
+ void ScheduleFilterSave()
+ {
+ filterSaveTimer?.Stop();
+ filterSaveTimer = new Avalonia.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
+ filterSaveTimer.Tick += (_, _) => { filterSaveTimer!.Stop(); SaveFilterConfig(); };
+ filterSaveTimer.Start();
+ }
+
+ deadZoneSlider.PropertyChanged += (s, e) => { if (e.Property.Name == "Value") ScheduleFilterSave(); };
+ settleSlider.PropertyChanged += (s, e) => { if (e.Property.Name == "Value") ScheduleFilterSave(); };
+ filterToggle.PropertyChanged += (s, e) => { if (e.Property.Name == "IsChecked") ScheduleFilterSave(); };
+
void UpdateTrackerVisibility()
{
var isEyeGaze = methods[methodCombo.SelectedIndex] == AccessMethod.EyeGaze;
@@ -631,6 +755,15 @@ void UpdateTrackerVisibility()
var isUdp = isEyeGaze && trackerCombo.SelectedIndex == 1;
portPanel.IsVisible = isUdp;
trackerHelp.IsVisible = false;
+ filterHeader.IsVisible = isEyeGaze;
+ filterTogglePanel.IsVisible = isEyeGaze;
+ gazeFilterHelp.IsVisible = isEyeGaze;
+ diagTogglePanel.IsVisible = isEyeGaze;
+ diagHelp.IsVisible = isEyeGaze;
+ deadZoneSlider.IsVisible = isEyeGaze;
+ deadZoneLabel.IsVisible = isEyeGaze;
+ settleSlider.IsVisible = isEyeGaze;
+ settleLabel.IsVisible = isEyeGaze;
if (!isEyeGaze) return;
@@ -806,6 +939,15 @@ void UpdateTrackerVisibility()
panel.Children.Add(trackerCombo);
panel.Children.Add(portPanel);
panel.Children.Add(trackerHelp);
+ panel.Children.Add(filterHeader);
+ panel.Children.Add(filterTogglePanel);
+ panel.Children.Add(gazeFilterHelp);
+ panel.Children.Add(diagTogglePanel);
+ panel.Children.Add(diagHelp);
+ panel.Children.Add(deadZoneSlider);
+ panel.Children.Add(deadZoneLabel);
+ panel.Children.Add(settleSlider);
+ panel.Children.Add(settleLabel);
panel.Children.Add(filterLabel);
panel.Children.Add(filterCombo);
panel.Children.Add(filterHelp);
diff --git a/src/Dasher.Windows/Engine/AccessConfiguration.cs b/src/Dasher.Windows/Engine/AccessConfiguration.cs
index 7816d3e..15b0ff0 100644
--- a/src/Dasher.Windows/Engine/AccessConfiguration.cs
+++ b/src/Dasher.Windows/Engine/AccessConfiguration.cs
@@ -11,6 +11,29 @@ public class AccessConfiguration
public string EyeTrackerType { get; set; } = "WindowsNative";
public int UdpPort { get; set; } = 5555;
+ // ── Gaze filter (transit zoom fix; Juan's Tobii PCEye + TD Control) ────
+
+ /// Minimum px movement before sending (0 = always send).
+ public double GazeDeadZonePx { get; set; } = 8;
+
+ /// Maximum velocity px/ms before holding (0 = disabled).
+ public double GazeMaxVelocityPxPerMs { get; set; } = 3.0;
+
+ /// Ms the gaze must dwell before sending (0 = immediate).
+ public double GazeSettleMs { get; set; } = 80;
+
+ /// Master switch: false = raw passthrough (pre-filter behaviour).
+ public bool GazeFilterEnabled { get; set; } = true;
+
+ ///
+ /// Detailed eye-tracker diagnostics (per-sample flow lines, raw struct
+ /// dumps, filter stats) written to %APPDATA%\Dasher\eyegaze.log. The
+ /// always-on layer is lifecycle events only (connect, resume, heartbeat
+ /// counters, gaze lost/resumed, warnings). Default off; turn on when
+ /// reporting tracker problems.
+ ///
+ public bool GazeDiagnostics { get; set; }
+
///
/// The engine input filter (SP_INPUT_FILTER). MIRROR ONLY — the engine's
/// own persistence (dasher_settings.xml, DasherCore #76 / v0.2.16+) owns
diff --git a/src/Dasher.Windows/Engine/EyeGazeIntegration.cs b/src/Dasher.Windows/Engine/EyeGazeIntegration.cs
index 39cbae1..ac790cd 100644
--- a/src/Dasher.Windows/Engine/EyeGazeIntegration.cs
+++ b/src/Dasher.Windows/Engine/EyeGazeIntegration.cs
@@ -26,6 +26,11 @@ public class Settings
}
public event EventHandler? GazePositionChanged;
+
+ /// Forwarded tracker warnings (degraded mode, permissions)
+ /// — raised on the tracker's thread (see IEyeTrackerService.Warning).
+ public event Action? Warning;
+
public bool IsActive => _device?.IsEnabled ?? false;
public async Task InitializeAsync(Settings settings)
@@ -41,6 +46,8 @@ public async Task InitializeAsync(Settings settings)
return false;
}
+ tracker.Warning += w => Warning?.Invoke(w);
+
_device = new EyeGazeInputDevice();
var ok = await _device.InitializeAsync(tracker);
if (ok)
diff --git a/src/Dasher.Windows/Engine/NativeBridge.cs b/src/Dasher.Windows/Engine/NativeBridge.cs
index 64e6620..cc8da0d 100644
--- a/src/Dasher.Windows/Engine/NativeBridge.cs
+++ b/src/Dasher.Windows/Engine/NativeBridge.cs
@@ -231,6 +231,32 @@ public static extern void dasher_frame(IntPtr ctx, long time_ms,
public static extern int dasher_import_training_text(IntPtr ctx,
[MarshalAs(UnmanagedType.LPUTF8Str)] string text);
+ // ── Viewport / coordinate conversion (engine screen space) ────────────
+ // Used by the gaze-loss hold: the crosshair is the zero-velocity point,
+ // so parking the pointer on its screen position freezes the zoom WITHOUT
+ // stopping the engine (no resume click needed — the next valid sample
+ // simply moves again). NOTE: dasher.h marks these as testing-oriented
+ // APIs that may change between engine versions — the pinned submodule +
+ // struct_size versioning keep that safe here.
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct DasherViewport
+ {
+ public int struct_size;
+ public long crosshair_x;
+ public long crosshair_y;
+ public long visible_min_y;
+ public long visible_max_y;
+ public int screen_width;
+ public int screen_height;
+ }
+
+ [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int dasher_get_viewport(IntPtr ctx, ref DasherViewport viewport);
+
+ [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int dasher_dasher_to_screen(IntPtr ctx, long dx, long dy, out int sx, out int sy);
+
// Absolute path of the current alphabet's user training file — the file
// adaptive learning appends to and the UI reads/exports/resets
// (DasherCore#84 / #53). Pointer valid until the next engine call.
diff --git a/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs b/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs
index 8ebed10..e8f4e78 100644
--- a/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs
+++ b/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs
@@ -11,6 +11,16 @@ public static class EyeGazeLogger
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Dasher", "eyegaze.log");
+ ///
+ /// Detailed diagnostics (per-sample flow lines, heartbeat detail, raw
+ /// struct dumps, gaze filter stats). Off by default — the always-on
+ /// layer is lifecycle events only (connect, resume, warnings, gaze
+ /// lost/resumed), which is low-volume and exactly what field triage
+ /// needs first. Users reporting tracker problems turn this on in
+ /// Settings > Input ("Save eye-tracker diagnostics").
+ ///
+ public static bool Verbose;
+
private static long _gazeLogCounter;
private const long GazeLogInterval = 60; // log every 60th sample (~2s at 33Hz)
@@ -40,6 +50,7 @@ public static void Log(string message)
public static void LogGazeData(float x, float y, bool valid)
{
+ if (!Verbose) return;
var count = Interlocked.Increment(ref _gazeLogCounter);
if (count == 1)
Log($"First gaze data received: x={x:F1} y={y:F1} valid={valid}");
diff --git a/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs b/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs
index ab1f4a7..0f5acb0 100644
--- a/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs
+++ b/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs
@@ -16,6 +16,7 @@ public sealed class EyetuitiveTracker : IEyeTrackerService
private bool _disposed;
public event EventHandler? GazeDataReceived;
+ public event Action? Warning;
public string TrackerName => "eyetuitive";
public bool IsConnected { get; private set; }
diff --git a/src/Dasher.Windows/EyeGaze/GazeFilter.cs b/src/Dasher.Windows/EyeGaze/GazeFilter.cs
new file mode 100644
index 0000000..1a7e392
--- /dev/null
+++ b/src/Dasher.Windows/EyeGaze/GazeFilter.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Generic;
+
+namespace Dasher.Windows.EyeGaze;
+
+///
+/// Filters raw gaze data before it reaches the engine's zoom. Solves the
+/// "transit zoom" problem reported by eye-tracker users (Juan, Argentina,
+/// Tobii PCEye + TD Control): when the user looks at a letter, the tracker
+/// takes milliseconds to move its cursor there, and every intermediate
+/// position was sent to dasher_mouse_move — zooming the canvas toward the
+/// wrong places during transit.
+///
+/// Three filters, in order:
+///
+/// 1. VELOCITY GATE: if the gaze is moving faster than N px/ms, it's in
+/// transit (saccade) — hold the last sent position until velocity drops.
+///
+/// 2. DEAD ZONE: if the gaze hasn't moved more than N screen px from the
+/// last SENT position, don't send — reduces jitter noise.
+///
+/// 3. SETTLE WINDOW: the gaze must stay near the candidate for N ms
+/// before the position is sent — ensures the user has ARRIVED at their
+/// target, not just passing through.
+///
+/// All thresholds configurable for experimentation; 0 disables each filter.
+///
+public sealed class GazeFilter
+{
+ // ── Configuration (all tunable; 0 = disabled) ─────────────────────────
+
+ /// Minimum px movement before sending (0 = always send). Default 8.
+ public double DeadZonePx { get; set; } = 8;
+
+ /// Maximum velocity px/ms before positions are held (0 = disabled). Default 3.0.
+ public double MaxVelocityPxPerMs { get; set; } = 3.0;
+
+ /// Ms the gaze must dwell before sending (0 = send immediately). Default 80.
+ public double SettleMs { get; set; } = 80;
+
+ // ── State ───────────────────────────────────────────────────────────────
+
+ private double _lastSentX, _lastSentY;
+ private double _candidateX, _candidateY;
+ private long _candidateStartMs;
+ private bool _hasCandidate;
+ private long _lastSampleMs;
+ private double _lastSampleX, _lastSampleY;
+ private double _smoothedVelocity;
+ private bool _hasSent;
+
+ // Diagnostics (for the eyegaze log)
+ public int TotalSamples { get; private set; }
+ public int SentSamples { get; private set; }
+ public int DeadZoneBlocks { get; private set; }
+ public int VelocityBlocks { get; private set; }
+ public int SettleBlocks { get; private set; }
+ public double LastVelocity { get; private set; }
+
+ ///
+ /// Process a raw gaze sample. Returns true when the position should be
+ /// sent to the engine (passed all filters), with the filtered position
+ /// in the out parameters.
+ ///
+ public bool Process(double x, double y, long timestampMs, out double outX, out double outY)
+ {
+ outX = x; outY = y;
+ TotalSamples++;
+
+ // Compute instantaneous velocity (px per ms). The FIRST sample after
+ // construction/Reset must not be measured against the origin (0,0) —
+ // that phantom "saccade" (e.g. 8.8 px/ms for a first gaze at
+ // (100,100)) blocked the first real positions after every gaze-loss
+ // recovery; found by GazeFilterTests while pinning the semantics.
+ double velocity = 0;
+ if (_lastSampleMs != 0)
+ {
+ var dtMs = Math.Max(1, timestampMs - _lastSampleMs);
+ var dx = x - _lastSampleX;
+ var dy = y - _lastSampleY;
+ var distance = Math.Sqrt(dx * dx + dy * dy);
+ velocity = distance / dtMs;
+ // Exponential smoothing (alpha=0.3) — single-sample spikes don't
+ // trigger the gate; sustained transit does
+ _smoothedVelocity = _smoothedVelocity * 0.7 + velocity * 0.3;
+ }
+ else
+ {
+ _smoothedVelocity = 0;
+ }
+ LastVelocity = _smoothedVelocity;
+ _lastSampleMs = timestampMs;
+ _lastSampleX = x;
+ _lastSampleY = y;
+
+ // FILTER 1: velocity gate — in transit, hold position
+ if (MaxVelocityPxPerMs > 0 && _smoothedVelocity > MaxVelocityPxPerMs)
+ {
+ VelocityBlocks++;
+ _hasCandidate = false; // cancel any pending settle
+ return false;
+ }
+
+ // FILTER 2: dead zone — too close to last sent position
+ if (_hasSent && DeadZonePx > 0)
+ {
+ var sentDx = x - _lastSentX;
+ var sentDy = y - _lastSentY;
+ if (Math.Sqrt(sentDx * sentDx + sentDy * sentDy) < DeadZonePx)
+ {
+ DeadZoneBlocks++;
+ _hasCandidate = false; // within dead zone — reset settle
+ return false;
+ }
+ }
+
+ // FILTER 3: settle window — must dwell for N ms
+ if (SettleMs <= 0)
+ {
+ // No settle required — send immediately
+ Send(x, y, ref outX, ref outY);
+ return true;
+ }
+
+ if (!_hasCandidate)
+ {
+ // New candidate position — start the settle timer. The tolerance
+ // is its own (NOT the dead zone): a zero dead zone with settle
+ // on must still allow sub-pixel jitter to complete the dwell —
+ // reusing DeadZonePx there meant 1px noise restarted the timer
+ // forever and NOTHING was ever sent (review finding).
+ _candidateX = x;
+ _candidateY = y;
+ _candidateStartMs = timestampMs;
+ _hasCandidate = true;
+ SettleBlocks++;
+ return false;
+ }
+
+ var settleTol = DeadZonePx > 0 ? DeadZonePx : 2.0;
+ if (Math.Abs(x - _candidateX) > settleTol || Math.Abs(y - _candidateY) > settleTol)
+ {
+ _candidateX = x;
+ _candidateY = y;
+ _candidateStartMs = timestampMs;
+ SettleBlocks++;
+ return false;
+ }
+
+ // Same candidate — check if settled long enough
+ if (timestampMs - _candidateStartMs >= SettleMs)
+ {
+ Send(_candidateX, _candidateY, ref outX, ref outY);
+ _hasCandidate = false;
+ return true;
+ }
+
+ SettleBlocks++;
+ return false;
+ }
+
+ private void Send(double x, double y, ref double outX, ref double outY)
+ {
+ _lastSentX = x;
+ _lastSentY = y;
+ _hasSent = true;
+ SentSamples++;
+ outX = x;
+ outY = y;
+ }
+
+ /// Reset all state (mode switch, re-calibration).
+ public void Reset()
+ {
+ _hasCandidate = false;
+ _hasSent = false;
+ _lastSampleMs = 0;
+ _smoothedVelocity = 0;
+ TotalSamples = SentSamples = DeadZoneBlocks = VelocityBlocks = SettleBlocks = 0;
+ }
+}
diff --git a/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs b/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs
index 23c419a..a9743d8 100644
--- a/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs
+++ b/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs
@@ -6,6 +6,14 @@ namespace Dasher.Windows.EyeGaze
public interface IEyeTrackerService : IDisposable
{
event EventHandler GazeDataReceived;
+
+ ///
+ /// Non-fatal tracker condition the user should know about (degraded
+ /// mode, permissions, licensing) — surfaces as a toast + log line.
+ /// Raised from the tracker's connect/callback thread.
+ ///
+ event Action? Warning;
+
string TrackerName { get; }
bool IsConnected { get; }
Task ConnectAsync();
diff --git a/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs b/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs
index f647ee3..6e8a5cb 100644
--- a/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs
+++ b/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs
@@ -106,6 +106,27 @@ private static string GetTobiiError(int code)
private bool _disposed;
public event EventHandler? GazeDataReceived;
+ public event Action? Warning;
+
+ ///
+ /// Next-log-round diagnostics (the "run as administrator" experiment):
+ /// resume_device needs elevation on OEM IS5 sensors — logging whether
+ /// this process IS elevated makes the next eyegaze.log self-interpreting.
+ ///
+ private static void LogElevationStatus()
+ {
+ try
+ {
+ using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
+ var principal = new System.Security.Principal.WindowsPrincipal(identity);
+ var elevated = principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
+ EyeGazeLogger.Log($"Tobii: process elevation = {elevated}");
+ }
+ catch (Exception ex)
+ {
+ EyeGazeLogger.Log($"Tobii: elevation check failed: {ex.Message}");
+ }
+ }
public string TrackerName => "Tobii (Stream Engine)";
public bool IsConnected { get; private set; }
@@ -126,11 +147,12 @@ private static void DeviceUrlCallback(string url, IntPtr user_data)
public Task ConnectAsync()
{
- return Task.Run(() =>
+ return Task.Run(async () =>
{
try
{
EyeGazeLogger.Log("Tobii: ConnectAsync starting");
+ LogElevationStatus();
EnsureDllLoaded();
EyeGazeLogger.Log("Tobii: DLL resolved");
@@ -209,9 +231,30 @@ public Task ConnectAsync()
int ssResult = tobii_stream_supported(_device, 0, out bool gazeSupported);
EyeGazeLogger.Log($"Tobii: Gaze point stream supported = {gazeSupported} (result code {ssResult})");
- // Resume device in case it's paused
+ // Resume device in case it's paused. Some OEM sensors (IS5/
+ // Dynavox, e.g. IS514) require elevation for this — a
+ // permissions failure leaves them in a degraded low-rate,
+ // low-accuracy mode (one field session measured a 30 Hz
+ // ceiling at 60% validity instead of 90 Hz). Retry once,
+ // then surface the degradation to the user.
int rdResult = tobii_resume_device(_device);
- EyeGazeLogger.Log($"Tobii: resume_device result = {rdResult}{(rdResult != 0 ? $": {GetTobiiError(rdResult)}" : "")}");
+ if (rdResult != 0)
+ {
+ EyeGazeLogger.Log($"Tobii: resume_device result = {rdResult}: {GetTobiiError(rdResult)} — retrying once in 500ms");
+ await Task.Delay(500);
+ rdResult = tobii_resume_device(_device);
+ }
+ if (rdResult != 0)
+ {
+ EyeGazeLogger.Log($"Tobii: resume_device still failing ({rdResult}: {GetTobiiError(rdResult)}) — continuing in possibly degraded mode");
+ Warning?.Invoke(
+ $"The eye tracker could not be fully resumed ({GetTobiiError(rdResult)}). " +
+ "Tracking may run at reduced speed and accuracy — try running Dasher as administrator.");
+ }
+ else
+ {
+ EyeGazeLogger.Log("Tobii: resume_device ok");
+ }
return true;
}
catch (Exception ex)
@@ -428,11 +471,13 @@ private void ProcessCallbacks(CancellationToken ct)
continue;
}
- // Heartbeat every 5 seconds
+ // Heartbeat every 5 seconds — the valid/invalid counters are
+ // the field-triage signal (degradation = rate ceiling +
+ // validity collapse), so this rides the ALWAYS-ON layer.
var now = DateTime.UtcNow;
if (now - lastHeartbeat >= TimeSpan.FromSeconds(5))
{
- EyeGazeLogger.Log($"Tobii: Heartbeat — {sampleCount} callbacks processed, {_validCount} valid, {_invalidCount} invalid");
+ EyeGazeLogger.Log($"Tobii: Heartbeat — {sampleCount} callbacks processed, {_validCount} valid, {_invalidCount} invalid{(EyeGazeLogger.Verbose ? "" : " (diagnostics off — Settings > Input to enable per-sample lines)")}");
lastHeartbeat = now;
}
}
@@ -455,18 +500,28 @@ private void ProcessCallbacks(CancellationToken ct)
private void OnGazePoint(ref tobii_gaze_point_t gaze_point)
{
- // Dump raw struct bytes for the first few samples to verify layout
- var dumpIdx = Interlocked.Increment(ref _rawDumpCount);
- if (dumpIdx <= MaxRawDumps)
+ // Raw struct dumps (diagnostics only — layout verification)
+ if (EyeGazeLogger.Verbose)
{
- EyeGazeLogger.Log($"Tobii: RAW gaze #{dumpIdx}: ts={gaze_point.timestamp_us} validity={gaze_point.validity} x={gaze_point.x:F6} y={gaze_point.y:F6}");
- EyeGazeLogger.Log($" raw floats: ts_hex={BitConverter.DoubleToInt64Bits(gaze_point.timestamp_us):X16} x_bits={BitConverter.SingleToInt32Bits(gaze_point.x):X8} y_bits={BitConverter.SingleToInt32Bits(gaze_point.y):X8}");
+ var dumpIdx = Interlocked.Increment(ref _rawDumpCount);
+ if (dumpIdx <= MaxRawDumps)
+ {
+ EyeGazeLogger.Log($"Tobii: RAW gaze #{dumpIdx}: ts={gaze_point.timestamp_us} validity={gaze_point.validity} x={gaze_point.x:F6} y={gaze_point.y:F6}");
+ EyeGazeLogger.Log($" raw floats: ts_hex={BitConverter.DoubleToInt64Bits(gaze_point.timestamp_us):X16} x_bits={BitConverter.SingleToInt32Bits(gaze_point.x):X8} y_bits={BitConverter.SingleToInt32Bits(gaze_point.y):X8}");
+ }
}
if (gaze_point.validity == 0)
{
Interlocked.Increment(ref _invalidCount);
EyeGazeLogger.LogGazeData(gaze_point.x, gaze_point.y, valid: false);
+ // Forward the LOSS itself: the canvas pauses the engine on
+ // invalid samples (dropping them silently left the last gaze
+ // position driving the zoom — the eyegaze.log field session
+ // showed a 100-minute 0%-valid stretch).
+ GazeDataReceived?.Invoke(this, new GazePoint(
+ gaze_point.x * ScreenWidth, gaze_point.y * ScreenHeight,
+ DateTimeOffset.UtcNow, isValid: false, isScreenCoordinates: true));
return;
}
diff --git a/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs b/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs
index 85fb3e8..73d54b9 100644
--- a/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs
+++ b/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs
@@ -15,6 +15,7 @@ public class UdpGazeTracker : IEyeTrackerService
private Task? _receiveTask;
public event EventHandler? GazeDataReceived;
+ public event Action? Warning;
public string TrackerName => "UDP Gaze Tracker";
public bool IsConnected { get; private set; }
public int UdpPort { get; set; } = 5555;
diff --git a/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs b/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs
index c4cb620..8163c1f 100644
--- a/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs
+++ b/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs
@@ -10,6 +10,7 @@ public sealed class WindowsGazeTracker : IEyeTrackerService
private bool _isTracking;
public event EventHandler? GazeDataReceived;
+ public event Action? Warning;
public string TrackerName => "Windows Eye Tracker";
public bool IsConnected { get; private set; }
@@ -57,7 +58,13 @@ private void OnGazeMoved(GazeInputSourcePreview sender, GazeMovedPreviewEventArg
var point = args.CurrentPoint;
var pos = point.EyeGazePosition;
- if (pos == null) return;
+ if (pos == null)
+ {
+ // No gaze for this frame (eyes not found) — forward the loss
+ // so the canvas can pause the engine (see TobiiStreamEngine).
+ GazeDataReceived?.Invoke(this, new GazePoint(-1, -1, DateTimeOffset.UtcNow, isValid: false, isScreenCoordinates: true));
+ return;
+ }
var coords = pos.Value;
GazeDataReceived?.Invoke(this, new GazePoint(
diff --git a/src/Dasher.Windows/Views/MainWindow.axaml.cs b/src/Dasher.Windows/Views/MainWindow.axaml.cs
index 539722b..4d81b98 100644
--- a/src/Dasher.Windows/Views/MainWindow.axaml.cs
+++ b/src/Dasher.Windows/Views/MainWindow.axaml.cs
@@ -714,6 +714,23 @@ private async void RestoreEyeGazeFromConfig()
if (_canvas == null) return;
_canvas.DisableJoystick();
+ // Detailed eye-tracker diagnostics (per-sample lines, raw dumps,
+ // filter stats). Lifecycle logging is always on.
+ EyeGaze.EyeGazeLogger.Verbose = config.GazeDiagnostics;
+
+ // Apply the gaze filter configuration (transit zoom fix)
+ if (config.GazeFilterEnabled)
+ {
+ _canvas.ConfigureGazeFilter(
+ config.GazeDeadZonePx,
+ config.GazeMaxVelocityPxPerMs,
+ config.GazeSettleMs);
+ }
+ else
+ {
+ _canvas.ConfigureGazeFilter(); // disable (raw passthrough)
+ }
+
var ok = await _canvas.InitializeEyeGazeAsync(trackerType, config.UdpPort);
if (!ok)
{
diff --git a/tests/Dasher.Windows.Tests/GazeFilterTests.cs b/tests/Dasher.Windows.Tests/GazeFilterTests.cs
new file mode 100644
index 0000000..8c13f3b
--- /dev/null
+++ b/tests/Dasher.Windows.Tests/GazeFilterTests.cs
@@ -0,0 +1,152 @@
+using Dasher.Windows.EyeGaze;
+
+namespace Dasher.Windows.Tests;
+
+///
+/// GazeFilter (dead zone + velocity gate + settle window) — the transit-zoom
+/// fix from the eyegaze diagnostic work (Juan's report; validated against the
+/// eyegaze.log field session). Pure class, deterministic timestamps.
+///
+public class GazeFilterTests
+{
+ private static long T(int ms) => 1_000_000 + ms; // arbitrary epoch offset
+
+ [Fact]
+ public void First_sample_with_no_settle_sent_immediately()
+ {
+ var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 0, SettleMs = 0 };
+ Assert.True(f.Process(100, 100, T(0), out var x, out var y));
+ Assert.Equal(100, x);
+ Assert.Equal(100, y);
+ }
+
+ [Fact]
+ public void Dead_zone_blocks_jitter_around_last_sent()
+ {
+ var f = new GazeFilter { DeadZonePx = 10, MaxVelocityPxPerMs = 0, SettleMs = 0 };
+ Assert.True(f.Process(100, 100, T(0), out _, out _)); // sent at (100,100)
+ Assert.False(f.Process(105, 103, T(16), out _, out _)); // 5.8px < 10px: jitter
+ Assert.False(f.Process(108, 104, T(32), out _, out _)); // still inside
+ Assert.Equal(1, f.SentSamples);
+ Assert.Equal(2, f.DeadZoneBlocks);
+ }
+
+ [Fact]
+ public void Movement_beyond_dead_zone_passes()
+ {
+ var f = new GazeFilter { DeadZonePx = 10, MaxVelocityPxPerMs = 0, SettleMs = 0 };
+ Assert.True(f.Process(100, 100, T(0), out _, out _));
+ Assert.True(f.Process(130, 100, T(16), out var x, out _)); // 30px jump
+ Assert.Equal(130, x);
+ }
+
+ [Fact]
+ public void Velocity_gate_holds_position_during_saccade_transit()
+ {
+ var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 2.0, SettleMs = 0 };
+ Assert.True(f.Process(100, 100, T(0), out _, out _));
+ // 100px in 16ms = 6.25 px/ms — well over the gate. The smoothed
+ // velocity (0.7*0 + 0.3*6.25 = 1.9) is still under on the FIRST
+ // transit sample, then crosses it.
+ Assert.True(f.Process(200, 100, T(16), out _, out _)); // smoothing lags
+ Assert.False(f.Process(300, 100, T(32), out _, out _)); // gate active
+ Assert.False(f.Process(400, 100, T(48), out _, out _)); // still in transit
+ // Arrive: dwell at the target. The smoothed velocity decays
+ // exponentially (alpha 0.3) — needs a few stationary samples to
+ // cross back under the gate.
+ var sent = false;
+ for (var t = 100; t <= 300 && !sent; t += 16)
+ sent = f.Process(401, 100, T(t), out _, out _);
+ Assert.True(sent);
+ Assert.True(f.VelocityBlocks >= 1);
+ }
+
+ [Fact]
+ public void Settle_window_requires_dwell_before_sending()
+ {
+ var f = new GazeFilter { DeadZonePx = 8, MaxVelocityPxPerMs = 0, SettleMs = 80 };
+ // First sample becomes the settle CANDIDATE — nothing is sent until
+ // the gaze dwells on it for SettleMs.
+ Assert.False(f.Process(100, 100, T(0), out _, out _));
+ Assert.False(f.Process(102, 101, T(40), out _, out _)); // dwelling, 40ms < 80ms
+ // 90ms dwell on the same candidate: sent — at the CANDIDATE
+ // position where the dwell began, not the latest sample.
+ Assert.True(f.Process(103, 101, T(90), out var x, out var y));
+ Assert.Equal(100, x);
+ Assert.Equal(100, y);
+ }
+
+ [Fact]
+ public void Settle_candidate_resets_when_gaze_moves_on()
+ {
+ var f = new GazeFilter { DeadZonePx = 8, MaxVelocityPxPerMs = 0, SettleMs = 80 };
+ f.Process(100, 100, T(0), out _, out _); // candidate (100,100)
+ f.Process(250, 100, T(40), out _, out _); // moved way off: NEW candidate
+ // 60ms into the new candidate's dwell — still not settled
+ Assert.False(f.Process(251, 100, T(100), out _, out _));
+ // ...and the original (100,100) never got sent at 90ms (it was
+ // cancelled when the gaze moved).
+ Assert.Equal(0, f.SentSamples);
+ }
+
+ [Fact]
+ public void Reset_clears_state_for_a_fresh_session()
+ {
+ var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 0, SettleMs = 0 };
+ Assert.True(f.Process(100, 100, T(0), out _, out _));
+ Assert.Equal(1, f.SentSamples);
+ f.Reset();
+ Assert.Equal(0, f.SentSamples);
+ Assert.Equal(0, f.TotalSamples);
+ // After reset the filter behaves as new: first sample sends (no
+ // dead-zone reference, no velocity baseline).
+ Assert.True(f.Process(500, 500, T(1000), out _, out _));
+ }
+
+ [Fact]
+ public void First_sample_is_not_measured_against_the_origin()
+ {
+ // Regression (found while writing these tests): the first sample
+ // after construction/Reset computed velocity from the ORIGIN (0,0) —
+ // a phantom ~8.8 px/ms saccade for a first gaze at (100,100) that
+ // velocity-blocked the first real positions after every gaze-loss
+ // recovery. With the gate ON and settle off, the first sample must
+ // send immediately.
+ var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 2.0, SettleMs = 0 };
+ Assert.True(f.Process(100, 100, T(0), out _, out _));
+ Assert.Equal(0, f.LastVelocity, precision: 6);
+ // And after Reset (every gaze-loss recovery):
+ f.Reset();
+ Assert.True(f.Process(400, 300, T(1000), out _, out _));
+ }
+
+ [Fact]
+ public void Zero_dead_zone_with_settle_still_completes_the_dwell()
+ {
+ // Regression (review finding): the settle candidate check reused
+ // DeadZonePx as its tolerance — with the dead zone set to 0 (the
+ // settings slider's minimum), 1px jitter restarted the dwell timer
+ // forever and NOTHING was ever sent.
+ var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 0, SettleMs = 50 };
+ Assert.False(f.Process(100, 100, T(0), out _, out _)); // candidate
+ Assert.False(f.Process(101, 100, T(20), out _, out _)); // 1px jitter
+ Assert.False(f.Process(100, 101, T(40), out _, out _)); // more jitter
+ Assert.True(f.Process(101, 100, T(60), out _, out _)); // dwell completed despite jitter
+ }
+
+ [Fact]
+ public void Velocity_gate_cancels_pending_settle()
+ {
+ // Dwelling at a candidate completes and sends; a saccade after a NEW
+ // candidate begins must cancel the dwell — nothing sent mid-transit.
+ var f = new GazeFilter { DeadZonePx = 8, MaxVelocityPxPerMs = 1.0, SettleMs = 50 };
+ Assert.False(f.Process(100, 100, T(0), out _, out _)); // candidate starts
+ Assert.True(f.Process(100, 100, T(60), out _, out _)); // dwell completed: sent
+ Assert.Equal(1, f.SentSamples);
+ // New target: candidate starts, then transit begins before dwell
+ f.Process(400, 100, T(80), out _, out _); // fast move: gate or new candidate
+ var sentBefore = f.SentSamples;
+ Assert.False(f.Process(500, 100, T(96), out _, out _)); // transit: held
+ Assert.Equal(sentBefore, f.SentSamples);
+ }
+}