自习组件加入
This commit is contained in:
lincube
2026-03-04 11:28:59 +08:00
parent 3d22c04a04
commit 55852a2cb8
16 changed files with 1343 additions and 60 deletions

View File

@@ -13,6 +13,8 @@ public static class BuiltInComponentIds
public const string DesktopClassSchedule = "DesktopClassSchedule";
public const string DesktopMusicControl = "DesktopMusicControl";
public const string DesktopAudioRecorder = "DesktopAudioRecorder";
public const string DesktopStudyEnvironment = "DesktopStudyEnvironment";
public const string DesktopStudyNoiseCurve = "DesktopStudyNoiseCurve";
public const string Blank2x4 = "Blank2x4";
public const string Date = "Date";
public const string MonthCalendar = "MonthCalendar";

View File

@@ -121,6 +121,24 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopStudyEnvironment,
"Study Environment",
"MicOn",
"Study",
MinWidthCells: 2,
MinHeightCells: 1,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopStudyNoiseCurve,
"Noise Curve",
"DataLine",
"Study",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopDailyPoetry,
"Daily Poetry",

View File

@@ -213,6 +213,7 @@
"component_category.board": "Board",
"component_category.media": "Media",
"component_category.info": "Info",
"component_category.study": "Study",
"component.date": "Calendar",
"component.month_calendar": "Month Calendar",
"component.lunar_calendar": "Lunar Calendar",
@@ -232,6 +233,8 @@
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser",
"component.holiday_calendar": "Holiday Calendar",
"component.study_environment": "Environment",
"component.study_noise_curve": "Noise Curve",
"poetry.widget.loading_content": "Loading poetry...",
"poetry.widget.loading_author": "Loading...",
"poetry.widget.fetch_failed": "Poetry fetch failed",
@@ -267,6 +270,24 @@
"recording.widget.hint.saved_format": "Saved {0}",
"recording.widget.save_picker_title": "Save recording file",
"recording.widget.save_picker_type": "WAV audio",
"study.environment.status_label": "Environment",
"study.environment.status.initializing": "Initializing",
"study.environment.status.ready": "Ready",
"study.environment.status.quiet": "Quiet",
"study.environment.status.noisy": "Noisy",
"study.environment.status.paused": "Paused",
"study.environment.status.error": "Error",
"study.environment.status.unsupported": "Unsupported",
"study.environment.value.unavailable": "--",
"study.environment.value.display_format": "{0:F1} dB",
"study.environment.value.dbfs_format": "{0:F1} dBFS",
"study.environment.settings.title": "Environment Widget Settings",
"study.environment.settings.desc": "Configure real-time noise value display on the right side.",
"study.environment.settings.show_display_db": "Show display dB",
"study.environment.settings.show_dbfs": "Show dBFS",
"study.environment.settings.hint": "At least one display mode must stay enabled.",
"study.noise_curve.value_format": "{0:F1} dB",
"study.noise_curve.axis.now": "Now",
"desktop.add_page": "Add page",
"desktop.delete_page": "Delete page",
"placement.fill": "Fill",

View File

@@ -213,6 +213,7 @@
"component_category.board": "白板",
"component_category.media": "媒体",
"component_category.info": "信息推荐",
"component_category.study": "自习",
"component.date": "日历",
"component.month_calendar": "月历",
"component.lunar_calendar": "农历",
@@ -232,6 +233,8 @@
"component.blackboard_landscape": "横向小黑板",
"component.browser": "浏览器",
"component.holiday_calendar": "节假日日历",
"component.study_environment": "环境",
"component.study_noise_curve": "噪音曲线",
"poetry.widget.loading_content": "正在加载诗词",
"poetry.widget.loading_author": "加载中",
"poetry.widget.fetch_failed": "诗词获取失败",
@@ -267,6 +270,24 @@
"recording.widget.hint.saved_format": "已保存 {0}",
"recording.widget.save_picker_title": "保存录音文件",
"recording.widget.save_picker_type": "WAV 音频",
"study.environment.status_label": "环境状态",
"study.environment.status.initializing": "初始化中",
"study.environment.status.ready": "待机",
"study.environment.status.quiet": "安静",
"study.environment.status.noisy": "嘈杂",
"study.environment.status.paused": "已暂停",
"study.environment.status.error": "错误",
"study.environment.status.unsupported": "不支持",
"study.environment.value.unavailable": "--",
"study.environment.value.display_format": "{0:F1} dB",
"study.environment.value.dbfs_format": "{0:F1} dBFS",
"study.environment.settings.title": "环境组件设置",
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
"study.environment.settings.show_display_db": "显示 display dB",
"study.environment.settings.show_dbfs": "显示 dBFS",
"study.environment.settings.hint": "至少启用一种显示方式。",
"study.noise_curve.value_format": "{0:F1} dB",
"study.noise_curve.axis.now": "现在",
"desktop.add_page": "新增页面",
"desktop.delete_page": "删除页面",
"placement.fill": "填充",

View File

@@ -71,4 +71,8 @@ public sealed class AppSettingsSnapshot
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
public bool StudyEnvironmentShowDbfs { get; set; }
}

