mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a671db8b69 | ||
|
|
8c94253f92 | ||
|
|
6849a467d6 | ||
|
|
e69bbf8b19 | ||
|
|
d30af21317 |
82
CHANGELOG.md
Normal file
82
CHANGELOG.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 更新日志 / Changelog
|
||||||
|
|
||||||
|
所有重要的更改都将记录在此文件中。
|
||||||
|
|
||||||
|
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||||
|
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 新增 (Added)
|
||||||
|
- 待发布的新功能
|
||||||
|
|
||||||
|
### 变更 (Changed)
|
||||||
|
- 待发布的变更
|
||||||
|
|
||||||
|
### 修复 (Fixed)
|
||||||
|
- 待发布的修复
|
||||||
|
|
||||||
|
### 移除 (Removed)
|
||||||
|
- 待发布的移除项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.8.3.1] - 2026-04-08
|
||||||
|
|
||||||
|
### 新增 (Added)
|
||||||
|
- ✨ **快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
|
||||||
|
- 支持创建快捷方式,统一管理应用和文件
|
||||||
|
- 提供单击打开和双击打开两种交互模式
|
||||||
|
- 支持配置是否显示背景
|
||||||
|
- 📝 初始化更新日志文档,为后续版本发布建立基础
|
||||||
|
|
||||||
|
### 变更 (Changed)
|
||||||
|
- 无
|
||||||
|
|
||||||
|
### 修复 (Fixed)
|
||||||
|
- 无
|
||||||
|
|
||||||
|
### 移除 (Removed)
|
||||||
|
- 无
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本说明
|
||||||
|
|
||||||
|
### 版本号规则
|
||||||
|
|
||||||
|
本项目采用语义化版本号 `MAJOR.MINOR.PATCH.BUILD`:
|
||||||
|
|
||||||
|
- **MAJOR (主版本号)**: 不兼容的 API 修改
|
||||||
|
- **MINOR (次版本号)**: 向下兼容的功能性新增
|
||||||
|
- **PATCH (修订号)**: 向下兼容的问题修正
|
||||||
|
- **BUILD (构建号)**: 内部构建版本,用于区分同一 PATCH 版本的不同构建
|
||||||
|
|
||||||
|
### 分类说明
|
||||||
|
|
||||||
|
- **新增 (Added)**: 新功能、新特性
|
||||||
|
- **变更 (Changed)**: 对现有功能的变更
|
||||||
|
- **修复 (Fixed)**: Bug 修复
|
||||||
|
- **移除 (Removed)**: 移除的功能或特性
|
||||||
|
|
||||||
|
### 图例
|
||||||
|
|
||||||
|
- 🎉 **重大更新**: 重要功能或里程碑
|
||||||
|
- ✨ **新功能**: 新增功能特性
|
||||||
|
- 🐛 **Bug修复**: 问题修复
|
||||||
|
- 🔧 **配置**: 配置相关变更
|
||||||
|
- 📝 **文档**: 文档更新
|
||||||
|
- 🎨 **样式**: UI/UX 改进
|
||||||
|
- ♻️ **重构**: 代码重构
|
||||||
|
- ⚡ **性能**: 性能优化
|
||||||
|
- 🔒 **安全**: 安全相关
|
||||||
|
- 🌐 **国际化**: 国际化/本地化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 链接
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/yourorg/LanMountainDesktop/compare/v0.8.3.1...HEAD
|
||||||
|
[0.8.3.1]: https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.1
|
||||||
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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,4 +46,5 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
|
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
|
||||||
public const string DesktopFileManager = "DesktopFileManager";
|
public const string DesktopFileManager = "DesktopFileManager";
|
||||||
public const string DesktopNotificationBox = "DesktopNotificationBox";
|
public const string DesktopNotificationBox = "DesktopNotificationBox";
|
||||||
|
public const string DesktopShortcut = "DesktopShortcut";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,6 +420,16 @@ public sealed class ComponentRegistry
|
|||||||
MinHeightCells: 2,
|
MinHeightCells: 2,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true,
|
AllowDesktopPlacement: true,
|
||||||
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopShortcut,
|
||||||
|
"快捷方式",
|
||||||
|
"App",
|
||||||
|
"File",
|
||||||
|
MinWidthCells: 1,
|
||||||
|
MinHeightCells: 1,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true,
|
||||||
ResizeMode: DesktopComponentResizeMode.Free)
|
ResizeMode: DesktopComponentResizeMode.Free)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,25 @@ public sealed class ComponentSettingsSnapshot
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Shortcut Component Settings (快捷方式组件设置)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 快捷方式目标路径
|
||||||
|
/// </summary>
|
||||||
|
public string? ShortcutTargetPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 点击模式:Single(单击打开) 或 Double(双击打开)
|
||||||
|
/// </summary>
|
||||||
|
public string ShortcutClickMode { get; set; } = "Double";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否显示背景
|
||||||
|
/// </summary>
|
||||||
|
public bool ShortcutShowBackground { get; set; } = true;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
public ComponentSettingsSnapshot Clone()
|
public ComponentSettingsSnapshot Clone()
|
||||||
{
|
{
|
||||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public enum StudyDataMode
|
|||||||
|
|
||||||
public sealed record StudyAnalyticsConfig(
|
public sealed record StudyAnalyticsConfig(
|
||||||
int FrameMs = 50,
|
int FrameMs = 50,
|
||||||
|
int UiPublishIntervalMs = 125,
|
||||||
int SliceSec = 30,
|
int SliceSec = 30,
|
||||||
double ScoreThresholdDbfs = -50,
|
double ScoreThresholdDbfs = -50,
|
||||||
int SegmentMergeGapMs = 500,
|
int SegmentMergeGapMs = 500,
|
||||||
|
|||||||
@@ -272,7 +272,12 @@ public static class DesktopComponentEditorRegistryFactory
|
|||||||
BuiltInComponentIds.DesktopNotificationBox,
|
BuiltInComponentIds.DesktopNotificationBox,
|
||||||
context => new NotificationBoxComponentEditor(context),
|
context => new NotificationBoxComponentEditor(context),
|
||||||
preferredWidth: 480d,
|
preferredWidth: 480d,
|
||||||
preferredHeight: 520d)
|
preferredHeight: 520d),
|
||||||
|
[BuiltInComponentIds.DesktopShortcut] = new(
|
||||||
|
BuiltInComponentIds.DesktopShortcut,
|
||||||
|
context => new ShortcutComponentEditor(context),
|
||||||
|
preferredWidth: 420d,
|
||||||
|
preferredHeight: 400d)
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
|
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ internal readonly record struct NoisePipelineTickResult(
|
|||||||
internal sealed class NoiseFramePipeline
|
internal sealed class NoiseFramePipeline
|
||||||
{
|
{
|
||||||
private StudyAnalyticsConfig _config;
|
private StudyAnalyticsConfig _config;
|
||||||
private readonly Queue<NoiseRealtimePoint> _realtimeBuffer = new();
|
|
||||||
private readonly List<NoiseRealtimePoint> _slicePoints = [];
|
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 _sliceStartAt;
|
||||||
private DateTimeOffset _lastFrameAt;
|
private DateTimeOffset _lastFrameAt;
|
||||||
@@ -28,18 +33,29 @@ internal sealed class NoiseFramePipeline
|
|||||||
public NoiseFramePipeline(StudyAnalyticsConfig config)
|
public NoiseFramePipeline(StudyAnalyticsConfig config)
|
||||||
{
|
{
|
||||||
_config = NormalizeConfig(config);
|
_config = NormalizeConfig(config);
|
||||||
|
_realtimeBuffer = new NoiseRealtimePoint[_config.RealtimeBufferCapacity];
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateConfig(StudyAnalyticsConfig config)
|
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();
|
Reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Reset()
|
public void Reset()
|
||||||
{
|
{
|
||||||
_realtimeBuffer.Clear();
|
|
||||||
_slicePoints.Clear();
|
_slicePoints.Clear();
|
||||||
|
_realtimeBufferStart = 0;
|
||||||
|
_realtimeBufferCount = 0;
|
||||||
|
_realtimeBufferVersion++;
|
||||||
|
_realtimeSnapshot = Array.Empty<NoiseRealtimePoint>();
|
||||||
|
_realtimeSnapshotVersion = -1;
|
||||||
_sliceStartAt = default;
|
_sliceStartAt = default;
|
||||||
_lastFrameAt = default;
|
_lastFrameAt = default;
|
||||||
_lastOverThresholdAt = default;
|
_lastOverThresholdAt = default;
|
||||||
@@ -52,7 +68,27 @@ internal sealed class NoiseFramePipeline
|
|||||||
|
|
||||||
public IReadOnlyList<NoiseRealtimePoint> GetRealtimeBufferSnapshot()
|
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)
|
public NoisePipelineTickResult AddFrame(DateTimeOffset timestamp, double rms, double dbfs, double displayDb, double peak)
|
||||||
@@ -114,12 +150,7 @@ internal sealed class NoiseFramePipeline
|
|||||||
peak,
|
peak,
|
||||||
isOverThreshold);
|
isOverThreshold);
|
||||||
_slicePoints.Add(point);
|
_slicePoints.Add(point);
|
||||||
_realtimeBuffer.Enqueue(point);
|
AddRealtimePoint(point);
|
||||||
|
|
||||||
while (_realtimeBuffer.Count > _config.RealtimeBufferCapacity)
|
|
||||||
{
|
|
||||||
_realtimeBuffer.Dequeue();
|
|
||||||
}
|
|
||||||
|
|
||||||
var elapsedSeconds = (timestamp - _sliceStartAt).TotalSeconds;
|
var elapsedSeconds = (timestamp - _sliceStartAt).TotalSeconds;
|
||||||
if (elapsedSeconds + 1e-6 < _config.SliceSec)
|
if (elapsedSeconds + 1e-6 < _config.SliceSec)
|
||||||
@@ -132,6 +163,29 @@ internal sealed class NoiseFramePipeline
|
|||||||
return new NoisePipelineTickResult(point, slice);
|
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)
|
private NoiseSliceSummary BuildClosedSlice(DateTimeOffset endAt)
|
||||||
{
|
{
|
||||||
var sampledDurationMs = _slicePoints.Count * _config.FrameMs;
|
var sampledDurationMs = _slicePoints.Count * _config.FrameMs;
|
||||||
@@ -247,6 +301,7 @@ internal sealed class NoiseFramePipeline
|
|||||||
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
||||||
{
|
{
|
||||||
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
|
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 sliceSec = Math.Clamp(config.SliceSec, 5, 600);
|
||||||
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
||||||
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
||||||
@@ -259,6 +314,7 @@ internal sealed class NoiseFramePipeline
|
|||||||
return config with
|
return config with
|
||||||
{
|
{
|
||||||
FrameMs = frameMs,
|
FrameMs = frameMs,
|
||||||
|
UiPublishIntervalMs = uiPublishIntervalMs,
|
||||||
SliceSec = sliceSec,
|
SliceSec = sliceSec,
|
||||||
ScoreThresholdDbfs = threshold,
|
ScoreThresholdDbfs = threshold,
|
||||||
SegmentMergeGapMs = mergeGapMs,
|
SegmentMergeGapMs = mergeGapMs,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
private readonly List<StudySessionReport> _sessionHistory = [];
|
private readonly List<StudySessionReport> _sessionHistory = [];
|
||||||
private string? _selectedSessionReportId;
|
private string? _selectedSessionReportId;
|
||||||
private string _lastError = string.Empty;
|
private string _lastError = string.Empty;
|
||||||
|
private DateTimeOffset _lastUiPublishedAt;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null)
|
public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null)
|
||||||
@@ -102,6 +103,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
ThrowIfDisposedLocked();
|
ThrowIfDisposedLocked();
|
||||||
_config = NormalizeConfig(config);
|
_config = NormalizeConfig(config);
|
||||||
_pipeline.UpdateConfig(_config);
|
_pipeline.UpdateConfig(_config);
|
||||||
|
_lastUiPublishedAt = default;
|
||||||
if (_state == StudyAnalyticsRuntimeState.Running)
|
if (_state == StudyAnalyticsRuntimeState.Running)
|
||||||
{
|
{
|
||||||
StartTimerLocked();
|
StartTimerLocked();
|
||||||
@@ -546,7 +548,11 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
|
|
||||||
_lastError = string.Empty;
|
_lastError = string.Empty;
|
||||||
UpdateDataModeLocked();
|
UpdateDataModeLocked();
|
||||||
|
if (ShouldPublishRealtimeSnapshotLocked(now, closedSlice is not null))
|
||||||
|
{
|
||||||
snapshot = BuildSnapshotLocked(now);
|
snapshot = BuildSnapshotLocked(now);
|
||||||
|
_lastUiPublishedAt = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,6 +605,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
|
|
||||||
private void StartTimerLocked()
|
private void StartTimerLocked()
|
||||||
{
|
{
|
||||||
|
_lastUiPublishedAt = default;
|
||||||
_samplingTimer.Change(
|
_samplingTimer.Change(
|
||||||
dueTime: TimeSpan.Zero,
|
dueTime: TimeSpan.Zero,
|
||||||
period: TimeSpan.FromMilliseconds(_config.FrameMs));
|
period: TimeSpan.FromMilliseconds(_config.FrameMs));
|
||||||
@@ -673,6 +680,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
||||||
{
|
{
|
||||||
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
|
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 sliceSec = Math.Clamp(config.SliceSec, 5, 600);
|
||||||
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
||||||
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
||||||
@@ -685,6 +693,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
return config with
|
return config with
|
||||||
{
|
{
|
||||||
FrameMs = frameMs,
|
FrameMs = frameMs,
|
||||||
|
UiPublishIntervalMs = uiPublishIntervalMs,
|
||||||
SliceSec = sliceSec,
|
SliceSec = sliceSec,
|
||||||
ScoreThresholdDbfs = threshold,
|
ScoreThresholdDbfs = threshold,
|
||||||
SegmentMergeGapMs = mergeGapMs,
|
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()
|
private void ThrowIfDisposedLocked()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
|
|||||||
@@ -222,10 +222,37 @@
|
|||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- 向后兼容的旧样式类(已弃用) -->
|
<!-- 向后兼容的旧样式类(已弃用) -->
|
||||||
<Style Selector="Border.glass-panel" />
|
<Style Selector="Border.glass-panel">
|
||||||
<Style Selector="Border.glass-strong" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
||||||
<Style Selector="Border.glass-island" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||||
<Style Selector="Border.mica-strong" />
|
<Setter Property="BorderThickness" Value="1.2" />
|
||||||
<Style Selector="Border.glass-overlay" />
|
<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>
|
</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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.ShortcutComponentEditor"
|
||||||
|
x:DataType="vm:ShortcutEditorViewModel">
|
||||||
|
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<!-- 说明卡片 -->
|
||||||
|
<Border Classes="component-editor-card" Padding="20">
|
||||||
|
<TextBlock Text="{Binding DescriptionText}"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 目标路径 -->
|
||||||
|
<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="{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>
|
||||||
|
|
||||||
|
<!-- 打开方式 -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 背景设置 -->
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class ShortcutComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private ShortcutEditorViewModel? _viewModel;
|
||||||
|
|
||||||
|
public ShortcutComponentEditor()
|
||||||
|
: this(null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShortcutComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_viewModel = new ShortcutEditorViewModel(context);
|
||||||
|
DataContext = _viewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
if (topLevel?.StorageProvider is not { } storageProvider)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "选择目标文件",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter =
|
||||||
|
[
|
||||||
|
new FilePickerFileType("可执行文件")
|
||||||
|
{
|
||||||
|
Patterns = ["*.exe", "*.lnk", "*.bat", "*.cmd"]
|
||||||
|
},
|
||||||
|
new FilePickerFileType("所有文件")
|
||||||
|
{
|
||||||
|
Patterns = ["*.*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var files = await storageProvider.OpenFilePickerAsync(options);
|
||||||
|
var localPath = files.FirstOrDefault()?.TryGetLocalPath();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(localPath))
|
||||||
|
{
|
||||||
|
var folderOptions = new FolderPickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "选择目标文件夹",
|
||||||
|
AllowMultiple = false
|
||||||
|
};
|
||||||
|
|
||||||
|
var folders = await storageProvider.OpenFolderPickerAsync(folderOptions);
|
||||||
|
localPath = folders.FirstOrDefault()?.TryGetLocalPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(localPath))
|
||||||
|
{
|
||||||
|
_viewModel?.SetTargetPath(localPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClearClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_viewModel?.ClearTargetPath();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -479,7 +479,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
new DesktopComponentRuntimeRegistration(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.DesktopNotificationBox,
|
BuiltInComponentIds.DesktopNotificationBox,
|
||||||
"component.notification_box",
|
"component.notification_box",
|
||||||
() => new NotificationBoxWidget())
|
() => new NotificationBoxWidget()),
|
||||||
|
new DesktopComponentRuntimeRegistration(
|
||||||
|
BuiltInComponentIds.DesktopShortcut,
|
||||||
|
"component.shortcut",
|
||||||
|
() => new ShortcutWidget())
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
46
LanMountainDesktop/Views/Components/ShortcutWidget.axaml
Normal file
46
LanMountainDesktop/Views/Components/ShortcutWidget.axaml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<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"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="96"
|
||||||
|
d:DesignHeight="96"
|
||||||
|
x:Class="LanMountainDesktop.Views.Components.ShortcutWidget">
|
||||||
|
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||||
|
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<Grid RowDefinitions="*,Auto"
|
||||||
|
x:Name="ContentGrid">
|
||||||
|
|
||||||
|
<Border x:Name="IconHost"
|
||||||
|
Grid.Row="0"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Panel>
|
||||||
|
<Image x:Name="IconImage"
|
||||||
|
Stretch="Uniform"
|
||||||
|
IsVisible="False" />
|
||||||
|
<ContentControl x:Name="SymbolIconHost"
|
||||||
|
IsVisible="False" />
|
||||||
|
</Panel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="NameTextBlock"
|
||||||
|
Grid.Row="1"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="2"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Margin="4,0,4,4"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
396
LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs
Normal file
396
LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using FluentIcons.Avalonia;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
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 _showBackground = true;
|
||||||
|
private double _currentCellSize = 48;
|
||||||
|
private bool _isDisposed;
|
||||||
|
|
||||||
|
private const double TapMovementThreshold = 10;
|
||||||
|
private const long TapTimeThresholdMs = 500;
|
||||||
|
|
||||||
|
private readonly Dictionary<int, PointerGestureState> _gestureStates = new();
|
||||||
|
|
||||||
|
private record PointerGestureState(
|
||||||
|
Point StartPosition,
|
||||||
|
long StartTime
|
||||||
|
);
|
||||||
|
|
||||||
|
public ShortcutWidget()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DoubleTapped += OnDoubleTapped;
|
||||||
|
UpdateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||||
|
{
|
||||||
|
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||||
|
? BuiltInComponentIds.DesktopShortcut
|
||||||
|
: componentId.Trim();
|
||||||
|
_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";
|
||||||
|
_showBackground = snapshot.ShortcutShowBackground;
|
||||||
|
UpdateDisplay();
|
||||||
|
ApplyChrome();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
_currentCellSize = cellSize;
|
||||||
|
|
||||||
|
// 图标大小:从 cellSize 的 50% 计算,最小 24px,最大 128px
|
||||||
|
var iconSize = Math.Clamp(cellSize * 0.5, 24, 128);
|
||||||
|
IconImage.Width = iconSize;
|
||||||
|
IconImage.Height = iconSize;
|
||||||
|
|
||||||
|
// 字体大小:从 cellSize 的 18% 计算,最小 10px,最大 24px
|
||||||
|
var fontSize = Math.Clamp(cellSize * 0.18, 10, 24);
|
||||||
|
NameTextBlock.FontSize = fontSize;
|
||||||
|
|
||||||
|
// 更新符号图标的大小(如果当前显示的是符号图标)
|
||||||
|
if (SymbolIconHost.Content is SymbolIcon symbolIcon)
|
||||||
|
{
|
||||||
|
symbolIcon.FontSize = iconSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDisplay()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||||
|
{
|
||||||
|
ShowEmptyState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var name = GetDisplayName(_targetPath);
|
||||||
|
NameTextBlock.Text = name;
|
||||||
|
// 文字颜色由 XAML 中的 DynamicResource 自动适配主题
|
||||||
|
|
||||||
|
LoadIcon(_targetPath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
ShowEmptyState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowEmptyState()
|
||||||
|
{
|
||||||
|
NameTextBlock.Text = "添加快捷方式";
|
||||||
|
// 使用次要文字颜色(由主题自动适配)
|
||||||
|
NameTextBlock.Foreground = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush;
|
||||||
|
|
||||||
|
var iconBrush = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush;
|
||||||
|
|
||||||
|
// 隐藏图片图标,显示符号图标
|
||||||
|
IconImage.IsVisible = false;
|
||||||
|
IconImage.Source = null;
|
||||||
|
|
||||||
|
// 计算图标大小
|
||||||
|
var iconSize = Math.Clamp(_currentCellSize * 0.5, 24, 128);
|
||||||
|
|
||||||
|
var iconHostContent = new SymbolIcon
|
||||||
|
{
|
||||||
|
Symbol = FluentIcons.Common.Symbol.Add,
|
||||||
|
FontSize = iconSize,
|
||||||
|
Foreground = iconBrush,
|
||||||
|
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
SymbolIconHost.Content = iconHostContent;
|
||||||
|
SymbolIconHost.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDisplayName(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return "快捷方式";
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
return Path.GetFileName(path.TrimEnd('\\', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(path);
|
||||||
|
return string.IsNullOrWhiteSpace(fileName) ? path : fileName;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadIcon(string path)
|
||||||
|
{
|
||||||
|
byte[]? pngBytes = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
pngBytes = WindowsIconService.TryGetSystemFolderIconPngBytes();
|
||||||
|
}
|
||||||
|
else if (File.Exists(path))
|
||||||
|
{
|
||||||
|
pngBytes = WindowsIconService.TryGetIconPngBytes(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
pngBytes = LinuxIconService.TryGetSystemFolderIconPngBytes();
|
||||||
|
}
|
||||||
|
else if (File.Exists(path))
|
||||||
|
{
|
||||||
|
pngBytes = LinuxIconService.TryGetIconPngBytes(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
pngBytes = MacIconService.TryGetSystemFolderIconPngBytes();
|
||||||
|
}
|
||||||
|
else if (File.Exists(path))
|
||||||
|
{
|
||||||
|
pngBytes = MacIconService.TryGetIconPngBytes(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
pngBytes = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pngBytes is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(pngBytes);
|
||||||
|
IconImage.Source = new Bitmap(stream);
|
||||||
|
IconImage.IsVisible = true;
|
||||||
|
SymbolIconHost.IsVisible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadFallbackIcon(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadFallbackIcon(string path)
|
||||||
|
{
|
||||||
|
var symbol = Directory.Exists(path)
|
||||||
|
? FluentIcons.Common.Symbol.Folder
|
||||||
|
: FluentIcons.Common.Symbol.Document;
|
||||||
|
|
||||||
|
// 使用强调色(由主题自动适配)
|
||||||
|
var iconBrush = this.FindResource("AdaptiveAccentBrush") as IBrush;
|
||||||
|
|
||||||
|
// 隐藏图片图标,显示符号图标
|
||||||
|
IconImage.IsVisible = false;
|
||||||
|
IconImage.Source = null;
|
||||||
|
|
||||||
|
// 计算图标大小
|
||||||
|
var iconSize = Math.Clamp(_currentCellSize * 0.5, 24, 128);
|
||||||
|
|
||||||
|
var iconHostContent = new SymbolIcon
|
||||||
|
{
|
||||||
|
Symbol = symbol,
|
||||||
|
FontSize = iconSize,
|
||||||
|
Foreground = iconBrush,
|
||||||
|
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
SymbolIconHost.Content = iconHostContent;
|
||||||
|
SymbolIconHost.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyChrome()
|
||||||
|
{
|
||||||
|
if (!_showBackground)
|
||||||
|
{
|
||||||
|
RootBorder.Background = Brushes.Transparent;
|
||||||
|
RootBorder.BorderBrush = Brushes.Transparent;
|
||||||
|
RootBorder.BorderThickness = new Thickness(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复默认的实心背景样式
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
base.OnPointerPressed(e);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pointer = e.GetCurrentPoint(this);
|
||||||
|
var pointerId = e.Pointer.Id;
|
||||||
|
var position = pointer.Position;
|
||||||
|
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
_gestureStates[pointerId] = new PointerGestureState(position, timestamp);
|
||||||
|
e.Pointer.Capture(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerMoved(PointerEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnPointerMoved(e);
|
||||||
|
|
||||||
|
var pointerId = e.Pointer.Id;
|
||||||
|
if (!_gestureStates.TryGetValue(pointerId, out var state))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentPoint = e.GetCurrentPoint(this);
|
||||||
|
var distance = Math.Sqrt(
|
||||||
|
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||||
|
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance > TapMovementThreshold)
|
||||||
|
{
|
||||||
|
_gestureStates.Remove(pointerId);
|
||||||
|
e.Pointer.Capture(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnPointerReleased(e);
|
||||||
|
|
||||||
|
var pointerId = e.Pointer.Id;
|
||||||
|
if (!_gestureStates.Remove(pointerId, out var state))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Pointer.Capture(null);
|
||||||
|
|
||||||
|
var currentPoint = e.GetCurrentPoint(this);
|
||||||
|
var distance = Math.Sqrt(
|
||||||
|
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||||
|
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - state.StartTime;
|
||||||
|
|
||||||
|
if (distance > TapMovementThreshold || elapsed > TapTimeThresholdMs)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_clickMode == "Single")
|
||||||
|
{
|
||||||
|
OpenTarget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDoubleTapped(object? sender, TappedEventArgs e)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_clickMode == "Double")
|
||||||
|
{
|
||||||
|
OpenTarget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenTarget()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(_targetPath)
|
||||||
|
{
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
{
|
||||||
|
Process.Start("xdg-open", _targetPath);
|
||||||
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
{
|
||||||
|
Process.Start("open", _targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("ShortcutWidget", $"Failed to open target: {_targetPath}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_isDisposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDisposed = true;
|
||||||
|
_gestureStates.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
@@ -20,10 +20,24 @@ public sealed class StudyNoiseCurveChartControl : Control
|
|||||||
|
|
||||||
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||||
private Point[]? _pointBuffer;
|
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)
|
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();
|
InvalidateVisual();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +48,18 @@ public sealed class StudyNoiseCurveChartControl : Control
|
|||||||
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
||||||
_pointBuffer = null;
|
_pointBuffer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_lineGeometry = null;
|
||||||
|
_fillGeometry = null;
|
||||||
|
_geometryDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
ReleasePointBuffer();
|
ReleasePointBuffer();
|
||||||
|
_lineGeometry = null;
|
||||||
|
_fillGeometry = null;
|
||||||
|
_geometryDirty = true;
|
||||||
base.OnDetachedFromVisualTree(e);
|
base.OnDetachedFromVisualTree(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,16 +85,14 @@ public sealed class StudyNoiseCurveChartControl : Control
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360);
|
EnsureGeometry(plot);
|
||||||
var pointCount = BuildPlotPoints(plot, maxSamples);
|
if (_lineGeometry is null || _fillGeometry is null)
|
||||||
if (pointCount < 2 || _pointBuffer is null)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var span = _pointBuffer.AsSpan(0, pointCount);
|
context.DrawGeometry(FillBrush, pen: null, _fillGeometry);
|
||||||
DrawAreaFill(context, plot.Bottom, span);
|
context.DrawGeometry(brush: null, pen: LinePen, _lineGeometry);
|
||||||
DrawLine(context, span);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void DrawGrid(DrawingContext context, Rect plot)
|
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));
|
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();
|
if (!_geometryDirty && _cachedPlot == plot)
|
||||||
using (var builder = geometry.Open())
|
|
||||||
{
|
{
|
||||||
builder.BeginFigure(points[0], false);
|
return;
|
||||||
for (var i = 1; i < points.Length; i++)
|
}
|
||||||
|
|
||||||
|
_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)
|
||||||
{
|
{
|
||||||
builder.LineTo(points[i]);
|
_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(_pointBuffer[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.DrawGeometry(brush: null, pen: LinePen, geometry);
|
var fillGeometry = new StreamGeometry();
|
||||||
}
|
using (var builder = fillGeometry.Open())
|
||||||
|
|
||||||
private void DrawAreaFill(DrawingContext context, double baselineY, ReadOnlySpan<Point> points)
|
|
||||||
{
|
{
|
||||||
var geometry = new StreamGeometry();
|
var first = _pointBuffer[0];
|
||||||
using (var builder = geometry.Open())
|
builder.BeginFigure(new Point(first.X, plot.Bottom), true);
|
||||||
{
|
|
||||||
var first = points[0];
|
|
||||||
builder.BeginFigure(new Point(first.X, baselineY), true);
|
|
||||||
builder.LineTo(first);
|
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];
|
var last = _pointBuffer[pointCount - 1];
|
||||||
builder.LineTo(new Point(last.X, baselineY));
|
builder.LineTo(new Point(last.X, plot.Bottom));
|
||||||
builder.LineTo(new Point(first.X, baselineY));
|
builder.LineTo(new Point(first.X, plot.Bottom));
|
||||||
builder.EndFigure(true);
|
builder.EndFigure(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.DrawGeometry(FillBrush, pen: null, geometry);
|
_lineGeometry = lineGeometry;
|
||||||
|
_fillGeometry = fillGeometry;
|
||||||
|
_geometryDirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int BuildPlotPoints(Rect plot, int maxSamples)
|
private int BuildPlotPoints(Rect plot, int maxSamples)
|
||||||
@@ -295,4 +328,20 @@ public sealed class StudyNoiseCurveChartControl : Control
|
|||||||
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
||||||
_pointBuffer = null;
|
_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
|
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 GridBrush = new SolidColorBrush(Color.Parse("#2E5E7A96"));
|
||||||
private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1"));
|
private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1"));
|
||||||
private static readonly Pen GridPen = new(GridBrush, 1);
|
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 NormalBrush = new SolidColorBrush(Color.Parse("#FF60A5FA"));
|
||||||
private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B"));
|
private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B"));
|
||||||
private static readonly IBrush ExtremeBrush = new SolidColorBrush(Color.Parse("#FFEF4444"));
|
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 IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||||
|
private SampledPoint[] _sampledPoints = Array.Empty<SampledPoint>();
|
||||||
|
private int _sampledPointCount;
|
||||||
private double _baselineDb = 45;
|
private double _baselineDb = 45;
|
||||||
|
private Rect _cachedPlot;
|
||||||
|
private bool _sampleCacheDirty = true;
|
||||||
|
private int _lastSeriesSignature;
|
||||||
|
|
||||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb)
|
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb)
|
||||||
{
|
{
|
||||||
_points = points ?? Array.Empty<NoiseRealtimePoint>();
|
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||||
_baselineDb = Math.Clamp(baselineDb, 20, 85);
|
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();
|
InvalidateVisual();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,45 +75,34 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnsureSampleCache(plot);
|
||||||
|
if (_sampledPointCount < 2)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
DrawElectronCloud(context, plot);
|
DrawElectronCloud(context, plot);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawElectronCloud(DrawingContext context, Rect plot)
|
private void DrawElectronCloud(DrawingContext context, Rect plot)
|
||||||
{
|
{
|
||||||
var start = _points[0].Timestamp;
|
var cloudLayers = CloudAlphas.Length;
|
||||||
var end = _points[^1].Timestamp;
|
|
||||||
var totalTicks = Math.Max(1, (end - start).Ticks);
|
|
||||||
|
|
||||||
var pointCount = _points.Count;
|
|
||||||
var cloudLayers = 8;
|
|
||||||
var baseRadius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 45d, 3, 12);
|
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--)
|
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 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 jitterX = ComputeJitter(pt.X * 1000 + layer) * layerRadius * 0.3;
|
||||||
var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3;
|
var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3;
|
||||||
|
|
||||||
context.DrawEllipse(
|
context.DrawEllipse(
|
||||||
brush,
|
layerBrushes[(int)pt.Level],
|
||||||
pen: null,
|
pen: null,
|
||||||
center: new Point(pt.X + jitterX, pt.Y + jitterY),
|
center: new Point(pt.X + jitterX, pt.Y + jitterY),
|
||||||
radiusX: layerRadius,
|
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--)
|
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 layerRadius = baseRadius * (0.8 + layerRatio * 0.6);
|
||||||
var layerAlpha = (byte)(20 + layerRatio * 15);
|
var layerBrushes = GlowBrushes[layer];
|
||||||
|
for (var i = 0; i < _sampledPointCount; i++)
|
||||||
foreach (var pt in sortedPoints)
|
|
||||||
{
|
{
|
||||||
var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha);
|
var pt = _sampledPoints[i];
|
||||||
context.DrawEllipse(
|
context.DrawEllipse(
|
||||||
brush,
|
layerBrushes[(int)pt.Level],
|
||||||
pen: null,
|
pen: null,
|
||||||
center: new Point(pt.X, pt.Y),
|
center: new Point(pt.X, pt.Y),
|
||||||
radiusX: layerRadius,
|
radiusX: layerRadius,
|
||||||
@@ -117,34 +128,42 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var latest = _points[^1];
|
var latest = _sampledPoints[_sampledPointCount - 1];
|
||||||
var latestX = MapX(plot, latest.Timestamp, start, totalTicks);
|
|
||||||
var latestY = MapYContinuous(plot, latest.DisplayDb);
|
|
||||||
var latestLevel = ResolveLevel(latest.DisplayDb, _baselineDb);
|
|
||||||
|
|
||||||
for (var i = 3; i >= 0; i--)
|
for (var i = 3; i >= 0; i--)
|
||||||
{
|
{
|
||||||
var radius = baseRadius * (1.5 + i * 0.8);
|
var radius = baseRadius * (1.5 + i * 0.8);
|
||||||
var alpha = (byte)(30 - i * 6);
|
var alpha = (byte)(30 - i * 6);
|
||||||
var glowBrush = GetLevelBrushWithAlpha(latestLevel, alpha);
|
var glowBrush = GetAlphaBrush(latest.Level, alpha);
|
||||||
context.DrawEllipse(glowBrush, null, new Point(latestX, latestY), radius, radius * 0.6);
|
context.DrawEllipse(glowBrush, null, new Point(latest.X, latest.Y), radius, radius * 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.DrawEllipse(
|
context.DrawEllipse(
|
||||||
GetLevelBrush(latestLevel),
|
GetLevelBrush(latest.Level),
|
||||||
new Pen(Brushes.White, 1.5),
|
new Pen(Brushes.White, 1.5),
|
||||||
new Point(latestX, latestY),
|
new Point(latest.X, latest.Y),
|
||||||
baseRadius + 1,
|
baseRadius + 1,
|
||||||
baseRadius * 0.7 + 1);
|
baseRadius * 0.7 + 1);
|
||||||
|
|
||||||
context.DrawEllipse(
|
context.DrawEllipse(
|
||||||
Brushes.White,
|
Brushes.White,
|
||||||
null,
|
null,
|
||||||
new Point(latestX, latestY),
|
new Point(latest.X, latest.Y),
|
||||||
2,
|
2,
|
||||||
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)
|
private static void DrawGrid(DrawingContext context, Rect plot)
|
||||||
{
|
{
|
||||||
const int verticalDivisions = 4;
|
const int verticalDivisions = 4;
|
||||||
@@ -176,7 +195,10 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
|||||||
var minDb = _baselineDb - 5;
|
var minDb = _baselineDb - 5;
|
||||||
var maxDb = _baselineDb + 25;
|
var maxDb = _baselineDb + 25;
|
||||||
var dbRange = maxDb - minDb;
|
var dbRange = maxDb - minDb;
|
||||||
if (dbRange <= 0) dbRange = 30;
|
if (dbRange <= 0)
|
||||||
|
{
|
||||||
|
dbRange = 30;
|
||||||
|
}
|
||||||
|
|
||||||
var normalizedDb = (displayDb - minDb) / dbRange;
|
var normalizedDb = (displayDb - minDb) / dbRange;
|
||||||
normalizedDb = Math.Clamp(normalizedDb, 0, 1);
|
normalizedDb = Math.Clamp(normalizedDb, 0, 1);
|
||||||
@@ -243,6 +265,106 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
|||||||
_ => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA))
|
_ => 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
|
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 DarkSubstrate = Color.Parse("#FF0B1220");
|
||||||
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
|
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
|
||||||
|
|
||||||
|
private readonly object _snapshotSync = new();
|
||||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||||
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
|
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
|
||||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly DispatcherTimer _uiTimer = new()
|
|
||||||
{
|
|
||||||
Interval = TimeSpan.FromMilliseconds(100)
|
|
||||||
};
|
|
||||||
|
|
||||||
private double _currentCellSize = 48;
|
private double _currentCellSize = 48;
|
||||||
|
private StudyAnalyticsSnapshot? _pendingSnapshot;
|
||||||
private string _languageCode = "zh-CN";
|
private string _languageCode = "zh-CN";
|
||||||
|
private bool _dispatchQueued;
|
||||||
|
private bool _hasPendingSnapshot;
|
||||||
private bool _isAttached;
|
private bool _isAttached;
|
||||||
private bool _isOnActivePage = true;
|
private bool _isOnActivePage = true;
|
||||||
private bool _isDisposed;
|
private bool _isDisposed;
|
||||||
private bool _isCompactMode;
|
private bool _isCompactMode;
|
||||||
|
private bool _isSubscribed;
|
||||||
private bool _isUltraCompactMode;
|
private bool _isUltraCompactMode;
|
||||||
private bool _studyEnabled = true;
|
private bool _studyEnabled = true;
|
||||||
private IDisposable? _monitoringLease;
|
private IDisposable? _monitoringLease;
|
||||||
@@ -71,7 +72,6 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
_uiTimer.Tick += OnUiTimerTick;
|
|
||||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
SizeChanged += OnSizeChanged;
|
SizeChanged += OnSizeChanged;
|
||||||
@@ -80,7 +80,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
|||||||
ApplyCellSize(_currentCellSize);
|
ApplyCellSize(_currentCellSize);
|
||||||
ApplyDefaultXAxisLabels();
|
ApplyDefaultXAxisLabels();
|
||||||
ApplyLocalizedAxisLabels();
|
ApplyLocalizedAxisLabels();
|
||||||
RefreshVisual();
|
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ApplyCellSize(double cellSize)
|
public void ApplyCellSize(double cellSize)
|
||||||
@@ -99,19 +99,23 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
|||||||
|
|
||||||
if (isOnActivePage && !wasOnActivePage)
|
if (isOnActivePage && !wasOnActivePage)
|
||||||
{
|
{
|
||||||
RefreshVisual();
|
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateTimerState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
_isAttached = true;
|
_isAttached = true;
|
||||||
ReloadLanguageCode();
|
ReloadLanguageCode();
|
||||||
|
|
||||||
|
if (!_isSubscribed)
|
||||||
|
{
|
||||||
|
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
|
||||||
|
_isSubscribed = true;
|
||||||
|
}
|
||||||
|
|
||||||
UpdateMonitoringLeaseState();
|
UpdateMonitoringLeaseState();
|
||||||
UpdateTimerState();
|
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||||
RefreshVisual();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
@@ -119,7 +123,12 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
|||||||
_isAttached = false;
|
_isAttached = false;
|
||||||
_monitoringLease?.Dispose();
|
_monitoringLease?.Dispose();
|
||||||
_monitoringLease = null;
|
_monitoringLease = null;
|
||||||
_uiTimer.Stop();
|
|
||||||
|
if (_isSubscribed)
|
||||||
|
{
|
||||||
|
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||||
|
_isSubscribed = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
@@ -130,27 +139,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
|||||||
|
|
||||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
RefreshVisual();
|
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||||
}
|
|
||||||
|
|
||||||
private void OnUiTimerTick(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
RefreshVisual();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateTimerState()
|
|
||||||
{
|
|
||||||
if (_isAttached && _isOnActivePage)
|
|
||||||
{
|
|
||||||
if (!_uiTimer.IsEnabled)
|
|
||||||
{
|
|
||||||
_uiTimer.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiTimer.Stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateMonitoringLeaseState()
|
private void UpdateMonitoringLeaseState()
|
||||||
@@ -172,7 +161,52 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
|||||||
_monitoringLease = null;
|
_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();
|
var panelColor = ResolvePanelBackgroundColor();
|
||||||
ApplyTypographyByBackground(panelColor);
|
ApplyTypographyByBackground(panelColor);
|
||||||
@@ -189,8 +223,6 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
|
||||||
|
|
||||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||||
var isSessionView = isSessionRunning || isSessionReport;
|
var isSessionView = isSessionRunning || isSessionReport;
|
||||||
@@ -634,13 +666,17 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
|||||||
|
|
||||||
_isDisposed = true;
|
_isDisposed = true;
|
||||||
|
|
||||||
_uiTimer.Stop();
|
|
||||||
_uiTimer.Tick -= OnUiTimerTick;
|
|
||||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||||
SizeChanged -= OnSizeChanged;
|
SizeChanged -= OnSizeChanged;
|
||||||
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
|
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
|
||||||
|
|
||||||
|
if (_isSubscribed)
|
||||||
|
{
|
||||||
|
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||||
|
_isSubscribed = false;
|
||||||
|
}
|
||||||
|
|
||||||
_monitoringLease?.Dispose();
|
_monitoringLease?.Dispose();
|
||||||
_monitoringLease = null;
|
_monitoringLease = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
private string _placementId = string.Empty;
|
private string _placementId = string.Empty;
|
||||||
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||||
private bool _isApplyingPersistedSnapshot;
|
private bool _isApplyingPersistedSnapshot;
|
||||||
|
private bool? _lastBitmapCacheEnabled;
|
||||||
|
private int _lastBitmapCacheSize;
|
||||||
private bool _noteDirty;
|
private bool _noteDirty;
|
||||||
private int _noteLoadRevision;
|
private int _noteLoadRevision;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -119,11 +121,10 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
settings.IgnorePressure = true;
|
settings.IgnorePressure = true;
|
||||||
settings.InkThickness = _selectedInkThickness;
|
settings.InkThickness = _selectedInkThickness;
|
||||||
settings.EraserSize = new Size(20, 20);
|
settings.EraserSize = new Size(20, 20);
|
||||||
settings.IsBitmapCacheEnabled = true;
|
|
||||||
settings.MaxBitmapCacheSize = 2048;
|
|
||||||
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
|
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
|
||||||
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
|
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
|
||||||
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
|
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
|
||||||
|
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ApplyCellSize(double cellSize)
|
public void ApplyCellSize(double cellSize)
|
||||||
@@ -157,6 +158,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||||
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
||||||
settings.EraserSize = new Size(eraserSize, eraserSize);
|
settings.EraserSize = new Size(eraserSize, eraserSize);
|
||||||
|
UpdateInkCanvasCacheSettings(forceRefresh: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyThemeVisual(bool force)
|
private void ApplyThemeVisual(bool force)
|
||||||
@@ -711,8 +713,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
|
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
|
||||||
}
|
}
|
||||||
|
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
|
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
||||||
InkCanvas.InvalidateVisual();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
|
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
|
||||||
@@ -765,9 +766,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
|
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
|
||||||
InkCanvas.InvalidateVisual();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool HasValidPersistenceContext()
|
private bool HasValidPersistenceContext()
|
||||||
@@ -785,4 +784,47 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
|
|
||||||
return Array.Empty<InkStylusPoint>();
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,38 +508,34 @@
|
|||||||
</Grid.RenderTransform>
|
</Grid.RenderTransform>
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<Button x:Name="TaskbarPowerBackButton"
|
<Button x:Name="TaskbarPowerBackButton"
|
||||||
Padding="4,6"
|
Classes="taskbar-profile-popup-action"
|
||||||
Background="Transparent"
|
|
||||||
BorderThickness="0"
|
|
||||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
|
||||||
HorizontalAlignment="Left"
|
HorizontalAlignment="Left"
|
||||||
Click="OnPowerMenuBackClick">
|
Click="OnPowerMenuBackClick">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
<fi:SymbolIcon Classes="icon-s"
|
ColumnSpacing="12">
|
||||||
Symbol="ArrowLeft"
|
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||||
IconVariant="Regular" />
|
Kind="ArrowLeft" />
|
||||||
<TextBlock x:Name="TaskbarPowerBackTextBlock"
|
<TextBlock x:Name="TaskbarPowerBackTextBlock"
|
||||||
VerticalAlignment="Center"
|
Grid.Column="1"
|
||||||
|
Classes="taskbar-profile-popup-action-text"
|
||||||
Text="Back" />
|
Text="Back" />
|
||||||
</StackPanel>
|
</Grid>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<TextBlock x:Name="TaskbarPowerTitleTextBlock"
|
<TextBlock x:Name="TaskbarPowerTitleTextBlock"
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Foreground="{DynamicResource TaskbarProfilePopupTextBrush}"
|
Foreground="{DynamicResource TaskbarProfilePopupTextBrush}"
|
||||||
Margin="2,6,0,0"
|
|
||||||
Text="Power" />
|
Text="Power" />
|
||||||
|
|
||||||
<Border Height="1"
|
<Border Height="1"
|
||||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}"
|
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
|
||||||
Margin="0,4" />
|
|
||||||
|
|
||||||
<Button x:Name="PowerShutdownButton"
|
<Button x:Name="PowerShutdownButton"
|
||||||
Classes="taskbar-profile-popup-action"
|
Classes="taskbar-profile-popup-action"
|
||||||
Click="OnPowerShutdownClick">
|
Click="OnPowerShutdownClick">
|
||||||
<Grid ColumnDefinitions="Auto,*"
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
ColumnSpacing="14">
|
ColumnSpacing="12">
|
||||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||||
Kind="Power" />
|
Kind="Power" />
|
||||||
<TextBlock x:Name="PowerShutdownTextBlock"
|
<TextBlock x:Name="PowerShutdownTextBlock"
|
||||||
@@ -553,7 +549,7 @@
|
|||||||
Classes="taskbar-profile-popup-action"
|
Classes="taskbar-profile-popup-action"
|
||||||
Click="OnPowerRestartClick">
|
Click="OnPowerRestartClick">
|
||||||
<Grid ColumnDefinitions="Auto,*"
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
ColumnSpacing="14">
|
ColumnSpacing="12">
|
||||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||||
Kind="Refresh" />
|
Kind="Refresh" />
|
||||||
<TextBlock x:Name="PowerRestartTextBlock"
|
<TextBlock x:Name="PowerRestartTextBlock"
|
||||||
@@ -567,7 +563,7 @@
|
|||||||
Classes="taskbar-profile-popup-action"
|
Classes="taskbar-profile-popup-action"
|
||||||
Click="OnPowerLogoutClick">
|
Click="OnPowerLogoutClick">
|
||||||
<Grid ColumnDefinitions="Auto,*"
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
ColumnSpacing="14">
|
ColumnSpacing="12">
|
||||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||||
Kind="ExitToApp" />
|
Kind="ExitToApp" />
|
||||||
<TextBlock x:Name="PowerLogoutTextBlock"
|
<TextBlock x:Name="PowerLogoutTextBlock"
|
||||||
@@ -581,7 +577,7 @@
|
|||||||
Classes="taskbar-profile-popup-action"
|
Classes="taskbar-profile-popup-action"
|
||||||
Click="OnPowerSleepClick">
|
Click="OnPowerSleepClick">
|
||||||
<Grid ColumnDefinitions="Auto,*"
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
ColumnSpacing="14">
|
ColumnSpacing="12">
|
||||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||||
Kind="WeatherNight" />
|
Kind="WeatherNight" />
|
||||||
<TextBlock x:Name="PowerSleepTextBlock"
|
<TextBlock x:Name="PowerSleepTextBlock"
|
||||||
@@ -595,7 +591,7 @@
|
|||||||
Classes="taskbar-profile-popup-action"
|
Classes="taskbar-profile-popup-action"
|
||||||
Click="OnPowerLockClick">
|
Click="OnPowerLockClick">
|
||||||
<Grid ColumnDefinitions="Auto,*"
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
ColumnSpacing="14">
|
ColumnSpacing="12">
|
||||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||||
Kind="Lock" />
|
Kind="Lock" />
|
||||||
<TextBlock x:Name="PowerLockTextBlock"
|
<TextBlock x:Name="PowerLockTextBlock"
|
||||||
|
|||||||
Reference in New Issue
Block a user