settings_re8

This commit is contained in:
lincube
2026-03-14 22:45:09 +08:00
parent 91f9f3d6fb
commit 689be7b585
54 changed files with 5356 additions and 30 deletions

View File

@@ -0,0 +1,36 @@
<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"
x:Class="LanMountainDesktop.Views.ComponentEditors.ClassScheduleComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<Button x:Name="AddScheduleButton"
HorizontalAlignment="Left"
Classes="accent"
Padding="16,10"
Click="OnAddScheduleClick" />
<TextBlock x:Name="EmptyStateTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
<StackPanel x:Name="ScheduleItemsPanel"
Spacing="10" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,284 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
{
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
private string _activeScheduleId = string.Empty;
public ClassScheduleComponentEditor()
: this(null)
{
}
public ClassScheduleComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
LoadState();
ApplyState();
RenderImportedSchedules();
}
private void LoadState()
{
var snapshot = LoadSnapshot();
_importedSchedules.Clear();
foreach (var item in snapshot.ImportedClassSchedules)
{
if (string.IsNullOrWhiteSpace(item.Id) || string.IsNullOrWhiteSpace(item.FilePath))
{
continue;
}
_importedSchedules.Add(new ImportedClassScheduleSnapshot
{
Id = item.Id.Trim(),
DisplayName = item.DisplayName?.Trim() ?? string.Empty,
FilePath = item.FilePath.Trim()
});
}
_activeScheduleId = snapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty;
if (_importedSchedules.Count > 0 &&
!_importedSchedules.Any(item => string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase)))
{
_activeScheduleId = _importedSchedules[0].Id;
}
}
private void ApplyState()
{
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
}
private async void OnAddScheduleClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.StorageProvider is not { } storageProvider)
{
return;
}
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = L("schedule.settings.picker_title", "选择 ClassIsland 课表文件"),
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES 课表"))
{
Patterns = ["*.cses", "*.yaml", "*.yml"]
}
]
});
if (files.Count == 0)
{
return;
}
var importedPath = await ImportScheduleFileAsync(files[0]);
if (string.IsNullOrWhiteSpace(importedPath))
{
return;
}
var existing = _importedSchedules.FirstOrDefault(item =>
string.Equals(item.FilePath, importedPath, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
_activeScheduleId = existing.Id;
}
else
{
_importedSchedules.Add(new ImportedClassScheduleSnapshot
{
Id = Guid.NewGuid().ToString("N"),
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
?? L("schedule.settings.unnamed", "未命名课表"),
FilePath = importedPath
});
_activeScheduleId = _importedSchedules[^1].Id;
}
PersistState();
RenderImportedSchedules();
}
private async Task<string?> ImportScheduleFileAsync(IStorageFile file)
{
try
{
var extension = Path.GetExtension(file.Name);
if (string.IsNullOrWhiteSpace(extension))
{
extension = ".cses";
}
var importedDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Schedules");
Directory.CreateDirectory(importedDirectory);
var destinationPath = Path.Combine(
importedDirectory,
$"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}{extension}");
await using var sourceStream = await file.OpenReadAsync();
await using var destinationStream = File.Create(destinationPath);
await sourceStream.CopyToAsync(destinationStream);
return destinationPath;
}
catch
{
return null;
}
}
private void RenderImportedSchedules()
{
ScheduleItemsPanel.Children.Clear();
EmptyStateTextBlock.IsVisible = _importedSchedules.Count == 0;
if (_importedSchedules.Count == 0)
{
return;
}
foreach (var item in _importedSchedules)
{
var selector = new RadioButton
{
GroupName = "class_schedule_imports",
IsChecked = string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase),
VerticalAlignment = VerticalAlignment.Center,
Tag = item.Id
};
selector.IsCheckedChanged += OnScheduleSelectionChanged;
var title = new TextBlock
{
Text = string.IsNullOrWhiteSpace(item.DisplayName)
? L("schedule.settings.unnamed", "未命名课表")
: item.DisplayName,
FontWeight = FontWeight.SemiBold
};
var path = new TextBlock
{
Text = item.FilePath,
FontSize = 11,
Opacity = 0.7,
TextTrimming = TextTrimming.CharacterEllipsis
};
var deleteButton = new Button
{
Content = L("schedule.settings.delete", "删除"),
Tag = item.Id,
Padding = new Thickness(12, 8),
HorizontalAlignment = HorizontalAlignment.Right
};
deleteButton.Click += OnDeleteScheduleClick;
var row = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 12
};
var details = new StackPanel
{
Spacing = 4,
Children = { title, path }
};
row.Children.Add(selector);
row.Children.Add(details);
row.Children.Add(deleteButton);
Grid.SetColumn(details, 1);
Grid.SetColumn(deleteButton, 2);
ScheduleItemsPanel.Children.Add(new Border
{
Padding = new Thickness(12, 10),
CornerRadius = new CornerRadius(16),
Background = Brushes.Transparent,
BorderBrush = Brushes.Gray,
BorderThickness = new Thickness(1),
Child = row
});
}
}
private void OnScheduleSelectionChanged(object? sender, RoutedEventArgs e)
{
_ = e;
if (sender is not RadioButton radioButton || radioButton.IsChecked != true || radioButton.Tag is not string scheduleId)
{
return;
}
_activeScheduleId = scheduleId;
PersistState();
}
private void OnDeleteScheduleClick(object? sender, RoutedEventArgs e)
{
_ = e;
if (sender is not Button button || button.Tag is not string scheduleId)
{
return;
}
var removed = _importedSchedules.RemoveAll(item =>
string.Equals(item.Id, scheduleId, StringComparison.OrdinalIgnoreCase));
if (removed == 0)
{
return;
}
if (string.Equals(_activeScheduleId, scheduleId, StringComparison.OrdinalIgnoreCase))
{
_activeScheduleId = _importedSchedules.FirstOrDefault()?.Id ?? string.Empty;
}
PersistState();
RenderImportedSchedules();
}
private void PersistState()
{
var snapshot = LoadSnapshot();
snapshot.ImportedClassSchedules = _importedSchedules
.Select(item => new ImportedClassScheduleSnapshot
{
Id = item.Id,
DisplayName = item.DisplayName,
FilePath = item.FilePath
})
.ToList();
snapshot.ActiveImportedClassScheduleId = _activeScheduleId;
SaveSnapshot(
snapshot,
nameof(ComponentSettingsSnapshot.ImportedClassSchedules),
nameof(ComponentSettingsSnapshot.ActiveImportedClassScheduleId));
}
}