View File

@@ -165,6 +165,16 @@ public sealed class DesktopComponentRuntimeRegistry
"component.audio_recorder",
() => new RecordingWidget(),
cellSize => Math.Clamp(cellSize * 0.36, 16, 34)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopStudyEnvironment,
"component.study_environment",
() => new StudyEnvironmentWidget(),
cellSize => Math.Clamp(cellSize * 0.36, 12, 26)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopStudyNoiseCurve,
"component.study_noise_curve",
() => new StudyNoiseCurveWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 12, 26)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopDailyPoetry,
"component.daily_poetry",

View File

@@ -94,57 +94,56 @@
<Grid x:Name="SummaryInfoGrid"
Grid.Column="1"
VerticalAlignment="Center"
Margin="0,2,0,0"
Margin="2,0,0,0"
RowDefinitions="Auto,Auto"
ColumnDefinitions="*,Auto"
RowSpacing="2"
ColumnSpacing="8">
<Border x:Name="CityInfoBadge"
Grid.Row="0"
Grid.Column="0"
Grid.ColumnSpan="2"
Background="Transparent"
CornerRadius="0"
Padding="0">
<TextBlock x:Name="CityTextBlock"
Text="北京"
FontSize="18"
FontWeight="SemiBold"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</Border>
RowSpacing="2">
<StackPanel x:Name="BottomInfoStack"
Grid.Row="0"
Orientation="Horizontal"
Spacing="3"
Margin="0,0,0,1"
VerticalAlignment="Center">
<Border x:Name="CityInfoBadge"
Background="Transparent"
CornerRadius="0"
Padding="0">
<TextBlock x:Name="CityTextBlock"
Text="北京"
FontSize="18"
FontWeight="SemiBold"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</Border>
</StackPanel>
<Border x:Name="ConditionInfoBadge"
Grid.Row="1"
Grid.Column="0"
Background="Transparent"
CornerRadius="0"
Padding="0">
<TextBlock x:Name="ConditionTextBlock"
Text="雾"
FontSize="20"
FontWeight="SemiBold"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</Border>
<Border x:Name="RangeInfoBadge"
Grid.Row="1"
Grid.Column="1"
Background="Transparent"
CornerRadius="0"
Padding="0">
<TextBlock x:Name="RangeTextBlock"
Text="11°/4°"
FontSize="20"
FontWeight="SemiBold"
FontFeatures="tnum"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
MaxLines="1"
Opacity="0.92" />
Padding="0"
Margin="0">
<StackPanel x:Name="ConditionRangeStack"
Orientation="Horizontal"
VerticalAlignment="Center"
Spacing="9">
<TextBlock x:Name="ConditionTextBlock"
Text="雾"
FontSize="20"
FontWeight="SemiBold"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Text="11°/4°"
FontSize="20"
FontWeight="SemiBold"
FontFeatures="tnum"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
MaxLines="1"
Opacity="0.92" />
</StackPanel>
</Border>
</Grid>

View File

