diff --git a/YKanBan.Tests/ViewModels/SettingsDialogViewModelTests.cs b/YKanBan.Tests/ViewModels/SettingsDialogViewModelTests.cs
new file mode 100644
index 0000000..5304a48
--- /dev/null
+++ b/YKanBan.Tests/ViewModels/SettingsDialogViewModelTests.cs
@@ -0,0 +1,63 @@
+using YKanBan.Storage.AppData;
+using YKanBan.ViewModels.Dialogs;
+
+namespace YKanBan.Tests.ViewModels;
+
+///
+/// Tests for the settings dialog: the theme is previewed as soon as it is
+/// selected, saved on accept and restored on cancel.
+///
+[TestClass]
+public class SettingsDialogViewModelTests
+{
+ [TestMethod]
+ public void OpeningTheDialogAppliesNothing()
+ {
+ var applied = new List();
+
+ _ = new SettingsDialogViewModel(new AppConfig(), AppConfig.DefaultLanguage, applied.Add);
+
+ Assert.AreEqual(0, applied.Count);
+ }
+
+ [TestMethod]
+ public void SelectingAThemeAppliesItImmediatelyWithoutSavingIt()
+ {
+ var config = new AppConfig();
+ var applied = new List();
+ var dialog = new SettingsDialogViewModel(config, AppConfig.DefaultLanguage, applied.Add);
+
+ dialog.SelectedTheme = dialog.Themes.Single(option => option.Value.Equals(ThemeOption.Dark));
+
+ CollectionAssert.AreEqual(new[] { ThemeOption.Dark }, applied);
+ Assert.AreEqual(ThemeOption.FollowSystem, config.Theme);
+ }
+
+ [TestMethod]
+ public void AcceptSavesThePreviewedTheme()
+ {
+ var config = new AppConfig();
+ var applied = new List();
+ var dialog = new SettingsDialogViewModel(config, AppConfig.DefaultLanguage, applied.Add);
+ dialog.SelectedTheme = dialog.Themes.Single(option => option.Value.Equals(ThemeOption.Light));
+
+ dialog.Accept();
+
+ Assert.AreEqual(ThemeOption.Light, config.Theme);
+ CollectionAssert.AreEqual(new[] { ThemeOption.Light }, applied);
+ }
+
+ [TestMethod]
+ public void CancelRestoresTheThemeTheDialogOpenedWith()
+ {
+ var config = new AppConfig { Theme = ThemeOption.Light };
+ var applied = new List();
+ var dialog = new SettingsDialogViewModel(config, AppConfig.DefaultLanguage, applied.Add);
+ dialog.SelectedTheme = dialog.Themes.Single(option => option.Value.Equals(ThemeOption.Dark));
+
+ dialog.Cancel();
+
+ Assert.AreEqual(ThemeOption.Light, config.Theme);
+ CollectionAssert.AreEqual(new[] { ThemeOption.Dark, ThemeOption.Light }, applied);
+ }
+}
diff --git a/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs
index 94f33fd..2d16065 100644
--- a/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs
+++ b/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs
@@ -5,23 +5,27 @@ namespace YKanBan.ViewModels.Dialogs;
///
/// The settings dialog: language (restart to apply), theme and the four
-/// confirmation-dialog toggles. Nothing is applied until the user accepts;
-/// cancelling or closing the window discards the changes.
+/// confirmation-dialog toggles. A theme selection is previewed immediately;
+/// everything else waits until the user accepts. Cancelling or closing the
+/// window discards the changes and restores the theme the dialog opened with.
///
public sealed partial class SettingsDialogViewModel : ViewModelBase
{
private readonly AppConfig _config;
private readonly string _startupLanguage;
+ private readonly Action _applyTheme;
///
/// Initializes the dialog from the live configuration.
///
/// The live application configuration.
/// The language applied at startup, for the restart notice.
- public SettingsDialogViewModel(AppConfig config, string startupLanguage)
+ /// Applies a theme to the running app; defaults to .
+ public SettingsDialogViewModel(AppConfig config, string startupLanguage, Action? applyTheme = null)
{
_config = config;
_startupLanguage = startupLanguage;
+ _applyTheme = applyTheme ?? App.ApplyTheme;
Languages =
[
@@ -63,7 +67,8 @@ public sealed partial class SettingsDialogViewModel : ViewModelBase
private OptionItem? selectedLanguage;
///
- /// Gets or sets the selected theme; applied when the dialog is accepted.
+ /// Gets or sets the selected theme; applied to the running app as soon as it
+ /// changes, and saved to the configuration when the dialog is accepted.
///
[ObservableProperty]
private OptionItem? selectedTheme;
@@ -110,7 +115,6 @@ public sealed partial class SettingsDialogViewModel : ViewModelBase
if (SelectedTheme is not null)
{
_config.Theme = (ThemeOption)SelectedTheme.Value;
- App.ApplyTheme(_config.Theme);
}
_config.Confirmations.DeleteCard = ConfirmDeleteCard;
@@ -119,6 +123,23 @@ public sealed partial class SettingsDialogViewModel : ViewModelBase
_config.Confirmations.DiscardEdit = ConfirmDiscardEdit;
}
+ ///
+ /// Discards the working values and restores the theme the dialog opened with.
+ ///
+ public void Cancel() => _applyTheme(_config.Theme);
+
+ ///
+ /// Previews a theme selection on the running app.
+ ///
+ /// The new selection.
+ partial void OnSelectedThemeChanged(OptionItem? value)
+ {
+ if (value is not null)
+ {
+ _applyTheme((ThemeOption)value.Value);
+ }
+ }
+
///
/// Refreshes the restart notice after a language selection change.
///
diff --git a/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs b/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs
index ba0cac9..ad8b21e 100644
--- a/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs
+++ b/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs
@@ -6,6 +6,8 @@ namespace YKanBan.Views.Dialogs;
public partial class SettingsDialog : Window
{
+ private bool _accepted;
+
public SettingsDialog()
{
InitializeComponent();
@@ -15,9 +17,24 @@ public partial class SettingsDialog : Window
private void OnOkClick(object? sender, RoutedEventArgs e)
{
(DataContext as SettingsDialogViewModel)?.Accept();
+ _accepted = true;
Close(true);
}
/// Closes without applying anything.
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
+
+ ///
+ /// Restores the previewed theme on every close that did not accept,
+ /// including the title-bar close button and Escape.
+ ///
+ /// The event arguments.
+ protected override void OnClosed(EventArgs e)
+ {
+ if (!_accepted)
+ {
+ (DataContext as SettingsDialogViewModel)?.Cancel();
+ }
+ base.OnClosed(e);
+ }
}
diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md
index 3e53acf..43220d3 100644
--- a/docs/ACCEPTANCE.md
+++ b/docs/ACCEPTANCE.md
@@ -30,7 +30,8 @@
### A3 主题三态切换
-- [ ] 设置 → 主题 亮色 / 暗色 / 跟随系统 依次切换:**即时生效**,无重启提示
+- [ ] 设置 → 主题 亮色 / 暗色 / 跟随系统 依次切换:下拉框一选中就**即时生效**(无需点确认),无重启提示
+- [ ] 切换主题后点取消、按 Esc 或点标题栏关闭:恢复为打开设置时的主题;重启后仍是原主题
- [ ] 每种主题下检查:主窗口、看板列与卡片、标签 tab、全部模态、全部错误页颜色完整切换,无亮底暗字/暗底亮字残留
- [ ] 标签徽章在两种主题下均显示原色(§5.9 例外),文字颜色保持可读
- [ ] 跟随系统:程序运行中切换 Windows 深浅色 → 程序跟随变化
diff --git a/docs/PLAN.md b/docs/PLAN.md
index f74b778..5c1988a 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -214,7 +214,7 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE
入口 = 顶部显示区域右侧"设置"按钮,承载各类全局设置:
- 语言选择(英文 / 简体中文),切换后显示"重启后生效"提示(提示文本本身为资源键)
-- 主题选择(亮色 / 暗色 / 跟随系统),切换后**即时生效**,无重启提示(与语言项的行为差异)
+- 主题选择(亮色 / 暗色 / 跟随系统),下拉框选中即**即时生效**(预览),无重启提示(与语言项的行为差异);点确认才写入配置,取消或关闭设置模态则恢复打开时的主题
- 四类确认开关(见 §5.5)
### 5.7 关于模态