mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 16:14:28 +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 DesktopFileManager = "DesktopFileManager";
|
||||
public const string DesktopNotificationBox = "DesktopNotificationBox";
|
||||
public const string DesktopShortcut = "DesktopShortcut";
|
||||
}
|
||||
|
||||
@@ -420,6 +420,16 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopShortcut,
|
||||
"快捷方式",
|
||||
"App",
|
||||
"File",
|
||||
MinWidthCells: 1,
|
||||
MinHeightCells: 1,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free)
|
||||
};
|
||||
|
||||
|
||||
@@ -123,6 +123,25 @@ public sealed class ComponentSettingsSnapshot
|
||||
|
||||
#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()
|
||||
{
|
||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -272,7 +272,12 @@ public static class DesktopComponentEditorRegistryFactory
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
context => new NotificationBoxComponentEditor(context),
|
||||
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))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
"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.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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,38 +508,34 @@
|
||||
</Grid.RenderTransform>
|
||||
<StackPanel Spacing="8">
|
||||
<Button x:Name="TaskbarPowerBackButton"
|
||||
Padding="4,6"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
HorizontalAlignment="Left"
|
||||
Click="OnPowerMenuBackClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:SymbolIcon Classes="icon-s"
|
||||
Symbol="ArrowLeft"
|
||||
IconVariant="Regular" />
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="ArrowLeft" />
|
||||
<TextBlock x:Name="TaskbarPowerBackTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Back" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<TextBlock x:Name="TaskbarPowerTitleTextBlock"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TaskbarProfilePopupTextBrush}"
|
||||
Margin="2,6,0,0"
|
||||
Text="Power" />
|
||||
|
||||
<Border Height="1"
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}"
|
||||
Margin="0,4" />
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
|
||||
|
||||
<Button x:Name="PowerShutdownButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerShutdownClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Power" />
|
||||
<TextBlock x:Name="PowerShutdownTextBlock"
|
||||
@@ -553,7 +549,7 @@
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerRestartClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Refresh" />
|
||||
<TextBlock x:Name="PowerRestartTextBlock"
|
||||
@@ -567,7 +563,7 @@
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerLogoutClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="ExitToApp" />
|
||||
<TextBlock x:Name="PowerLogoutTextBlock"
|
||||
@@ -581,7 +577,7 @@
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerSleepClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="WeatherNight" />
|
||||
<TextBlock x:Name="PowerSleepTextBlock"
|
||||
@@ -595,7 +591,7 @@
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerLockClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Lock" />
|
||||
<TextBlock x:Name="PowerLockTextBlock"
|
||||
|
||||
Reference in New Issue
Block a user