@@ -426,30 +426,34 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
var compactness = Math.Clamp((0.90 - scale) / 0.55, 0, 1);
LayoutRoot.RowSpacing = Math.Clamp(height * 0.012, 5, 13);
SummaryGrid.ColumnSpacing = Math.Clamp(width * 0.016, 8, 22);
SummaryInfoGrid.ColumnSpacing = Math.Clamp(width * 0.010, 6, 14);
SummaryInfoGrid.RowSpacing = Math.Clamp(height * 0.003, 1, 4);
BottomInfoStack.Spacing = Math.Clamp(2.2 * scale, 1, 6);
ConditionRangeStack.Spacing = Math.Clamp(7 * scale, 4, 13);
HourlyGrid.ColumnSpacing = Math.Clamp(width * 0.007, 3, 10);
DailyGrid.RowSpacing = Math.Clamp(height * 0.009, 4, 10);
TemperatureTextBlock.FontSize = Math.Clamp(height * 0.18, 52, 154);
TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 370, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
var cityFontSize = Math.Clamp(height * 0.040, 12, 30);
var topInfoFontSize = Math.Clamp(height * 0.044, 12, 32);
var topScaleH = Math.Clamp(height / 640d, 0.62, 2.0);
var topScaleW = Math.Clamp(width / 640d, 0.62, 2.0);
var topScale = Math.Clamp((topScaleH * 0.68) + (topScaleW * 0.32), 0.62, 2.0);
var cityFontSize = Math.Clamp(18 * topScale, 11, 26);
var conditionFontSize = Math.Clamp(19 * topScale, 12, 27);
var rangeFontSize = Math.Clamp(20 * topScale, 12, 30);
CityTextBlock.FontSize = cityFontSize;
ConditionTextBlock.FontSize = topInfoFontSize;
RangeTextBlock.FontSize = topInfoFontSize;
CityTextBlock.FontWeight = ToVariableWeight(Lerp(520, 590, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
var topInfoWeight = ToVariableWeight(Lerp(570, 630, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
ConditionTextBlock.FontWeight = topInfoWeight;
RangeTextBlock.FontWeight = topInfoWeight;
CityTextBlock.LineHeight = cityFontSize * 1.10;
ConditionTextBlock.LineHeight = topInfoFontSize * 1.08;
RangeTextBlock.LineHeight = topInfoFontSize * 1.08;
ConditionTextBlock.FontSize = conditionFontSize;
RangeTextBlock.FontSize = rangeFontSize;
CityTextBlock.FontWeight = ToVariableWeight(540);
ConditionTextBlock.FontWeight = ToVariableWeight(600);
RangeTextBlock.FontWeight = ToVariableWeight(620);
CityTextBlock.LineHeight = cityFontSize * 1.08;
ConditionTextBlock.LineHeight = conditionFontSize * 1.06;
RangeTextBlock.LineHeight = rangeFontSize * 1.06;
var iconSize = Math.Clamp(height * 0.116, 36, 102);
WeatherIconImage.Width = iconSize;
WeatherIconImage.Height = iconSize;
ConditionTextBlock.MaxWidth = Math.Clamp(width * 0.24, 88, 280);
RangeTextBlock.MaxWidth = Math.Clamp(width * 0.17, 72, 210);
CityTextBlock.MaxWidth = Math.Clamp(width * 0.30, 96, 320);
ConditionTextBlock.MaxWidth = Math.Clamp(width * 0.24, 58, 220);
RangeTextBlock.MaxWidth = Math.Clamp(width * 0.30, 88, 270);
CityTextBlock.MaxWidth = Math.Clamp(width * 0.36, 112, 300);
HourlyPanelBorder.Padding = new Thickness(0);
HourlyPanelBorder.CornerRadius = new CornerRadius(0);

View File

@@ -0,0 +1,51 @@
<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="300"
d:DesignHeight="150"
x:Class="LanMontainDesktop.Views.Components.StudyEnvironmentWidget">
<Border x:Name="RootBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="18"
Padding="14,10">
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<StackPanel Spacing="2"
VerticalAlignment="Center">
<TextBlock x:Name="StatusTitleTextBlock"
Text="环境状态"
FontSize="11"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="StatusValueTextBlock"
Text="安静"
FontSize="20"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</StackPanel>
<StackPanel Grid.Column="1"
Spacing="1"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<TextBlock x:Name="NoiseValueTextBlock"
Text="--"
FontSize="22"
FontWeight="SemiBold"
TextAlignment="Right"
HorizontalAlignment="Right"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="NoiseSubValueTextBlock"
Text=""
FontSize="12"
TextAlignment="Right"
HorizontalAlignment="Right"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
IsVisible="False" />
</StackPanel>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,249 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using LanMontainDesktop.Models;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
{
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly DispatcherTimer _uiTimer = new()
{
Interval = TimeSpan.FromMilliseconds(250)
};
private double _currentCellSize = 48;
private bool _showDisplayDb = true;
private bool _showDbfs;
private string _languageCode = "zh-CN";
private bool _isAttached;
private bool _isOnActivePage = true;
public StudyEnvironmentWidget()
{
InitializeComponent();
_uiTimer.Tick += OnUiTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ReloadDisplaySettings();
ApplyCellSize(_currentCellSize);
RefreshVisual();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = Math.Clamp(_currentCellSize / 48d, 0.82, 2.2);
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 10, 28));
RootBorder.Padding = new Thickness(
Math.Clamp(14 * scale, 8, 20),
Math.Clamp(10 * scale, 6, 16));
StatusTitleTextBlock.FontSize = Math.Clamp(11 * scale, 9, 18);
StatusValueTextBlock.FontSize = Math.Clamp(20 * scale, 12, 34);
NoiseValueTextBlock.FontSize = Math.Clamp(22 * scale, 12, 38);
NoiseSubValueTextBlock.FontSize = Math.Clamp(12 * scale, 9, 18);
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_ = isEditMode;
_isOnActivePage = isOnActivePage;
UpdateTimerState();
}
public void RefreshFromSettings()
{
ReloadDisplaySettings();
RefreshVisual();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ReloadDisplaySettings();
_ = _studyAnalyticsService.StartOrResumeMonitoring();
UpdateTimerState();
RefreshVisual();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_uiTimer.Stop();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnUiTimerTick(object? sender, EventArgs e)
{
RefreshVisual();
}
private void UpdateTimerState()
{
if (_isAttached && _isOnActivePage)
{
_uiTimer.Start();
return;
}
_uiTimer.Stop();
}
private void ReloadDisplaySettings()
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
_showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
_showDbfs = snapshot.StudyEnvironmentShowDbfs;
if (!_showDisplayDb && !_showDbfs)
{
_showDisplayDb = true;
}
}
private void RefreshVisual()
{
var snapshot = _studyAnalyticsService.GetSnapshot();
StatusTitleTextBlock.Text = L("study.environment.status_label", "Environment");
StatusValueTextBlock.Text = ResolveStatusText(snapshot);
StatusValueTextBlock.Foreground = ResolveStatusBrush(snapshot);
if (snapshot.LatestRealtimePoint is not { } realtimePoint)
{
NoiseValueTextBlock.Text = L("study.environment.value.unavailable", "--");
NoiseSubValueTextBlock.IsVisible = false;
return;
}
var showDisplay = _showDisplayDb;
var showDbfs = _showDbfs;
if (!showDisplay && !showDbfs)
{
showDisplay = true;
}
if (showDisplay && showDbfs)
{
NoiseValueTextBlock.Text = FormatDisplayDb(realtimePoint.DisplayDb);
NoiseSubValueTextBlock.Text = FormatDbfs(realtimePoint.Dbfs);
NoiseSubValueTextBlock.IsVisible = true;
return;
}
NoiseValueTextBlock.Text = showDisplay
? FormatDisplayDb(realtimePoint.DisplayDb)
: FormatDbfs(realtimePoint.Dbfs);
NoiseSubValueTextBlock.IsVisible = false;
}
private string ResolveStatusText(StudyAnalyticsSnapshot snapshot)
{
if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported)
{
return L("study.environment.status.unsupported", "Unsupported");
}
if (snapshot.State == StudyAnalyticsRuntimeState.Error || snapshot.StreamStatus == NoiseStreamStatus.Error)
{
return L("study.environment.status.error", "Error");
}
if (snapshot.State == StudyAnalyticsRuntimeState.Paused)
{
return L("study.environment.status.paused", "Paused");
}
if (snapshot.StreamStatus == NoiseStreamStatus.Noisy)
{
return L("study.environment.status.noisy", "Noisy");
}
if (snapshot.State == StudyAnalyticsRuntimeState.Running && snapshot.StreamStatus == NoiseStreamStatus.Quiet)
{
return L("study.environment.status.quiet", "Quiet");
}
if (snapshot.State == StudyAnalyticsRuntimeState.Ready)
{
return L("study.environment.status.ready", "Ready");
}
return L("study.environment.status.initializing", "Initializing");
}
private IBrush ResolveStatusBrush(StudyAnalyticsSnapshot snapshot)
{
if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported ||
snapshot.State == StudyAnalyticsRuntimeState.Error ||
snapshot.StreamStatus == NoiseStreamStatus.Error)
{
return CreateBrush("#FFFF7B7B");
}
if (snapshot.StreamStatus == NoiseStreamStatus.Noisy)
{
return CreateBrush("#FFFFB14A");
}
if (snapshot.State == StudyAnalyticsRuntimeState.Running &&
snapshot.StreamStatus == NoiseStreamStatus.Quiet)
{
return CreateBrush("#FF6FD7A2");
}
return TryResolveThemeBrush("AdaptiveTextPrimaryBrush", "#FFEFF3FF");
}
private string FormatDisplayDb(double value)
{
return string.Format(
CultureInfo.InvariantCulture,
L("study.environment.value.display_format", "{0:F1} dB"),
value);
}
private string FormatDbfs(double value)
{
return string.Format(
CultureInfo.InvariantCulture,
L("study.environment.value.dbfs_format", "{0:F1} dBFS"),
value);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private static SolidColorBrush CreateBrush(string hexColor)
{
return new SolidColorBrush(Color.Parse(hexColor));
}
private IBrush TryResolveThemeBrush(string resourceKey, string fallbackHex)
{
if (TryGetResource(resourceKey, null, out var resource) && resource is IBrush brush)
{
return brush;
}
return CreateBrush(fallbackHex);
}
}

View File

@@ -0,0 +1,50 @@
<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="420"
d:DesignHeight="280"
x:Class="LanMontainDesktop.Views.Components.StudyEnvironmentWidgetSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<StackPanel Spacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="环境组件设置"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Text="配置右侧噪音值展示。"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="10">
<StackPanel Spacing="8">
<CheckBox x:Name="ShowDisplayDbCheckBox"
IsChecked="True"
Content="显示 display dB"
Checked="OnDisplayModeChanged"
Unchecked="OnDisplayModeChanged" />
<CheckBox x:Name="ShowDbfsCheckBox"
IsChecked="False"
Content="显示 dBFS"
Checked="OnDisplayModeChanged"
Unchecked="OnDisplayModeChanged" />
</StackPanel>
</Border>
<TextBlock x:Name="HintTextBlock"
Text="至少启用一种显示方式。"
FontSize="11"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
</Border>
</UserControl>

View File

@@ -0,0 +1,90 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class StudyEnvironmentWidgetSettingsWindow : UserControl
{
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private string _languageCode = "zh-CN";
private bool _suppressEvents;
public event EventHandler? SettingsChanged;
public StudyEnvironmentWidgetSettingsWindow()
{
InitializeComponent();
LoadState();
ApplyLocalization();
}
private void LoadState()
{
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
var showDbfs = snapshot.StudyEnvironmentShowDbfs;
if (!showDisplayDb && !showDbfs)
{
showDisplayDb = true;
}
_suppressEvents = true;
ShowDisplayDbCheckBox.IsChecked = showDisplayDb;
ShowDbfsCheckBox.IsChecked = showDbfs;
_suppressEvents = false;
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("study.environment.settings.title", "环境组件设置");
DescriptionTextBlock.Text = L(
"study.environment.settings.desc",
"配置右侧实时噪音值显示内容。");
ShowDisplayDbCheckBox.Content = L(
"study.environment.settings.show_display_db",
"显示 display dB");
ShowDbfsCheckBox.Content = L(
"study.environment.settings.show_dbfs",
"显示 dBFS");
HintTextBlock.Text = L(
"study.environment.settings.hint",
"至少启用一种显示方式。");
}
private void OnDisplayModeChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var showDisplayDb = ShowDisplayDbCheckBox.IsChecked == true;
var showDbfs = ShowDbfsCheckBox.IsChecked == true;
if (!showDisplayDb && !showDbfs)
{
_suppressEvents = true;
ShowDisplayDbCheckBox.IsChecked = true;
_suppressEvents = false;
showDisplayDb = true;
}
var snapshot = _appSettingsService.Load();
snapshot.StudyEnvironmentShowDisplayDb = showDisplayDb;
snapshot.StudyEnvironmentShowDbfs = showDbfs;
_appSettingsService.Save(snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
}

View File

@@ -0,0 +1,298 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using LanMontainDesktop.Models;
namespace LanMontainDesktop.Views.Components;
public sealed class StudyNoiseCurveChartControl : Control
{
private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#324E6780"));
private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1"));
private static readonly IBrush LineBrush = new SolidColorBrush(Color.Parse("#FF52AEEA"));
private static readonly IBrush FillBrush = new SolidColorBrush(Color.Parse("#3552AEEA"));
private static readonly Pen GridPen = new(GridBrush, 1);
private static readonly Pen AxisPen = new(AxisBrush, 1.1);
private static readonly Pen LinePen = new(LineBrush, 1.8);
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
private Point[]? _pointBuffer;
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points)
{
_points = points ?? Array.Empty<NoiseRealtimePoint>();
InvalidateVisual();
}
public void CompactCaches()
{
if (_pointBuffer is not null && _pointBuffer.Length > 2048)
{
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
_pointBuffer = null;
}
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
ReleasePointBuffer();
base.OnDetachedFromVisualTree(e);
}
public override void Render(DrawingContext context)
{
base.Render(context);
var bounds = Bounds;
if (bounds.Width <= 2 || bounds.Height <= 2)
{
return;
}
var plot = new Rect(
x: 1,
y: 1,
width: Math.Max(1, bounds.Width - 2),
height: Math.Max(1, bounds.Height - 2));
DrawGrid(context, plot);
if (_points.Count < 2)
{
return;
}
var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360);
var pointCount = BuildPlotPoints(plot, maxSamples);
if (pointCount < 2 || _pointBuffer is null)
{
return;
}
var span = _pointBuffer.AsSpan(0, pointCount);
DrawAreaFill(context, plot.Bottom, span);
DrawLine(context, span);
}
private static void DrawGrid(DrawingContext context, Rect plot)
{
const int horizontalDivisions = 4;
const int verticalDivisions = 4;
for (var i = 0; i <= horizontalDivisions; i++)
{
var y = plot.Top + plot.Height * (i / (double)horizontalDivisions);
context.DrawLine(GridPen, new Point(plot.Left, y), new Point(plot.Right, y));
}
for (var i = 0; i <= verticalDivisions; i++)
{
var x = plot.Left + plot.Width * (i / (double)verticalDivisions);
context.DrawLine(GridPen, new Point(x, plot.Top), new Point(x, plot.Bottom));
}
context.DrawLine(AxisPen, new Point(plot.Left, plot.Top), new Point(plot.Left, 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)
{
var geometry = new StreamGeometry();
using (var builder = geometry.Open())
{
builder.BeginFigure(points[0], false);
for (var i = 1; i < points.Length; i++)
{
builder.LineTo(points[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 first = points[0];
builder.BeginFigure(new Point(first.X, baselineY), true);
builder.LineTo(first);
for (var i = 1; i < points.Length; i++)
{
builder.LineTo(points[i]);
}
var last = points[^1];
builder.LineTo(new Point(last.X, baselineY));
builder.LineTo(new Point(first.X, baselineY));
builder.EndFigure(true);
}
context.DrawGeometry(FillBrush, pen: null, geometry);
}
private int BuildPlotPoints(Rect plot, int maxSamples)
{
var sourceCount = _points.Count;
if (sourceCount <= 1)
{
return 0;
}
if (sourceCount <= maxSamples)
{
EnsurePointBufferCapacity(sourceCount);
if (_pointBuffer is null)
{
return 0;
}
for (var i = 0; i < sourceCount; i++)
{
_pointBuffer[i] = MapToPlot(plot, _points[i], _points[0].Timestamp, _points[^1].Timestamp);
}
return sourceCount;
}
var bucketCount = Math.Max(1, (maxSamples - 2) / 2);
var targetCapacity = 2 + bucketCount * 2;
EnsurePointBufferCapacity(targetCapacity);
if (_pointBuffer is null)
{
return 0;
}
var outputIndex = 0;
var startTimestamp = _points[0].Timestamp;
var endTimestamp = _points[^1].Timestamp;
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[0], startTimestamp, endTimestamp);
var middleCount = sourceCount - 2;
var bucketWidth = middleCount / (double)bucketCount;
var lastSourceIndex = 0;
for (var bucket = 0; bucket < bucketCount; bucket++)
{
var rangeStart = 1 + (int)Math.Floor(bucket * bucketWidth);
var rangeEnd = 1 + (int)Math.Floor((bucket + 1) * bucketWidth);
if (bucket == bucketCount - 1)
{
rangeEnd = sourceCount - 1;
}
rangeStart = Math.Clamp(rangeStart, 1, sourceCount - 2);
rangeEnd = Math.Clamp(rangeEnd, rangeStart + 1, sourceCount - 1);
var minIndex = rangeStart;
var maxIndex = rangeStart;
var minValue = _points[rangeStart].DisplayDb;
var maxValue = minValue;
for (var i = rangeStart + 1; i < rangeEnd; i++)
{
var value = _points[i].DisplayDb;
if (value < minValue)
{
minValue = value;
minIndex = i;
}
if (value > maxValue)
{
maxValue = value;
maxIndex = i;
}
}
if (minIndex == maxIndex)
{
if (minIndex != lastSourceIndex)
{
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[minIndex], startTimestamp, endTimestamp);
lastSourceIndex = minIndex;
}
continue;
}
var first = minIndex < maxIndex ? minIndex : maxIndex;
var second = minIndex < maxIndex ? maxIndex : minIndex;
if (first != lastSourceIndex)
{
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[first], startTimestamp, endTimestamp);
lastSourceIndex = first;
}
if (second != lastSourceIndex)
{
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[second], startTimestamp, endTimestamp);
lastSourceIndex = second;
}
}
var finalIndex = sourceCount - 1;
if (finalIndex != lastSourceIndex)
{
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[finalIndex], startTimestamp, endTimestamp);
}
return outputIndex;
}
private static Point MapToPlot(
Rect plot,
NoiseRealtimePoint point,
DateTimeOffset start,
DateTimeOffset end)
{
const double minDisplayDb = 20;
const double maxDisplayDb = 100;
var rangeTicks = Math.Max(1, (end - start).Ticks);
var offsetTicks = Math.Clamp((point.Timestamp - start).Ticks, 0, rangeTicks);
var x = plot.Left + plot.Width * (offsetTicks / (double)rangeTicks);
var clampedDb = Math.Clamp(point.DisplayDb, minDisplayDb, maxDisplayDb);
var normalized = (clampedDb - minDisplayDb) / (maxDisplayDb - minDisplayDb);
var y = plot.Bottom - normalized * plot.Height;
return new Point(x, y);
}
private void EnsurePointBufferCapacity(int required)
{
if (required <= 0)
{
return;
}
if (_pointBuffer is not null && _pointBuffer.Length >= required)
{
return;
}
var next = ArrayPool<Point>.Shared.Rent(required);
if (_pointBuffer is not null)
{
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
}
_pointBuffer = next;
}
private void ReleasePointBuffer()
{
if (_pointBuffer is null)
{
return;
}
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
_pointBuffer = null;
}
}