View File

@@ -0,0 +1,56 @@
<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"
x:Class="LanMountainDesktop.Views.ComponentEditors.ClockComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="TimeZoneLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="TimeZoneComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="SecondHandLabelTextBlock"
Classes="component-editor-section-title" />
<Border Classes="component-editor-segmented-host"
HorizontalAlignment="Left">
<Grid ColumnDefinitions="*,*"
ColumnSpacing="4">
<ToggleButton x:Name="TickRadioButton"
Classes="component-editor-segmented-choice"
Checked="OnSecondHandChanged"
Unchecked="OnSecondHandChanged" />
<ToggleButton x:Name="SweepRadioButton"
Grid.Column="1"
Classes="component-editor-segmented-choice"
Checked="OnSecondHandChanged"
Unchecked="OnSecondHandChanged" />
</Grid>
</Border>
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,139 @@
using System;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class ClockComponentEditor : ComponentEditorViewBase
{
private readonly TimeZoneService _timeZoneService = new();
private bool _suppressEvents;
public ClockComponentEditor()
: this(null)
{
}
public ClockComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
if (context is not null)
{
_timeZoneService.CurrentTimeZone = context.SettingsFacade.Region.GetTimeZoneService().CurrentTimeZone;
}
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Clock";
DescriptionTextBlock.Text = L("clock.settings.desc", "配置时区和秒针动画。");
TimeZoneLabelTextBlock.Text = L("clock.settings.timezone", "时区");
SecondHandLabelTextBlock.Text = L("clock.settings.second_mode_label", "秒针方式");
TickRadioButton.Content = L("clock.second_mode.tick", "跳针");
SweepRadioButton.Content = L("clock.second_mode.sweep", "扫针");
var snapshot = LoadSnapshot();
var configuredTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
? TimeZoneInfo.Local.Id
: snapshot.DesktopClockTimeZoneId.Trim();
_suppressEvents = true;
TimeZoneComboBox.Items.Clear();
foreach (var timeZone in _timeZoneService.GetAllTimeZones()
.OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow))
.ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase))
{
var item = new ComboBoxItem
{
Tag = timeZone.Id,
Content = FormatTimeZone(timeZone)
};
item.Classes.Add("component-editor-select-item");
TimeZoneComboBox.Items.Add(item);
}
TimeZoneComboBox.SelectedItem = TimeZoneComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, configuredTimeZoneId, StringComparison.OrdinalIgnoreCase))
?? TimeZoneComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
var secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
TickRadioButton.IsChecked = string.Equals(secondHandMode, ClockSecondHandMode.Tick, StringComparison.OrdinalIgnoreCase);
SweepRadioButton.IsChecked = string.Equals(secondHandMode, ClockSecondHandMode.Sweep, StringComparison.OrdinalIgnoreCase);
_suppressEvents = false;
}
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void OnSecondHandChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
_suppressEvents = true;
if (sender == TickRadioButton)
{
TickRadioButton.IsChecked = true;
SweepRadioButton.IsChecked = false;
}
else if (sender == SweepRadioButton)
{
SweepRadioButton.IsChecked = true;
TickRadioButton.IsChecked = false;
}
if (TickRadioButton.IsChecked != true && SweepRadioButton.IsChecked != true)
{
TickRadioButton.IsChecked = true;
}
_suppressEvents = false;
SaveState();
}
private void SaveState()
{
var snapshot = LoadSnapshot();
snapshot.DesktopClockTimeZoneId = TimeZoneComboBox.SelectedItem is ComboBoxItem item && item.Tag is string timeZoneId
? timeZoneId
: TimeZoneInfo.Local.Id;
snapshot.DesktopClockSecondHandMode = SweepRadioButton.IsChecked == true
? ClockSecondHandMode.Sweep
: ClockSecondHandMode.Tick;
SaveSnapshot(
snapshot,
nameof(ComponentSettingsSnapshot.DesktopClockTimeZoneId),
nameof(ComponentSettingsSnapshot.DesktopClockSecondHandMode));
}
private static string FormatTimeZone(TimeZoneInfo timeZone)
{
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);
var sign = offset >= TimeSpan.Zero ? "+" : "-";
var totalMinutes = Math.Abs((int)offset.TotalMinutes);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {timeZone.StandardName}";
}
}

