Skip to content

Oxyplot improvements#23

Merged
HadenSmith merged 69 commits into
mainfrom
oxyplot-improvements
May 4, 2026
Merged

Oxyplot improvements#23
HadenSmith merged 69 commits into
mainfrom
oxyplot-improvements

Conversation

@HadenSmith

@HadenSmith HadenSmith commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

A lot of bug fixes and improvements

…heckout

The HintPath reference to ..\..\..\numerics\Numerics\bin\...\Numerics.dll
assumed a sibling Numerics repo was checked out next to wpf-framework.
This worked locally but broke CI and any clean checkout because the
referenced DLL does not exist on a standalone machine, producing ~120
CS0246 errors about missing Numerics / SortOrder / OrderedPairedData /
TimeSeries / UnivariateDistributionBase / etc. types.

Switch to the public RMC.Numerics 2.0.1 NuGet package, which exposes
the same Numerics namespace and types the consuming projects use. All
references updated in seven projects:

  src/DatabaseControls
  src/DatabaseControls.Demo
  src/FrameworkUI.Demo
  src/NumericControls
  src/NumericControls.Demo
  src/OxyPlotControls.Demo
  tests/NumericControls.Tests

Two of the seven (DatabaseControls, NumericControls) had also hardcoded
the Debug configuration path, which would have silently picked up the
wrong binary under Release builds. Fixed incidentally by the swap.

Verification: dotnet restore / build -c Release / test -c Release all
pass locally with 0 errors, 0 warnings, and 6722 passed / 6 skipped / 0
failed tests \u2014 matching the pre-change baseline.
Clean up the Integration workflow on a fresh checkout so CI reports 0
errors, 0 warnings, and 0 test failures \u2014 required for public 1.0.0
release.

FrameworkUI XML/nullability bugs (real code errors):
  - ShellPublicVariables.cs: rewrite malformed remarks block (unclosed
    para, wrong nesting) as valid XML
  - Project Explorer/View Models/Node.cs: close </summary> tag
  - Project Explorer/Views/NodeHeader.xaml.cs: remove duplicate </para>
  - Main Window/MainWindow.xaml.cs: pass 'this' instead of null to
    BasicMessageItem's non-nullable 'source' parameter
  - Utilities/UtilityFunctions.cs: remove <typeparam name="T"> on a
    non-generic method
  - Project Explorer/View Models/ProjectNode.cs: remove orphan XML doc
    comments above commented-out methods

OxyPlot fork CS0618 obsolete-API warnings (~145 suppressed at project
level): upstream OxyPlot's own deprecation warnings about Model.MouseDown
/ Element.MouseDown scheduled for v4.0 removal, internal to the fork's
code. Not first-party code; suppressing at the vendored-project boundary
does not hide legitimate CS0618 elsewhere.

  - OxyPlot.Wpf.csproj: add CS0618 to NoWarn
  - OxyPlot.Wpf.Shared.csproj: add CS0618 to NoWarn
  - OxyPlotControls.csproj: append CS0618 to existing NoWarn list
  - OxyPlot.ExampleLibrary.csproj: add CS0618 to NoWarn
  (OxyPlot.csproj already had CS0618)

Test helper CS0067 (unused event): move the existing #pragma warning
disable CS0067 to cover ElementIsDirtyChanged too (mock implementation
satisfying IElementCollection interface contract).

OxyPlot.Tests baseline SVGs (18 CI failures): OxyAssert.AreEqual
compares rendered plot SVGs against baselines stored under baseline/
in the current working directory. Previously baselines were generated
on first local run and never committed, so CI (which had no prior run)
failed every AxisTests baseline check with "Baseline file does not
exist. A new baseline has been created \u2014 rerun the test."

Now: 18 AxisTests baseline SVGs checked in under tests/OxyPlot.Tests/
baseline/ and deployed to the test output via <Content Include> with
CopyToOutputDirectory=PreserveNewest. OxyAssert.cs is unchanged \u2014 its
hardcoded baseline\ path resolves to the copied-to-output files. A
fresh CI checkout now finds all baselines and compares successfully.

OxyPlotControls DPI test: SavePlotImageDialog's initial Width/Height are
computed from SystemParameters.WorkArea, which varies by display/DPI and
between interactive and headless (CI) environments. Replace exact
Assert.Equal(636, dialog.Width) with tolerant Assert.InRange bounded by
the dialog's own MinWidth/MinHeight and a reasonable upper limit.

Verification: dotnet build/test -c Release locally after cleaning bin/
and obj/ for OxyPlot.Tests: 0 errors, 0 warnings, 6722 passed / 6
skipped / 0 failed.
Phase 1 item 1.1 of the OxyPlot performance overhaul. Adds a per-series
opt-out from the tracker hit-test path, letting consumers skip the O(n)
nearest-point scan that runs on every mouse move for dense or decorative
series (e.g. MCMC chain traces with 20 chains x 1751 iterations = 35k
points).

Changes:
- OxyPlot/Series/Series.cs: new IsHitTestEnabled property (default true),
  initialized in constructor, short-circuit at the top of HitTestOverride
  when false. Simple auto-property, no PropertyChanged wiring.
- OxyPlot/PlotModel/PlotModel.cs: GetSeriesFromPoint filter now includes
  s.IsHitTestEnabled alongside s.IsVisible.
- OxyPlot.Wpf/Series/Series.cs: matching WPF DependencyProperty with a
  custom HitTestRoutingChanged callback that syncs the value directly to
  InternalSeries. Crucially it does NOT use AppearanceChanged (which
  fires InvalidatePlot + PropertyChanged) - hit-test routing is neither
  a visual nor a data change, so it must not trigger renders or undo
  recording. SynchronizeProperties also forwards the value.
- tests/OxyPlotControls.Tests/Series/SeriesIsHitTestEnabledTests.cs:
  three tests covering default value, filter skip when disabled,
  filter pass-through when enabled. Uses a CountingLineSeries subclass
  to verify GetNearestPoint is NOT called for disabled series.

Behavior change: purely additive. Default IsHitTestEnabled=true preserves
all existing tracker behavior.
Phase 1 item 1.5 of the OxyPlot performance overhaul. Eliminates the
single biggest cause of multi-second interaction lag on large-data plots:
during a pan or zoom-rectangle drag, MouseMove fires at ~125 Hz, and the
controller was running BOTH MouseDownManipulators (pan) AND
MouseHoverManipulators (tracker) unconditionally on every event. On a
20-series x 1751-point plot the per-mouse-move tracker scan costs on the
order of 70k Transform calls, saturating the UI thread and backing up
the pan/zoom render queue behind it.

Changes:
- OxyPlot/Graphics/ControllerBase.cs:
  - New public EnableHoverDuringDrag property (default false). Opt-in
    escape valve for the legacy behavior where hover manipulators keep
    firing during drag.
  - HandleMouseMove now gates the MouseHoverManipulators loop on
    (EnableHoverDuringDrag || MouseDownManipulators.Count == 0). The
    tracker tooltip is not visible mid-drag anyway, so the skip has no
    user-visible effect aside from responsiveness.
- tests/OxyPlotControls.Tests/Controller/SuppressHoverDuringDragTests.cs:
  - three tests: hover fires normally with no drag active, hover is
    suppressed while a mouse-down manipulator is active, hover fires
    again when EnableHoverDuringDrag=true. Uses a TestableController
    subclass to expose the protected manipulator lists, a counting
    manipulator subclass, and a minimal IView stub.