View File

@@ -0,0 +1,104 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="using:LanMontainDesktop.Views.Components"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMontainDesktop.Views.Components.StudyNoiseCurveWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
CornerRadius="24"
Padding="14,10"
ClipToBounds="True">
<Grid RowDefinitions="Auto,*"
RowSpacing="8">
<Grid Grid.Row="0"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="StatusTextBlock"
Text="安静"
FontSize="16"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
VerticalAlignment="Center" />
<TextBlock x:Name="RealtimeValueTextBlock"
Grid.Column="1"
Text="-- dB"
HorizontalAlignment="Right"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="1"
RowDefinitions="*,Auto"
ColumnDefinitions="Auto,*"
RowSpacing="4"
ColumnSpacing="6">
<Grid Grid.Row="0"
Grid.Column="0"
RowDefinitions="Auto,*,*,*,Auto"
VerticalAlignment="Stretch">
<TextBlock x:Name="YTopTextBlock"
Grid.Row="0"
Text="100"
FontSize="10"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="YUpperTextBlock"
Grid.Row="1"
Text="80"
VerticalAlignment="Center"
FontSize="10"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="YMiddleTextBlock"
Grid.Row="2"
Text="60"
VerticalAlignment="Center"
FontSize="10"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="YLowerTextBlock"
Grid.Row="3"
Text="40"
VerticalAlignment="Center"
FontSize="10"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="YBottomTextBlock"
Grid.Row="4"
Text="20"
FontSize="10"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</Grid>
<local:StudyNoiseCurveChartControl x:Name="ChartControl"
Grid.Row="0"
Grid.Column="1"
MinHeight="64" />
<Grid Grid.Row="1"
Grid.Column="1"
ColumnDefinitions="Auto,*,Auto">
<TextBlock x:Name="XLeftTextBlock"
Text="-12s"
FontSize="10"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="XCenterTextBlock"
Grid.Column="1"
HorizontalAlignment="Center"
Text="-6s"
FontSize="10"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="XRightTextBlock"
Grid.Column="2"
HorizontalAlignment="Right"
Text="现在"
FontSize="10"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</Grid>
</Grid>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,294 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using LanMontainDesktop.Models;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
{
private readonly object _snapshotSync = new();
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly DispatcherTimer _renderTimer = new()
{
Interval = TimeSpan.FromMilliseconds(33)
};
private StudyAnalyticsSnapshot? _pendingSnapshot;
private bool _hasPendingSnapshot;
private double _currentCellSize = 48;
private string _languageCode = "zh-CN";
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isSubscribed;
private int _framesSinceCompaction;
public StudyNoiseCurveWidget()
{
InitializeComponent();
_renderTimer.Tick += OnRenderTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ReloadLanguageCode();
ApplyCellSize(_currentCellSize);
ApplyDefaultXAxisLabels();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = Math.Clamp(_currentCellSize / 48d, 0.78, 2.4);
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.44, 14, 42));
RootBorder.Padding = new Thickness(
Math.Clamp(14 * scale, 8, 22),
Math.Clamp(10 * scale, 6, 16));
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 11, 30);
RealtimeValueTextBlock.FontSize = Math.Clamp(18 * scale, 11, 34);
var axisFontSize = Math.Clamp(10 * scale, 8.5, 18);
YTopTextBlock.FontSize = axisFontSize;
YUpperTextBlock.FontSize = axisFontSize;
YMiddleTextBlock.FontSize = axisFontSize;
YLowerTextBlock.FontSize = axisFontSize;
YBottomTextBlock.FontSize = axisFontSize;
XLeftTextBlock.FontSize = axisFontSize;
XCenterTextBlock.FontSize = axisFontSize;
XRightTextBlock.FontSize = axisFontSize;
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_ = isEditMode;
_isOnActivePage = isOnActivePage;
UpdateRenderLoopState();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ReloadLanguageCode();
if (!_isSubscribed)
{
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
_isSubscribed = true;
}
_ = _studyAnalyticsService.StartOrResumeMonitoring();
lock (_snapshotSync)
{
_pendingSnapshot = _studyAnalyticsService.GetSnapshot();
_hasPendingSnapshot = true;
}
UpdateRenderLoopState();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_renderTimer.Stop();
if (_isSubscribed)
{
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
_isSubscribed = false;
}
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
{
lock (_snapshotSync)
{
_pendingSnapshot = e.Snapshot;
_hasPendingSnapshot = true;
}
}
private void OnRenderTimerTick(object? sender, EventArgs e)
{
StudyAnalyticsSnapshot? snapshot = null;
lock (_snapshotSync)
{
if (_hasPendingSnapshot)
{
snapshot = _pendingSnapshot;
_hasPendingSnapshot = false;
}
}
if (snapshot is null)
{
return;
}
ApplySnapshot(snapshot);
_framesSinceCompaction++;
if (_framesSinceCompaction >= 900)
{
ChartControl.CompactCaches();
_framesSinceCompaction = 0;
}
}
private void UpdateRenderLoopState()
{
if (_isAttached && _isOnActivePage)
{
if (!_renderTimer.IsEnabled)
{
_renderTimer.Start();
}
return;
}
_renderTimer.Stop();
}
private void ApplySnapshot(StudyAnalyticsSnapshot snapshot)
{
StatusTextBlock.Text = ResolveStatusText(snapshot);
StatusTextBlock.Foreground = ResolveStatusBrush(snapshot);
if (snapshot.LatestRealtimePoint is { } latestPoint)
{
RealtimeValueTextBlock.Text = string.Format(
CultureInfo.InvariantCulture,
L("study.noise_curve.value_format", "{0:F1} dB"),
latestPoint.DisplayDb);
}
else
{
RealtimeValueTextBlock.Text = L("study.environment.value.unavailable", "--");
}
ChartControl.UpdateSeries(snapshot.RealtimeBuffer);
UpdateXAxisLabels(snapshot);
}
private void UpdateXAxisLabels(StudyAnalyticsSnapshot snapshot)
{
var buffer = snapshot.RealtimeBuffer;
if (buffer.Count < 2)
{
ApplyDefaultXAxisLabels();
return;
}
var duration = (buffer[^1].Timestamp - buffer[0].Timestamp).TotalSeconds;
if (double.IsNaN(duration) || double.IsInfinity(duration) || duration <= 1)
{
duration = 12;
}
duration = Math.Clamp(duration, 4, 60);
var leftSeconds = Math.Round(duration, MidpointRounding.AwayFromZero);
var centerSeconds = Math.Round(duration / 2d, MidpointRounding.AwayFromZero);
XLeftTextBlock.Text = $"-{leftSeconds:0}s";
XCenterTextBlock.Text = $"-{centerSeconds:0}s";
XRightTextBlock.Text = L("study.noise_curve.axis.now", "现在");
}
private void ApplyDefaultXAxisLabels()
{
XLeftTextBlock.Text = "-12s";
XCenterTextBlock.Text = "-6s";
XRightTextBlock.Text = L("study.noise_curve.axis.now", "现在");
}
private string ResolveStatusText(StudyAnalyticsSnapshot snapshot)
{
if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported)
{
return L("study.environment.status.unsupported", "不支持");
}
if (snapshot.State == StudyAnalyticsRuntimeState.Error || snapshot.StreamStatus == NoiseStreamStatus.Error)
{
return L("study.environment.status.error", "错误");
}
if (snapshot.State == StudyAnalyticsRuntimeState.Paused)
{
return L("study.environment.status.paused", "已暂停");
}
if (snapshot.StreamStatus == NoiseStreamStatus.Noisy)
{
return L("study.environment.status.noisy", "嘈杂");
}
if (snapshot.State == StudyAnalyticsRuntimeState.Running && snapshot.StreamStatus == NoiseStreamStatus.Quiet)
{
return L("study.environment.status.quiet", "安静");
}
if (snapshot.State == StudyAnalyticsRuntimeState.Ready)
{
return L("study.environment.status.ready", "待机");
}
return L("study.environment.status.initializing", "初始化中");
}
private IBrush ResolveStatusBrush(StudyAnalyticsSnapshot snapshot)
{
if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported ||
snapshot.State == StudyAnalyticsRuntimeState.Error ||
snapshot.StreamStatus == NoiseStreamStatus.Error)
{
return new SolidColorBrush(Color.Parse("#FFFF9D9D"));
}
if (snapshot.StreamStatus == NoiseStreamStatus.Noisy)
{
return new SolidColorBrush(Color.Parse("#FFFFD791"));
}
if (snapshot.State == StudyAnalyticsRuntimeState.Running && snapshot.StreamStatus == NoiseStreamStatus.Quiet)
{
return new SolidColorBrush(Color.Parse("#FFCBFFE8"));
}
return TryResolveThemeBrush("AdaptiveTextPrimaryBrush", "#FF1E293B");
}
private void ReloadLanguageCode()
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private IBrush TryResolveThemeBrush(string resourceKey, string fallbackHex)
{
if (TryGetResource(resourceKey, null, out var resource) && resource is IBrush brush)
{
return brush;
}
return new SolidColorBrush(Color.Parse(fallbackHex));
}
}

