diff --git a/LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs b/LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs
new file mode 100644
index 0000000..1ba235e
--- /dev/null
+++ b/LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Threading;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.Services;
+using Xunit;
+
+namespace LanMountainDesktop.Tests;
+
+public sealed class StudyAnalyticsServiceTests
+{
+ [Fact]
+ public void SnapshotUpdated_UsesUiPublishThrottle()
+ {
+ using var recorder = new FakeAudioRecorderService();
+ using var service = new StudyAnalyticsService(recorder);
+ service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120));
+
+ var updateCount = 0;
+ service.SnapshotUpdated += (_, _) => Interlocked.Increment(ref updateCount);
+
+ Assert.True(service.StartOrResumeMonitoring());
+ Thread.Sleep(280);
+ Assert.True(service.PauseMonitoring());
+
+ var totalUpdates = Volatile.Read(ref updateCount);
+ Assert.InRange(totalUpdates, 2, 6);
+ }
+
+ [Fact]
+ public void GetSnapshot_ReusesRealtimeBufferSnapshot_WhenNoNewFramesArrive()
+ {
+ using var recorder = new FakeAudioRecorderService();
+ using var service = new StudyAnalyticsService(recorder);
+ service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120));
+
+ using var firstUpdate = new ManualResetEventSlim(false);
+ service.SnapshotUpdated += (_, args) =>
+ {
+ if (args.Snapshot.RealtimeBuffer.Count > 0)
+ {
+ firstUpdate.Set();
+ }
+ };
+
+ Assert.True(service.StartOrResumeMonitoring());
+ Assert.True(firstUpdate.Wait(TimeSpan.FromSeconds(2)));
+ Assert.True(service.PauseMonitoring());
+
+ var firstSnapshot = service.GetSnapshot();
+ var secondSnapshot = service.GetSnapshot();
+
+ Assert.NotEmpty(firstSnapshot.RealtimeBuffer);
+ Assert.Same(firstSnapshot.RealtimeBuffer, secondSnapshot.RealtimeBuffer);
+ }
+
+ private sealed class FakeAudioRecorderService : IAudioRecorderService
+ {
+ private readonly object _syncRoot = new();
+ private AudioRecorderRuntimeState _state = AudioRecorderRuntimeState.Ready;
+
+ public AudioRecorderSnapshot GetSnapshot()
+ {
+ lock (_syncRoot)
+ {
+ return new AudioRecorderSnapshot(
+ State: _state,
+ Duration: TimeSpan.Zero,
+ InputLevel: _state == AudioRecorderRuntimeState.Recording ? 0.55 : 0,
+ LastSavedFilePath: string.Empty,
+ LastError: string.Empty);
+ }
+ }
+
+ public bool StartOrResume()
+ {
+ lock (_syncRoot)
+ {
+ _state = AudioRecorderRuntimeState.Recording;
+ return true;
+ }
+ }
+
+ public bool Pause()
+ {
+ lock (_syncRoot)
+ {
+ _state = AudioRecorderRuntimeState.Paused;
+ return true;
+ }
+ }
+
+ public string? StopAndSave(string? outputPath = null)
+ {
+ lock (_syncRoot)
+ {
+ _state = AudioRecorderRuntimeState.Ready;
+ return outputPath;
+ }
+ }
+
+ public void Discard()
+ {
+ lock (_syncRoot)
+ {
+ _state = AudioRecorderRuntimeState.Ready;
+ }
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
index a62fa87..f5708da 100644
--- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
+++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
@@ -425,7 +425,7 @@ public sealed class ComponentRegistry
BuiltInComponentIds.DesktopShortcut,
"快捷方式",
"App",
- "Launcher",
+ "File",
MinWidthCells: 1,
MinHeightCells: 1,
AllowStatusBarPlacement: false,
diff --git a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
index efb1d5b..f2b8292 100644
--- a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
+++ b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
@@ -136,9 +136,9 @@ public sealed class ComponentSettingsSnapshot
public string ShortcutClickMode { get; set; } = "Double";
///
- /// 是否透明背景
+ /// 是否显示背景
///
- public bool ShortcutTransparentBackground { get; set; } = false;
+ public bool ShortcutShowBackground { get; set; } = true;
#endregion
diff --git a/LanMountainDesktop/Models/StudyAnalyticsModels.cs b/LanMountainDesktop/Models/StudyAnalyticsModels.cs
index b647a7a..163eb54 100644
--- a/LanMountainDesktop/Models/StudyAnalyticsModels.cs
+++ b/LanMountainDesktop/Models/StudyAnalyticsModels.cs
@@ -37,6 +37,7 @@ public enum StudyDataMode
public sealed record StudyAnalyticsConfig(
int FrameMs = 50,
+ int UiPublishIntervalMs = 125,
int SliceSec = 30,
double ScoreThresholdDbfs = -50,
int SegmentMergeGapMs = 500,
diff --git a/LanMountainDesktop/Services/StudyAnalyticsInternals.cs b/LanMountainDesktop/Services/StudyAnalyticsInternals.cs
index 7c8227e..aea19dd 100644
--- a/LanMountainDesktop/Services/StudyAnalyticsInternals.cs
+++ b/LanMountainDesktop/Services/StudyAnalyticsInternals.cs
@@ -12,8 +12,13 @@ internal readonly record struct NoisePipelineTickResult(
internal sealed class NoiseFramePipeline
{
private StudyAnalyticsConfig _config;
- private readonly Queue _realtimeBuffer = new();
private readonly List _slicePoints = [];
+ private NoiseRealtimePoint[] _realtimeBuffer;
+ private IReadOnlyList _realtimeSnapshot = Array.Empty();
+ private int _realtimeBufferStart;
+ private int _realtimeBufferCount;
+ private int _realtimeBufferVersion;
+ private int _realtimeSnapshotVersion = -1;
private DateTimeOffset _sliceStartAt;
private DateTimeOffset _lastFrameAt;
@@ -28,18 +33,29 @@ internal sealed class NoiseFramePipeline
public NoiseFramePipeline(StudyAnalyticsConfig config)
{
_config = NormalizeConfig(config);
+ _realtimeBuffer = new NoiseRealtimePoint[_config.RealtimeBufferCapacity];
}
public void UpdateConfig(StudyAnalyticsConfig config)
{
- _config = NormalizeConfig(config);
+ var normalized = NormalizeConfig(config);
+ if (normalized.RealtimeBufferCapacity != _config.RealtimeBufferCapacity)
+ {
+ _realtimeBuffer = new NoiseRealtimePoint[normalized.RealtimeBufferCapacity];
+ }
+
+ _config = normalized;
Reset();
}
public void Reset()
{
- _realtimeBuffer.Clear();
_slicePoints.Clear();
+ _realtimeBufferStart = 0;
+ _realtimeBufferCount = 0;
+ _realtimeBufferVersion++;
+ _realtimeSnapshot = Array.Empty();
+ _realtimeSnapshotVersion = -1;
_sliceStartAt = default;
_lastFrameAt = default;
_lastOverThresholdAt = default;
@@ -52,7 +68,27 @@ internal sealed class NoiseFramePipeline
public IReadOnlyList GetRealtimeBufferSnapshot()
{
- return _realtimeBuffer.ToArray();
+ if (_realtimeBufferCount == 0)
+ {
+ return Array.Empty();
+ }
+
+ if (_realtimeSnapshotVersion == _realtimeBufferVersion)
+ {
+ return _realtimeSnapshot;
+ }
+
+ var snapshot = new NoiseRealtimePoint[_realtimeBufferCount];
+ var firstSegmentLength = Math.Min(_realtimeBufferCount, _realtimeBuffer.Length - _realtimeBufferStart);
+ Array.Copy(_realtimeBuffer, _realtimeBufferStart, snapshot, 0, firstSegmentLength);
+ if (firstSegmentLength < _realtimeBufferCount)
+ {
+ Array.Copy(_realtimeBuffer, 0, snapshot, firstSegmentLength, _realtimeBufferCount - firstSegmentLength);
+ }
+
+ _realtimeSnapshot = snapshot;
+ _realtimeSnapshotVersion = _realtimeBufferVersion;
+ return snapshot;
}
public NoisePipelineTickResult AddFrame(DateTimeOffset timestamp, double rms, double dbfs, double displayDb, double peak)
@@ -114,12 +150,7 @@ internal sealed class NoiseFramePipeline
peak,
isOverThreshold);
_slicePoints.Add(point);
- _realtimeBuffer.Enqueue(point);
-
- while (_realtimeBuffer.Count > _config.RealtimeBufferCapacity)
- {
- _realtimeBuffer.Dequeue();
- }
+ AddRealtimePoint(point);
var elapsedSeconds = (timestamp - _sliceStartAt).TotalSeconds;
if (elapsedSeconds + 1e-6 < _config.SliceSec)
@@ -132,6 +163,29 @@ internal sealed class NoiseFramePipeline
return new NoisePipelineTickResult(point, slice);
}
+ private void AddRealtimePoint(NoiseRealtimePoint point)
+ {
+ if (_realtimeBuffer.Length == 0)
+ {
+ _realtimeBuffer = new NoiseRealtimePoint[Math.Max(1, _config.RealtimeBufferCapacity)];
+ }
+
+ if (_realtimeBufferCount < _realtimeBuffer.Length)
+ {
+ var writeIndex = (_realtimeBufferStart + _realtimeBufferCount) % _realtimeBuffer.Length;
+ _realtimeBuffer[writeIndex] = point;
+ _realtimeBufferCount++;
+ }
+ else
+ {
+ _realtimeBuffer[_realtimeBufferStart] = point;
+ _realtimeBufferStart = (_realtimeBufferStart + 1) % _realtimeBuffer.Length;
+ }
+
+ _realtimeBufferVersion++;
+ _realtimeSnapshotVersion = -1;
+ }
+
private NoiseSliceSummary BuildClosedSlice(DateTimeOffset endAt)
{
var sampledDurationMs = _slicePoints.Count * _config.FrameMs;
@@ -247,6 +301,7 @@ internal sealed class NoiseFramePipeline
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
{
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
+ var uiPublishIntervalMs = Math.Clamp(config.UiPublishIntervalMs, 50, 500);
var sliceSec = Math.Clamp(config.SliceSec, 5, 600);
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
@@ -259,6 +314,7 @@ internal sealed class NoiseFramePipeline
return config with
{
FrameMs = frameMs,
+ UiPublishIntervalMs = uiPublishIntervalMs,
SliceSec = sliceSec,
ScoreThresholdDbfs = threshold,
SegmentMergeGapMs = mergeGapMs,
diff --git a/LanMountainDesktop/Services/StudyAnalyticsService.cs b/LanMountainDesktop/Services/StudyAnalyticsService.cs
index 5d4b856..c69653f 100644
--- a/LanMountainDesktop/Services/StudyAnalyticsService.cs
+++ b/LanMountainDesktop/Services/StudyAnalyticsService.cs
@@ -46,6 +46,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
private readonly List _sessionHistory = [];
private string? _selectedSessionReportId;
private string _lastError = string.Empty;
+ private DateTimeOffset _lastUiPublishedAt;
private bool _disposed;
public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null)
@@ -102,6 +103,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
ThrowIfDisposedLocked();
_config = NormalizeConfig(config);
_pipeline.UpdateConfig(_config);
+ _lastUiPublishedAt = default;
if (_state == StudyAnalyticsRuntimeState.Running)
{
StartTimerLocked();
@@ -546,7 +548,11 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
_lastError = string.Empty;
UpdateDataModeLocked();
- snapshot = BuildSnapshotLocked(now);
+ if (ShouldPublishRealtimeSnapshotLocked(now, closedSlice is not null))
+ {
+ snapshot = BuildSnapshotLocked(now);
+ _lastUiPublishedAt = now;
+ }
}
}
@@ -599,6 +605,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
private void StartTimerLocked()
{
+ _lastUiPublishedAt = default;
_samplingTimer.Change(
dueTime: TimeSpan.Zero,
period: TimeSpan.FromMilliseconds(_config.FrameMs));
@@ -673,6 +680,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
{
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
+ var uiPublishIntervalMs = Math.Clamp(config.UiPublishIntervalMs, 50, 500);
var sliceSec = Math.Clamp(config.SliceSec, 5, 600);
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
@@ -685,6 +693,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
return config with
{
FrameMs = frameMs,
+ UiPublishIntervalMs = uiPublishIntervalMs,
SliceSec = sliceSec,
ScoreThresholdDbfs = threshold,
SegmentMergeGapMs = mergeGapMs,
@@ -696,6 +705,16 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
};
}
+ private bool ShouldPublishRealtimeSnapshotLocked(DateTimeOffset now, bool hasClosedSlice)
+ {
+ if (hasClosedSlice || _lastUiPublishedAt == default)
+ {
+ return true;
+ }
+
+ return (now - _lastUiPublishedAt).TotalMilliseconds >= _config.UiPublishIntervalMs;
+ }
+
private void ThrowIfDisposedLocked()
{
if (_disposed)
diff --git a/LanMountainDesktop/Styles/GlassModule.axaml b/LanMountainDesktop/Styles/GlassModule.axaml
index 7474126..d52e5de 100644
--- a/LanMountainDesktop/Styles/GlassModule.axaml
+++ b/LanMountainDesktop/Styles/GlassModule.axaml
@@ -222,10 +222,37 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs b/LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs
new file mode 100644
index 0000000..d09d7c2
--- /dev/null
+++ b/LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+using LanMountainDesktop.ComponentSystem;
+using LanMountainDesktop.Models;
+
+namespace LanMountainDesktop.ViewModels;
+
+public sealed partial class ShortcutEditorViewModel : ViewModelBase
+{
+ private readonly DesktopComponentEditorContext? _context;
+ private bool _isInitializing;
+
+ public ShortcutEditorViewModel(DesktopComponentEditorContext? context)
+ {
+ _context = context;
+
+ ClickModeOptions = new ObservableCollection
+ {
+ new("Double", "双击打开"),
+ new("Single", "单击打开")
+ };
+
+ LoadSettings();
+ }
+
+ private void LoadSettings()
+ {
+ var snapshot = _context?.ComponentSettingsAccessor.LoadSnapshot()
+ ?? new ComponentSettingsSnapshot();
+
+ _isInitializing = true;
+
+ TargetPath = snapshot.ShortcutTargetPath ?? string.Empty;
+ SelectedClickMode = ClickModeOptions.FirstOrDefault(o => o.Value == snapshot.ShortcutClickMode)
+ ?? ClickModeOptions[0];
+ ShowBackground = snapshot.ShortcutShowBackground;
+
+ _isInitializing = false;
+ }
+
+ private void SaveSettings()
+ {
+ if (_isInitializing || _context == null) return;
+
+ var snapshot = _context.ComponentSettingsAccessor.LoadSnapshot();
+
+ snapshot.ShortcutTargetPath = string.IsNullOrWhiteSpace(TargetPath) ? null : TargetPath;
+ snapshot.ShortcutClickMode = SelectedClickMode?.Value ?? "Double";
+ snapshot.ShortcutShowBackground = ShowBackground;
+
+ _context.ComponentSettingsAccessor.SaveSnapshot(snapshot);
+
+ _context.HostContext.RequestRefresh();
+ }
+
+ [ObservableProperty] private string _descriptionText = "配置此快捷方式组件的目标路径和打开方式。这些设置仅作用于当前组件实例。";
+ [ObservableProperty] private string _targetPathLabel = "目标路径";
+ [ObservableProperty] private string _targetPathPlaceholder = "未选择目标";
+ [ObservableProperty] private string _browseButtonText = "浏览...";
+ [ObservableProperty] private string _clearButtonText = "清除";
+ [ObservableProperty] private string _clickModeLabel = "打开方式";
+ [ObservableProperty] private string _backgroundLabel = "显示背景";
+ [ObservableProperty] private string _backgroundDescription = "关闭后组件背景将变为透明。";
+
+ [ObservableProperty] private string _targetPath = string.Empty;
+ [ObservableProperty] private SelectionOption? _selectedClickMode;
+ [ObservableProperty] private bool _showBackground = true;
+
+ public ObservableCollection ClickModeOptions { get; }
+
+ public void SetTargetPath(string? path)
+ {
+ TargetPath = path ?? string.Empty;
+ SaveSettings();
+ }
+
+ public void ClearTargetPath()
+ {
+ TargetPath = string.Empty;
+ SaveSettings();
+ }
+
+ partial void OnSelectedClickModeChanged(SelectionOption? value) => SaveSettings();
+ partial void OnShowBackgroundChanged(bool value) => SaveSettings();
+}
diff --git a/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml b/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml
index d448e99..211fa59 100644
--- a/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml
+++ b/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml
@@ -1,86 +1,66 @@
+ xmlns:vm="using:LanMountainDesktop.ViewModels"
+ x:Class="LanMountainDesktop.Views.ComponentEditors.ShortcutComponentEditor"
+ x:DataType="vm:ShortcutEditorViewModel">
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+ Watermark="{Binding TargetPathPlaceholder}"
+ Grid.Column="0" />
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml.cs b/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml.cs
index d556483..138d786 100644
--- a/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml.cs
+++ b/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml.cs
@@ -1,18 +1,15 @@
-using System;
-using System.IO;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LanMountainDesktop.ComponentSystem;
-using LanMountainDesktop.Models;
-using LanMountainDesktop.Services;
+using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class ShortcutComponentEditor : ComponentEditorViewBase
{
- private bool _suppressEvents;
+ private ShortcutEditorViewModel? _viewModel;
public ShortcutComponentEditor()
: this(null)
@@ -23,44 +20,8 @@ public partial class ShortcutComponentEditor : ComponentEditorViewBase
: base(context)
{
InitializeComponent();
- ApplyLocalizedText();
- ApplyState();
- AttachEventHandlers();
- }
-
- private void ApplyLocalizedText()
- {
- HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "快捷方式";
- DescriptionTextBlock.Text = L(
- "shortcut.settings.desc",
- "配置快捷方式的目标路径和打开方式。");
-
- BackgroundLabel.Text = L("shortcut.settings.show_background", "显示背景");
- BackgroundDescription.Text = L(
- "shortcut.settings.show_background.desc",
- "关闭后组件背景将变为透明。");
- }
-
- private void ApplyState()
- {
- var snapshot = LoadSnapshot();
- var targetPath = snapshot.ShortcutTargetPath;
- var clickMode = snapshot.ShortcutClickMode;
- var transparentBackground = snapshot.ShortcutTransparentBackground;
-
- _suppressEvents = true;
- TargetPathTextBox.Text = targetPath ?? string.Empty;
- SingleClickRadio.IsChecked = string.Equals(clickMode, "Single", StringComparison.OrdinalIgnoreCase);
- DoubleClickRadio.IsChecked = !SingleClickRadio.IsChecked;
- BackgroundToggle.IsChecked = !transparentBackground;
- _suppressEvents = false;
- }
-
- private void AttachEventHandlers()
- {
- BackgroundToggle.IsCheckedChanged += OnBackgroundToggleChanged;
- SingleClickRadio.IsCheckedChanged += OnClickModeChanged;
- DoubleClickRadio.IsCheckedChanged += OnClickModeChanged;
+ _viewModel = new ShortcutEditorViewModel(context);
+ DataContext = _viewModel;
}
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
@@ -73,15 +34,15 @@ public partial class ShortcutComponentEditor : ComponentEditorViewBase
var options = new FilePickerOpenOptions
{
- Title = L("shortcut.settings.picker_title", "选择目标文件或文件夹"),
+ Title = "选择目标文件",
AllowMultiple = false,
FileTypeFilter =
[
- new FilePickerFileType(L("shortcut.settings.picker_type.executable", "可执行文件"))
+ new FilePickerFileType("可执行文件")
{
Patterns = ["*.exe", "*.lnk", "*.bat", "*.cmd"]
},
- new FilePickerFileType(L("shortcut.settings.picker_type.all", "所有文件"))
+ new FilePickerFileType("所有文件")
{
Patterns = ["*.*"]
}
@@ -89,14 +50,13 @@ public partial class ShortcutComponentEditor : ComponentEditorViewBase
};
var files = await storageProvider.OpenFilePickerAsync(options);
- var file = files.FirstOrDefault();
- var localPath = file?.TryGetLocalPath();
+ var localPath = files.FirstOrDefault()?.TryGetLocalPath();
if (string.IsNullOrWhiteSpace(localPath))
{
var folderOptions = new FolderPickerOpenOptions
{
- Title = L("shortcut.settings.picker_title_folder", "选择目标文件夹"),
+ Title = "选择目标文件夹",
AllowMultiple = false
};
@@ -106,44 +66,12 @@ public partial class ShortcutComponentEditor : ComponentEditorViewBase
if (!string.IsNullOrWhiteSpace(localPath))
{
- TargetPathTextBox.Text = localPath;
- var snapshot = LoadSnapshot();
- snapshot.ShortcutTargetPath = localPath;
- SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ShortcutTargetPath));
+ _viewModel?.SetTargetPath(localPath);
}
}
private void OnClearClick(object? sender, RoutedEventArgs e)
{
- TargetPathTextBox.Text = string.Empty;
- var snapshot = LoadSnapshot();
- snapshot.ShortcutTargetPath = null;
- SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ShortcutTargetPath));
- }
-
- private void OnClickModeChanged(object? sender, RoutedEventArgs e)
- {
- if (_suppressEvents)
- {
- return;
- }
-
- var clickMode = SingleClickRadio.IsChecked == true ? "Single" : "Double";
- var snapshot = LoadSnapshot();
- snapshot.ShortcutClickMode = clickMode;
- SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ShortcutClickMode));
- }
-
- private void OnBackgroundToggleChanged(object? sender, RoutedEventArgs e)
- {
- if (_suppressEvents)
- {
- return;
- }
-
- var transparentBackground = BackgroundToggle.IsChecked != true;
- var snapshot = LoadSnapshot();
- snapshot.ShortcutTransparentBackground = transparentBackground;
- SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ShortcutTransparentBackground));
+ _viewModel?.ClearTargetPath();
}
}
diff --git a/LanMountainDesktop/Views/Components/ShortcutWidget.axaml b/LanMountainDesktop/Views/Components/ShortcutWidget.axaml
index b8a2e54..3f4b3ff 100644
--- a/LanMountainDesktop/Views/Components/ShortcutWidget.axaml
+++ b/LanMountainDesktop/Views/Components/ShortcutWidget.axaml
@@ -8,7 +8,9 @@
x:Class="LanMountainDesktop.Views.Components.ShortcutWidget">
-
+
+
+
+
+ FontSize="11"
+ Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
diff --git a/LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs b/LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs
index 83f5e31..0228bdc 100644
--- a/LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs
@@ -16,13 +16,13 @@ using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
-public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable
+public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IComponentSettingsContextAware, IDisposable
{
private string _componentId = BuiltInComponentIds.DesktopShortcut;
private string _placementId = string.Empty;
private string? _targetPath;
private string _clickMode = "Double";
- private bool _transparentBackground;
+ private bool _showBackground = true;
private double _currentCellSize = 48;
private bool _isDisposed;
@@ -51,13 +51,19 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
_placementId = placementId?.Trim() ?? string.Empty;
}
+ public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
+ {
+ var snapshot = context.ComponentSettingsAccessor.LoadSnapshot();
+ ApplySettings(snapshot);
+ }
+
public void ApplySettings(ComponentSettingsSnapshot snapshot)
{
_targetPath = snapshot.ShortcutTargetPath;
_clickMode = string.Equals(snapshot.ShortcutClickMode, "Single", StringComparison.OrdinalIgnoreCase)
? "Single"
: "Double";
- _transparentBackground = snapshot.ShortcutTransparentBackground;
+ _showBackground = snapshot.ShortcutShowBackground;
UpdateDisplay();
ApplyChrome();
}
@@ -85,7 +91,7 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
{
var name = GetDisplayName(_targetPath);
NameTextBlock.Text = name;
- NameTextBlock.Foreground = this.FindResource("AdaptiveTextPrimaryBrush") as IBrush ?? new SolidColorBrush(Colors.White);
+ // 文字颜色由 XAML 中的 DynamicResource 自动适配主题
LoadIcon(_targetPath);
}
@@ -98,9 +104,13 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
private void ShowEmptyState()
{
NameTextBlock.Text = "添加快捷方式";
- NameTextBlock.Foreground = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush ?? new SolidColorBrush(Colors.Gray);
+ // 使用次要文字颜色(由主题自动适配)
+ NameTextBlock.Foreground = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush;
- var iconBrush = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush ?? new SolidColorBrush(Colors.Gray);
+ var iconBrush = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush;
+
+ // 隐藏图片图标,显示符号图标
+ IconImage.IsVisible = false;
IconImage.Source = null;
var iconHostContent = new SymbolIcon
@@ -111,7 +121,8 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
- IconHost.Child = iconHostContent;
+ SymbolIconHost.Content = iconHostContent;
+ SymbolIconHost.IsVisible = true;
}
private static string GetDisplayName(string path)
@@ -188,7 +199,8 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
{
using var stream = new MemoryStream(pngBytes);
IconImage.Source = new Bitmap(stream);
- IconHost.Child = IconImage;
+ IconImage.IsVisible = true;
+ SymbolIconHost.IsVisible = false;
return;
}
catch
@@ -205,9 +217,13 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
? FluentIcons.Common.Symbol.Folder
: FluentIcons.Common.Symbol.Document;
- var iconBrush = this.FindResource("AdaptiveAccentBrush") as IBrush ?? new SolidColorBrush(Colors.DodgerBlue);
+ // 使用强调色(由主题自动适配)
+ var iconBrush = this.FindResource("AdaptiveAccentBrush") as IBrush;
+ // 隐藏图片图标,显示符号图标
+ IconImage.IsVisible = false;
IconImage.Source = null;
+
var iconHostContent = new SymbolIcon
{
Symbol = symbol,
@@ -216,28 +232,24 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
- IconHost.Child = iconHostContent;
+ SymbolIconHost.Content = iconHostContent;
+ SymbolIconHost.IsVisible = true;
}
private void ApplyChrome()
{
- if (_transparentBackground)
+ if (!_showBackground)
{
- RootBorder.Classes.Remove("glass-panel");
RootBorder.Background = Brushes.Transparent;
RootBorder.BorderBrush = Brushes.Transparent;
RootBorder.BorderThickness = new Thickness(0);
return;
}
- if (!RootBorder.Classes.Contains("glass-panel"))
- {
- RootBorder.Classes.Add("glass-panel");
- }
-
- RootBorder.ClearValue(Border.BackgroundProperty);
- RootBorder.ClearValue(Border.BorderBrushProperty);
- RootBorder.ClearValue(Border.BorderThicknessProperty);
+ // 恢复默认的实心背景样式
+ RootBorder.Background = this.FindResource("AdaptiveSurfaceRaisedBrush") as IBrush ?? Brushes.Transparent;
+ RootBorder.BorderBrush = this.FindResource("AdaptiveButtonBorderBrush") as IBrush ?? Brushes.Transparent;
+ RootBorder.BorderThickness = new Thickness(1);
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
diff --git a/LanMountainDesktop/Views/Components/StudyNoiseCurveChartControl.cs b/LanMountainDesktop/Views/Components/StudyNoiseCurveChartControl.cs
index 8352808..87056ae 100644
--- a/LanMountainDesktop/Views/Components/StudyNoiseCurveChartControl.cs
+++ b/LanMountainDesktop/Views/Components/StudyNoiseCurveChartControl.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Buffers;
using System.Collections.Generic;
using Avalonia;
@@ -20,10 +20,24 @@ public sealed class StudyNoiseCurveChartControl : Control
private IReadOnlyList _points = Array.Empty();
private Point[]? _pointBuffer;
+ private StreamGeometry? _lineGeometry;
+ private StreamGeometry? _fillGeometry;
+ private Rect _cachedPlot;
+ private bool _geometryDirty = true;
+ private int _lastSeriesSignature;
public void UpdateSeries(IReadOnlyList? points)
{
- _points = points ?? Array.Empty();
+ var nextPoints = points ?? Array.Empty();
+ var nextSignature = ComputeSeriesSignature(nextPoints);
+ if (ReferenceEquals(_points, nextPoints) && _lastSeriesSignature == nextSignature)
+ {
+ return;
+ }
+
+ _points = nextPoints;
+ _lastSeriesSignature = nextSignature;
+ _geometryDirty = true;
InvalidateVisual();
}
@@ -34,11 +48,18 @@ public sealed class StudyNoiseCurveChartControl : Control
ArrayPool.Shared.Return(_pointBuffer, clearArray: false);
_pointBuffer = null;
}
+
+ _lineGeometry = null;
+ _fillGeometry = null;
+ _geometryDirty = true;
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
ReleasePointBuffer();
+ _lineGeometry = null;
+ _fillGeometry = null;
+ _geometryDirty = true;
base.OnDetachedFromVisualTree(e);
}
@@ -64,16 +85,14 @@ public sealed class StudyNoiseCurveChartControl : Control
return;
}
- var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360);
- var pointCount = BuildPlotPoints(plot, maxSamples);
- if (pointCount < 2 || _pointBuffer is null)
+ EnsureGeometry(plot);
+ if (_lineGeometry is null || _fillGeometry is null)
{
return;
}
- var span = _pointBuffer.AsSpan(0, pointCount);
- DrawAreaFill(context, plot.Bottom, span);
- DrawLine(context, span);
+ context.DrawGeometry(FillBrush, pen: null, _fillGeometry);
+ context.DrawGeometry(brush: null, pen: LinePen, _lineGeometry);
}
private static void DrawGrid(DrawingContext context, Rect plot)
@@ -97,42 +116,56 @@ public sealed class StudyNoiseCurveChartControl : Control
context.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom));
}
- private void DrawLine(DrawingContext context, ReadOnlySpan points)
+ private void EnsureGeometry(Rect plot)
{
- var geometry = new StreamGeometry();
- using (var builder = geometry.Open())
+ if (!_geometryDirty && _cachedPlot == plot)
{
- builder.BeginFigure(points[0], false);
- for (var i = 1; i < points.Length; i++)
+ return;
+ }
+
+ _cachedPlot = plot;
+ _lineGeometry = null;
+ _fillGeometry = null;
+
+ var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360);
+ var pointCount = BuildPlotPoints(plot, maxSamples);
+ if (pointCount < 2 || _pointBuffer is null)
+ {
+ _geometryDirty = false;
+ return;
+ }
+
+ var lineGeometry = new StreamGeometry();
+ using (var builder = lineGeometry.Open())
+ {
+ builder.BeginFigure(_pointBuffer[0], false);
+ for (var i = 1; i < pointCount; i++)
{
- builder.LineTo(points[i]);
+ builder.LineTo(_pointBuffer[i]);
}
}
- context.DrawGeometry(brush: null, pen: LinePen, geometry);
- }
-
- private void DrawAreaFill(DrawingContext context, double baselineY, ReadOnlySpan points)
- {
- var geometry = new StreamGeometry();
- using (var builder = geometry.Open())
+ var fillGeometry = new StreamGeometry();
+ using (var builder = fillGeometry.Open())
{
- var first = points[0];
- builder.BeginFigure(new Point(first.X, baselineY), true);
+ var first = _pointBuffer[0];
+ builder.BeginFigure(new Point(first.X, plot.Bottom), true);
builder.LineTo(first);
- for (var i = 1; i < points.Length; i++)
+ for (var i = 1; i < pointCount; i++)
{
- builder.LineTo(points[i]);
+ builder.LineTo(_pointBuffer[i]);
}
- var last = points[^1];
- builder.LineTo(new Point(last.X, baselineY));
- builder.LineTo(new Point(first.X, baselineY));
+ var last = _pointBuffer[pointCount - 1];
+ builder.LineTo(new Point(last.X, plot.Bottom));
+ builder.LineTo(new Point(first.X, plot.Bottom));
builder.EndFigure(true);
}
- context.DrawGeometry(FillBrush, pen: null, geometry);
+ _lineGeometry = lineGeometry;
+ _fillGeometry = fillGeometry;
+ _geometryDirty = false;
}
private int BuildPlotPoints(Rect plot, int maxSamples)
@@ -295,4 +328,20 @@ public sealed class StudyNoiseCurveChartControl : Control
ArrayPool.Shared.Return(_pointBuffer, clearArray: false);
_pointBuffer = null;
}
+
+ private static int ComputeSeriesSignature(IReadOnlyList points)
+ {
+ if (points.Count == 0)
+ {
+ return 0;
+ }
+
+ var first = points[0];
+ var last = points[^1];
+ return HashCode.Combine(
+ points.Count,
+ first.Timestamp.UtcTicks,
+ last.Timestamp.UtcTicks,
+ Math.Round(last.DisplayDb, 2));
+ }
}
diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs b/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs
index d8ff30c..e74cf9d 100644
--- a/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs
+++ b/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs
@@ -9,6 +9,8 @@ namespace LanMountainDesktop.Views.Components;
public sealed class StudyNoiseDistributionScatterChartControl : Control
{
+ private readonly record struct SampledPoint(double X, double Y, NoiseDistributionLevel Level);
+
private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#2E5E7A96"));
private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1"));
private static readonly Pen GridPen = new(GridBrush, 1);
@@ -18,14 +20,35 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
private static readonly IBrush NormalBrush = new SolidColorBrush(Color.Parse("#FF60A5FA"));
private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B"));
private static readonly IBrush ExtremeBrush = new SolidColorBrush(Color.Parse("#FFEF4444"));
+ private static readonly byte[] CloudAlphas = [44, 58, 72, 86];
+ private static readonly byte[] GlowAlphas = [26, 36];
+ private static readonly IBrush[][] CloudBrushes = CreateBrushTable(CloudAlphas);
+ private static readonly IBrush[][] GlowBrushes = CreateBrushTable(GlowAlphas);
private IReadOnlyList _points = Array.Empty();
+ private SampledPoint[] _sampledPoints = Array.Empty();
+ private int _sampledPointCount;
private double _baselineDb = 45;
+ private Rect _cachedPlot;
+ private bool _sampleCacheDirty = true;
+ private int _lastSeriesSignature;
public void UpdateSeries(IReadOnlyList? points, double baselineDb)
{
- _points = points ?? Array.Empty();
- _baselineDb = Math.Clamp(baselineDb, 20, 85);
+ var nextPoints = points ?? Array.Empty();
+ var nextBaselineDb = Math.Clamp(baselineDb, 20, 85);
+ var nextSignature = ComputeSeriesSignature(nextPoints, nextBaselineDb);
+ if (ReferenceEquals(_points, nextPoints) &&
+ Math.Abs(_baselineDb - nextBaselineDb) < 0.001 &&
+ _lastSeriesSignature == nextSignature)
+ {
+ return;
+ }
+
+ _points = nextPoints;
+ _baselineDb = nextBaselineDb;
+ _lastSeriesSignature = nextSignature;
+ _sampleCacheDirty = true;
InvalidateVisual();
}
@@ -52,45 +75,34 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
return;
}
+ EnsureSampleCache(plot);
+ if (_sampledPointCount < 2)
+ {
+ return;
+ }
+
DrawElectronCloud(context, plot);
}
private void DrawElectronCloud(DrawingContext context, Rect plot)
{
- var start = _points[0].Timestamp;
- var end = _points[^1].Timestamp;
- var totalTicks = Math.Max(1, (end - start).Ticks);
-
- var pointCount = _points.Count;
- var cloudLayers = 8;
+ var cloudLayers = CloudAlphas.Length;
var baseRadius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 45d, 3, 12);
-
- var sortedPoints = new List<(double X, double Y, NoiseDistributionLevel Level)>();
- for (var i = 0; i < pointCount; i++)
- {
- var point = _points[i];
- var x = MapX(plot, point.Timestamp, start, totalTicks);
- var y = MapYContinuous(plot, point.DisplayDb);
- var level = ResolveLevel(point.DisplayDb, _baselineDb);
- sortedPoints.Add((x, y, level));
- }
-
- sortedPoints.Sort((a, b) => a.X.CompareTo(b.X));
for (var layer = cloudLayers - 1; layer >= 0; layer--)
{
- var layerRatio = (double)layer / (cloudLayers - 1);
+ var layerRatio = cloudLayers == 1 ? 0d : layer / (double)(cloudLayers - 1);
var layerRadius = baseRadius * (1.2 + layerRatio * 0.8);
- var layerAlpha = (byte)(40 + layerRatio * 25);
+ var layerBrushes = CloudBrushes[layer];
- foreach (var pt in sortedPoints)
+ for (var i = 0; i < _sampledPointCount; i++)
{
- var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha);
+ var pt = _sampledPoints[i];
var jitterX = ComputeJitter(pt.X * 1000 + layer) * layerRadius * 0.3;
var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3;
-
+
context.DrawEllipse(
- brush,
+ layerBrushes[(int)pt.Level],
pen: null,
center: new Point(pt.X + jitterX, pt.Y + jitterY),
radiusX: layerRadius,
@@ -98,18 +110,17 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
}
}
- var glowLayers = 5;
+ var glowLayers = GlowAlphas.Length;
for (var layer = glowLayers - 1; layer >= 0; layer--)
{
- var layerRatio = (double)layer / (glowLayers - 1);
+ var layerRatio = glowLayers == 1 ? 0d : layer / (double)(glowLayers - 1);
var layerRadius = baseRadius * (0.8 + layerRatio * 0.6);
- var layerAlpha = (byte)(20 + layerRatio * 15);
-
- foreach (var pt in sortedPoints)
+ var layerBrushes = GlowBrushes[layer];
+ for (var i = 0; i < _sampledPointCount; i++)
{
- var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha);
+ var pt = _sampledPoints[i];
context.DrawEllipse(
- brush,
+ layerBrushes[(int)pt.Level],
pen: null,
center: new Point(pt.X, pt.Y),
radiusX: layerRadius,
@@ -117,34 +128,42 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
}
}
- var latest = _points[^1];
- var latestX = MapX(plot, latest.Timestamp, start, totalTicks);
- var latestY = MapYContinuous(plot, latest.DisplayDb);
- var latestLevel = ResolveLevel(latest.DisplayDb, _baselineDb);
-
+ var latest = _sampledPoints[_sampledPointCount - 1];
for (var i = 3; i >= 0; i--)
{
var radius = baseRadius * (1.5 + i * 0.8);
var alpha = (byte)(30 - i * 6);
- var glowBrush = GetLevelBrushWithAlpha(latestLevel, alpha);
- context.DrawEllipse(glowBrush, null, new Point(latestX, latestY), radius, radius * 0.6);
+ var glowBrush = GetAlphaBrush(latest.Level, alpha);
+ context.DrawEllipse(glowBrush, null, new Point(latest.X, latest.Y), radius, radius * 0.6);
}
context.DrawEllipse(
- GetLevelBrush(latestLevel),
+ GetLevelBrush(latest.Level),
new Pen(Brushes.White, 1.5),
- new Point(latestX, latestY),
+ new Point(latest.X, latest.Y),
baseRadius + 1,
baseRadius * 0.7 + 1);
context.DrawEllipse(
Brushes.White,
null,
- new Point(latestX, latestY),
+ new Point(latest.X, latest.Y),
2,
2);
}
+ private void EnsureSampleCache(Rect plot)
+ {
+ if (!_sampleCacheDirty && _cachedPlot == plot)
+ {
+ return;
+ }
+
+ _cachedPlot = plot;
+ _sampledPointCount = BuildSampledPoints(plot);
+ _sampleCacheDirty = false;
+ }
+
private static void DrawGrid(DrawingContext context, Rect plot)
{
const int verticalDivisions = 4;
@@ -176,7 +195,10 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
var minDb = _baselineDb - 5;
var maxDb = _baselineDb + 25;
var dbRange = maxDb - minDb;
- if (dbRange <= 0) dbRange = 30;
+ if (dbRange <= 0)
+ {
+ dbRange = 30;
+ }
var normalizedDb = (displayDb - minDb) / dbRange;
normalizedDb = Math.Clamp(normalizedDb, 0, 1);
@@ -243,6 +265,106 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
_ => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA))
};
}
+
+ private int BuildSampledPoints(Rect plot)
+ {
+ if (_points.Count < 2)
+ {
+ return 0;
+ }
+
+ var maxSamples = Math.Clamp((int)Math.Ceiling(plot.Width / 2d), 48, 144);
+ var targetCount = Math.Min(_points.Count, maxSamples);
+ if (_sampledPoints.Length < targetCount)
+ {
+ _sampledPoints = new SampledPoint[targetCount];
+ }
+
+ var start = _points[0].Timestamp;
+ var end = _points[^1].Timestamp;
+ var totalTicks = Math.Max(1, (end - start).Ticks);
+ var step = _points.Count <= targetCount
+ ? 1d
+ : (_points.Count - 1d) / Math.Max(1d, targetCount - 1d);
+
+ var outputIndex = 0;
+ var lastSourceIndex = -1;
+ for (var i = 0; i < targetCount; i++)
+ {
+ var sourceIndex = i == targetCount - 1
+ ? _points.Count - 1
+ : (int)Math.Round(i * step);
+ sourceIndex = Math.Clamp(sourceIndex, 0, _points.Count - 1);
+ if (sourceIndex == lastSourceIndex)
+ {
+ continue;
+ }
+
+ var point = _points[sourceIndex];
+ _sampledPoints[outputIndex++] = new SampledPoint(
+ MapX(plot, point.Timestamp, start, totalTicks),
+ MapYContinuous(plot, point.DisplayDb),
+ ResolveLevel(point.DisplayDb, _baselineDb));
+ lastSourceIndex = sourceIndex;
+ }
+
+ return outputIndex;
+ }
+
+ private static int ComputeSeriesSignature(IReadOnlyList points, double baselineDb)
+ {
+ if (points.Count == 0)
+ {
+ return HashCode.Combine(0, baselineDb);
+ }
+
+ var first = points[0];
+ var last = points[^1];
+ return HashCode.Combine(
+ points.Count,
+ first.Timestamp.UtcTicks,
+ last.Timestamp.UtcTicks,
+ Math.Round(last.DisplayDb, 2),
+ Math.Round(baselineDb, 2));
+ }
+
+ private static IBrush[][] CreateBrushTable(IReadOnlyList alphas)
+ {
+ var table = new IBrush[alphas.Count][];
+ for (var i = 0; i < alphas.Count; i++)
+ {
+ table[i] =
+ [
+ GetLevelBrushWithAlpha(NoiseDistributionLevel.Quiet, alphas[i]),
+ GetLevelBrushWithAlpha(NoiseDistributionLevel.Normal, alphas[i]),
+ GetLevelBrushWithAlpha(NoiseDistributionLevel.Noisy, alphas[i]),
+ GetLevelBrushWithAlpha(NoiseDistributionLevel.Extreme, alphas[i])
+ ];
+ }
+
+ return table;
+ }
+
+ private static IBrush GetAlphaBrush(NoiseDistributionLevel level, byte alpha)
+ {
+ for (var i = 0; i < CloudAlphas.Length; i++)
+ {
+ if (CloudAlphas[i] == alpha)
+ {
+ return CloudBrushes[i][(int)level];
+ }
+ }
+
+ for (var i = 0; i < GlowAlphas.Length; i++)
+ {
+ if (GlowAlphas[i] == alpha)
+ {
+ return GlowBrushes[i][(int)level];
+ }
+ }
+
+ return GetLevelBrushWithAlpha(level, alpha);
+ }
}
public enum NoiseDistributionLevel
diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs
index 31a0030..40f4d9c 100644
--- a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs
@@ -39,21 +39,22 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220");
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
+ private readonly object _snapshotSync = new();
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
private readonly LocalizationService _localizationService = new();
- private readonly DispatcherTimer _uiTimer = new()
- {
- Interval = TimeSpan.FromMilliseconds(100)
- };
private double _currentCellSize = 48;
+ private StudyAnalyticsSnapshot? _pendingSnapshot;
private string _languageCode = "zh-CN";
+ private bool _dispatchQueued;
+ private bool _hasPendingSnapshot;
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isDisposed;
private bool _isCompactMode;
+ private bool _isSubscribed;
private bool _isUltraCompactMode;
private bool _studyEnabled = true;
private IDisposable? _monitoringLease;
@@ -71,7 +72,6 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
{
InitializeComponent();
- _uiTimer.Tick += OnUiTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
@@ -80,7 +80,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
ApplyCellSize(_currentCellSize);
ApplyDefaultXAxisLabels();
ApplyLocalizedAxisLabels();
- RefreshVisual();
+ QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
}
public void ApplyCellSize(double cellSize)
@@ -94,24 +94,28 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
_ = isEditMode;
var wasOnActivePage = _isOnActivePage;
_isOnActivePage = isOnActivePage;
-
+
UpdateMonitoringLeaseState();
-
+
if (isOnActivePage && !wasOnActivePage)
{
- RefreshVisual();
+ QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
}
-
- UpdateTimerState();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ReloadLanguageCode();
+
+ if (!_isSubscribed)
+ {
+ _studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
+ _isSubscribed = true;
+ }
+
UpdateMonitoringLeaseState();
- UpdateTimerState();
- RefreshVisual();
+ QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
@@ -119,7 +123,12 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
_isAttached = false;
_monitoringLease?.Dispose();
_monitoringLease = null;
- _uiTimer.Stop();
+
+ if (_isSubscribed)
+ {
+ _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
+ _isSubscribed = false;
+ }
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
@@ -130,27 +139,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
- RefreshVisual();
- }
-
- private void OnUiTimerTick(object? sender, EventArgs e)
- {
- RefreshVisual();
- }
-
- private void UpdateTimerState()
- {
- if (_isAttached && _isOnActivePage)
- {
- if (!_uiTimer.IsEnabled)
- {
- _uiTimer.Start();
- }
-
- return;
- }
-
- _uiTimer.Stop();
+ QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
}
private void UpdateMonitoringLeaseState()
@@ -172,7 +161,52 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
_monitoringLease = null;
}
- private void RefreshVisual()
+ private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
+ {
+ _ = sender;
+ QueueSnapshotForRender(e.Snapshot);
+ }
+
+ private void QueueSnapshotForRender(StudyAnalyticsSnapshot snapshot)
+ {
+ lock (_snapshotSync)
+ {
+ _pendingSnapshot = snapshot;
+ _hasPendingSnapshot = true;
+ if (_dispatchQueued)
+ {
+ return;
+ }
+
+ _dispatchQueued = true;
+ }
+
+ Dispatcher.UIThread.Post(ProcessPendingSnapshot, DispatcherPriority.Background);
+ }
+
+ private void ProcessPendingSnapshot()
+ {
+ StudyAnalyticsSnapshot? snapshot = null;
+ lock (_snapshotSync)
+ {
+ _dispatchQueued = false;
+ if (_hasPendingSnapshot)
+ {
+ snapshot = _pendingSnapshot;
+ _pendingSnapshot = null;
+ _hasPendingSnapshot = false;
+ }
+ }
+
+ if (!_isAttached || !_isOnActivePage || snapshot is null)
+ {
+ return;
+ }
+
+ ApplySnapshot(snapshot);
+ }
+
+ private void ApplySnapshot(StudyAnalyticsSnapshot snapshot)
{
var panelColor = ResolvePanelBackgroundColor();
ApplyTypographyByBackground(panelColor);
@@ -189,8 +223,6 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
return;
}
- var snapshot = _studyAnalyticsService.GetSnapshot();
-
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
var isSessionView = isSessionRunning || isSessionReport;
@@ -634,13 +666,17 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
_isDisposed = true;
- _uiTimer.Stop();
- _uiTimer.Tick -= OnUiTimerTick;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged;
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
+ if (_isSubscribed)
+ {
+ _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
+ _isSubscribed = false;
+ }
+
_monitoringLease?.Dispose();
_monitoringLease = null;
}
diff --git a/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs
index 943d5d0..82fec53 100644
--- a/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs
@@ -45,6 +45,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private string _placementId = string.Empty;
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
private bool _isApplyingPersistedSnapshot;
+ private bool? _lastBitmapCacheEnabled;
+ private int _lastBitmapCacheSize;
private bool _noteDirty;
private int _noteLoadRevision;
private bool _disposed;
@@ -119,11 +121,10 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
settings.IgnorePressure = true;
settings.InkThickness = _selectedInkThickness;
settings.EraserSize = new Size(20, 20);
- settings.IsBitmapCacheEnabled = true;
- settings.MaxBitmapCacheSize = 2048;
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
+ UpdateInkCanvasCacheSettings(forceRefresh: true);
}
public void ApplyCellSize(double cellSize)
@@ -157,6 +158,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
settings.EraserSize = new Size(eraserSize, eraserSize);
+ UpdateInkCanvasCacheSettings(forceRefresh: false);
}
private void ApplyThemeVisual(bool force)
@@ -711,8 +713,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
}
- InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
- InkCanvas.InvalidateVisual();
+ UpdateInkCanvasCacheSettings(forceRefresh: true);
}
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
@@ -765,9 +766,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
}
- InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
- InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
- InkCanvas.InvalidateVisual();
+ UpdateInkCanvasCacheSettings(forceRefresh: true);
}
private bool HasValidPersistenceContext()
@@ -785,4 +784,47 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
return Array.Empty();
}
+
+ private void UpdateInkCanvasCacheSettings(bool forceRefresh)
+ {
+ var renderScaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1d;
+ var widthPx = Math.Max(1d, CanvasBorder.Bounds.Width * renderScaling);
+ var heightPx = Math.Max(1d, CanvasBorder.Bounds.Height * renderScaling);
+ var longestSide = Math.Max(widthPx, heightPx);
+ var area = widthPx * heightPx;
+
+ var cacheEnabled = longestSide <= 1536d && area <= 1_400_000d;
+ var cacheSize = (int)Math.Clamp(Math.Ceiling(longestSide), 384d, 1536d);
+ if (!forceRefresh &&
+ _lastBitmapCacheEnabled == cacheEnabled &&
+ _lastBitmapCacheSize == cacheSize)
+ {
+ return;
+ }
+
+ _lastBitmapCacheEnabled = cacheEnabled;
+ _lastBitmapCacheSize = cacheSize;
+
+ var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
+ settings.IsBitmapCacheEnabled = cacheEnabled;
+ settings.MaxBitmapCacheSize = cacheSize;
+
+ try
+ {
+ InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(cacheEnabled);
+ if (cacheEnabled)
+ {
+ InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
+ }
+ else
+ {
+ InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
+ InkCanvas.InvalidateVisual();
+ }
+ }
+ catch
+ {
+ // Keep drawing available even if the underlying cache backend rejects the cache update.
+ }
+ }
}