Behavior change: DEFAULT behavior changes. Tracker no longer fires during
pan/zoom drag unless the consumer explicitly sets EnableHoverDuringDrag=true.
This is the documented Phase 1 decision.
Phase 1 item 1.6 of the OxyPlot performance overhaul. Replaces the
thread-unsafe `foreach (var p in points.Skip(startIdx))` pattern with an
indexed for-loop for IList<DataPoint> (the common case), with a snapshot-
to-array fallback for arbitrary IEnumerable<DataPoint> sources.

The legacy pattern constructed a state-machine enumerator over the points
list; if the underlying collection was mutated on another thread during
the hit-test scan, the enumerator's version check would throw
InvalidOperationException: Collection was modified, bubbling up to the
mouse-move handler and sometimes crashing the app. This manifests in
apps that stream data updates into a plot (upstream issue OxyPlot #1482).

Changes:
- OxyPlot/Series/XYAxisSeries.cs GetNearestPointInternal:
  - Fast path: indexed for-loop over IList<DataPoint>, re-reading
    list.Count each iteration so a shrink terminates the loop cleanly.
    A try/catch around list[i] handles the narrow window between the
    bounds check and the indexer access where the list could shrink.
  - Fallback path: snapshot to DataPoint[] at entry via Skip().ToArray().
    Used when the source is not an IList<DataPoint>. Produces a stable
    view of the collection at scan start.
- tests/OxyPlotControls.Tests/Series/GetNearestPointThreadSafetyTests.cs:
  single stress test that drives GetNearestPoint 2000 times while a
  background task adds/removes points rapidly. Legacy code throws
  within a handful of iterations; 1.6 completes without throwing.

Behavior change: none observable to well-behaved single-threaded consumers.
Concurrent mutation cases that would previously throw now return cleanly
(possibly with a null hit if the race was in a decisive location);
at worst the tracker misses one frame, indistinguishable from normal
tracker jitter.
…DrawText

Phase 1 item 1.7 of the OxyPlot performance overhaul. Mirrors the existing
MeasureText cache at DrawingVisualRenderContext:615 by caching FormattedText
instances in DrawText as well. Axis tick labels, axis titles, and legend
entries repeat across render frames; a typical plot allocates ~100
FormattedText objects per frame and discards them immediately, producing
measurable GC pressure on long-running dashboards.

Changes:
- OxyPlot.Wpf/DrawingVisualRenderContext.cs:
  - New drawTextCache dictionary keyed by
    (text, fontFamily, fontSize, isBold, OxyColor). Color is part of
    the key because FormattedText embeds its foreground brush.
  - DpiScale setter now clears both measureCache AND drawTextCache
    (cached dimensions are DPI-dependent).
  - DrawText looks up the cache before allocating. Cache hit returns
    the shared FormattedText; cache miss creates and stores it.
  - Documented invariant: cached FormattedText instances must NOT be
    mutated after construction. DrawText only reads Width/Height and
    passes the instance to DrawingContext.DrawText - safe reuse.
- tests/OxyPlotControls.Tests/Rendering/DrawTextCacheTests.cs:
  - four tests: identical-inputs cache hit, different fontSize miss,
    different color miss, DpiScale change clears cache.
  - Uses reflection to inspect the private drawTextCache dictionary
    without exposing it on the public API.
- tests/OxyPlotControls.Tests/Series/GetNearestPointThreadSafetyTests.cs:
  - Convert blocking Task.Wait to awaited WaitAsync per xUnit1031
    guidance (cleanup carried in this commit to keep the full tree
    warning-free).

Behavior change: none observable. Pure allocation reduction + GC relief.
Root cause of the multi-second lag on many-series plots (e.g. 20-chain
MCMC trace): Plot.SuppressPropertyChanged suppresses the plot's OWN events
(OnSeriesChanged, OnAxesChanged, etc.) but not cascading InvalidatePlot
calls that flow back in from child Series. Two cascades were bypassing
the suppression:

1. WPF logical-tree inherited-DP cascade on Plot.Series.Add: each wrapper
   added to Plot.Series causes WPF to re-evaluate inherited dependency
   properties (Visibility, Background, FontFamily, FontSize, FontWeight,
   Foreground). Each inherited change fires AppearanceChanged on the
   wrapper, which calls OnVisualChanged, which calls InvalidatePlot(false).
   For N series x 6 inherited DPs = 6N synchronous Model.Update calls.
   For 20 series = 120 updates during a single bulk Series.Add block.

2. ItemsSource-per-series cascade: each ItemsSource assignment fires
   OnItemsSourceChanged -> OnDataChanged -> InvalidatePlot(true), producing
   N full Model.Update(true) cycles during a bulk data refresh.

Both cascades were captured live in a 20-chain MCMC trace by adding
PlotViewBase diagnostic instrumentation and observing the stack traces
at 2-3 ms intervals per call during control load and parameter switch.

Fix:

- OxyPlot.Wpf/Plot.cs InvalidatePlot override: gate all work on
  SuppressPropertyChanged at the top. When suppression is active, mark
  _needsSynchronization = true and return immediately. The consumer's
  explicit InvalidatePlot at the end of their bulk-update block runs
  the full sync once. Sync happens exactly once instead of 6N+N times.

- OxyPlot.Wpf.Shared/PlotViewBase.cs InvalidatePlot: opt-in per-call
  diagnostic instrumentation gated by the new static
  InvalidatePlotDiagnosticsEnabled and InvalidatePlotDiagnosticsTitleFilter
  properties. Debug-only; zero cost in Release. Logs callId, plot title,
  updateData flag, delta-time from previous call, and top-8 caller frames
  on each invocation. Used to diagnose invalidation loops in consumer
  apps; leave the instrumentation in place for future diagnosis.

All wpf-framework tests green: 5636 passed, 6 skipped, 0 failed.

Safety: the gate preserves every existing SuppressPropertyChanged
invariant. Any consumer already pairs suppression with an explicit
InvalidatePlot, so the deferred work runs on that explicit call. No new
API surface; no breaking changes.
Adds InvalidatePlotPhaseDiagnosticsEnabled static property that, when
true alongside the existing InvalidatePlotDiagnosticsEnabled, logs per-
phase timing for every InvalidatePlot call:
- syncMs: cost of SynchronizeProperties/Series/Axes/Annotations
- baseMs: cost of base.InvalidatePlot (Model.Update + render-queue)
- paintMs: render-complete time (via ContextIdle callback)

Also captures whether synchronization ran, whether _needsSynchronization
was true on entry, and the updateData flag, so a slow frame can be
attributed to the phase responsible.

Used to diagnose why wheel zoom is 150ms per tick while pan is 7-12ms
despite both calling InvalidatePlot(false) through the same pipeline.

Debug-only; zero cost in Release.
Replace LICENSE with 0BSD text, update PackageLicenseExpression in
Directory.Build.props (drop PackageRequireLicenseAcceptance and the
LICENSE Pack item), strip the 29-line USACE-RMC license header from
431 .cs and 2 .xaml files, remove Copyright/AssemblyCopyright lines,
and update README, CONTRIBUTING, codemeta.json, CITATION.cff to
reflect the new license. Vendored OxyPlot (MIT) and AvalonDock
(Xceed Community) headers and LICENSE files are left untouched.
… and input

Round-trip serialization (must use InvariantCulture so files written under one
locale load on another):
- AvalonDock LayoutContent.cs: int.Parse/ToString for PreviousContainerIndex /
  InitialContainerIndex
- OxyPlot Axis.cs: removeNoise lambda no longer drifts under non-en cultures
- DatabaseManager DBFReader.cs: 5 ToString sites for DBF binary export
- DatabaseControls NumericColumnStats.xaml.cs: F15 format for histogram breaks
  pushed into a DataView that re-parses the strings
- FrameworkUI UserSettings.cs: int round-trip for settings XML

DataTableView.ConvertToColumnType: introduces TryParseDoubleDualCulture /
TryParseSingleDualCulture helpers so paste/edit recovers from cross-culture
clipboard data (e.g., a US-format CSV pasted on a German machine). Mirrors the
existing CopyPasteDataGrid pattern; CopyPasteDataGrid's non-double paste path
also gets the dual-culture fallback for int/long/decimal/etc.

Display formatting (must use CurrentCulture so users see their locale):
- NumericControls Selector.xaml.cs: ~30 sites of stat-table strings flipped
  from InvariantCulture to CurrentCulture (DataStat / DistStat are bound to
  the SummaryTable DataGrid for read-only display)
- FrameworkUI FileSizeManager.cs: explicit CurrentCulture
- HazardControl.xaml.cs: explicit CurrentCulture for column headers
- DistributionWithSelectorControl / DistributionSelectorPopup: string.Format
  with explicit CurrentCulture

WPF binding hardening (controls work under non-en locales even when the host
app does not call FrameworkElement.LanguageProperty.OverrideMetadata):
- 9 XAML files (NumericTextBox2, NumericPropertyControl, NumericAutoProperty,
  NumericSliderProperty, NumericPropertySelector, HazardControl, MainWindow,
  Selector, DistributionSelectorControl): added xmlns:glob and ConverterCulture
  ={x:Static glob:CultureInfo.CurrentCulture} on numeric StringFormat bindings
- StringToNAConverter (Converters.cs): now honors the WPF-supplied culture
  parameter instead of the thread default
- DAG.Demo and ExpressionParserControls.Demo: App constructors now call
  FrameworkElement.LanguageProperty.OverrideMetadata so XAML bindings inherit
  the user's locale (matching the pattern in the other 5 demos)

OxyPlotControls AxisControl.xaml.cs: replaces fragile
DecimalPlaces.Number.ToString() == "NaN" with double.IsNaN, casts to int with
InvariantCulture before building the axis StringFormat suffix, and pins
InvariantCulture on the 5 internal-pipeline TryParse sites.

Tests:
- DAG.Tests / OxyPlot.Wpf.Tests / DatabaseManager.Tests: round-trip tests
  under de-DE / fr-FR / tr-TR, bit-exact via BitConverter.DoubleToInt64Bits
- OxyPlot.Tests AxisCultureTests: cross-culture ActualMajorStep equality
- GenericControls.Tests NumberFormatHelperCultureTests: 4-culture round-trip,
  Persian/Arabic digit normalization
- GenericControls.Tests XamlBindingCultureTests: structural regression guard
  for the ConverterCulture pattern in 9 modified XAML files
- NumericControls.Tests SelectorDisplayCultureTests: format pattern guard
- StringToNAConverterTests: 3 tests covering thread-vs-parameter culture
  mismatch using "1.5e308" as the genuine differentiator (∞ in de-DE,
  finite in Invariant)
- All xunit culture tests use [Collection("CultureSensitive")] with
  DisableParallelization to prevent thread-culture leakage to parallel tests
- CULTURE_SMOKE_TESTS.md documents manual verification under German Windows
  region, especially for AvalonDock layout (no test project)

Net: 6,815 tests pass (+37 vs prior), 0 failures, 0 build warnings.
Renderer + interaction:
- DrawingVisualRenderContext.DrawLineRange uses PolyLineTo (bulk native
  append) instead of a per-point LineTo loop; pooled Point[] buffer.
- StreamGeometryTileSize raised 1024 -> 16384, eliminating per-tile
  geometry allocation in normal cases.
- DrawingVisualHost gains DisableShapeAntiAliasing and UseBitmapCache
  opt-in properties (mirrored on PlotViewBase via reflection-based
  propagator). EdgeMode.Aliased disables the WPF stroke-tessellation AA
  pass that dominates per-series cost on dense multi-line plots.
- New ZoomRectangleAdorner (lightweight FrameworkElement with OnRender)
  replaces the ContentControl + ControlTemplate approach for the zoom-
  rectangle drag overlay. Per-mouse-move updates only InvalidateVisual
  with no layout pass.
- ShowZoomRectangle simplified; HideZoomRectangle delegates to adorner.

InvalidatePlot misuse fixes:
- ZoomRectangleManipulator.Completed: updateData true -> false (zoom is
  a range change, not a data change).
- OxyPlotToolbar polygon-annotation drag (PlotModelMouseMove): true ->
  false; mouse-move handler short-circuits during active drag.
- OxyPlotToolbar ZoomAllButton_Click: removed redundant second
  InvalidatePlot (ResetAllAxes already invalidates).
- Axis.Wpf MinMaxChanged: routes through OnVisualChanged
  (InvalidatePlot(false)) instead of OnDataChanged.
- LegendControl orientation change: true -> false (display only).
- SeriesSelectorControl reorder up/down: true -> false (Z-order, not
  data).

Wheel-stack diagnostics (debug-only, off by default):
- New OxyPlot.PlotDiagnostics with WheelTraceEnabled flag and Trace
  scope helper. Plot.InvalidatePlotPhaseDiagnosticsEnabled mirrors to
  WheelTraceEnabled.
- Per-method ENTER/EXIT instrumentation across the wheel-zoom path:
  PlotViewBase.OnMouseWheel, ControllerBase.HandleMouseWheel/
  HandleCommand, PlotCommands.HandleZoomByWheel,
  ZoomStepManipulator.Started, Axis.ZoomAt + OnAxisChanged,
  PlotViewBase.InvalidatePlot/RenderOverride, PlotModel.Update (per
  sub-phase), PlotModel.Render (per layer), LineSeries.Render +
  RenderPoints (with useFused/decimator status).
- Plot.InvalidatePlot phase diagnostic extended with first-frame timing
  via CompositionTarget.Rendering and per-call GC counter delta.

All changes #if DEBUG-gated where they are diagnostic-only.
…nds, log-axis fused path, binary-search tracker

Closes verified findings from a multi-agent code review of the recent
oxyplot-improvements branch. Every fix preserves PropertyChanged semantics
and the SuppressPropertyChanged contract that the undo system depends on.

Correctness / undo-redo:
- F-1: wrap all OxyPlotToolbar SuppressPropertyChanged blocks (3 plot-level,
  8 annotation-drag MouseUp handlers, plus StopAddAnnotation /
  CancelAddAnnotation) in try/finally so a thrown exception cannot leave a
  plot or annotation permanently silenced.
- F-2: preserve updateData=true across the InvalidatePlot suppression gate
  via a private _pendingUpdateData flag — a suppressed InvalidatePlot(true)
  is no longer silently lost when the consumer's flush call passes false.
- F-3: post-clear InvalidatePlot(false) on the three Plot-level toolbar
  suppress blocks so theme changes flush immediately.
- F-4: extend drawTextCache key with TextFormattingMode + culture name so
  RTL or mode-switch scenarios don't return a stale cached layout. Convert
  TextFormattingMode to a backing-field property that clears caches.
- F-5: cap measureCache and drawTextCache at LRU 512 entries (new private
  LruCache<TKey,TValue>) so live-data plots can't grow them unboundedly.
- F-6: add PlotDiagnostics.EndWheel() and call it from PlotViewBase.OnMouseWheel
  so cross-event diagnostic noise no longer leaks past the wheel scope.
- F-9: re-apply BitmapCache.RenderAtScale on per-monitor DPI change.
- F-10: fire one final hover Delta on mouse-up after the last drag
  manipulator completes so the tracker doesn't stay frozen until the next
  mouse-move.
- F-12: clear ZoomRectangleAdorner bindings before discarding on re-template
  to prevent retention across AvalonDock dock/undock.
- F-13: coalesce CompositionTarget.Rendering subscriptions in the DEBUG
  diagnostic so rapid InvalidatePlot doesn't accumulate stale handlers.
- F-14: convert SuppressPropertyChanged to a volatile bool backing field for
  reliable cross-thread reads.
- F-CTL-4-AUDIT: guard the polygon and polyline first-click paths in the
  toolbar with DataPoint.IsDefined() so axes-not-yet-laid-out plots no
  longer accept undefined points into annotation collections.
- F-CTL-9: add TryFromPrettyVectorText / TryFromPrettyVectorString overloads
  so vector-attribute parsing distinguishes parse failure from a genuine
  zero vector (legacy methods preserved as wrappers).

Performance:
- F-7: replace LINQ Series.Reverse().Where(...) in GetSeriesFromPoint with a
  backward indexed for-loop — eliminates iterator allocation at 60Hz.
- F-8: add IReadOnlyList<DataPoint> fast path in GetNearestPointInternal so
  IReadOnlyList-backed series skip the ToArray() snapshot allocation.
- O-2: binary-search nearest-point fast path for IsXMonotonic series in
  GetNearestPointInternal — bisects on data X then expands outward with
  early termination on screen-X distance, converting per-mouse-move cost
  from O(N) to O(log N + k) per series.
- O-3: extend FusedExtractAndDecimate to base-10 LogarithmicAxis (X and/or
  Y), inlining (Math.Log10(v) - offset) * scale for ~10x render speedup
  on log-axis plots. Non-base-10 log axes fall through to the slow path.
- O-4: cache the single-element marker-size array on LineSeries; rebuild
  only on MarkerSize change.
- O-5: raise DrawEllipses tile size 500 → 4096 — fewer StreamGeometry
  objects emitted for high-density marker plots.

Documentation:
- F-15: doc-comment cref Series.SuppressPropertyChanged → Plot.SuppressPropertyChanged.
- F-CORE-7: clarify IsHitTestEnabled is intentionally non-notifying.
- F-CORE-9: note CurrentWheelSeq returns 0 from non-UI threads.
- F-WPF-5: load-bearing comment on Axis.OnVisualChanged ordering.
- F-WPF-10: comment on ArraySegmentList unreachable empty-slice path.
- O-1, O-6: three-scenario pairing table for DisableShapeAntiAliasing
  + UseBitmapCache (static / streaming / interactive).

Defensive:
- Wrap GetItem(index) calls in XYAxisSeries.GetNearestPointInternal in
  try/catch for ArgumentOutOfRangeException to survive concurrent
  ItemsSource mutation under the new fast paths.

Test update: DrawTextCacheTests now reflects against the LruCache.Count
property since the cache type changed from Dictionary to a private LRU.

All 4,589 tests pass (3,266 OxyPlot, 775 OxyPlot.Wpf, 548 OxyPlotControls).
Zero new build warnings. PropertyChanged contracts unchanged — undo-redo
behavior is preserved by construction (every suppression site retains its
true/false symmetry under the new try/finally guards).
…olygon

The polygon path's IsDefined() guard added in the prior commit was missed for
the polyline subsequent-click branch — it still called InverseTransform
directly. On a plot whose axes are not yet laid out (AvalonDock dock/undock
flow), this could append DataPoint.Undefined to the polyline's points
collection. Mirror the polygon pattern so the polyline also skips undefined
points cleanly.
Two Plot instances in the same process previously shared the static flag,
so a CompositionTarget.Rendering tick fired for one would clear the other's
pending state and produce misleading [FirstFrame] log output. Per-instance
state is correct since each Plot has its own diagnostic timeline. DEBUG-only
path; zero release impact.
…d model

Two doc-only additions following review:

- FusedExtractAndDecimate: note the implicit coupling between the useFused
  gate (which excludes non-base-10 LogarithmicAxis) and the inline log
  transform that assumes Base==10. Future log-axis subclasses must update
  the gate together with this code.
- _pendingUpdateData: explain the intentional non-volatile choice (both
  writer and reader run on the dispatcher thread; cross-thread visibility
  lives on SuppressPropertyChanged, not this flag).
…of Loaded

DispatcherPriority.Render runs alongside WPF's composition tick — the
deferred Render() executes at the start of the next frame rather than
after pending Layout/DataBind work. Cuts roughly one dispatcher cycle
(~16ms at 60Hz) of latency on rapid wheel-zoom + pan interaction. The
renderPending flag still coalesces multiple invalidations into one
dispatch. Cross-thread BeginInvoke fallback also moved to Render priority
for consistency.
… Func dispatch

The generic FindWindowStartIndex<T> takes a Func<T,double> xgetter and goes
through the delegate on every binary-search step. For LineSeries, AreaSeries,
and StairStepSeries with monotonic X, this dispatch fires dozens of times per
render per series.

Add a non-generic overload that takes IList<DataPoint> and reads the .x field
directly. The JIT can then inline the field read on every step. Generic
overload is preserved unchanged for series that need a custom xgetter
(CandleStickSeries, RectangleBarSeries, TwoColorAreaSeries).

Behavior is identical — the algorithm is a line-for-line copy of the generic
version with the delegate call replaced by .x access.
…ack path

Extend the existing WindowStartIndex left-edge culling with a matching
WindowEndIndex right-edge bound. For monotonic series that fall through to
the slow fallback path (custom decimator, non-base-10 LogarithmicAxis,
GumbelProbabilityAxis, NormalProbabilityAxis), the outer iteration now
caps at WindowEndIndex + 2 (one extra for line continuity past the right
edge, matching the WindowStartIndex - 1 left-edge convention).

The fused fast path is unaffected (FusedExtractAndDecimate already exits
cleanly when X > xmax). Non-monotonic data is unaffected (the fallback
keeps scanning the full collection because X is unsorted).

WindowEndIndex defaults to int.MaxValue so series that don't compute it
fall back to the existing per-point clipCount termination.
…text

Rotated text (axis titles, angled tick labels, rotated annotations) was
allocating two new transforms per call — a TranslateTransform and a
RotateTransform — and pushing both onto the DrawingContext. On a typical
plot with two axes (one vertical title, one set of angled labels), a single
render would create 40-60 short-lived transforms.

Cache one of each as private fields, mutate in place before each push.
DrawingContext copies the matrix internally during PushTransform, and the
matching Pop returns control to us before the next text draw — single-
threaded mutation is safe by construction.
Two micro-optimisations in DrawingVisualRenderContext:

(1) CleanUp(): the previous imageCache.Keys.Where(...).ToList() allocated
    an enumerator chain and a fresh List on every render — even for plots
    with no images. Replace with a reused private List buffer and two fast
    paths (no images cached / all images used) so non-image plots skip the
    walk entirely.

(2) SetClip(): cache the frozen RectangleGeometry across calls. The plot-
    area clipping rect is identical for every series within a render and
    typically identical across renders (only changes on resize). On a 20-
    series plot this eliminates 20 RectangleGeometry allocations per render.

DashStyle caching (W-7) was also considered but skipped — DashStyle is
already created once per unique pen configuration via the existing penCache
(misses only). Adding a separate DashStyle cache would only save memory
across pens with different colors but identical dash arrays — too marginal
to justify the extra infrastructure.
Two related per-render caches:

- isInVisualTree: previously re-walked the visual tree on every Render()
  call. Now updated on Loaded/Unloaded (covers the normal case) and
  re-checked only when the cached value says false (handles popup hosting
  / ElementHost transitions). OnLayoutUpdated retains the false→true
  transition path for popup edge cases.
- cachedDpiScale: previously fetched via PresentationSource.FromVisual on
  every UpdateDpi() call. Cache lazily, invalidate on:
    * OnDpiChanged (per-monitor DPI change)
    * OnApplyTemplate (presenter re-creation: AvalonDock detach to floating
      window on a different monitor)
    * OnPlotViewLoaded (cross-monitor reload)

Both cuts a non-trivial amount of work from the per-render hot path —
PresentationSource.FromVisual is a COM-boundary lookup, and the visual-
tree walk is O(depth).
…ynchronization

Single Series.Color edit on a 20-series plot previously triggered
SynchronizeProperties + SynchronizeSeries + SynchronizeAxes +
SynchronizeAnnotations on the next InvalidatePlot — re-creating model
wrappers for 22 unrelated items.

Add four per-collection dirty flags (_seriesDirty, _axesDirty,
_annotationsDirty, _propertiesDirty). Each WPF callback sets only the
flag for the collection it changed:
- AppearanceChanged (Plot DP):     _propertiesDirty
- OnSeriesChanged:                  _seriesDirty
- OnAxesChanged:                    _axesDirty
- OnAnnotationsChanged:             _annotationsDirty
- OnCollectionItemPropertyChanged:  dispatch by sender type

The InvalidatePlot sync block runs only the matching Synchronize* method.
A Series.Color edit now runs only SynchronizeSeries.

Backward compatibility: _needsSynchronization is preserved as a 'force
full sync' fallback. External callers that set it directly (Axis.cs's
OnVisualChanged path) continue to trigger all four sync methods. Same
for unknown sender types in OnCollectionItemPropertyChanged.