View File

@@ -703,6 +703,12 @@ public partial class MainWindow
if (placement.ComponentId == BuiltInComponentIds.DesktopClassSchedule)
{
OpenClassScheduleComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment)
{
OpenStudyEnvironmentComponentSettings();
}
}
@@ -738,6 +744,22 @@ public partial class MainWindow
ComponentSettingsWindow.Opacity = 1;
}
private void OpenStudyEnvironmentComponentSettings()
{
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
{
return;
}
var settingsContent = new StudyEnvironmentWidgetSettingsWindow();
settingsContent.SettingsChanged += OnStudyEnvironmentSettingsChanged;
ComponentSettingsContentHost.Content = settingsContent;
ComponentSettingsWindow.IsVisible = true;
ComponentSettingsWindow.Opacity = 0;
ComponentSettingsWindow.Opacity = 1;
}
private void OnClassScheduleSettingsChanged(object? sender, EventArgs e)
{
if (_selectedDesktopComponentHost is null)
@@ -751,6 +773,21 @@ public partial class MainWindow
}
}
private void OnStudyEnvironmentSettingsChanged(object? sender, EventArgs e)
{
_ = sender;
_ = e;
if (_selectedDesktopComponentHost is null)
{
return;
}
if (TryGetContentHost(_selectedDesktopComponentHost)?.Child is StudyEnvironmentWidget widget)
{
widget.RefreshFromSettings();
}
}
private void CloseComponentSettingsWindow()
{
if (ComponentSettingsWindow is null)
@@ -763,6 +800,11 @@ public partial class MainWindow
classScheduleSettingsWindow.SettingsChanged -= OnClassScheduleSettingsChanged;
}
if (ComponentSettingsContentHost?.Content is StudyEnvironmentWidgetSettingsWindow studyEnvironmentSettingsWindow)
{
studyEnvironmentSettingsWindow.SettingsChanged -= OnStudyEnvironmentSettingsChanged;
}
ComponentSettingsWindow.Opacity = 0;
DispatcherTimer.RunOnce(() =>
@@ -1166,6 +1208,22 @@ public partial class MainWindow
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyEnvironment, StringComparison.OrdinalIgnoreCase))
{
// Keep study environment widget in a 2:1 ratio: 2x1, 4x2, 6x3...
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 1));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyNoiseCurve, StringComparison.OrdinalIgnoreCase))
{
// Keep noise curve widget in a 2:1 ratio with minimum 4x2.
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
return span;
}
@@ -2279,6 +2337,11 @@ public partial class MainWindow
return Symbol.Apps;
}
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Apps;
}
return Symbol.Apps;
}
@@ -2314,6 +2377,11 @@ public partial class MainWindow
return L("component_category.info", "Info");
}
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.study", "Study");
}
return categoryId;
}