View File

@@ -0,0 +1,44 @@
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public abstract class ComponentEditorViewBase : UserControl
{
protected ComponentEditorViewBase(DesktopComponentEditorContext? context)
{
Context = context;
}
protected DesktopComponentEditorContext? Context { get; }
protected LocalizationService LocalizationService { get; } = new();
protected string LanguageCode =>
LocalizationService.NormalizeLanguageCode(Context?.SettingsFacade.Region.Get().LanguageCode);
protected string L(string key, string fallback)
{
return LocalizationService.GetString(LanguageCode, key, fallback);
}
protected ComponentSettingsSnapshot LoadSnapshot()
{
return Context?.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>() ?? new ComponentSettingsSnapshot();
}
protected void SaveSnapshot(ComponentSettingsSnapshot snapshot, params string[] changedKeys)
{
if (Context is null)
{
return;
}
Context.ComponentSettingsAccessor.SaveSnapshot(
snapshot,
changedKeys.Length == 0 ? null : changedKeys);
Context.HostContext.RequestRefresh();
}
}

View File

@@ -0,0 +1,30 @@
<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"
x:Class="LanMountainDesktop.Views.ComponentEditors.DailyArtworkComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="SourceLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="SourceComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnSourceSelectionChanged">
<ComboBoxItem x:Name="DomesticItem"
Classes="component-editor-select-item"
Tag="Domestic" />
<ComboBoxItem x:Name="OverseasItem"
Classes="component-editor-select-item"
Tag="Overseas" />
</ComboBox>
<TextBlock x:Name="StatusTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,64 @@
using System;
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class DailyArtworkComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public DailyArtworkComponentEditor()
: this(null)
{
}
public DailyArtworkComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
SourceLabelTextBlock.Text = L("artwork.settings.source_label", "Mirror Source");
DomesticItem.Content = L("artwork.settings.source_domestic", "Domestic Mirror");
OverseasItem.Content = L("artwork.settings.source_overseas", "Overseas Mirror");
_suppressEvents = true;
var source = DailyArtworkMirrorSources.Normalize(LoadSnapshot().DailyArtworkMirrorSource);
SourceComboBox.SelectedItem = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
? DomesticItem
: OverseasItem;
UpdateStatus(source);
_suppressEvents = false;
}
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var source = SourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? DailyArtworkMirrorSources.Normalize(tag)
: DailyArtworkMirrorSources.Overseas;
var snapshot = LoadSnapshot();
snapshot.DailyArtworkMirrorSource = source;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.DailyArtworkMirrorSource));
UpdateStatus(source);
}
private void UpdateStatus(string source)
{
StatusTextBlock.Text = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
? L("artwork.settings.source_status_domestic", "当前源:国内镜像(优先中国网络)")
: L("artwork.settings.source_status_overseas", "当前源:国外镜像(艺术馆推荐)");
}
}