Undo-redo unchanged: PropertyChanged events fire identically — the dirty
flags only gate which Synchronize* method runs, not which events fire.
…h fast case

ExtractNextContiguousLineSegment previously called the virtual IsValidPoint
on every point in the fallback path. For clean telemetry data — both axes
at default filters, no FilterFunction set — the check reduces to a struct
NaN+Infinity test that the JIT can inline.

Detect the fast-path conditions once at method entry (default filters on
both axes, no FilterFunction). When the conditions hold, replace the
virtual call with an aggressive-inlined IsFiniteFast(x) && IsFiniteFast(y).
For series with custom filter ranges or FilterFunction set, keep using
the virtual IsValidPoint. Behaviour is identical in both paths.
The plot-margin stabilization loop in RenderOverride may iterate up to 10
times. Each iteration previously called axis.Measure for every visible
axis, regardless of whether the axis actually moved. Measure iterates
all major tick values, formats each as a label string, and calls
rc.MeasureText per tick — a non-trivial cost on plots with dense
tick labels at high DPI.

Add comparison-based dirty detection: track Scale, Offset, ActualMinimum,
ActualMaximum from the most recent measure. Skip re-measure on subsequent
iterations when none have changed. The NaN sentinel on first call ensures
the initial measure always runs.

Comparison-based detection (vs. an explicit dirty flag) avoids the risk of
missing a code path that mutates the axis without flagging it. Any future
axis-state mutation that affects tick layout already updates one of the
four tracked parameters.
…erpolation

