mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
fead.快捷方式组件。fix.优化了噪音检测组件与白板组件的性能
This commit is contained in:
113
LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs
Normal file
113
LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs
Normal file
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,7 +425,7 @@ public sealed class ComponentRegistry
|
||||
BuiltInComponentIds.DesktopShortcut,
|
||||
"快捷方式",
|
||||
"App",
|
||||
"Launcher",
|
||||
"File",
|
||||
MinWidthCells: 1,
|
||||
MinHeightCells: 1,
|
||||
AllowStatusBarPlacement: false,
|
||||
|
||||
@@ -136,9 +136,9 @@ public sealed class ComponentSettingsSnapshot
|
||||
public string ShortcutClickMode { get; set; } = "Double";
|
||||
|
||||
/// <summary>
|
||||
/// 是否透明背景
|
||||
/// 是否显示背景
|
||||
/// </summary>
|
||||
public bool ShortcutTransparentBackground { get; set; } = false;
|
||||
public bool ShortcutShowBackground { get; set; } = true;
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,8 +12,13 @@ internal readonly record struct NoisePipelineTickResult(
|
||||
internal sealed class NoiseFramePipeline
|
||||
{
|
||||
private StudyAnalyticsConfig _config;
|
||||
private readonly Queue<NoiseRealtimePoint> _realtimeBuffer = new();
|
||||
private readonly List<NoiseRealtimePoint> _slicePoints = [];
|
||||
private NoiseRealtimePoint[] _realtimeBuffer;
|
||||
private IReadOnlyList<NoiseRealtimePoint> _realtimeSnapshot = Array.Empty<NoiseRealtimePoint>();
|
||||
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<NoiseRealtimePoint>();
|
||||
_realtimeSnapshotVersion = -1;
|
||||
_sliceStartAt = default;
|
||||
_lastFrameAt = default;
|
||||
_lastOverThresholdAt = default;
|
||||
@@ -52,7 +68,27 @@ internal sealed class NoiseFramePipeline
|
||||
|
||||
public IReadOnlyList<NoiseRealtimePoint> GetRealtimeBufferSnapshot()
|
||||
{
|
||||
return _realtimeBuffer.ToArray();
|
||||
if (_realtimeBufferCount == 0)
|
||||
{
|
||||
return Array.Empty<NoiseRealtimePoint>();
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -46,6 +46,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
private readonly List<StudySessionReport> _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)
|
||||
|
||||
@@ -222,10 +222,37 @@
|
||||
</Style>
|
||||
|
||||
<!-- 向后兼容的旧样式类(已弃用) -->
|
||||
<Style Selector="Border.glass-panel" />
|
||||
<Style Selector="Border.glass-strong" />
|
||||
<Style Selector="Border.glass-island" />
|
||||
<Style Selector="Border.mica-strong" />
|
||||
<Style Selector="Border.glass-overlay" />
|
||||
<Style Selector="Border.glass-panel">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.2" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassPanelOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.glass-strong">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.glass-island">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.mica-strong">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.glass-overlay">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
|
||||
87
LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs
Normal file
87
LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs
Normal file
@@ -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<SelectionOption>
|
||||
{
|
||||
new("Double", "双击打开"),
|
||||
new("Single", "单击打开")
|
||||
};
|
||||
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
var snapshot = _context?.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>()
|
||||
?? 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<ComponentSettingsSnapshot>();
|
||||
|
||||
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<SelectionOption> 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();
|
||||
}
|
||||
@@ -1,86 +1,66 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="360"
|
||||
d:DesignHeight="400"
|
||||
x:Class="LanMountainDesktop.Views.ComponentEditors.ShortcutComponentEditor">
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
x:Class="LanMountainDesktop.Views.ComponentEditors.ShortcutComponentEditor"
|
||||
x:DataType="vm:ShortcutEditorViewModel">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="16" Margin="20">
|
||||
|
||||
<TextBlock x:Name="HeadlineTextBlock"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Opacity="0.75"
|
||||
<StackPanel Spacing="16">
|
||||
<!-- 说明卡片 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<TextBlock Text="{Binding DescriptionText}"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<ui:SettingsExpander x:Name="TargetPathExpander">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Folder" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<Button x:Name="BrowseButton"
|
||||
Content="浏览..."
|
||||
Click="OnBrowseClick" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
<ui:SettingsExpanderItem>
|
||||
<TextBox x:Name="TargetPathTextBox"
|
||||
<!-- 目标路径 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding TargetPathLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Text="{Binding TargetPath}"
|
||||
IsReadOnly="True"
|
||||
Watermark="未选择目标"
|
||||
MinWidth="200" />
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Button x:Name="ClearButton"
|
||||
Content="清除"
|
||||
Click="OnClearClick"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
Watermark="{Binding TargetPathPlaceholder}"
|
||||
Grid.Column="0" />
|
||||
<Button Content="{Binding BrowseButtonText}"
|
||||
Click="OnBrowseClick"
|
||||
Grid.Column="1"
|
||||
Margin="8,0,0,0" />
|
||||
</Grid>
|
||||
<Button Content="{Binding ClearButtonText}"
|
||||
Click="OnClearClick"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ui:SettingsExpander>
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="CursorClick" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpanderItem>
|
||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
||||
<RadioButton x:Name="SingleClickRadio"
|
||||
Content="单击打开"
|
||||
GroupName="ClickModeGroup" />
|
||||
<RadioButton x:Name="DoubleClickRadio"
|
||||
Content="双击打开"
|
||||
GroupName="ClickModeGroup"
|
||||
IsChecked="True" />
|
||||
</StackPanel>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
<!-- 打开方式 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding ClickModeLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox ItemsSource="{Binding ClickModeOptions}"
|
||||
SelectedItem="{Binding SelectedClickMode}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ui:SettingsExpander>
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="ColorBackground" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch x:Name="BackgroundToggle"
|
||||
OnContent=""
|
||||
OffContent=""
|
||||
IsChecked="True" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
<ui:SettingsExpanderItem>
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock x:Name="BackgroundLabel" />
|
||||
<TextBlock x:Name="BackgroundDescription"
|
||||
Opacity="0.75"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<!-- 背景设置 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding BackgroundLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<TextBlock Text="{Binding BackgroundDescription}"
|
||||
Classes="component-editor-secondary-text" />
|
||||
<CheckBox IsChecked="{Binding ShowBackground}"
|
||||
Content="{Binding BackgroundLabel}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
x:Class="LanMountainDesktop.Views.Components.ShortcutWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-panel"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True">
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
@@ -18,8 +20,13 @@
|
||||
Grid.Row="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<Image x:Name="IconImage"
|
||||
Stretch="Uniform" />
|
||||
<Panel>
|
||||
<Image x:Name="IconImage"
|
||||
Stretch="Uniform"
|
||||
IsVisible="False" />
|
||||
<ContentControl x:Name="SymbolIconHost"
|
||||
IsVisible="False" />
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="NameTextBlock"
|
||||
@@ -31,7 +38,8 @@
|
||||
MaxLines="2"
|
||||
TextWrapping="Wrap"
|
||||
Margin="4,0,4,4"
|
||||
FontSize="11" />
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -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<ComponentSettingsSnapshot>();
|
||||
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)
|
||||
|
||||
@@ -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<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||
private Point[]? _pointBuffer;
|
||||
private StreamGeometry? _lineGeometry;
|
||||
private StreamGeometry? _fillGeometry;
|
||||
private Rect _cachedPlot;
|
||||
private bool _geometryDirty = true;
|
||||
private int _lastSeriesSignature;
|
||||
|
||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points)
|
||||
{
|
||||
_points = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
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<Point>.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<Point> 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<Point> 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<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
||||
_pointBuffer = null;
|
||||
}
|
||||
|
||||
private static int ComputeSeriesSignature(IReadOnlyList<NoiseRealtimePoint> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||
private SampledPoint[] _sampledPoints = Array.Empty<SampledPoint>();
|
||||
private int _sampledPointCount;
|
||||
private double _baselineDb = 45;
|
||||
private Rect _cachedPlot;
|
||||
private bool _sampleCacheDirty = true;
|
||||
private int _lastSeriesSignature;
|
||||
|
||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb)
|
||||
{
|
||||
_points = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
_baselineDb = Math.Clamp(baselineDb, 20, 85);
|
||||
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
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<NoiseRealtimePoint> 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<byte> 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<InkStylusPoint>();
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user