View File

@@ -0,0 +1,37 @@
<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"
x:Class="LanMountainDesktop.Views.ComponentEditors.InformationalComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-card"
Padding="20">
<Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto,Auto"
ColumnSpacing="12"
RowSpacing="8">
<TextBlock x:Name="ComponentLabelTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="ComponentValueTextBlock"
Grid.Column="1" />
<TextBlock Grid.Row="1"
x:Name="PlacementLabelTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="PlacementValueTextBlock"
Grid.Row="1"
Grid.Column="1" />
<TextBlock Grid.Row="2"
x:Name="ScopeLabelTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="ScopeValueTextBlock"
Grid.Row="2"
Grid.Column="1"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</Grid>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,31 @@
using LanMountainDesktop.ComponentSystem;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class InformationalComponentEditor : ComponentEditorViewBase
{
private readonly string _description;
public InformationalComponentEditor()
: this(null, "This component currently exposes instance information only.")
{
}
public InformationalComponentEditor(DesktopComponentEditorContext? context, string description)
: base(context)
{
_description = description;
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
ComponentLabelTextBlock.Text = L("component.editor.id_label", "Component");
ComponentValueTextBlock.Text = Context?.ComponentId ?? "-";
PlacementLabelTextBlock.Text = L("component.editor.placement_label", "Placement");
PlacementValueTextBlock.Text = Context?.PlacementId ?? "-";
ScopeLabelTextBlock.Text = L("component.editor.scope_label", "Scope");
ScopeValueTextBlock.Text = L("component.editor.scope_instance", "Instance-scoped editor");
}
}

View File

@@ -0,0 +1,45 @@
<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"
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="DisplayDbHeaderTextBlock"
Classes="component-editor-section-title" />
<ToggleSwitch x:Name="DisplayDbToggleSwitch"
IsCheckedChanged="OnToggleChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="DbfsHeaderTextBlock"
Classes="component-editor-section-title" />
<ToggleSwitch x:Name="DbfsToggleSwitch"
IsCheckedChanged="OnToggleChanged" />
</StackPanel>
</Border>
<TextBlock x:Name="HintTextBlock"
Classes="component-editor-secondary-text"
Margin="12,0"
TextWrapping="Wrap" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,72 @@
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public StudyEnvironmentComponentEditor()
: this(null)
{
}
public StudyEnvironmentComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
var showDbfs = snapshot.StudyEnvironmentShowDbfs;
if (!showDisplayDb && !showDbfs)
{
showDisplayDb = true;
}
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "显示 display dB");
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "显示 dBFS");
HintTextBlock.Text = L("study.environment.settings.hint", "至少启用一种显示方式。");
_suppressEvents = true;
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
DbfsToggleSwitch.IsChecked = showDbfs;
_suppressEvents = false;
}
private void OnToggleChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var showDisplayDb = DisplayDbToggleSwitch.IsChecked == true;
var showDbfs = DbfsToggleSwitch.IsChecked == true;
if (!showDisplayDb && !showDbfs)
{
_suppressEvents = true;
DisplayDbToggleSwitch.IsChecked = true;
_suppressEvents = false;
showDisplayDb = true;
}
var snapshot = LoadSnapshot();
snapshot.StudyEnvironmentShowDisplayDb = showDisplayDb;
snapshot.StudyEnvironmentShowDbfs = showDbfs;
SaveSnapshot(
snapshot,
nameof(ComponentSettingsSnapshot.StudyEnvironmentShowDisplayDb),
nameof(ComponentSettingsSnapshot.StudyEnvironmentShowDbfs));
}
}