Spline smoothing on a large segment runs O(N) on top of a fresh allocation
of the smoothed point list — every render. On 100k-point data this is a
noticeable per-frame cost.

Add MaxSmoothingPoints property: when set to a positive value, segments
larger than the threshold skip the spline path and render the raw decimated
line directly. Default 0 preserves existing behaviour (smoothing always
applied when InterpolationAlgorithm is set). Recommended value 1000 for
large-data plots.
…st loops

Three small but compounding optimisations:

(1) LineSeries.RenderPointLabels: clip iteration to the WindowStartIndex /
    WindowEndIndex range for monotonic series. Off-screen points were
    previously formatted and submitted to DrawText anyway. On a 100k-point
    series with labels enabled, this cuts the per-render walk to the
    visible slice.

(2) Axis.IsValidValue: cache hasFilterFunction bool flag on FilterFunction
    setter. The JIT predicts the branch as always-false in the common case
    (no filter set) where the original null-check property read could not
    be devirtualized. IsValidValue is on the per-point hot path (10 series
    × 100k points = 2M calls/render).

(3) LogarithmicAxis.PowList / LogList: replace the Where().TakeWhile()
    .Select().ToList() LINQ chain with a direct for-loop. Eliminates three
    iterator state machines per call. Tick generation runs multiple times
    per render via AdjustPlotMargins, so the per-render allocation savings
    compound on log-axis plots.

