diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index 05c925ea..be5815b0 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -265,7 +265,7 @@ LogExpert/ - UI components in `LogExpert.UI` project - Follow existing High DPI patterns (no AutoScale on controls) -- Test with both light and dark mode (see `SetDarkMode()` in Program.cs) +- Test with both light and dark mode (see `SetColorMode()` in Program.cs; the `ColorMode` preference is Light/Dark/System) - Use localization resources from `LogExpert.Resources` project - Windows Forms designer files: `*.designer.cs` diff --git a/src/LogExpert.Core/Config/ColorMode.cs b/src/LogExpert.Core/Config/ColorMode.cs new file mode 100644 index 00000000..004c1d01 --- /dev/null +++ b/src/LogExpert.Core/Config/ColorMode.cs @@ -0,0 +1,27 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace LogExpert.Core.Config; + +/// +/// Color scheme the application applies at startup (issue #698). +/// +[Serializable] +[JsonConverter(typeof(StringEnumConverter))] +public enum ColorMode +{ + /// + /// Forced light mode, ignoring the Windows theme (maps to SystemColorMode.Classic). + /// + Light = 0, + + /// + /// Forced dark mode, ignoring the Windows theme. + /// + Dark = 1, + + /// + /// Follow the Windows theme. + /// + System = 2, +} diff --git a/src/LogExpert.Core/Config/Preferences.cs b/src/LogExpert.Core/Config/Preferences.cs index 61668708..ff4dd1bd 100644 --- a/src/LogExpert.Core/Config/Preferences.cs +++ b/src/LogExpert.Core/Config/Preferences.cs @@ -79,7 +79,44 @@ public List HilightGroupList public bool AskForClose { get; set; } - public bool DarkMode { get; set; } + /// + /// Color scheme applied at startup: forced light, forced dark, or follow the Windows theme. + /// Takes effect after a restart. Replaces the old bool DarkMode setting (issue #698). + /// + public ColorMode ColorMode + { + get => _colorMode; + set + { + _colorMode = value; + _colorModeAssigned = true; + } + } + + private ColorMode _colorMode = ColorMode.Light; + + private bool _colorModeAssigned; + + /// + /// Legacy property for backward compatibility with old settings files that stored the color scheme as the bool + /// "DarkMode". true maps to , false to — an unchecked + /// box meant forced light, not follow-OS (issue #698). An explicit value always wins. + /// This setter redirects data on load; the property is never written back. + /// + [Obsolete("This property exists only for backward compatibility with old settings files. Use ColorMode instead. This will be removed with version 1.50")] + [Newtonsoft.Json.JsonProperty("DarkMode", DefaultValueHandling = Newtonsoft.Json.DefaultValueHandling.Ignore, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + [System.Text.Json.Serialization.JsonIgnore] + public bool? DarkMode + { + get => null; // Always return null so Newtonsoft.Json won't serialize this property + set + { + if (!_colorModeAssigned && value.HasValue) + { + _colorMode = value.Value ? ColorMode.Dark : ColorMode.Light; + } + } + } [Obsolete("This setting is no longer used and will be removed in version 1.50. The 'UseLegacyReader' now works with ReaderType.Legacy")] [System.Text.Json.Serialization.JsonIgnore] diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 1c400f52..22218342 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -5194,11 +5194,11 @@ public static string SettingsDialog_UI_CheckBox_checkBoxControlCharsItalic { } /// - /// Looks up a localized string similar to Dark Mode (restart required). + /// Looks up a localized string similar to Color mode (restart required). /// - public static string SettingsDialog_UI_CheckBox_checkBoxDarkMode { + public static string SettingsDialog_UI_Label_labelColorMode { get { - return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxDarkMode", resourceCulture); + return ResourceManager.GetString("SettingsDialog_UI_Label_labelColorMode", resourceCulture); } } diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index e0e19567..a83dac05 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -976,8 +976,8 @@ Damit die anderen Fenster beim Auswählen einer Zeile (Mausklick oder Pfeiltaste Dem Ende folgen aktivieren - - Dark Mode (neustart benötigt) + + Farbmodus (Neustart benötigt) Fragen vor dem schließen des Tabs diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index a08b755c..22abd07a 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -863,8 +863,8 @@ Checked tools will appear in the icon bar. All other tools are available in the Ask before closing tabs - - Dark Mode (restart required) + + Color mode (restart required) Follow tail enabled diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index f4af56f5..10233076 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -760,8 +760,8 @@ 关闭标签页前询问 - - 深色模式(需重启生效) + + 颜色模式(需重启生效) 启用跟随尾部 diff --git a/src/LogExpert.Tests/ConfigManagerTests/PreferencesColorModeTests.cs b/src/LogExpert.Tests/ConfigManagerTests/PreferencesColorModeTests.cs new file mode 100644 index 00000000..fa4e3e1d --- /dev/null +++ b/src/LogExpert.Tests/ConfigManagerTests/PreferencesColorModeTests.cs @@ -0,0 +1,79 @@ +using LogExpert.Core.Config; + +using Newtonsoft.Json; + +using NUnit.Framework; + +namespace LogExpert.Tests.ConfigManagerTests; + +[TestFixture] +public class PreferencesColorModeTests +{ + [Test] + public void Deserialize_LegacyDarkModeTrue_MapsToDark () + { + const string legacyJson = "{\"DarkMode\": true}"; + + var prefs = JsonConvert.DeserializeObject(legacyJson); + + Assert.That(prefs, Is.Not.Null); + Assert.That(prefs!.ColorMode, Is.EqualTo(ColorMode.Dark)); + } + + [Test] + public void Deserialize_LegacyDarkModeFalse_MapsToLight () + { + // Unchecked "Dark Mode" meant forced light, not follow-OS (issue #698). + const string legacyJson = "{\"DarkMode\": false}"; + + var prefs = JsonConvert.DeserializeObject(legacyJson); + + Assert.That(prefs, Is.Not.Null); + Assert.That(prefs!.ColorMode, Is.EqualTo(ColorMode.Light)); + } + + [Test] + public void Deserialize_NoColorSetting_DefaultsToLight () + { + const string legacyJson = "{}"; + + var prefs = JsonConvert.DeserializeObject(legacyJson); + + Assert.That(prefs, Is.Not.Null); + Assert.That(prefs!.ColorMode, Is.EqualTo(ColorMode.Light)); + } + + [Test] + public void Deserialize_ColorModeString_ReadsEnum () + { + const string json = "{\"ColorMode\": \"System\"}"; + + var prefs = JsonConvert.DeserializeObject(json); + + Assert.That(prefs, Is.Not.Null); + Assert.That(prefs!.ColorMode, Is.EqualTo(ColorMode.System)); + } + + [Test] + public void Deserialize_ColorModeWinsOverLegacyDarkMode () + { + // A file that carries both keys must obey the new one, regardless of key order. + const string json = "{\"ColorMode\": \"Light\", \"DarkMode\": true}"; + + var prefs = JsonConvert.DeserializeObject(json); + + Assert.That(prefs, Is.Not.Null); + Assert.That(prefs!.ColorMode, Is.EqualTo(ColorMode.Light)); + } + + [Test] + public void Serialize_WritesColorModeAsString_AndNoLegacyDarkMode () + { + var prefs = new Preferences { ColorMode = ColorMode.System }; + + var json = JsonConvert.SerializeObject(prefs); + + Assert.That(json, Does.Contain("\"ColorMode\":\"System\"")); + Assert.That(json, Does.Not.Contain("\"DarkMode\"")); + } +} diff --git a/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs b/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs index cfd9aa09..50ac8db0 100644 --- a/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs +++ b/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs @@ -41,7 +41,8 @@ private void InitializeComponent () groupBoxDefaults = new GroupBox(); labelLanguage = new Label(); comboBoxLanguage = new ComboBox(); - checkBoxDarkMode = new CheckBox(); + labelColorMode = new Label(); + comboBoxColorMode = new ComboBox(); checkBoxFollowTail = new CheckBox(); checkBoxColumnFinder = new CheckBox(); checkBoxSyncFilter = new CheckBox(); @@ -495,7 +496,8 @@ private void InitializeComponent () // groupBoxDefaults.Controls.Add(labelLanguage); groupBoxDefaults.Controls.Add(comboBoxLanguage); - groupBoxDefaults.Controls.Add(checkBoxDarkMode); + groupBoxDefaults.Controls.Add(labelColorMode); + groupBoxDefaults.Controls.Add(comboBoxColorMode); groupBoxDefaults.Controls.Add(checkBoxFollowTail); groupBoxDefaults.Controls.Add(checkBoxColumnFinder); groupBoxDefaults.Controls.Add(checkBoxSyncFilter); @@ -529,17 +531,26 @@ private void InitializeComponent () comboBoxLanguage.Size = new Size(177, 23); comboBoxLanguage.TabIndex = 9; toolTip.SetToolTip(comboBoxLanguage, "Userinterface language"); - // - // checkBoxDarkMode - // - checkBoxDarkMode.AutoSize = true; - checkBoxDarkMode.Location = new Point(9, 144); - checkBoxDarkMode.Margin = new Padding(4); - checkBoxDarkMode.Name = "checkBoxDarkMode"; - checkBoxDarkMode.Size = new Size(175, 19); - checkBoxDarkMode.TabIndex = 6; - checkBoxDarkMode.Text = "Dark Mode (restart required)"; - checkBoxDarkMode.UseVisualStyleBackColor = true; + // + // labelColorMode + // + labelColorMode.AutoSize = true; + labelColorMode.Location = new Point(9, 147); + labelColorMode.Margin = new Padding(4, 0, 4, 0); + labelColorMode.Name = "labelColorMode"; + labelColorMode.Size = new Size(160, 15); + labelColorMode.TabIndex = 18; + labelColorMode.Text = "Color mode (restart required)"; + // + // comboBoxColorMode + // + comboBoxColorMode.DropDownStyle = ComboBoxStyle.DropDownList; + comboBoxColorMode.FormattingEnabled = true; + comboBoxColorMode.Location = new Point(204, 144); + comboBoxColorMode.Margin = new Padding(4, 5, 4, 5); + comboBoxColorMode.Name = "comboBoxColorMode"; + comboBoxColorMode.Size = new Size(177, 23); + comboBoxColorMode.TabIndex = 6; // // checkBoxFollowTail // @@ -2280,7 +2291,8 @@ private void InitializeComponent () private System.Windows.Forms.CheckBox checkBoxPortableMode; private System.Windows.Forms.RadioButton radioButtonSessionApplicationStartupDir; private System.Windows.Forms.CheckBox checkBoxShowErrorMessageOnlyOneInstance; - private System.Windows.Forms.CheckBox checkBoxDarkMode; + private System.Windows.Forms.Label labelColorMode; + private System.Windows.Forms.ComboBox comboBoxColorMode; private System.Windows.Forms.NumericUpDown upDownMaximumLineLength; private System.Windows.Forms.Label labelMaximumLineLength; private System.Windows.Forms.Label labelWarningMaximumLineLength; diff --git a/src/LogExpert.UI/Dialogs/SettingsDialog.cs b/src/LogExpert.UI/Dialogs/SettingsDialog.cs index 4620e18d..d32f0801 100644 --- a/src/LogExpert.UI/Dialogs/SettingsDialog.cs +++ b/src/LogExpert.UI/Dialogs/SettingsDialog.cs @@ -185,7 +185,7 @@ private void FillDialog () FillPortableMode(); - checkBoxDarkMode.Checked = Preferences.DarkMode; + FillColorModeList(); checkBoxTimestamp.Checked = Preferences.TimestampControl; checkBoxSyncFilter.Checked = Preferences.FilterSync; checkBoxFilterTail.Checked = Preferences.FilterTail; @@ -325,6 +325,16 @@ private void FillReaderTypeList () comboBoxReaderType.SelectedItem = Preferences.ReaderType; } + private void FillColorModeList () + { + foreach (var colorMode in Enum.GetValues().Where(cm => !comboBoxColorMode.Items.Contains(cm))) + { + _ = comboBoxColorMode.Items.Add(colorMode); + } + + comboBoxColorMode.SelectedItem = Preferences.ColorMode; + } + internal void FillPortableMode () { // Detach the handler while syncing the checkbox from preferences: CheckedChanged also @@ -818,7 +828,7 @@ private void OnBtnOkClick (object sender, EventArgs e) Preferences.MaximumFilterEntries = (int)upDownMaximumFilterEntries.Value; Preferences.MaximumFilterEntriesDisplayed = (int)upDownMaximumFilterEntriesDisplayed.Value; Preferences.ShowErrorMessageAllowOnlyOneInstances = checkBoxShowErrorMessageOnlyOneInstance.Checked; - Preferences.DarkMode = checkBoxDarkMode.Checked; + Preferences.ColorMode = comboBoxColorMode.SelectedItem is ColorMode colorMode ? colorMode : ColorMode.Light; Preferences.MaxLineLength = (int)upDownMaximumLineLength.Value; Preferences.MaxDisplayLength = Math.Min((int)upDownMaxDisplayLength.Value, (int)upDownMaximumLineLength.Value); diff --git a/src/LogExpert/Program.cs b/src/LogExpert/Program.cs index 2122957f..220a5134 100644 --- a/src/LogExpert/Program.cs +++ b/src/LogExpert/Program.cs @@ -130,7 +130,7 @@ private static void Main (string[] args) _ = PluginRegistry.PluginRegistry.Create(ConfigManager.Instance.ActiveConfigDir, ConfigManager.Instance.Settings.Preferences.PollingInterval); SetCulture(); - SetDarkMode(); + SetColorMode(); ColumnizerLib.Column.SetMaxDisplayLength(ConfigManager.Instance.Settings.Preferences.MaxDisplayLength); @@ -248,17 +248,14 @@ or ArgumentNullException } [SupportedOSPlatform("windows")] - private static void SetDarkMode () + private static void SetColorMode () { - var darkModeEnabled = ConfigManager.Instance.Settings.Preferences.DarkMode; - if (darkModeEnabled) + Application.SetColorMode(ConfigManager.Instance.Settings.Preferences.ColorMode switch { - Application.SetColorMode(SystemColorMode.Dark); - } - else - { - Application.SetColorMode(SystemColorMode.System); - } + ColorMode.Dark => SystemColorMode.Dark, + ColorMode.System => SystemColorMode.System, + _ => SystemColorMode.Classic, + }); } [SupportedOSPlatform("windows")]