View File

@@ -0,0 +1,56 @@
<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"
x:Class="LanMountainDesktop.Views.ComponentEditors.ToggleIntervalComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-card"
Padding="20">
<Grid RowDefinitions="Auto,Auto"
ColumnDefinitions="*,Auto"
ColumnSpacing="16"
RowSpacing="6">
<StackPanel Spacing="4">
<TextBlock x:Name="ToggleLabelTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="ToggleDescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch x:Name="EnabledToggleSwitch"
Grid.Column="1"
VerticalAlignment="Center"
Checked="OnEnabledChanged"
Unchecked="OnEnabledChanged" />
</Grid>
</Border>
<Border x:Name="IntervalCard"
Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="IntervalLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="IntervalComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnIntervalSelectionChanged" />
</StackPanel>
</Border>
<Border x:Name="ExtraSelectorCard"
Classes="component-editor-card"
Padding="20"
IsVisible="False">
<StackPanel Spacing="12">
<TextBlock x:Name="ExtraSelectorLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ExtraSelectorComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnExtraSelectionChanged" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public sealed record ComponentEditorSelectionOption(
string Value,
string LabelKey,
string FallbackLabel);
public sealed class ToggleIntervalComponentEditorOptions
{
public required string DescriptionKey { get; init; }
public required string DescriptionFallback { get; init; }
public required string ToggleLabelKey { get; init; }
public required string ToggleLabelFallback { get; init; }
public string ToggleDescriptionKey { get; init; } = string.Empty;
public string ToggleDescriptionFallback { get; init; } = string.Empty;
public required string IntervalLabelKey { get; init; }
public required string IntervalLabelFallback { get; init; }
public required Func<ComponentSettingsSnapshot, bool> GetEnabled { get; init; }
public required Action<ComponentSettingsSnapshot, bool> SetEnabled { get; init; }
public required Func<ComponentSettingsSnapshot, int> GetInterval { get; init; }
public required Action<ComponentSettingsSnapshot, int> SetInterval { get; init; }
public required int DefaultInterval { get; init; }
public IReadOnlyList<string> ChangedKeys { get; init; } = Array.Empty<string>();
public string ExtraSelectorLabelKey { get; init; } = string.Empty;
public string ExtraSelectorLabelFallback { get; init; } = string.Empty;
public IReadOnlyList<ComponentEditorSelectionOption> ExtraOptions { get; init; } = Array.Empty<ComponentEditorSelectionOption>();
public Func<ComponentSettingsSnapshot, string>? GetExtraValue { get; init; }
public Action<ComponentSettingsSnapshot, string>? SetExtraValue { get; init; }
}
public partial class ToggleIntervalComponentEditor : ComponentEditorViewBase
{
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly ToggleIntervalComponentEditorOptions _options;
private bool _suppressEvents;
public ToggleIntervalComponentEditor()
: this(null, new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "component.editor.desc",
DescriptionFallback = "Configure this component.",
ToggleLabelKey = "component.editor.toggle",
ToggleLabelFallback = "Enabled",
IntervalLabelKey = "component.editor.interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 15,
GetEnabled = _ => true,
SetEnabled = (_, _) => { },
GetInterval = _ => 15,
SetInterval = (_, _) => { }
})
{
}
public ToggleIntervalComponentEditor(
DesktopComponentEditorContext? context,
ToggleIntervalComponentEditorOptions options)
: base(context)
{
_options = options;
InitializeComponent();
BuildOptions();
ApplyState();
}
private void BuildOptions()
{
IntervalComboBox.Items.Clear();
foreach (var minutes in SupportedIntervals)
{
var item = new ComboBoxItem
{
Tag = minutes.ToString(),
Content = L("refresh.frequency." + RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes), RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes))
};
item.Classes.Add("component-editor-select-item");
IntervalComboBox.Items.Add(item);
}
ExtraSelectorComboBox.Items.Clear();
foreach (var option in _options.ExtraOptions)
{
var item = new ComboBoxItem
{
Tag = option.Value,
Content = L(option.LabelKey, option.FallbackLabel)
};
item.Classes.Add("component-editor-select-item");
ExtraSelectorComboBox.Items.Add(item);
}
}
private void ApplyState()
{
ToggleLabelTextBlock.Text = L(_options.ToggleLabelKey, _options.ToggleLabelFallback);
ToggleDescriptionTextBlock.Text = string.IsNullOrWhiteSpace(_options.ToggleDescriptionKey)
? L("component.editor.instance_scope", "Changes are stored per component instance.")
: L(_options.ToggleDescriptionKey, _options.ToggleDescriptionFallback);
IntervalLabelTextBlock.Text = L(_options.IntervalLabelKey, _options.IntervalLabelFallback);
ExtraSelectorLabelTextBlock.Text = L(_options.ExtraSelectorLabelKey, _options.ExtraSelectorLabelFallback);
ExtraSelectorCard.IsVisible = _options.ExtraOptions.Count > 0;
_suppressEvents = true;
try
{
var snapshot = LoadSnapshot();
var enabled = _options.GetEnabled(snapshot);
EnabledToggleSwitch.IsChecked = enabled;
IntervalCard.IsVisible = enabled;
var normalizedInterval = RefreshIntervalCatalog.Normalize(_options.GetInterval(snapshot), _options.DefaultInterval);
IntervalComboBox.SelectedItem = IntervalComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => item.Tag is string tag && int.TryParse(tag, out var minutes) && minutes == normalizedInterval);
if (_options.GetExtraValue is not null && _options.ExtraOptions.Count > 0)
{
var extraValue = _options.GetExtraValue(snapshot);
ExtraSelectorComboBox.SelectedItem = ExtraSelectorComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, extraValue, StringComparison.OrdinalIgnoreCase))
?? ExtraSelectorComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
}
finally
{
_suppressEvents = false;
}
}
private void OnEnabledChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
IntervalCard.IsVisible = EnabledToggleSwitch.IsChecked == true;
SaveState();
}
private void OnIntervalSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void OnExtraSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void SaveState()
{
var snapshot = LoadSnapshot();
_options.SetEnabled(snapshot, EnabledToggleSwitch.IsChecked == true);
_options.SetInterval(snapshot, GetSelectedInterval());
if (_options.SetExtraValue is not null &&
ExtraSelectorComboBox.SelectedItem is ComboBoxItem extraItem &&
extraItem.Tag is string extraValue)
{
_options.SetExtraValue(snapshot, extraValue);
}
SaveSnapshot(snapshot, _options.ChangedKeys.ToArray());
}
private int GetSelectedInterval()
{
if (IntervalComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string tag &&
int.TryParse(tag, out var minutes))
{
return RefreshIntervalCatalog.Normalize(minutes, _options.DefaultInterval);
}
return _options.DefaultInterval;
}
}