C-7 (Math.Round → integer cast in Decimator) was considered but skipped
— the (int)(x+0.5) trick is incorrect for negative values, and safer
alternatives are barely faster than Math.Round.
Rapid wheel scrolling (high-Hz mice, trackpad inertia) previously fired N
independent OnMouseWheel handlers, each calling Controller.HandleMouseWheel
synchronously. On a multi-LineSeries large-data plot every event paid the
full Model.Update cost — N events stacked into multi-frame latency before
the first paint catches up.

Coalesce: accumulate Delta into a private field, capture the latest
position+modifiers as the OxyMouseWheelEventArgs, and schedule one combined
flush at DispatcherPriority.Render. Subsequent wheel events within the same
dispatcher tick add to the running delta instead of dispatching their own
controller call. The flush dispatches the synthesised summed event through
ActualController, then resets state for the next window.

The user sees the cumulative zoom on the very next frame — no behavioural
change beyond the latency cut. Position taken from the latest event so
zoom-at-cursor remains accurate.
…ext to share position

Phase B4 (commit c9d7646) was incorrect. DrawingContext.PushTransform on a
DrawingVisual captures the Transform by reference, not by value. The visual
command stream re-evaluates each transform's current matrix at composition
time (i.e., after the entire DrawingContext has closed and rendering runs).

Mutating a cached transform between DrawText calls therefore retroactively
changes every previously-recorded rotated text instance to use the latest
position+angle. The visible symptom: 'Test Y' (the rotated Y-axis title)
jumped to whichever annotation label was drawn last; vertical-line and
polyline annotation labels overlapped each other near the cursor. Only
manifested in DrawingVisual rendering — Canvas rendering uses immediate-
mode TextBlocks that capture coordinates at draw time.

Reverted to the original allocate-per-call pattern. The 'win' was illusory
— two short-lived heap allocations per rotated text, on a path that's not
the bottleneck for large-data plots. Correctness over micro-optimisation.

Tests had not caught this because the WPF test suite renders to canvas
(or asserts on properties / round-trip serialisation), not on the actual
DrawingVisual command playback under composition.
… 'X' / 'Y'

Pre-existing demo placeholder text from the 1.0.0 baseline (d8dd67f).
Surfaced on annotation placement once Phase B4's transform-cache bug
made the rotated Y-axis title relocate to wherever the latest rotated
text was drawn — now harmless after that revert (a5f306a), but the
placeholder text was untidy regardless. Replace with neutral 'X' / 'Y'
labels across all 10 demo plots and the TestAxisNameBinding default.
…ement MouseMove

The leader-line preview during polygon/polyline placement is a plain WPF
Polyline shape — mutating its Points collection invalidates only that
shape. The polygon annotation isn't yet in Plot.Annotations during
placement (added at click 3); the polyline IS in Plot.Annotations but its
Points collection isn't changing between clicks, only the leader-line is.
Either way the trailing Plot.InvalidatePlot(false) re-rendered the entire
plot for nothing.

User experience is identical: leader-line still tracks the cursor, click
sequences are unchanged, Escape cancellation works as before. The placed
annotation matches what today produces.
HadenSmith added 29 commits May 4, 2026 11:11
…h (F-003)

