diff --git a/.copilot-tracking/changes/20260219-sketch-style-editing-changes.md b/.copilot-tracking/changes/20260219-sketch-style-editing-changes.md new file mode 100644 index 0000000..b9a7b4d --- /dev/null +++ b/.copilot-tracking/changes/20260219-sketch-style-editing-changes.md @@ -0,0 +1,62 @@ + +# Release Changes: Phase 4.0 Sketch-Style Curve Editing + +**Related Plan**: .github/planning/phase-4-plan.md (Section 0) +**Implementation Date**: 2026-02-19 + +## Summary + +Implements sketch-style curve editing on the chart, allowing users to click and drag to draw torque values directly onto a curve series. The mouse position is snapped to the nearest speed data point (X axis) and rounded to a configurable torque increment (Y axis, default 0.2). + +## Changes + +### Added + +- src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs - Added `SketchEditSeriesName`, `IsSketchEditActive`, `TorqueSnapIncrement` properties and `SetSketchEditSeries`, `ClearSketchEditSeries`, `ApplySketchPoint`, `FindNearestSpeedIndex`, `SnapTorque` methods for sketch-edit mode +- src/MotorEditor.Avalonia/Views/ChartView.axaml.cs - Added `_isSketchDragging` state, `TryApplySketchAtPixel` helper, and sketch-edit handling in pointer event handlers +- tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs - Added 26 unit tests covering sketch-edit activation/deactivation, torque snapping, nearest speed index, and ApplySketchPoint behavior + +### Modified + +- src/MotorEditor.Avalonia/Models/UserPreferences.cs - Added `TorqueSnapIncrement` property (default 0.2) with clone support +- src/MotorEditor.Avalonia/Views/ChartView.axaml - Added sketch-edit mode indicator overlay showing active series name +- src/MotorEditor.Avalonia/Views/ChartView.axaml.cs - Extended pointer pressed/moved/released/capture-lost handlers to support sketch-edit drag interactions +- src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml - Added per-series ✏ sketch-edit toggle button in the column header +- src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml.cs - Added `OnSketchEditToggleClick` and `RefreshSketchEditToggles` handlers for sketch-edit toggle + +### Removed + +(none) + +## Release Summary + +**Total Files Affected**: 6 + +### Files Created (0) + +(none) + +### Files Modified (6) + +- src/MotorEditor.Avalonia/Models/UserPreferences.cs - Added TorqueSnapIncrement preference +- src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs - Added sketch-edit state management and point application logic +- src/MotorEditor.Avalonia/Views/ChartView.axaml - Added sketch-edit mode indicator overlay +- src/MotorEditor.Avalonia/Views/ChartView.axaml.cs - Added sketch-edit mouse interaction handling +- src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml - Added per-series sketch-edit toggle button in column header +- src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml.cs - Added sketch-edit toggle click handler and sibling refresh logic +- tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs - Added 26 unit tests for sketch-edit functionality + +### Files Removed (0) + +(none) + +### Dependencies & Infrastructure + +- **New Dependencies**: None +- **Updated Dependencies**: None +- **Infrastructure Changes**: None +- **Configuration Updates**: None + +### Deployment Notes + +No special deployment considerations. The sketch-edit feature is purely additive and does not change existing behavior. diff --git a/.github/planning/phase-4-plan.md b/.github/planning/phase-4-plan.md index 9f2a98a..3dfabec 100644 --- a/.github/planning/phase-4-plan.md +++ b/.github/planning/phase-4-plan.md @@ -3,12 +3,12 @@ ### 0. Sketch-Style Curve Editing - **Goal**: Enable simple, straightforward editing of a specified torque curve by clicking and dragging the mouse in the graph area. - **Steps**: - - [ ] Enable "Sketch edit" mode for a curve series. Only one curve series can be in sketch edit mode at a time. - - [ ] Implement a manner to track mouse position over the chart area in near-real time, in terms of x (speed), and y (torque). - - [ ] The speed component of the mouse position shall be rounded to the nearest speed data point in the chart field. - - [ ] The torque component of the mouse position shall be rounded to the nearest 0.2; this value shall be set-able in preferences. - - [ ] When the mouse is clicked while in the graph area, the curve with an active sketch-edit mode, will record the position of the mouse in the curve data, at the position of the x (speed) value in the dataset. E.g. if the mouse is nearest to 5% speed, and is nearest to 10.2 Nm and the user clicks, the curve series data should update such that at 5% speed that curve torque value is equal to 10.2 Nm. - - [ ] The user shall be able to click and drag the mouse over the graph, with the positions of the active sketch-edit curve being written to the curve data series as the mouse moves. Hence, the user could "sketch" over an image motor curve, cleaning up slight data errors very easily. + - [X] Enable "Sketch edit" mode for a curve series. Only one curve series can be in sketch edit mode at a time. + - [X] Implement a manner to track mouse position over the chart area in near-real time, in terms of x (speed), and y (torque). + - [X] The speed component of the mouse position shall be rounded to the nearest speed data point in the chart field. + - [X] The torque component of the mouse position shall be rounded to the nearest 0.2; this value shall be set-able in preferences. + - [X] When the mouse is clicked while in the graph area, the curve with an active sketch-edit mode, will record the position of the mouse in the curve data, at the position of the x (speed) value in the dataset. E.g. if the mouse is nearest to 5% speed, and is nearest to 10.2 Nm and the user clicks, the curve series data should update such that at 5% speed that curve torque value is equal to 10.2 Nm. + - [X] The user shall be able to click and drag the mouse over the graph, with the positions of the active sketch-edit curve being written to the curve data series as the mouse moves. Hence, the user could "sketch" over an image motor curve, cleaning up slight data errors very easily. ### 1. EQ-Style Curve Editing - **Goal**: Enable rich, EQ-style editing of torque curves directly on the chart while keeping the data grid and underlying models in sync. diff --git a/src/MotorEditor.Avalonia/Models/UserPreferences.cs b/src/MotorEditor.Avalonia/Models/UserPreferences.cs index 6c0fc04..4108f4b 100644 --- a/src/MotorEditor.Avalonia/Models/UserPreferences.cs +++ b/src/MotorEditor.Avalonia/Models/UserPreferences.cs @@ -43,6 +43,20 @@ public sealed class UserPreferences /// public bool ShowVoltageMaxSpeedLine { get; set; } = true; + /// + /// Torque snap increment used during sketch editing. The torque value + /// recorded when the user sketches on the chart is rounded to the + /// nearest multiple of this value. Defaults to 0.2. + /// + public decimal TorqueSnapIncrement { get; set; } = 0.2m; + + /// + /// Drag sketch band width as a percent of nearest point spacing. + /// Lower values require the pointer to stay closer to each point while + /// click-hold-drag sketching. Defaults to 5%. + /// + public decimal SketchDragBandPercent { get; set; } = 5m; + /// /// Creates a copy of the current preferences. /// @@ -54,7 +68,9 @@ public UserPreferences Clone() Theme = Theme, CurveColors = new List(CurveColors), ShowMotorRatedSpeedLine = ShowMotorRatedSpeedLine, - ShowVoltageMaxSpeedLine = ShowVoltageMaxSpeedLine + ShowVoltageMaxSpeedLine = ShowVoltageMaxSpeedLine, + TorqueSnapIncrement = TorqueSnapIncrement, + SketchDragBandPercent = SketchDragBandPercent }; } } diff --git a/src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs b/src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs index 6afc511..6e1a90d 100644 --- a/src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs +++ b/src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs @@ -98,6 +98,17 @@ public partial class ChartViewModel : ViewModelBase private string? _underlayImagePath; private double _underlayAnchorX; private double _underlayAnchorY; + private string? _sketchEditSeriesName; + private decimal _torqueSnapIncrement = 0.2m; + private double _dragSketchBandFraction = 0.05; + private bool _showTooltips = true; + private bool _tooltipStateBeforeSketch = true; + private double _zoomLevel = 1.0; + private bool _baseLimitsCaptured; + private double _baseXMin; + private double _baseXMax; + private double _baseYMin; + private double _baseYMax; [ObservableProperty] private ObservableCollection _series = []; @@ -468,6 +479,38 @@ partial void OnShowVoltageMaxSpeedLineChanged(bool value) /// public static bool EnableZoomPan => false; + /// + /// Gets or sets whether the data tooltip popup is shown when hovering + /// over data points on the chart. When disabled the tooltip is hidden. + /// This is automatically turned off when sketch-edit mode activates + /// and restored when sketch-edit mode deactivates. + /// + public bool ShowTooltips + { + get => _showTooltips; + set + { + if (_showTooltips == value) + { + return; + } + + _showTooltips = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ChartTooltipPosition)); + } + } + + /// + /// Gets the effective + /// to bind to the chart control. Returns Hidden when tooltips are + /// disabled; otherwise returns Top. + /// + public LiveChartsCore.Measure.TooltipPosition ChartTooltipPosition => + _showTooltips + ? LiveChartsCore.Measure.TooltipPosition.Top + : LiveChartsCore.Measure.TooltipPosition.Hidden; + /// /// Event raised when any series data point changes. /// @@ -1693,4 +1736,622 @@ private double RoundToNiceValue(double value, bool roundUp, bool isPowerValue = return result; } + + /// + /// Gets the name of the curve series currently in sketch-edit mode, + /// or null if no series is being sketch-edited. + /// Only one series can be in sketch-edit mode at a time. + /// + public string? SketchEditSeriesName + { + get => _sketchEditSeriesName; + private set + { + if (string.Equals(_sketchEditSeriesName, value, StringComparison.Ordinal)) + { + return; + } + + _sketchEditSeriesName = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsSketchEditActive)); + } + } + + /// + /// Gets a value indicating whether sketch-edit mode is currently active + /// for any curve series. + /// + public bool IsSketchEditActive => !string.IsNullOrEmpty(_sketchEditSeriesName); + + /// + /// Gets or sets the torque snap increment used during sketch editing. + /// The torque value is rounded to the nearest multiple of this value. + /// Must be greater than zero. Defaults to 0.2. + /// + public decimal TorqueSnapIncrement + { + get => _torqueSnapIncrement; + set + { + if (value <= 0) + { + return; + } + + if (_torqueSnapIncrement == value) + { + return; + } + + _torqueSnapIncrement = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the drag sketch activation band as a fraction of the + /// nearest point spacing. Used only during click-hold-drag sketching. + /// For example, 0.05 means 5% of spacing. + /// + public double DragSketchBandFraction + { + get => _dragSketchBandFraction; + set + { + if (value <= 0 || value > 1) + { + return; + } + + if (Math.Abs(_dragSketchBandFraction - value) < 0.0001) + { + return; + } + + _dragSketchBandFraction = value; + OnPropertyChanged(); + } + } + + /// + /// Activates sketch-edit mode for the specified curve series. + /// Any previously active sketch-edit series is deactivated first. + /// + /// The name of the curve series to edit. + /// Must match a series in the current voltage configuration. + public void SetSketchEditSeries(string seriesName) + { + if (string.IsNullOrWhiteSpace(seriesName)) + { + return; + } + + if (_currentVoltage is null) + { + return; + } + + var curve = _currentVoltage.Curves.FirstOrDefault(c => c.Name == seriesName); + if (curve is null) + { + return; + } + + // Locked curves cannot be sketch-edited. + if (curve.Locked) + { + return; + } + + // Save the current tooltip state so we can restore it later, + // then hide tooltips while sketching. + if (!IsSketchEditActive) + { + _tooltipStateBeforeSketch = _showTooltips; + ShowTooltips = false; + + // Only capture base limits if not already zoomed in; otherwise + // we would overwrite the real base with the zoomed-in limits, + // preventing the user from zooming back out. + if (Math.Abs(_zoomLevel - 1.0) < 0.001) + { + CaptureBaseAxisLimits(); + } + } + + SketchEditSeriesName = seriesName; + } + + /// + /// Deactivates sketch-edit mode. Restores the tooltip visibility + /// to the state it had before sketch mode was activated. + /// Zoom state is preserved across sketch-mode toggling. + /// + public void ClearSketchEditSeries() + { + if (IsSketchEditActive) + { + ShowTooltips = _tooltipStateBeforeSketch; + } + + SketchEditSeriesName = null; + } + + /// + /// Applies a sketch point to the active sketch-edit curve series. + /// The value is snapped to the nearest + /// speed (RPM) data point in the series, and + /// is rounded to the nearest . + /// + /// The X coordinate in chart data space (RPM). + /// The Y coordinate in chart data space (torque). + /// true if a data point was modified; otherwise false. + public bool ApplySketchPoint(double chartX, double chartY) + { + return ApplySketchPoint(chartX, chartY, isDragStroke: false); + } + + /// + /// Applies a sketch point to the active sketch-edit curve series. + /// The value is snapped to the nearest + /// speed (RPM) data point in the series, and + /// is rounded to the nearest . + /// When is true, sketch edits use a + /// narrower horizontal activation band so freehand dragging traces + /// curves more smoothly instead of snapping across wide point regions. + /// + /// The X coordinate in chart data space (RPM). + /// The Y coordinate in chart data space (torque). + /// True when the point is from a continuous drag operation. + /// true if a data point was modified; otherwise false. + public bool ApplySketchPoint(double chartX, double chartY, bool isDragStroke) + { + if (!IsSketchEditActive || _currentVoltage is null) + { + return false; + } + + var curve = _currentVoltage.Curves.FirstOrDefault(c => c.Name == _sketchEditSeriesName); + if (curve is null || curve.Data.Count == 0) + { + return false; + } + + if (curve.Locked) + { + ClearSketchEditSeries(); + return false; + } + + // Snap X to the nearest speed data point. + var nearestIndex = FindNearestSpeedIndex(curve, chartX); + if (nearestIndex < 0) + { + return false; + } + + if (isDragStroke && !IsWithinDragSketchBand(curve, nearestIndex, chartX, _dragSketchBandFraction)) + { + return false; + } + + // Snap Y to the nearest torque increment. + var snappedTorque = SnapTorque((decimal)chartY, _torqueSnapIncrement); + + var point = curve.Data[nearestIndex]; + + // No change needed if the torque is already at the snapped value. + if (point.Torque == snappedTorque) + { + return false; + } + + if (_undoStack is not null) + { + var command = new EditPointCommand(curve, nearestIndex, point.Rpm, snappedTorque); + _undoStack.PushAndExecute(command); + } + else + { + point.Torque = snappedTorque; + } + + // Update the cached observable point so the chart line updates + // without a full rebuild. + if (_seriesDataCache.TryGetValue(curve.Name, out var cachedPoints) + && nearestIndex < cachedPoints.Count) + { + cachedPoints[nearestIndex].Y = (double)snappedTorque; + } + + DataChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + internal static bool IsWithinDragSketchBand(Curve curve, int index, double rpm, double bandFraction) + { + if (index < 0 || index >= curve.Data.Count) + { + return false; + } + + if (bandFraction <= 0) + { + return false; + } + + var targetRpm = (double)curve.Data[index].Rpm; + var nearestNeighborSpacing = GetNearestNeighborSpacing(curve, index); + if (nearestNeighborSpacing <= 0) + { + return true; + } + + var maxDistance = nearestNeighborSpacing * bandFraction; + return Math.Abs(rpm - targetRpm) <= maxDistance; + } + + private static double GetNearestNeighborSpacing(Curve curve, int index) + { + var hasLeft = index > 0; + var hasRight = index < curve.Data.Count - 1; + + if (!hasLeft && !hasRight) + { + return 0; + } + + var leftSpacing = hasLeft + ? Math.Abs((double)(curve.Data[index].Rpm - curve.Data[index - 1].Rpm)) + : double.MaxValue; + var rightSpacing = hasRight + ? Math.Abs((double)(curve.Data[index + 1].Rpm - curve.Data[index].Rpm)) + : double.MaxValue; + + var spacing = Math.Min(leftSpacing, rightSpacing); + return spacing == double.MaxValue ? 0 : spacing; + } + + /// + /// Finds the index of the data point in + /// whose RPM is closest to . + /// + internal static int FindNearestSpeedIndex(Curve curve, double rpm) + { + if (curve.Data.Count == 0) + { + return -1; + } + + var bestIndex = 0; + var bestDist = Math.Abs((double)curve.Data[0].Rpm - rpm); + + for (var i = 1; i < curve.Data.Count; i++) + { + var dist = Math.Abs((double)curve.Data[i].Rpm - rpm); + if (dist < bestDist) + { + bestDist = dist; + bestIndex = i; + } + } + + return bestIndex; + } + + /// + /// Rounds to the nearest multiple of + /// . + /// + internal static decimal SnapTorque(decimal torque, decimal increment) + { + if (increment <= 0) + { + return torque; + } + + return Math.Round(torque / increment, MidpointRounding.AwayFromZero) * increment; + } + + /// + /// Current chart zoom magnification level. + /// 1.0 means no zoom; values greater than 1.0 mean we are zoomed in. + /// + public double ZoomLevel => _zoomLevel; + + /// + /// The base (unzoomed) X-axis minimum limit, captured before the + /// first zoom action. Used by the view to position the underlay + /// image correctly during zoom. + /// + public double BaseXMin => _baseXMin; + + /// + /// The base (unzoomed) X-axis maximum limit. + /// + public double BaseXMax => _baseXMax; + + /// + /// The base (unzoomed) Y-axis minimum limit. + /// + public double BaseYMin => _baseYMin; + + /// + /// The base (unzoomed) Y-axis maximum limit. + /// + public double BaseYMax => _baseYMax; + + /// + /// Whether the base axis limits have been captured for zoom calculations. + /// Used by the view to decide whether to apply zoom-based underlay scaling. + /// + public bool BaseLimitsCaptured => _baseLimitsCaptured; + + /// + /// Zoom level expressed as a percentage for display (e.g. 100 = no zoom, + /// 200 = 2× magnification). Rounded to the nearest integer. + /// + public int ZoomPercentage => (int)Math.Round(_zoomLevel * 100.0); + + /// + /// Zoom level as a slider-friendly value. Setting this adjusts the + /// zoom around the current viewport centre. + /// + public double ZoomSliderValue + { + get => _zoomLevel; + set + { + var clamped = Math.Clamp(value, 1.0, 20.0); + if (Math.Abs(_zoomLevel - clamped) < 0.001) + { + return; + } + + if (XAxes.Length == 0 || YAxes.Length == 0) + { + return; + } + + if (!_baseLimitsCaptured) + { + CaptureBaseAxisLimits(); + } + + _zoomLevel = clamped; + + // Zoom around the centre of the current viewport. + var centreX = XAxes.Length > 0 + ? ((XAxes[0].MinLimit ?? _baseXMin) + (XAxes[0].MaxLimit ?? _baseXMax)) / 2.0 + : (_baseXMin + _baseXMax) / 2.0; + var centreY = YAxes.Length > 0 + ? ((YAxes[0].MinLimit ?? _baseYMin) + (YAxes[0].MaxLimit ?? _baseYMax)) / 2.0 + : (_baseYMin + _baseYMax) / 2.0; + + ApplyZoomAroundPoint(centreX, centreY); + OnPropertyChanged(nameof(ZoomLevel)); + OnPropertyChanged(nameof(ZoomPercentage)); + OnPropertyChanged(nameof(ZoomSliderValue)); + } + } + + /// + /// Applies a zoom step relative to the given data-space focus point. + /// A positive zooms in; negative zooms out. + /// Works at any time when axes are present. + /// + /// Focus X in data space (RPM). + /// Focus Y in data space (Torque). + /// Zoom delta; positive zooms in, negative zooms out. + public void ApplyZoom(double focusX, double focusY, double delta) + { + if (XAxes.Length == 0 || YAxes.Length == 0) + { + return; + } + + // Capture the base (unzoomed) axis limits on the first zoom action. + if (!_baseLimitsCaptured) + { + CaptureBaseAxisLimits(); + } + + // Compute the new zoom level with a clamped range [1, 20]. + const double zoomFactor = 0.15; + var multiplier = delta > 0 ? (1.0 - zoomFactor) : (1.0 + zoomFactor); + _zoomLevel = Math.Clamp(_zoomLevel / multiplier, 1.0, 20.0); + + ApplyZoomAroundPoint(focusX, focusY); + OnPropertyChanged(nameof(ZoomLevel)); + OnPropertyChanged(nameof(ZoomPercentage)); + OnPropertyChanged(nameof(ZoomSliderValue)); + } + + /// + /// Resets the chart zoom to the full (unzoomed) view. + /// Can be triggered by the user via = key or middle-double-click. + /// + public void ResetZoom() + { + if (Math.Abs(_zoomLevel - 1.0) < 0.001) + { + return; + } + + _zoomLevel = 1.0; + _baseLimitsCaptured = false; + + if (XAxes.Length > 0) + { + XAxes[0].MinLimit = _baseXMin; + XAxes[0].MaxLimit = _baseXMax; + } + + if (YAxes.Length > 0) + { + YAxes[0].MinLimit = _baseYMin; + YAxes[0].MaxLimit = _baseYMax; + } + + OnPropertyChanged(nameof(ZoomLevel)); + OnPropertyChanged(nameof(ZoomPercentage)); + OnPropertyChanged(nameof(ZoomSliderValue)); + OnPropertyChanged(nameof(XAxes)); + OnPropertyChanged(nameof(YAxes)); + } + + /// + /// Pans the zoomed viewport by the given data-space deltas. + /// Only effective when zoomed in (ZoomLevel > 1). The viewport + /// is clamped to the base axis limits. + /// + /// Pan amount in data-space X units (positive = pan right). + /// Pan amount in data-space Y units (positive = pan up). + public void PanBy(double deltaX, double deltaY) + { + if (Math.Abs(_zoomLevel - 1.0) < 0.001) + { + return; + } + + if (XAxes.Length == 0 || YAxes.Length == 0) + { + return; + } + + var curXMin = XAxes[0].MinLimit ?? _baseXMin; + var curXMax = XAxes[0].MaxLimit ?? _baseXMax; + var curYMin = YAxes[0].MinLimit ?? _baseYMin; + var curYMax = YAxes[0].MaxLimit ?? _baseYMax; + + var newXMin = curXMin - deltaX; + var newXMax = curXMax - deltaX; + var newYMin = curYMin - deltaY; + var newYMax = curYMax - deltaY; + + // Clamp to base limits. + if (newXMin < _baseXMin) + { + var shift = _baseXMin - newXMin; + newXMin += shift; + newXMax += shift; + } + + if (newXMax > _baseXMax) + { + var shift = newXMax - _baseXMax; + newXMin -= shift; + newXMax -= shift; + } + + if (newYMin < _baseYMin) + { + var shift = _baseYMin - newYMin; + newYMin += shift; + newYMax += shift; + } + + if (newYMax > _baseYMax) + { + var shift = newYMax - _baseYMax; + newYMin -= shift; + newYMax -= shift; + } + + XAxes[0].MinLimit = newXMin; + XAxes[0].MaxLimit = newXMax; + YAxes[0].MinLimit = newYMin; + YAxes[0].MaxLimit = newYMax; + + OnPropertyChanged(nameof(XAxes)); + OnPropertyChanged(nameof(YAxes)); + } + + /// + /// Captures the current axis limits as the baseline for zoom calculations. + /// + private void CaptureBaseAxisLimits() + { + if (XAxes.Length > 0) + { + _baseXMin = XAxes[0].MinLimit ?? 0; + _baseXMax = XAxes[0].MaxLimit ?? 6000; + } + + if (YAxes.Length > 0) + { + _baseYMin = YAxes[0].MinLimit ?? 0; + _baseYMax = YAxes[0].MaxLimit ?? 100; + } + + _baseLimitsCaptured = true; + } + + /// + /// Recomputes the X and Y axis limits for the current zoom level + /// centred on the given data-space focus point. + /// + private void ApplyZoomAroundPoint(double focusX, double focusY) + { + var fullWidth = _baseXMax - _baseXMin; + var fullHeight = _baseYMax - _baseYMin; + + var newWidth = fullWidth / _zoomLevel; + var newHeight = fullHeight / _zoomLevel; + + // Fraction of the full range where the focus point sits. + var fx = fullWidth > 0 ? (focusX - _baseXMin) / fullWidth : 0.5; + var fy = fullHeight > 0 ? (focusY - _baseYMin) / fullHeight : 0.5; + + fx = Math.Clamp(fx, 0, 1); + fy = Math.Clamp(fy, 0, 1); + + var newXMin = focusX - fx * newWidth; + var newXMax = focusX + (1 - fx) * newWidth; + var newYMin = focusY - fy * newHeight; + var newYMax = focusY + (1 - fy) * newHeight; + + // Clamp to base limits so the viewport never extends beyond + // the original data range. + if (newXMin < _baseXMin) + { + newXMin = _baseXMin; + newXMax = _baseXMin + newWidth; + } + + if (newXMax > _baseXMax) + { + newXMax = _baseXMax; + newXMin = _baseXMax - newWidth; + } + + if (newYMin < _baseYMin) + { + newYMin = _baseYMin; + newYMax = _baseYMin + newHeight; + } + + if (newYMax > _baseYMax) + { + newYMax = _baseYMax; + newYMin = _baseYMax - newHeight; + } + + if (XAxes.Length > 0) + { + XAxes[0].MinLimit = newXMin; + XAxes[0].MaxLimit = newXMax; + } + + if (YAxes.Length > 0) + { + YAxes[0].MinLimit = newYMin; + YAxes[0].MaxLimit = newYMax; + } + + // Notify so the chart and underlay update in sync. + OnPropertyChanged(nameof(XAxes)); + OnPropertyChanged(nameof(YAxes)); + } } diff --git a/src/MotorEditor.Avalonia/ViewModels/MainWindowViewModel.cs b/src/MotorEditor.Avalonia/ViewModels/MainWindowViewModel.cs index 9b49dbe..be6c4c5 100644 --- a/src/MotorEditor.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/MotorEditor.Avalonia/ViewModels/MainWindowViewModel.cs @@ -957,9 +957,11 @@ public MainWindowViewModel() WireEditingCoordinator(); WireUndoInfrastructure(); WireDirectoryBrowserIntegration(); + _userPreferencesService.PreferencesChanged += OnUserPreferencesChanged; // Load saved power curves preference chartViewModel.ShowPowerCurves = _settingsStore.LoadBool("ShowPowerCurves", false); + ApplyUserPreferencesToChart(chartViewModel); } /// @@ -1010,9 +1012,11 @@ public MainWindowViewModel(IUserPreferencesService userPreferencesService) WireEditingCoordinator(); WireUndoInfrastructure(); WireDirectoryBrowserIntegration(); + _userPreferencesService.PreferencesChanged += OnUserPreferencesChanged; // Load saved power curves preference chartViewModel.ShowPowerCurves = _settingsStore.LoadBool("ShowPowerCurves", false); + ApplyUserPreferencesToChart(chartViewModel); } public MainWindowViewModel(IFileService fileService, ICurveGeneratorService curveGeneratorService) @@ -1053,9 +1057,11 @@ public MainWindowViewModel(IFileService fileService, ICurveGeneratorService curv WireEditingCoordinator(); WireUndoInfrastructure(); WireDirectoryBrowserIntegration(); + _userPreferencesService.PreferencesChanged += OnUserPreferencesChanged; // Load saved power curves preference chartViewModel.ShowPowerCurves = _settingsStore.LoadBool("ShowPowerCurves", false); + ApplyUserPreferencesToChart(chartViewModel); } /// @@ -1107,11 +1113,13 @@ public MainWindowViewModel( WireEditingCoordinator(); WireUndoInfrastructure(); WireDirectoryBrowserIntegration(); + _userPreferencesService.PreferencesChanged += OnUserPreferencesChanged; // Load saved preferences ChartViewModel!.ShowPowerCurves = _settingsStore.LoadBool("ShowPowerCurves", false); ChartViewModel!.ShowMotorRatedSpeedLine = _settingsStore.LoadBool("ShowMotorRatedSpeedLine", true); ChartViewModel!.ShowVoltageMaxSpeedLine = _settingsStore.LoadBool("ShowVoltageMaxSpeedLine", true); + ApplyUserPreferencesToChart(ChartViewModel); } private void WireDirectoryBrowserIntegration() @@ -1172,6 +1180,7 @@ private DocumentTab CreateNewTab() tab.ChartViewModel.ShowPowerCurves = _settingsStore.LoadBool("ShowPowerCurves", false); tab.ChartViewModel.ShowMotorRatedSpeedLine = _settingsStore.LoadBool("ShowMotorRatedSpeedLine", true); tab.ChartViewModel.ShowVoltageMaxSpeedLine = _settingsStore.LoadBool("ShowVoltageMaxSpeedLine", true); + ApplyUserPreferencesToChart(tab.ChartViewModel); tab.ChartViewModel.DataChanged += (s, e) => tab.MarkDirty(); tab.CurveDataTableViewModel.DataChanged += (s, e) => @@ -3903,6 +3912,13 @@ private void ToggleSeriesLock(Curve? series) ActiveTab?.UndoStack.PushAndExecute(command); UpdateDirtyFromUndoDepth(); + if (newLocked && + ChartViewModel is not null && + string.Equals(ChartViewModel.SketchEditSeriesName, series.Name, StringComparison.Ordinal)) + { + ChartViewModel.ClearSketchEditSeries(); + } + // Refresh the curve data table so that the DataGrid columns // are rebuilt with the correct read-only state for the // affected series. This keeps the editor behavior and @@ -4009,6 +4025,37 @@ private async Task ShowPreferencesAsync() } } + private void OnUserPreferencesChanged(object? sender, EventArgs e) + { + foreach (var tab in Tabs) + { + if (tab.ChartViewModel is not null) + { + ApplyUserPreferencesToChart(tab.ChartViewModel); + } + } + } + + private void ApplyUserPreferencesToChart(ChartViewModel? chartViewModel) + { + if (chartViewModel is null) + { + return; + } + + var snapIncrement = _userPreferencesService.Preferences.TorqueSnapIncrement; + if (snapIncrement > 0) + { + chartViewModel.TorqueSnapIncrement = snapIncrement; + } + + var dragBandPercent = _userPreferencesService.Preferences.SketchDragBandPercent; + if (dragBandPercent > 0) + { + chartViewModel.DragSketchBandFraction = (double)(dragBandPercent / 100m); + } + } + /// /// Called when any property on the motor changes. /// diff --git a/src/MotorEditor.Avalonia/ViewModels/PreferencesViewModel.cs b/src/MotorEditor.Avalonia/ViewModels/PreferencesViewModel.cs index f59ccb9..467a1ed 100644 --- a/src/MotorEditor.Avalonia/ViewModels/PreferencesViewModel.cs +++ b/src/MotorEditor.Avalonia/ViewModels/PreferencesViewModel.cs @@ -26,6 +26,12 @@ public partial class PreferencesViewModel : ViewModelBase [ObservableProperty] private string? _selectedCurveColor; + [ObservableProperty] + private decimal _torqueSnapIncrement; + + [ObservableProperty] + private decimal _sketchDragBandPercent; + /// /// Creates a new PreferencesViewModel. /// @@ -38,6 +44,8 @@ public PreferencesViewModel(IUserPreferencesService preferencesService) _decimalPrecision = _preferencesService.Preferences.DecimalPrecision; _theme = _preferencesService.Preferences.Theme; _curveColors = new ObservableCollection(_preferencesService.Preferences.CurveColors); + _torqueSnapIncrement = _preferencesService.Preferences.TorqueSnapIncrement; + _sketchDragBandPercent = _preferencesService.Preferences.SketchDragBandPercent; } /// @@ -54,6 +62,8 @@ private void Save() _preferencesService.Preferences.CurveColors.Add(color); } + _preferencesService.Preferences.TorqueSnapIncrement = TorqueSnapIncrement; + _preferencesService.Preferences.SketchDragBandPercent = SketchDragBandPercent; _preferencesService.SavePreferences(); } diff --git a/src/MotorEditor.Avalonia/Views/ChartView.axaml b/src/MotorEditor.Avalonia/Views/ChartView.axaml index a2fab72..a9c174f 100644 --- a/src/MotorEditor.Avalonia/Views/ChartView.axaml +++ b/src/MotorEditor.Avalonia/Views/ChartView.axaml @@ -32,7 +32,7 @@ Series="{Binding Series}" XAxes="{Binding XAxes}" YAxes="{Binding YAxes}" - TooltipPosition="Top" + TooltipPosition="{Binding ChartTooltipPosition}" ZoomMode="None" AnimationsSpeed="00:00:00.000"> @@ -74,6 +74,44 @@ VerticalAlignment="Top" Margin="0,20,0,0" IsHitTestVisible="False"/> + + + + + + + + + + + + + + + + + diff --git a/src/MotorEditor.Avalonia/Views/ChartView.axaml.cs b/src/MotorEditor.Avalonia/Views/ChartView.axaml.cs index 360c989..e70ca50 100644 --- a/src/MotorEditor.Avalonia/Views/ChartView.axaml.cs +++ b/src/MotorEditor.Avalonia/Views/ChartView.axaml.cs @@ -23,12 +23,22 @@ public partial class ChartView : UserControl { private ChartViewModel? _chartViewModel; private bool _isUnderlayDragging; + private bool _isSketchDragging; + private bool _isPanning; + private Point _panStart; private Point _dragStart; private double _startOffsetX; private double _startOffsetY; private Rect _underlayBounds = new(); private bool _underlayLayoutQueued; + /// + /// Tracks the last known pointer position over the chart in + /// data-space coordinates, used as the focus point for keyboard + /// zoom (+/- keys). + /// + private Point _lastPointerDataPosition; + /// /// Creates a new ChartView instance. /// @@ -43,11 +53,25 @@ public ChartView() TorqueChart.LayoutUpdated += (_, _) => QueueUnderlayLayout(); TorqueChart.UpdateFinished += _ => QueueUnderlayLayout(); + // Use tunneling (handledEventsToo: true) for wheel events so we + // intercept them before LiveCharts processes them internally. + TorqueChart.AddHandler( + PointerWheelChangedEvent, + OnChartPointerWheelChanged, + Avalonia.Interactivity.RoutingStrategies.Tunnel, + handledEventsToo: true); + // Handle mouse clicks on the chart to support basic point // selection. This wiring keeps the interaction logic in the // view while delegating selection state to the EditingCoordinator // via the ChartViewModel. TorqueChart.PointerPressed += OnChartPointerPressed; + + // Wire keyboard zoom to the chart control itself and ensure + // it can receive focus when the pointer enters the chart area. + TorqueChart.Focusable = true; + TorqueChart.KeyDown += OnChartKeyDown; + TorqueChart.PointerEntered += OnChartPointerEntered; } protected override void OnDataContextChanged(EventArgs e) @@ -67,6 +91,15 @@ protected override void OnDataContextChanged(EventArgs e) QueueUnderlayLayout(); } + /// + /// Gives the chart control keyboard focus when the pointer enters, + /// so that +/- zoom keys work without requiring a click first. + /// + private void OnChartPointerEntered(object? sender, PointerEventArgs e) + { + TorqueChart.Focus(); + } + private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e) { if (TryBeginUnderlayDrag(e)) @@ -81,11 +114,45 @@ private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e) } var pointerPoint = e.GetCurrentPoint(TorqueChart); + + // Middle-button double-click resets zoom to unzoomed view. + if (pointerPoint.Properties.IsMiddleButtonPressed && e.ClickCount == 2) + { + vm.ResetZoom(); + QueueUnderlayLayout(); + e.Handled = true; + return; + } + + // Middle-button single press begins a pan drag. + if (pointerPoint.Properties.IsMiddleButtonPressed && e.ClickCount <= 1) + { + _isPanning = true; + _panStart = e.GetPosition(TorqueChart); + e.Pointer.Capture(TorqueChart); + e.Handled = true; + return; + } + if (!pointerPoint.Properties.IsLeftButtonPressed) { return; } + // When sketch-edit mode is active, apply the sketch point + // and begin tracking the drag. + if (vm.IsSketchEditActive) + { + if (TryApplySketchAtPixel(vm, e.GetPosition(TorqueChart))) + { + _isSketchDragging = true; + e.Pointer.Capture(TorqueChart); + e.Handled = true; + } + + return; + } + // Use the underlying chart to find the nearest point under the // cursor within a reasonable radius. var chart = TorqueChart.CoreChart; @@ -143,7 +210,44 @@ private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e) private void OnChartPointerMoved(object? sender, PointerEventArgs e) { - if (!_isUnderlayDragging || _chartViewModel is null) + if (_chartViewModel is null) + { + return; + } + + // Track the data-space position for keyboard zoom focus. + var pixelPos = e.GetPosition(TorqueChart); + if (TryPixelToDataSpace(pixelPos, out var dataX, out var dataY)) + { + _lastPointerDataPosition = new Point(dataX, dataY); + } + + // Middle-mouse pan: convert pixel delta to data-space delta. + if (_isPanning) + { + var currentPos = e.GetPosition(TorqueChart); + var pixelDelta = currentPos - _panStart; + _panStart = currentPos; + + if (TryPixelDeltaToDataDelta(pixelDelta, out var dataDeltaX, out var dataDeltaY)) + { + _chartViewModel.PanBy(dataDeltaX, dataDeltaY); + QueueUnderlayLayout(); + } + + e.Handled = true; + return; + } + + // Continue sketch-edit drag: apply sketch point as the mouse moves. + if (_isSketchDragging && _chartViewModel.IsSketchEditActive) + { + TryApplySketchAtPixel(_chartViewModel, pixelPos, isDragStroke: true); + e.Handled = true; + return; + } + + if (!_isUnderlayDragging) { return; } @@ -175,6 +279,22 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e) private void OnChartPointerReleased(object? sender, PointerEventArgs e) { + if (_isPanning) + { + _isPanning = false; + e.Pointer.Capture(null); + e.Handled = true; + return; + } + + if (_isSketchDragging) + { + _isSketchDragging = false; + e.Pointer.Capture(null); + e.Handled = true; + return; + } + if (!_isUnderlayDragging) { return; @@ -188,6 +308,8 @@ private void OnChartPointerReleased(object? sender, PointerEventArgs e) private void OnChartPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) { _isUnderlayDragging = false; + _isSketchDragging = false; + _isPanning = false; } private void OnChartViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -245,6 +367,232 @@ _chartViewModel.UnderlayImage is null || return true; } + /// + /// Converts a pixel position on the chart to data-space coordinates + /// and forwards it to the view model's sketch-edit logic. + /// + private bool TryApplySketchAtPixel(ChartViewModel vm, Point pixelPosition, bool isDragStroke = false) + { + var chart = TorqueChart.CoreChart; + if (chart is null) + { + return false; + } + + var drawLocation = chart.DrawMarginLocation; + var drawSize = chart.DrawMarginSize; + if (drawSize.Width <= 0 || drawSize.Height <= 0) + { + return false; + } + + // Only apply when the pointer is within the draw margin area. + if (pixelPosition.X < drawLocation.X || + pixelPosition.X > drawLocation.X + drawSize.Width || + pixelPosition.Y < drawLocation.Y || + pixelPosition.Y > drawLocation.Y + drawSize.Height) + { + return false; + } + + // Use the primary X and Y axes to convert pixel to data coordinates. + var xAxes = vm.XAxes; + var yAxes = vm.YAxes; + if (xAxes.Length == 0 || yAxes.Length == 0) + { + return false; + } + + var xAxis = xAxes[0]; + var yAxis = yAxes[0]; + + var xMin = xAxis.MinLimit ?? 0; + var xMax = xAxis.MaxLimit ?? 1; + var yMin = yAxis.MinLimit ?? 0; + var yMax = yAxis.MaxLimit ?? 1; + + if (xMax - xMin <= 0 || yMax - yMin <= 0) + { + return false; + } + + // Linear interpolation from pixel space to data space. + var chartX = xMin + (pixelPosition.X - drawLocation.X) / drawSize.Width * (xMax - xMin); + var chartY = yMax - (pixelPosition.Y - drawLocation.Y) / drawSize.Height * (yMax - yMin); + + return vm.ApplySketchPoint(chartX, chartY, isDragStroke); + } + + /// + /// Converts a pixel position on the chart to data-space coordinates + /// using the current axis limits. + /// + private bool TryPixelToDataSpace(Point pixelPosition, out double dataX, out double dataY) + { + dataX = 0; + dataY = 0; + + if (_chartViewModel is null) + { + return false; + } + + var chart = TorqueChart.CoreChart; + if (chart is null) + { + return false; + } + + var drawLocation = chart.DrawMarginLocation; + var drawSize = chart.DrawMarginSize; + if (drawSize.Width <= 0 || drawSize.Height <= 0) + { + return false; + } + + var xAxes = _chartViewModel.XAxes; + var yAxes = _chartViewModel.YAxes; + if (xAxes.Length == 0 || yAxes.Length == 0) + { + return false; + } + + var xMin = xAxes[0].MinLimit ?? 0; + var xMax = xAxes[0].MaxLimit ?? 1; + var yMin = yAxes[0].MinLimit ?? 0; + var yMax = yAxes[0].MaxLimit ?? 1; + + if (xMax - xMin <= 0 || yMax - yMin <= 0) + { + return false; + } + + dataX = xMin + (pixelPosition.X - drawLocation.X) / drawSize.Width * (xMax - xMin); + dataY = yMax - (pixelPosition.Y - drawLocation.Y) / drawSize.Height * (yMax - yMin); + return true; + } + + /// + /// Converts a pixel delta (movement) to data-space delta for panning. + /// + private bool TryPixelDeltaToDataDelta(Point pixelDelta, out double deltaX, out double deltaY) + { + deltaX = 0; + deltaY = 0; + + if (_chartViewModel is null) + { + return false; + } + + var chart = TorqueChart.CoreChart; + if (chart is null) + { + return false; + } + + var drawSize = chart.DrawMarginSize; + if (drawSize.Width <= 0 || drawSize.Height <= 0) + { + return false; + } + + var xAxes = _chartViewModel.XAxes; + var yAxes = _chartViewModel.YAxes; + if (xAxes.Length == 0 || yAxes.Length == 0) + { + return false; + } + + var xRange = (xAxes[0].MaxLimit ?? 1) - (xAxes[0].MinLimit ?? 0); + var yRange = (yAxes[0].MaxLimit ?? 1) - (yAxes[0].MinLimit ?? 0); + + if (xRange <= 0 || yRange <= 0) + { + return false; + } + + deltaX = pixelDelta.X / drawSize.Width * xRange; + // Y axis is inverted: positive pixel delta = downward = negative data Y. + deltaY = -(pixelDelta.Y / drawSize.Height * yRange); + return true; + } + + /// + /// Handles Ctrl+mouse wheel / Ctrl+touchpad scroll to zoom the chart. + /// Also handles Ctrl+touchpad pinch-to-zoom (reported as wheel events + /// with Ctrl modifier on most platforms). + /// + private void OnChartPointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (_chartViewModel is null) + { + return; + } + + // Only zoom when the Ctrl key is held. + if (!e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + return; + } + + var pixelPos = e.GetPosition(TorqueChart); + if (!TryPixelToDataSpace(pixelPos, out var focusX, out var focusY)) + { + return; + } + + // Delta.Y is positive for scroll-up (zoom in) and negative for scroll-down (zoom out). + _chartViewModel.ApplyZoom(focusX, focusY, e.Delta.Y); + QueueUnderlayLayout(); + e.Handled = true; + } + + /// + /// Handles keyboard zoom: + to zoom in, - to zoom out, + /// = to reset zoom to the unzoomed view. + /// + private void OnChartKeyDown(object? sender, KeyEventArgs e) + { + if (_chartViewModel is null) + { + return; + } + + // + key (Shift+= on most keyboards) or numpad + — zoom in. + if ((e.Key is Key.OemPlus && e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + || e.Key is Key.Add) + { + _chartViewModel.ApplyZoom( + _lastPointerDataPosition.X, + _lastPointerDataPosition.Y, + 1.0); + QueueUnderlayLayout(); + e.Handled = true; + return; + } + + // - key or numpad - — zoom out. + if (e.Key is Key.OemMinus or Key.Subtract) + { + _chartViewModel.ApplyZoom( + _lastPointerDataPosition.X, + _lastPointerDataPosition.Y, + -1.0); + QueueUnderlayLayout(); + e.Handled = true; + return; + } + + // = key (OemPlus without Shift) — reset zoom. + if (e.Key is Key.OemPlus && !e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + _chartViewModel.ResetZoom(); + QueueUnderlayLayout(); + e.Handled = true; + } + } + private void QueueUnderlayLayout() { if (_underlayLayoutQueued) @@ -291,11 +639,79 @@ private void UpdateUnderlayLayoutCore() return; } - var imageWidth = drawSize.Width * _chartViewModel.UnderlayXScale; - var imageHeight = drawSize.Height * _chartViewModel.UnderlayYScale; - - var x = drawLocation.X + (_chartViewModel.UnderlayOffsetX * drawSize.Width); - var y = drawLocation.Y + drawSize.Height - imageHeight - (_chartViewModel.UnderlayOffsetY * drawSize.Height); + // Base image size and position in the unzoomed coordinate system. + var baseImageWidth = drawSize.Width * _chartViewModel.UnderlayXScale; + var baseImageHeight = drawSize.Height * _chartViewModel.UnderlayYScale; + + var baseX = drawLocation.X + (_chartViewModel.UnderlayOffsetX * drawSize.Width); + var baseY = drawLocation.Y + drawSize.Height - baseImageHeight + - (_chartViewModel.UnderlayOffsetY * drawSize.Height); + + double imageWidth; + double imageHeight; + double x; + double y; + + // When zoom is active and base limits have been captured, scale + // the underlay image to keep it aligned with the chart data. + // The draw-margin pixel area stays the same but represents a + // narrower data range, so the image must grow proportionally + // and shift so that the visible slice matches. + if (_chartViewModel.BaseLimitsCaptured && _chartViewModel.ZoomLevel > 1.001) + { + var xAxes = _chartViewModel.XAxes; + var yAxes = _chartViewModel.YAxes; + if (xAxes.Length > 0 && yAxes.Length > 0) + { + var baseXRange = _chartViewModel.BaseXMax - _chartViewModel.BaseXMin; + var baseYRange = _chartViewModel.BaseYMax - _chartViewModel.BaseYMin; + var curXMin = xAxes[0].MinLimit ?? _chartViewModel.BaseXMin; + var curXMax = xAxes[0].MaxLimit ?? _chartViewModel.BaseXMax; + var curYMin = yAxes[0].MinLimit ?? _chartViewModel.BaseYMin; + var curYMax = yAxes[0].MaxLimit ?? _chartViewModel.BaseYMax; + var curXRange = curXMax - curXMin; + var curYRange = curYMax - curYMin; + + if (curXRange > 0 && curYRange > 0 && baseXRange > 0 && baseYRange > 0) + { + var xZoom = baseXRange / curXRange; + var yZoom = baseYRange / curYRange; + + imageWidth = baseImageWidth * xZoom; + imageHeight = baseImageHeight * yZoom; + + // Shift so the image data alignment is preserved. + // The base image left edge corresponds to a data-space X + // offset. We need to map that into the zoomed pixel space. + x = drawLocation.X + (baseX - drawLocation.X) * xZoom + - (curXMin - _chartViewModel.BaseXMin) / baseXRange * drawSize.Width * xZoom; + // Y axis is inverted (top of draw area = max data value). + y = drawLocation.Y + (baseY - drawLocation.Y) * yZoom + - (_chartViewModel.BaseYMax - curYMax) / baseYRange * drawSize.Height * yZoom; + } + else + { + imageWidth = baseImageWidth; + imageHeight = baseImageHeight; + x = baseX; + y = baseY; + } + } + else + { + imageWidth = baseImageWidth; + imageHeight = baseImageHeight; + x = baseX; + y = baseY; + } + } + else + { + imageWidth = baseImageWidth; + imageHeight = baseImageHeight; + x = baseX; + y = baseY; + } Canvas.SetLeft(UnderlayImage, x); Canvas.SetTop(UnderlayImage, y); diff --git a/src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml b/src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml index 8c2134a..ea20dcd 100644 --- a/src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml +++ b/src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml @@ -122,7 +122,7 @@ HorizontalAlignment="Center" Cursor="Hand" DoubleTapped="OnSeriesNameDoubleTapped"/> - + @@ -143,7 +143,20 @@ - + + + + + + + + + private void OnSketchEditToggleClick(object? sender, RoutedEventArgs e) + { + if (sender is not ToggleButton toggleButton || toggleButton.DataContext is not Curve series) + { + return; + } + + if (DataContext is not MainWindowViewModel viewModel || viewModel.ChartViewModel is null) + { + return; + } + + // Locked curves cannot be sketch-edited. + if (series.Locked) + { + toggleButton.IsChecked = false; + return; + } + + var chart = viewModel.ChartViewModel; + + if (string.Equals(chart.SketchEditSeriesName, series.Name, StringComparison.Ordinal)) + { + // Already the active sketch series — deactivate. + chart.ClearSketchEditSeries(); + toggleButton.IsChecked = false; + } + else + { + // Activate for this series (replaces any previous). + chart.SetSketchEditSeries(series.Name); + toggleButton.IsChecked = true; + } + + // Uncheck sibling sketch-edit toggle buttons so only one is active. + RefreshSketchEditToggles(chart.SketchEditSeriesName); + } + + /// + /// Walks the series header to ensure only the toggle button for + /// is checked. + /// + private void RefreshSketchEditToggles(string? activeSeriesName) + { + var headerScrollViewer = this.FindControl("HeaderScrollViewer"); + if (headerScrollViewer is null) + { + return; + } + + foreach (var toggle in headerScrollViewer.GetVisualDescendants().OfType()) + { + if (toggle.DataContext is not Curve curve) + { + continue; + } + + // Identify sketch-edit toggles by tooltip text to avoid + // interfering with other toggle buttons in the template. + if (toggle.GetValue(ToolTip.TipProperty) is not string tip || + !string.Equals(tip, "Toggle Sketch Edit", StringComparison.Ordinal)) + { + continue; + } + + toggle.IsChecked = string.Equals(curve.Name, activeSeriesName, StringComparison.Ordinal); + } + } + /// /// Handles delete series button click. /// diff --git a/src/MotorEditor.Avalonia/Views/MainWindow.axaml b/src/MotorEditor.Avalonia/Views/MainWindow.axaml index d0dbeaf..b1ffe7e 100644 --- a/src/MotorEditor.Avalonia/Views/MainWindow.axaml +++ b/src/MotorEditor.Avalonia/Views/MainWindow.axaml @@ -308,6 +308,9 @@ IsChecked="{Binding ChartViewModel.ShowVoltageMaxSpeedLine, Mode=TwoWay}" Command="{Binding ToggleShowVoltageMaxSpeedLineCommand}" CommandParameter="{Binding IsChecked, RelativeSource={RelativeSource Self}}"/> + diff --git a/src/MotorEditor.Avalonia/Views/PreferencesWindow.axaml b/src/MotorEditor.Avalonia/Views/PreferencesWindow.axaml index 62bec4e..5787ffe 100644 --- a/src/MotorEditor.Avalonia/Views/PreferencesWindow.axaml +++ b/src/MotorEditor.Avalonia/Views/PreferencesWindow.axaml @@ -77,6 +77,38 @@ MinWidth="100"/> + + + + + + + + + + + + { "#FF0000", "#00FF00" } + CurveColors = new List { "#FF0000", "#00FF00" }, + SketchDragBandPercent = 5m }; var clone = original.Clone(); @@ -162,11 +163,13 @@ public void UserPreferences_Clone_CreatesIndependentCopy() clone.DecimalPrecision = 5; clone.Theme = "Light"; clone.CurveColors.Add("#0000FF"); + clone.SketchDragBandPercent = 12m; // Original should remain unchanged Assert.Equal(3, original.DecimalPrecision); Assert.Equal("Dark", original.Theme); Assert.Equal(2, original.CurveColors.Count); + Assert.Equal(5m, original.SketchDragBandPercent); } [Fact] diff --git a/tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs b/tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs index 60ebe8f..60f4fde 100644 --- a/tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs +++ b/tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs @@ -996,4 +996,608 @@ public void TorqueAxisLabels_UseWholeNumbersForLargeValues(string torqueUnit) Assert.DoesNotContain(".", label100); Assert.DoesNotContain(".", label50); } + + // --- Sketch-edit tests --- + + [Fact] + public void SetSketchEditSeries_WithValidName_ActivatesSketchEdit() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + + viewModel.SetSketchEditSeries("Peak"); + + Assert.True(viewModel.IsSketchEditActive); + Assert.Equal("Peak", viewModel.SketchEditSeriesName); + } + + [Fact] + public void SetSketchEditSeries_WithInvalidName_DoesNotActivate() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + + viewModel.SetSketchEditSeries("NonExistent"); + + Assert.False(viewModel.IsSketchEditActive); + Assert.Null(viewModel.SketchEditSeriesName); + } + + [Fact] + public void SetSketchEditSeries_WithNullVoltage_DoesNotActivate() + { + var viewModel = new ChartViewModel(); + + viewModel.SetSketchEditSeries("Peak"); + + Assert.False(viewModel.IsSketchEditActive); + } + + [Fact] + public void ClearSketchEditSeries_DeactivatesSketchEdit() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + viewModel.SetSketchEditSeries("Peak"); + + viewModel.ClearSketchEditSeries(); + + Assert.False(viewModel.IsSketchEditActive); + Assert.Null(viewModel.SketchEditSeriesName); + } + + [Fact] + public void SetSketchEditSeries_ReplacesExisting() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + viewModel.SetSketchEditSeries("Peak"); + + viewModel.SetSketchEditSeries("Continuous"); + + Assert.Equal("Continuous", viewModel.SketchEditSeriesName); + } + + [Theory] + [InlineData(10.0, 0.2, 10.0)] + [InlineData(10.09, 0.2, 10.0)] + [InlineData(10.15, 0.2, 10.2)] + [InlineData(10.3, 0.2, 10.4)] + [InlineData(10.5, 0.5, 10.5)] + [InlineData(10.6, 0.5, 10.5)] + [InlineData(10.75, 0.5, 11.0)] + [InlineData(10.0, 1.0, 10.0)] + [InlineData(10.4, 1.0, 10.0)] + [InlineData(10.5, 1.0, 11.0)] + public void SnapTorque_RoundsToNearestIncrement(double torque, double increment, double expected) + { + var result = ChartViewModel.SnapTorque((decimal)torque, (decimal)increment); + + Assert.Equal((decimal)expected, result); + } + + [Fact] + public void SnapTorque_WithZeroIncrement_ReturnsTorqueUnchanged() + { + var result = ChartViewModel.SnapTorque(10.3m, 0m); + + Assert.Equal(10.3m, result); + } + + [Fact] + public void FindNearestSpeedIndex_ReturnsClosestIndex() + { + var curve = new Curve("Test"); + curve.InitializeData(1000, 50); + // Data points at 0%, 1%, 2%… of 1000 RPM → 0, 10, 20, 30… + + var index = ChartViewModel.FindNearestSpeedIndex(curve, 25); + + // Closest data point to 25 RPM is at index 3 (30 RPM) or index 2 (20 RPM). + // 25 is equidistant between 20 and 30, but the algorithm picks the first found with lower distance. + // At 20 RPM dist=5, at 30 RPM dist=5. Since loop iterates forward, the first match (index 2) stays. + Assert.Equal(2, index); // 20 RPM (closest or tied) + } + + [Fact] + public void FindNearestSpeedIndex_WithEmptyCurve_ReturnsNegativeOne() + { + var curve = new Curve("Empty"); + + var index = ChartViewModel.FindNearestSpeedIndex(curve, 100); + + Assert.Equal(-1, index); + } + + [Fact] + public void ApplySketchPoint_WhenNotActive_ReturnsFalse() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + + var result = viewModel.ApplySketchPoint(100, 50); + + Assert.False(result); + } + + [Fact] + public void ApplySketchPoint_WhenActive_UpdatesTorqueAndReturnsTrue() + { + var viewModel = new ChartViewModel(); + var voltage = CreateTestVoltage(); + viewModel.CurrentVoltage = voltage; + viewModel.SetSketchEditSeries("Peak"); + + // Pick an RPM that is close to a known data point. + var peakCurve = voltage.Curves.First(c => c.Name == "Peak"); + var targetRpm = (double)peakCurve.Data[5].Rpm; // 5% of 5000 = 250 RPM + var newTorque = 42.3; // Should snap to 42.4 (nearest 0.2) + + var result = viewModel.ApplySketchPoint(targetRpm, newTorque); + + Assert.True(result); + Assert.Equal(42.4m, peakCurve.Data[5].Torque); + } + + [Fact] + public void ApplySketchPoint_WithCustomSnapIncrement_SnapsCorrectly() + { + var viewModel = new ChartViewModel(); + var voltage = CreateTestVoltage(); + viewModel.CurrentVoltage = voltage; + viewModel.TorqueSnapIncrement = 0.5m; + viewModel.SetSketchEditSeries("Peak"); + + var peakCurve = voltage.Curves.First(c => c.Name == "Peak"); + var targetRpm = (double)peakCurve.Data[10].Rpm; + var newTorque = 42.3; + + viewModel.ApplySketchPoint(targetRpm, newTorque); + + Assert.Equal(42.5m, peakCurve.Data[10].Torque); + } + + [Fact] + public void ApplySketchPoint_WhenActiveSeriesLocked_ReturnsFalseAndClearsSketchMode() + { + var viewModel = new ChartViewModel(); + var voltage = CreateTestVoltage(); + viewModel.CurrentVoltage = voltage; + viewModel.SetSketchEditSeries("Peak"); + + var peakCurve = voltage.Curves.First(c => c.Name == "Peak"); + peakCurve.Locked = true; + + var result = viewModel.ApplySketchPoint((double)peakCurve.Data[5].Rpm, 42.3); + + Assert.False(result); + Assert.False(viewModel.IsSketchEditActive); + Assert.Null(viewModel.SketchEditSeriesName); + } + + [Fact] + public void ApplySketchPoint_DragStrokeOutsideNarrowBand_DoesNotUpdateTorque() + { + var viewModel = new ChartViewModel(); + var voltage = CreateTestVoltage(); + viewModel.CurrentVoltage = voltage; + viewModel.TorqueSnapIncrement = 0.1m; + viewModel.SetSketchEditSeries("Peak"); + + var peakCurve = voltage.Curves.First(c => c.Name == "Peak"); + var index = 10; + var originalTorque = peakCurve.Data[index].Torque; + var outsideBandRpm = (double)peakCurve.Data[index].Rpm + 3d; + + var result = viewModel.ApplySketchPoint(outsideBandRpm, 11.7, isDragStroke: true); + + Assert.False(result); + Assert.Equal(originalTorque, peakCurve.Data[index].Torque); + } + + [Fact] + public void ApplySketchPoint_DragStrokeInsideNarrowBand_UpdatesTorque() + { + var viewModel = new ChartViewModel(); + var voltage = CreateTestVoltage(); + viewModel.CurrentVoltage = voltage; + viewModel.TorqueSnapIncrement = 0.1m; + viewModel.SetSketchEditSeries("Peak"); + + var peakCurve = voltage.Curves.First(c => c.Name == "Peak"); + var index = 10; + var insideBandRpm = (double)peakCurve.Data[index].Rpm + 2d; + + var result = viewModel.ApplySketchPoint(insideBandRpm, 11.7, isDragStroke: true); + + Assert.True(result); + Assert.Equal(11.7m, peakCurve.Data[index].Torque); + } + + [Fact] + public void DragSketchBandFraction_DefaultsToFivePercent() + { + var viewModel = new ChartViewModel(); + + Assert.Equal(0.05, viewModel.DragSketchBandFraction, 3); + } + + [Fact] + public void DragSketchBandFraction_RejectsInvalidValues() + { + var viewModel = new ChartViewModel(); + + viewModel.DragSketchBandFraction = 0; + Assert.Equal(0.05, viewModel.DragSketchBandFraction, 3); + + viewModel.DragSketchBandFraction = 1.5; + Assert.Equal(0.05, viewModel.DragSketchBandFraction, 3); + } + + [Fact] + public void ApplySketchPoint_RaisesDataChanged() + { + var viewModel = new ChartViewModel(); + var voltage = CreateTestVoltage(); + viewModel.CurrentVoltage = voltage; + viewModel.SetSketchEditSeries("Peak"); + var eventRaised = false; + viewModel.DataChanged += (_, _) => eventRaised = true; + + var peakCurve = voltage.Curves.First(c => c.Name == "Peak"); + var targetRpm = (double)peakCurve.Data[5].Rpm; + viewModel.ApplySketchPoint(targetRpm, 42.3); + + Assert.True(eventRaised); + } + + [Fact] + public void ApplySketchPoint_WhenTorqueAlreadyAtSnappedValue_ReturnsFalse() + { + var viewModel = new ChartViewModel(); + var voltage = CreateTestVoltage(); + viewModel.CurrentVoltage = voltage; + viewModel.SetSketchEditSeries("Peak"); + + var peakCurve = voltage.Curves.First(c => c.Name == "Peak"); + // Set the target torque to the current snapped value. + var currentTorque = peakCurve.Data[5].Torque; + var targetRpm = (double)peakCurve.Data[5].Rpm; + + var result = viewModel.ApplySketchPoint(targetRpm, (double)currentTorque); + + Assert.False(result); + } + + [Fact] + public void TorqueSnapIncrement_DefaultsToPointTwo() + { + var viewModel = new ChartViewModel(); + + Assert.Equal(0.2m, viewModel.TorqueSnapIncrement); + } + + [Fact] + public void TorqueSnapIncrement_RejectsZeroOrNegative() + { + var viewModel = new ChartViewModel(); + + viewModel.TorqueSnapIncrement = 0m; + Assert.Equal(0.2m, viewModel.TorqueSnapIncrement); + + viewModel.TorqueSnapIncrement = -1m; + Assert.Equal(0.2m, viewModel.TorqueSnapIncrement); + } + + [Fact] + public void SetSketchEditSeries_WithWhitespaceName_DoesNotActivate() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + + viewModel.SetSketchEditSeries(" "); + + Assert.False(viewModel.IsSketchEditActive); + } + + // --- Tooltip toggle tests --- + + [Fact] + public void ShowTooltips_DefaultsToTrue() + { + var viewModel = new ChartViewModel(); + + Assert.True(viewModel.ShowTooltips); + } + + [Fact] + public void ChartTooltipPosition_ReturnsTopWhenEnabled() + { + var viewModel = new ChartViewModel(); + + Assert.Equal(LiveChartsCore.Measure.TooltipPosition.Top, viewModel.ChartTooltipPosition); + } + + [Fact] + public void ChartTooltipPosition_ReturnsHiddenWhenDisabled() + { + var viewModel = new ChartViewModel(); + + viewModel.ShowTooltips = false; + + Assert.Equal(LiveChartsCore.Measure.TooltipPosition.Hidden, viewModel.ChartTooltipPosition); + } + + [Fact] + public void SetSketchEditSeries_HidesTooltipsAutomatically() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + Assert.True(viewModel.ShowTooltips); + + viewModel.SetSketchEditSeries("Peak"); + + Assert.False(viewModel.ShowTooltips); + } + + [Fact] + public void ClearSketchEditSeries_RestoresTooltipState() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + viewModel.SetSketchEditSeries("Peak"); + Assert.False(viewModel.ShowTooltips); + + viewModel.ClearSketchEditSeries(); + + Assert.True(viewModel.ShowTooltips); + } + + [Fact] + public void ClearSketchEditSeries_RestoresTooltipToFalseIfWasDisabledBeforeSketch() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + viewModel.ShowTooltips = false; + viewModel.SetSketchEditSeries("Peak"); + + viewModel.ClearSketchEditSeries(); + + Assert.False(viewModel.ShowTooltips); + } + + // --- Sketch zoom tests --- + + [Fact] + public void ZoomLevel_DefaultsToOne() + { + var viewModel = new ChartViewModel(); + + Assert.Equal(1.0, viewModel.ZoomLevel); + } + + [Fact] + public void ApplyZoom_WithoutSketchMode_StillZooms() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + + viewModel.ApplyZoom(100, 50, 1.0); + + Assert.True(viewModel.ZoomLevel > 1.0); + } + + [Fact] + public void ApplyZoom_WhenActive_IncreasesZoomLevel() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + viewModel.SetSketchEditSeries("Peak"); + + viewModel.ApplyZoom(2500, 27, 1.0); + + Assert.True(viewModel.ZoomLevel > 1.0); + } + + [Fact] + public void ApplyZoom_ZoomOutDoesNotGoBelowOne() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + + viewModel.ApplyZoom(2500, 27, -1.0); + + Assert.Equal(1.0, viewModel.ZoomLevel); + } + + [Fact] + public void ResetZoom_RestoresZoomLevelToOne() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + viewModel.ApplyZoom(2500, 27, 1.0); + Assert.True(viewModel.ZoomLevel > 1.0); + + viewModel.ResetZoom(); + + Assert.Equal(1.0, viewModel.ZoomLevel); + } + + [Fact] + public void ClearSketchEditSeries_PreservesZoom() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + viewModel.SetSketchEditSeries("Peak"); + viewModel.ApplyZoom(2500, 27, 1.0); + Assert.True(viewModel.ZoomLevel > 1.0); + + viewModel.ClearSketchEditSeries(); + + Assert.True(viewModel.ZoomLevel > 1.0); + } + + [Fact] + public void ApplyZoom_NarrowsAxisLimits() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + + var originalXMax = viewModel.XAxes[0].MaxLimit; + var originalYMax = viewModel.YAxes[0].MaxLimit; + + viewModel.ApplyZoom(2500, 27, 1.0); + + // After zooming in, the visible range should be narrower. + var newXRange = (viewModel.XAxes[0].MaxLimit ?? 0) - (viewModel.XAxes[0].MinLimit ?? 0); + var originalXRange = (originalXMax ?? 0) - 0; + Assert.True(newXRange < originalXRange); + } + + [Fact] + public void SetSketchEditSeries_WithLockedCurve_DoesNotActivate() + { + var viewModel = new ChartViewModel(); + var voltage = CreateTestVoltage(); + var peak = voltage.Curves.First(c => c.Name == "Peak"); + peak.Locked = true; + viewModel.CurrentVoltage = voltage; + + viewModel.SetSketchEditSeries("Peak"); + + Assert.False(viewModel.IsSketchEditActive); + Assert.Null(viewModel.SketchEditSeriesName); + } + + [Fact] + public void SetSketchEditSeries_WithUnlockedCurve_Activates() + { + var viewModel = new ChartViewModel(); + var voltage = CreateTestVoltage(); + viewModel.CurrentVoltage = voltage; + + viewModel.SetSketchEditSeries("Peak"); + + Assert.True(viewModel.IsSketchEditActive); + Assert.Equal("Peak", viewModel.SketchEditSeriesName); + } + + [Fact] + public void ZoomPercentage_DefaultsTo100() + { + var viewModel = new ChartViewModel(); + + Assert.Equal(100, viewModel.ZoomPercentage); + } + + [Fact] + public void ZoomPercentage_IncreasesAfterZoomIn() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + + viewModel.ApplyZoom(2500, 27, 1.0); + + Assert.True(viewModel.ZoomPercentage > 100); + } + + [Fact] + public void ZoomSliderValue_ControlsZoomLevel() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + + viewModel.ZoomSliderValue = 5.0; + + Assert.Equal(5.0, viewModel.ZoomLevel, 2); + Assert.Equal(500, viewModel.ZoomPercentage); + } + + [Fact] + public void ZoomSliderValue_ClampedToRange() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + + viewModel.ZoomSliderValue = 25.0; + + Assert.Equal(20.0, viewModel.ZoomLevel, 2); + } + + [Fact] + public void PanBy_WhenNotZoomed_DoesNothing() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + var originalXMin = viewModel.XAxes[0].MinLimit; + + viewModel.PanBy(100, 100); + + Assert.Equal(originalXMin, viewModel.XAxes[0].MinLimit); + } + + [Fact] + public void PanBy_WhenZoomed_ShiftsAxisLimits() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + viewModel.ApplyZoom(2500, 27, 1.0); + var xMinBefore = viewModel.XAxes[0].MinLimit ?? 0; + + viewModel.PanBy(100, 0); + + var xMinAfter = viewModel.XAxes[0].MinLimit ?? 0; + Assert.True(xMinAfter < xMinBefore); + } + + [Fact] + public void SetSketchEditSeries_WhileZoomed_DoesNotOverwriteBaseLimits() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + var originalXMax = viewModel.XAxes[0].MaxLimit; + + // Zoom in first. + viewModel.ApplyZoom(2500, 27, 1.0); + Assert.True(viewModel.ZoomLevel > 1.0); + + // Activate sketch mode while zoomed — should not corrupt base limits. + viewModel.SetSketchEditSeries("Peak"); + + // Should still be able to reset zoom to the original full view. + viewModel.ResetZoom(); + Assert.Equal(1.0, viewModel.ZoomLevel); + Assert.Equal(originalXMax, viewModel.XAxes[0].MaxLimit); + } + + [Fact] + public void BaseLimitsCaptured_FalseByDefault() + { + var viewModel = new ChartViewModel(); + + Assert.False(viewModel.BaseLimitsCaptured); + } + + [Fact] + public void BaseLimitsCaptured_TrueAfterZoom() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + + viewModel.ApplyZoom(2500, 27, 1.0); + + Assert.True(viewModel.BaseLimitsCaptured); + } + + [Fact] + public void BaseLimitsCaptured_FalseAfterReset() + { + var viewModel = new ChartViewModel(); + viewModel.CurrentVoltage = CreateTestVoltage(); + viewModel.ApplyZoom(2500, 27, 1.0); + + viewModel.ResetZoom(); + + Assert.False(viewModel.BaseLimitsCaptured); + } } diff --git a/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelTests.cs index cdc99be..397d273 100644 --- a/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelTests.cs +++ b/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelTests.cs @@ -1,7 +1,9 @@ using CurveEditor.Services; using CurveEditor.ViewModels; using JordanRobot.MotorDefinition.Model; +using MotorEditor.Avalonia.Models; using Moq; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using Xunit; @@ -130,4 +132,46 @@ public void OnSelectedDriveChanged_ClearsSelectedVoltageWhenDriveIsNull() Assert.Empty(vm.AvailableVoltages); } + [Fact] + public void Constructor_WithPreferencesService_AppliesTorqueSnapIncrementToChart() + { + var preferences = new UserPreferences + { + TorqueSnapIncrement = 0.03m, + SketchDragBandPercent = 5m + }; + + var preferencesServiceMock = new Mock(); + preferencesServiceMock.SetupGet(s => s.Preferences).Returns(preferences); + preferencesServiceMock.SetupGet(s => s.State).Returns(new ApplicationState()); + + var vm = new MainWindowViewModel(preferencesServiceMock.Object); + + Assert.Equal(0.03m, vm.ChartViewModel!.TorqueSnapIncrement); + Assert.Equal(0.05, vm.ChartViewModel.DragSketchBandFraction, 3); + } + + [Fact] + public void PreferencesChanged_WhenTorqueSnapIncrementUpdated_PropagatesToChart() + { + var preferences = new UserPreferences + { + TorqueSnapIncrement = 0.03m, + SketchDragBandPercent = 5m + }; + + var preferencesServiceMock = new Mock(); + preferencesServiceMock.SetupGet(s => s.Preferences).Returns(preferences); + preferencesServiceMock.SetupGet(s => s.State).Returns(new ApplicationState()); + + var vm = new MainWindowViewModel(preferencesServiceMock.Object); + + preferences.TorqueSnapIncrement = 0.07m; + preferences.SketchDragBandPercent = 8m; + preferencesServiceMock.Raise(s => s.PreferencesChanged += null, EventArgs.Empty); + + Assert.Equal(0.07m, vm.ChartViewModel!.TorqueSnapIncrement); + Assert.Equal(0.08, vm.ChartViewModel.DragSketchBandFraction, 3); + } + } diff --git a/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelUndoRedoIntegrationTests.cs b/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelUndoRedoIntegrationTests.cs index 62d2e90..bae37c2 100644 --- a/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelUndoRedoIntegrationTests.cs +++ b/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelUndoRedoIntegrationTests.cs @@ -232,4 +232,57 @@ public void ToggleSeriesLock_ThenUndoRedo_RevertsLockedState() Assert.True(series.Locked); } + + [Fact] + public void ToggleSeriesLock_WhenSketchEditActive_ClearsSketchEditMode() + { + var fileServiceMock = new Mock(); + var curveGeneratorMock = new Mock(); + + fileServiceMock.SetupGet(f => f.IsDirty).Returns(false); + + var vm = new MainWindowViewModel(fileServiceMock.Object, curveGeneratorMock.Object); + + var motor = new ServoMotor + { + MotorName = "Test Motor", + Drives = new List + { + new() + { + Name = "Drive A", + Voltages = new List + { + new() + { + Value = 208, + Curves = new List + { + new() + { + Name = "Peak", + Locked = false + } + } + } + } + } + } + }; + + vm.CurrentMotor = motor; + vm.SelectedDrive = motor.Drives[0]; + vm.SelectedVoltage = motor.Drives[0].Voltages[0]; + + var series = motor.Drives[0].Voltages[0].Curves[0]; + vm.ChartViewModel.SetSketchEditSeries(series.Name); + + Assert.True(vm.ChartViewModel.IsSketchEditActive); + + vm.ToggleSeriesLockCommand.Execute(series); + + Assert.True(series.Locked); + Assert.False(vm.ChartViewModel.IsSketchEditActive); + Assert.Null(vm.ChartViewModel.SketchEditSeriesName); + } } diff --git a/tests/CurveEditor.Tests/ViewModels/PreferencesViewModelTests.cs b/tests/CurveEditor.Tests/ViewModels/PreferencesViewModelTests.cs index fd38168..bd39905 100644 --- a/tests/CurveEditor.Tests/ViewModels/PreferencesViewModelTests.cs +++ b/tests/CurveEditor.Tests/ViewModels/PreferencesViewModelTests.cs @@ -51,11 +51,24 @@ public void SaveCommand_UpdatesPreferences() var viewModel = new PreferencesViewModel(_preferencesService); viewModel.DecimalPrecision = 4; viewModel.Theme = "Dark"; + viewModel.SketchDragBandPercent = 7m; viewModel.SaveCommand.Execute(null); Assert.Equal(4, _preferencesService.Preferences.DecimalPrecision); Assert.Equal("Dark", _preferencesService.Preferences.Theme); + Assert.Equal(7m, _preferencesService.Preferences.SketchDragBandPercent); + } + + [Fact] + public void Constructor_LoadsSketchDragBandPercent() + { + _preferencesService.Preferences.SketchDragBandPercent = 6.5m; + _preferencesService.SavePreferences(); + + var viewModel = new PreferencesViewModel(_preferencesService); + + Assert.Equal(6.5m, viewModel.SketchDragBandPercent); } [Fact]