View File

@@ -0,0 +1,92 @@
<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"
x:Class="LanMountainDesktop.Views.ComponentEditors.WorldClockComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ClockOneLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ClockOneComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ClockTwoLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ClockTwoComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ClockThreeLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ClockThreeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ClockFourLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ClockFourComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="SecondHandLabelTextBlock"
Classes="component-editor-section-title" />
<Border Classes="component-editor-segmented-host"
HorizontalAlignment="Left">
<Grid ColumnDefinitions="*,*"
ColumnSpacing="4">
<ToggleButton x:Name="TickRadioButton"
Classes="component-editor-segmented-choice"
Checked="OnSecondHandChanged"
Unchecked="OnSecondHandChanged" />
<ToggleButton x:Name="SweepRadioButton"
Grid.Column="1"
Classes="component-editor-segmented-choice"
Checked="OnSecondHandChanged"
Unchecked="OnSecondHandChanged" />
</Grid>
</Border>
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,152 @@
using System;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class WorldClockComponentEditor : ComponentEditorViewBase
{
private readonly TimeZoneService _timeZoneService = new();
private readonly ComboBox[] _timeZoneCombos;
private bool _suppressEvents;
public WorldClockComponentEditor()
: this(null)
{
}
public WorldClockComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
if (context is not null)
{
_timeZoneService.CurrentTimeZone = context.SettingsFacade.Region.GetTimeZoneService().CurrentTimeZone;
}
InitializeComponent();
_timeZoneCombos = [ClockOneComboBox, ClockTwoComboBox, ClockThreeComboBox, ClockFourComboBox];
ApplyState();
}
private void ApplyState()
{
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "World Clock";
DescriptionTextBlock.Text = L("worldclock.settings.desc", "分别为四个时钟选择时区。");
ClockOneLabelTextBlock.Text = L("worldclock.settings.clock_1", "时钟 1");
ClockTwoLabelTextBlock.Text = L("worldclock.settings.clock_2", "时钟 2");
ClockThreeLabelTextBlock.Text = L("worldclock.settings.clock_3", "时钟 3");
ClockFourLabelTextBlock.Text = L("worldclock.settings.clock_4", "时钟 4");
SecondHandLabelTextBlock.Text = L("worldclock.settings.second_mode_label", "秒针方式");
TickRadioButton.Content = L("clock.second_mode.tick", "跳针");
SweepRadioButton.Content = L("clock.second_mode.sweep", "扫针");
var allTimeZones = _timeZoneService.GetAllTimeZones()
.OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow))
.ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
var selectedIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(LoadSnapshot().WorldClockTimeZoneIds, allTimeZones);
_suppressEvents = true;
foreach (var combo in _timeZoneCombos)
{
combo.Items.Clear();
foreach (var zone in allTimeZones)
{
var item = new ComboBoxItem
{
Tag = zone.Id,
Content = FormatTimeZone(zone)
};
item.Classes.Add("component-editor-select-item");
combo.Items.Add(item);
}
}
for (var index = 0; index < _timeZoneCombos.Length; index++)
{
var combo = _timeZoneCombos[index];
var targetId = index < selectedIds.Count ? selectedIds[index] : TimeZoneInfo.Local.Id;
combo.SelectedItem = combo.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, targetId, StringComparison.OrdinalIgnoreCase))
?? combo.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
var secondMode = ClockSecondHandMode.Normalize(LoadSnapshot().WorldClockSecondHandMode);
TickRadioButton.IsChecked = string.Equals(secondMode, ClockSecondHandMode.Tick, StringComparison.OrdinalIgnoreCase);
SweepRadioButton.IsChecked = string.Equals(secondMode, ClockSecondHandMode.Sweep, StringComparison.OrdinalIgnoreCase);
_suppressEvents = false;
}
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void OnSecondHandChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
_suppressEvents = true;
if (sender == TickRadioButton)
{
TickRadioButton.IsChecked = true;
SweepRadioButton.IsChecked = false;
}
else if (sender == SweepRadioButton)
{
SweepRadioButton.IsChecked = true;
TickRadioButton.IsChecked = false;
}
if (TickRadioButton.IsChecked != true && SweepRadioButton.IsChecked != true)
{
TickRadioButton.IsChecked = true;
}
_suppressEvents = false;
SaveState();
}
private void SaveState()
{
var snapshot = LoadSnapshot();
snapshot.WorldClockTimeZoneIds = _timeZoneCombos
.Select(combo => combo.SelectedItem is ComboBoxItem item && item.Tag is string tag ? tag : TimeZoneInfo.Local.Id)
.ToList();
snapshot.WorldClockSecondHandMode = SweepRadioButton.IsChecked == true
? ClockSecondHandMode.Sweep
: ClockSecondHandMode.Tick;
SaveSnapshot(
snapshot,
nameof(ComponentSettingsSnapshot.WorldClockTimeZoneIds),
nameof(ComponentSettingsSnapshot.WorldClockSecondHandMode));
}
private static string FormatTimeZone(TimeZoneInfo timeZone)
{
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);
var sign = offset >= TimeSpan.Zero ? "+" : "-";
var totalMinutes = Math.Abs((int)offset.TotalMinutes);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {timeZone.StandardName}";
}
}