Both UpdaterBootstrapper.LaunchUpdater and GitHubUpdateService.InstallUpdateAndRestart
launch the updater with CreateNoWindow=true. Without an attached console,
Console.ReadKey throws InvalidOperationException — the failure path's
unguarded ReadKey escaped its catch block as an unhandled exception,
crashing the updater mid-failure with no UI feedback to the user.

Replace the unguarded Console.ReadKey(true) with WaitForKeyWithTimeout,
which already catches InvalidOperationException for the no-console case.
Add a longer FailureCloseTimeoutMs (15s) so users with attached consoles
have time to read the error message before auto-close.
ThemeService.SetTheme mutated Application.Current.Resources.MergedDictionaries
while holding _lock. WPF resource invalidation can synchronously fire
subscriber callbacks; any subscriber that reads ThemeService.Instance.CurrentTheme
(which acquires the same _lock in its getter) re-enters under the lock and
deadlocks the dispatcher.

Refactor: build the new ResourceDictionary and resolve its pack URI outside
the lock; under the lock, only swap _currentTheme + _currentColorDictionary
references and capture the previous values; release the lock; perform
MergedDictionaries.Remove/Add and raise OnThemeChanged afterward. The whole
method already marshals to the UI thread, so MergedDictionaries access
remains single-threaded after lock release.
…C-012)

SQLiteManager builds many statements via [bracket]-quoted concat of
table/column names sourced from callers (CSV import headers in
ConvertCsvToSqLite, FieldCalculator user input, etc.). The bracket
quoting does not escape inner ']' so a name containing ']' breaks the
quote and enables SQL injection.

Add a private static ValidateIdentifier helper that rejects names
containing ']' or null bytes, and apply it at the user-facing API
surface where untrusted names enter:
- Public table methods: CopyTable, RenameTable, CreateTable (incl.
  every member of the columnNames array), AppendRows, DeleteTable,
  DeleteTableData, GetTableManager, GetStoredNumberOfRows,
  GetStoredNumberOfColumns.
- Protected SqLiteTableManager column methods: 9 AddColumnToDatabase
  + 9 EditDatabaseColumn overloads.

This blocks the realistic injection vector (CSV header) without a
70-site internal-concat refactor; ConvertCsvToSqLite flows through
CreateTable so its imported headers are also validated.
LayoutDocumentControl.SetResourcesFromObject wrapped
this.Resources.Add in a bare catch(Exception) {} that silently swallowed
OutOfMemoryException, ThreadAbortException, NullReference, and any other
genuine bug during the resource walk. The expected faults are duplicate-key
adds (ArgumentException) and dictionary mutation during iteration
(InvalidOperationException).

Replace with two narrow catches that log via System.Diagnostics.Trace.WriteLine
so genuine bugs are no longer hidden and the expected faults remain non-fatal.
…D-002)

These are documented WPF Framework perf opt-ins (notably IsHitTestEnabled=false
for dense MCMC traces and EdgeRenderingMode.PreferSpeed for line drawing).
Previously they were never written to or read from the Series XML, so every
Save/Open silently reverted them to defaults — restoring the O(n) hit-test
scan and undoing user perf tuning.

Add both properties to the General serialization block in SeriesToXElement
and reciprocal GetBoolean/GetEnum reads in XElementToSeries.
Plot.TextColor is the global text color for every default-colored text
element on the plot. Previously not written to General XML, so any user
customization (e.g. plot.TextColor = Colors.DarkBlue) was silently reset
to default on Save/Open.

Write the property as an attribute on the <General> root and add the
reciprocal GetColorAttribute read in XElementToGeneralProperties.
The AxisSerializer was missing several DPs that the WPF Axis wrapper
exposes. Each Save/Open silently reset them to defaults:
  Numbers block:  MaximumPadding, MinimumPadding, MaximumRange, MinimumRange
  Title block:    ClipTitle, TitleClippingLength
  Labels block:   IntervalLength
  ExtraGridlines: the ExtraGridlines double[] array (explicit gridline values)

Add a companion list helper to SerializerExtensions:
  ToXElement(this double[], string name)  — writes <Value G17/Invariant>
  DoublesFromXElement(this XElement)      — reads them back to double[]

The audit also called out CropGridlines, MaximumDataMargin, MinimumDataMargin,
MaximumMargin, MinimumMargin, MinimumMajorStep, MinimumMinorStep,
MaximumMajorIntervalCount, MinimumMajorIntervalCount, MinorTicklineColor.
These exist on the OxyPlot core Axis model but are not DPs on the WPF
Axis wrapper. Persisting them at this layer would be inconsistent with
the WPF user-facing API; promoting them to wrapper DPs (with
Plot.SynchronizeProperties wiring) is tracked as a follow-up.
XElementToAnnotation previously fell through to 'new TextAnnotation()' when
the AnnotationType string didn't match any known type. The result was a
phantom empty TextAnnotation appearing in the user's plot when reading XML
authored by a future framework version or hand-edited.

Return null on unknown type (matching SeriesSerializer.XElementToSeries
pattern) and emit a Debug diagnostic. The caller XElementToAnnotations
already handles null with 'continue'.
…Fill (B-001)

OverlayButtons.xaml had 8 arrow glyphs rendered through
Rectangle.Fill > DrawingBrush > DrawingGroup > GeometryDrawing.Brush. The
DrawingBrush/DrawingGroup/GeometryDrawing chain is a Freezable subtree —
DynamicResource cannot resolve through Freezables, so the foreground arrow
brush silently fell back to its default value (transparent), making the
dock-position indicator arrows invisible during drag-to-dock.

Replace each Rectangle > DrawingBrush block with a Path placed directly
inside the surrounding Grid. Path is a real visual-tree element so
Fill="{DynamicResource ...DockingButtonForegroundArrowBrusKey}" resolves
correctly. Same width/height/rotation preserved on each of the 8 sites
(left, right, top, bottom + the 4 DockDocumentAsAnchorable variants).
…heme (B-002)

LayoutFloatingWindowControl.UpdateThemeResources had a fallback that walks
up via Window.GetWindow(manager) to copy theme dictionaries from the parent
MainWindow when the DockingManager.Theme property is not set — required
because FrameworkUI's intended pattern adds VS2013 dictionaries to
MainWindow.Resources, not to the DockingManager.

NavigatorWindow.UpdateThemeResources and OverlayWindow.UpdateThemeResources
were never patched with the same fallback, so under FrameworkUI's pattern
the Ctrl+Tab navigator and the drag-to-dock overlay rendered system grey
instead of the VS2013 theme.

Apply the identical fallback (manager.Resources first, then
Window.GetWindow(manager).Resources) to both classes.
…-014)

10 SolidColorBrush definitions across BlueBrushs.xaml, DarkBrushs.xaml,
and LightBrushs.xaml had the form
  <SolidColorBrush ... Color="{DynamicResource ControlAccentColorKey}"
                       options:Freeze="true" />
Once the WPF Freezable was frozen, subsequent updates to
ControlAccentColorKey could no longer flow through to the brush — runtime
accent-color swaps (e.g. mirroring the Windows accent) would not update
these brushes after first resolve. Brushes that bind hardcoded color
literals are unaffected and remain frozen for performance.

Remove options:Freeze="true" only from the brushes whose Color is a
DynamicResource. Initial resolution still works; runtime updates now
propagate.
…round binding (B-006)

ArrowPanelPath.Fill="{TemplateBinding Foreground}" resolved Foreground at
template-application time. If the menu instance was created before the
VS2013 theme dictionary had merged into the window's resources, the
TemplateBinding picked up the system-default Foreground (un-themed) — on
dark theme this rendered as dark arrow on dark background, effectively
invisible.

Replace TemplateBinding with a direct DynamicResource against
MenuKeys.TextBrushKey (the menu's themed text brush). DynamicResource
re-resolves on theme change so the arrow stays consistent with surrounding
menu text regardless of theme load order. Use the local: namespace alias
since ResourceKeys lives in the parent namespace; the existing reskeys
alias maps to the Menu sub-namespace.
…(A-002)

The 500ms merge window comparison used DateTime.Now wall-clock time. NTP
corrections, DST transitions, and any system clock adjustment that moves
backward by even 1ms caused legitimate merges within the window to be
silently rejected (the negative timeDiff guard at line 166 short-circuited
the merge). Users typing quickly during such a clock event got per-keystroke
undo entries instead of merged entries.

Capture Stopwatch.GetTimestamp() at construction (a high-resolution
monotonic counter that never moves backward) and use it for the merge-window
delta. Convert to milliseconds via Stopwatch.Frequency. The public
DateTime Timestamp is preserved for the IUndoableAction contract and for
human-facing display.
…r (A-003)

Two nested SuspendRecording calls created the bug:
  1. Outer ctor:  -= OnPropertyChanged  (handler off)
  2. Inner ctor:  -= OnPropertyChanged  (no-op, already off)
  3. Inner Dispose: += OnPropertyChanged  (handler back on, count = 1)
  4. Outer Dispose: += OnPropertyChanged  (now subscribed twice)
After the outer dispose, every subsequent property change recorded TWO undo
actions; user saw Ctrl+Z 'undo half a step at a time'.

Add a _suspendCount field on UndoableStateBridge (Interlocked-managed) and
gate the actual subscribe/unsubscribe on the 0-1 / 1-0 transitions.
Inner suspensions just bump/decrement the counter. Disposed-bridge path
preserved.
… A-005, A-010, A-011)

UndoManager.Undo/Redo popped the action then called action.Undo() / Execute().
If the inner call threw — a property setter validation failure, a dynamic
collection-bridge RuntimeBinderException, an IndexOutOfRangeException after
the collection had shrunk, etc — the action was never pushed to the
opposite stack. The user lost the action from BOTH stacks and could no
longer undo or redo to that point.

Wrap action.Undo / action.Execute in a nested try/catch that, on exception,
pushes the action back onto its source stack and rethrows. The outer
finally still clears _isExecutingAction.

A-010: RollbackTransaction now raises OnStateChanged when it completes,
so UI bindings to CanUndo/CanRedo/Description refresh after rollback
(previously the UI showed the rolled-back action description as though
it were still pending).

A-011: Document IUndoManager.ExecuteAction's silent-drop behavior on
re-entrant calls. The drop is intentional (suppresses recursive recording
during property-change-driven re-entry) but was undocumented; consumers
should use BeginTransaction for chained actions instead.
…e-only (A-001)

ElementBaseBuff.RaisePropertyChange(string, bool) unconditionally assigned
IsDirty = isDirty. Calling with isDirty=false (the documented "notify-only"
pattern, used for derived/computed property notifications) silently CLEARED
existing dirty state — so a Buff-derived element that wanted to fire a
PropertyChanged event without changing dirty state instead lost its
unsaved-change indicator.

Match ElementBase.RaisePropertyChange semantics: only promote IsDirty to
true when isDirty=true; never clear. Update XML doc to reflect the
promote-only contract. The ElementBase/ElementBaseBuff parity gotcha is
now back in alignment.
ElementCollectionBase.MoveElement performed RemoveAt + Insert + SetIsDirty
but never called RecordMoveElement, so any reorder (drag-drop in TreeView,
programmatic) was non-undoable. Ctrl+Z after a drag-reorder did nothing.

The RecordMoveElement helper already exists at line 579 — wire it in.
… mutation (A-008)

GetHashCode hashed mutable Code (which Messenger.AddItemInternal mutates
post-construction for Event-type messages) and mutable Source.Name /
ParentCollection.Name. Adding the message to a HashSet/Dictionary then
mutating the source's Name moved the message into a stale bucket —
Contains/Remove silently failed.

Snapshot the identity components at construction in private fields and
prefer the snapshot in Equals/GetHashCode. Falls back to current state
when snapshots are null (parameterless ctor + property init pattern,
preserves existing test semantics for that path). For non-IElement /
non-IProject sources, hash by reference identity (RuntimeHelpers.GetHashCode)
which is also stable.
…success (A-023)

RestoreFromBackup_Click did File.Move(fullFileName, newFileName) then
OpenProject(newFileName). The Move consumed the .bak before the open
result was known. Any subsequent OpenProject failure (corrupt file,
version mismatch, missing dependency) left the user without a backup
they could try a different recovery on — and an unhandled File.Move
IOException would crash the dialog handler entirely.

Replace Move-then-open with Copy-then-open. Wrap each step in try/catch
with informative messages. On open failure, delete the half-restored
copy (so it doesn't masquerade as a usable project) and leave the
original .bak in place. Only delete the original .bak after the
restored project is verified open.
…C-001, C-008)

NumericBinaryNode, IncrementNode, and IfNode determined their OutputType
with `OutputType == ResultType.Double || ... == ResultType.Double`. ResultType
is a flags enum where Single, Short, Byte are distinct values from Double; the
equality check missed Single-typed operands and silently fell through to
Integer arithmetic, rounding the result.

Replace with `(... & ResultType.FloatingPoint) > 0` so any floating-point
operand (Double | Single) widens the output to Double.

C-008: IfNode.Simplify's switch covered only Integer/Double/Boolean/String
and routed everything else through `new DecimalNode(Convert.ToDouble(result))`.
Replace the int/double cases with `(OutputType & ResultType.FloatingPoint)`
and `(OutputType & ResultType.IntegerValue)` flag tests so Single, Short, Byte
route through the correct simplified literal node.
…aches ParseError (C-002)

Token-to-IntegerNode conversion silently produced IntegerNode(0) on
int.TryParse failure. Expressions with literals larger than int.MaxValue
(e.g. 9999999999) parsed to 0 with no error feedback; 9999999999 * 2
evaluated to 0.

On TryParse failure, fall back through long.TryParse and double.TryParse,
promoting to a DecimalNode that preserves the value within representable
range. On all-fail, attach a ParseError to the fallback IntegerNode(0)
so the caller's GetErrors surface the failure rather than silently producing
0 as a hidden datum.
…(C-017)

BuildNumber consumed digits and at most one decimal point but never `e`/`E`
exponent suffixes. `1.5e10` lexed as DecimalNumber `1.5` followed by
identifier `e10`, producing the parser error 'Cannot have two values next
to each other without an operator'.

Extend BuildNumber: when the next char is `e` or `E` (and we have at least
one digit already, and no exponent yet), consume it plus an optional
`+`/`-` sign and continue consuming digits. Setting hasDecimal=true on
exponent ensures the resulting token is treated as DecimalNumber.
.NET's Random.Next(min, max) is `[min, max)` exclusive on max; Excel's
RANDBETWEEN(min, max) is inclusive on both ends. Users migrating Excel
formulas got distributions that never produced the upper bound.

Bump the upper bound by 1 (with overflow guard for int.MaxValue) so the
upper value occurs.
…s null (C-003, C-007)

C-003: GetDefaultFromType returned `new NotImplementedException()` (the
exception object as a value!) for unknown types. Each cell of a new column
was set to an exception object; downstream type-validation crashed with
a stack trace far from the root cause. Replace with `throw new
NotImplementedException(...)` so the failure surfaces at column-creation
time.

C-007: ApplyCellEdit unguarded `cellToEdit.Value.GetType()` NRE'd on null
or DBNull. ConvertToColumnType normally maps null to DBNull before edits
reach this stack, but a direct edit-stack add could bypass that. Skip
the apply with a diagnostic Debug.WriteLine; the cell retains its prior
stored value, preserving stack consistency.
…B-007, B-008, B-010, B-012, B-013, B-016)

Defensive null/empty handling for paths that previously NRE'd or threw
InvalidOperationException under custom themes, race conditions during
tear-down, or missing template parts.

  - B-003 OverlayWindow.OnApplyTemplate: guard each PART_*DropTargets grid
    cast (mirrors existing _gridDocumentPaneFullDropTargets pattern).
  - B-004 LayoutAnchorableFloatingWindowControl.FilterMessage: First →
    FirstOrDefault on the WM_NCLBUTTONDOWN active-content lookup.
  - B-005 LayoutFloatingWindowControl.OnClosed: HwndSource cleanup is now
    unconditional — the Content!=null guard let the native hook leak when
    Content was cleared before close.
  - B-007 LayoutAnchorableFloatingWindow.SinglePane: Single → SingleOrDefault;
    guard predicate uses ILayoutAnchorablePane (interface) but lookup used
    LayoutAnchorablePane (concrete).
  - B-008 AnchorablePaneTitle.OnMouseLeftButtonDown: Single → FirstOrDefault
    on the FloatingWindows lookup; race during FW tear-down made Single throw.
  - B-010 OverlayWindow.DragEnter / DragLeave: null-guard _previewBox.
  - B-012 LayoutFloatingWindowControl.UpdateThemeResources / OnLoaded:
    defensive _model?.Root?.Manager walk matching the pattern used elsewhere
    in the same class.
  - B-013 ContextMenuEx.OnOpened: GetBindingExpression can return null when
    ItemsSource has no binding; null-guard before .UpdateTarget.
  - B-016 DockingManager.HideOverlayWindow: null-guard _overlayWindow for
    the race where Unloaded clears it between drag start and drag end.
…tance (E-004)

Eleven DependencyProperty registrations across NumericControls, GenericControls,
and DAGControls used reference-type defaults (`new TimeSeries()`,
`new OrderedPairedData(...)`, `new SolidColorBrush(...)`, `new List<double>()`,
`new DoubleCollection()`, `new Normal()`, etc). Reference-type DP defaults are
SHARED across every instance of the owner control — two unbound editors would
silently mutate one another's underlying data, and color changes on one
ClockControl would leak visually to every other ClockControl whose color hadn't
been explicitly set.

Standard fix per WPF: register the DP with a null default and initialize a
fresh instance per-control in the constructor. Sites changed:

  - NumericControls/Data/Time Series Editor/TimeSeriesTable
  - NumericControls/Data/Ordered Curve Editor/OrderedDataTableEditor
  - NumericControls/Distributions/Univariate/Distribution Selector/DistributionSelectorPopup
  - GenericControls/Properties Controls/{ColorPropertyControl, LineWidthSelectorControl,
      NumericPropertySelectorControl, StringListPropertyControl, LineStyleSelectorControl}
  - GenericControls/Date and Time Controls/ClockControl (SelectedColor, FaceColor)
  - DAGControls/NodeControl (HeaderColor)
…ot Foreground binding (B-006)"

This reverts commit a7cba54.
Bulk commit of remaining Phase 3 (consumer-impacting), Phase 4 (lifecycle/leak),
and Phase 5 (public-API XML doc) audit-remediation work that was completed by
parallel agents. Sandbox restrictions prevented per-finding commits.

Phase 3 (25 findings):
  A-009 RecentFiles trim, A-013 LoadFromXML defensive, A-015 ThemeManager
  suffix-match, A-018 TryParseTheme IsDefined check, A-035 default Light theme,
  C-009 Random.Shared, C-013 OrdinalIgnoreCase search, C-015 RoundNode promote
  to long/double, C-016 drop no-op ConnectionsRemoved, C-021 drop misleading
  null-prop, C-024 Parser threading XML doc, E-001 OxyPlotToolbar static cursor
  cache (drops IDisposable), E-002 drop dead GenericControls reference, E-007
  AxisControl direct double cast, E-008 canonical pack URI, E-014
  TimeSeriesTable Unloaded unsubscribe, E-015 OrdinateRowItem cached Index,
  E-018 Converters honor culture, F-005 SemVer skip-key strip metadata + migrate,
  F-006 EnsureState helper, F-007 cleanup empty SoftwareUpdate temp dir,
  F-010 unique tempPath, F-011 null events on Dispose, F-013 stricter SemVer
  pre-release regex, F-015 size check before WriteAsync.

Phase 4 (15 findings):
  A-012 try/catch shutdown saves, A-014 ThemeManager Dispatcher.Invoke marshal,
  A-016 AutoBackup snapshot Project, A-017 honor CancellationPending, A-021
  Messenger Code mutation outside lock, A-022 ProjectBase SaveAs/ZipProject
  try/catch, A-024 AutoBackup gate "saved" message on actual completion,
  A-031 ThemeService.SetTheme Invoke (sync), A-032 RecentFiles JumpList dedup,
  A-033 ConnectToMenu graceful skip, B-009 LayoutGridControl named handler +
  Unloaded unsubscribe, B-015/16/17/19 defensive null-checks, D-007 use
  FirstOrDefault for legacy enumerator paths, F-014 stage zip in install dir,
  F-016 Process.Start null-result logging.

Phase 5 (XML doc additions):
  IUpdateService threading + InstallUpdateAndRestart save-state contract,
  ColorPicker SolidColorBrush + grayscale-hue retention, BasicMessageItem
  identity snapshots, PropertyChangeAction monotonic merge, Plot.TextColor /
  Series.IsHitTestEnabled / EdgeRenderingMode round-trip remarks.

E-003 deliberately skipped per Phase 3 agent: switching to Microsoft.NET.Sdk.WindowsDesktop
triggers .NET 10 SDK warning NETSDK1137; current Microsoft.NET.Sdk + UseWPF is correct.

Build: 0 warnings, 0 errors. All previously-green test projects remain green.
Adds 32 new tests across 9 test projects to codify the expected behavior of
the Phase 1-5 audit fixes so future regressions are caught:

  F-002 SoftwareUpdate.Tests.GitHub.Sha256ExtractionTests          (6)
  D-003 OxyPlot.Tests.Axes.ProbabilityAxisTests                    (5)
  A-001/A-007 FrameworkInterfaces.Tests.AuditRegressionTests       (5)
  D-001/D-002 OxyPlot.Wpf.Tests.AuditRegressionTests               (5)
  F-003 SoftwareUpdate.Updater.Tests.WaitForKeyTimeoutTests        (1)
  A-006/A-014 Themes.Tests.Core.ThemeServiceConcurrencyTests       (2)
  C-001 ExpressionParser.Tests.AuditRegressionTests                (2)
  C-012 DatabaseManager.Tests.AuditRegressionTests                 (3)
  E-001 OxyPlotControls.Tests.AuditRegressionTests                 (3)

Tests use private-method reflection where needed to exercise internal
helpers (ExtractSha256Checksum, UpdateActualMaxMin, CursorCache).
A-006/A-014 share a DispatcherTestHost (dedicated STA thread + Application.Current)
so theme tests can run reliably without StaFact's per-test STA (which deadlocks
when Application.Current is process-global).

xunit.runner.json added to Themes.Tests with parallelism disabled (the
DispatcherTestHost is a singleton owned by the assembly).

F-001/B-001/B-002 tests skipped — they require integration/visual-tree harness
beyond the test-only scope of this phase.

All 32 new tests pass. No regressions in existing tests.
@HadenSmith HadenSmith merged commit 80086d8 into main May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant