From e46b718e007d4c323d38045d8dff1339916861f2 Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 17:46:46 -0700 Subject: [PATCH 1/3] Add theme files for GUI runners' dark look (View > Theme > Dark Theme) --- CHANGELOG.md | 5 + README.md | 27 ++ gui/src/ASWUnitTests_GUI_Commands.h | 2 +- gui/src/ASWUnitTests_GUI_INIDocument.cpp | 24 ++ gui/src/ASWUnitTests_GUI_INIDocument.h | 5 + gui/src/ASWUnitTests_GUI_Theme.cpp | 207 +++++++++++ gui/src/ASWUnitTests_GUI_Theme.h | 69 ++++ gui/src/ASWUnitTests_GUI_View.cpp | 15 + gui/src/ASWUnitTests_GUI_View.h | 3 + .../Test_ASWUnitTests_GUI_INIDocument.cpp | 64 ++++ gui/tests/Test_ASWUnitTests_GUI_INIDocument.h | 2 + gui/tests/Test_ASWUnitTests_GUI_Theme.cpp | 238 ++++++++++++ gui/tests/Test_ASWUnitTests_GUI_Theme.h | 8 + gui/tests/Test_ASWUnitTests_GUI_View.cpp | 13 + gui/tests/Test_ASWUnitTests_GUI_View.h | 1 + vcl/gui/rad370/ASWUnitTests_VCL_GUI.cpp | 17 +- vcl/gui/src/ASWUnitTests_GUI_MainForm.cpp | 131 ++++++- vcl/gui/src/ASWUnitTests_GUI_MainForm.dfm | 20 ++ vcl/gui/src/ASWUnitTests_GUI_MainForm.h | 30 +- vcl/gui/src/ASWUnitTests_GUI_VCLSystem.cpp | 157 +++++++- vcl/gui/src/ASWUnitTests_GUI_VCLSystem.h | 24 +- vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.cpp | 130 ++++++- vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.h | 3 + .../src/ASWUnitTests_Win32GUI_MainWindow.cpp | 339 +++++++++++++++--- .../src/ASWUnitTests_Win32GUI_MainWindow.h | 23 +- .../gui/src/ASWUnitTests_Win32GUI_Palette.cpp | 245 +++++++++++++ win32/gui/src/ASWUnitTests_Win32GUI_Palette.h | 48 ++- .../gui/src/ASWUnitTests_Win32GUI_System.cpp | 78 ++++ win32/gui/src/ASWUnitTests_Win32GUI_System.h | 16 + win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp | 169 +++++++-- win32/gui/src/ASWUnitTests_Win32GUI_Theme.h | 18 +- .../gui/src/ASWUnitTests_Win32GUI_WinMain.cpp | 16 +- .../Test_ASWUnitTests_Win32GUI_Palette.cpp | 155 ++++++++ .../Test_ASWUnitTests_Win32GUI_Palette.h | 3 + .../Test_ASWUnitTests_Win32GUI_System.cpp | 57 ++- .../tests/Test_ASWUnitTests_Win32GUI_System.h | 2 + 36 files changed, 2264 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d685c12..5677b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,11 @@ versioned baseline. runner paints Carbon's colors itself, so it needs nothing more; its message boxes are its own dialogs, in its theme, and its tree, results tabs and progress bar are drawn without flicker as the window is resized. +- Theme files for the dark look in both GUI runners, chosen in **View > Theme > Dark Theme**, which + lists Carbon and the files in `%APPDATA%\ASWUnitTests\Themes`, shared by every test app. The Win32 + GUI runner's are INI files that set any of its colors by role, and **Save Theme Template...** + saves Carbon's as one to start from; the VCL GUI runner's are VCL style files (`.vsf`). The + choice is saved in the layout file, and a theme file's problems are reported in the log. - An ASWUnitTests icon, `resource\ASWUnitTests.ico`, which this repository's console and GUI runners now have on Windows, with the PNGs it's made from and `resource\make_icon.ps1`, which draws them. `ASWUNITTESTS_WIN32_GUI_SOURCES` includes `resource\ASWUnitTests_Icon.rc`, so a Win32 diff --git a/README.md b/README.md index 7d3537f..42c343f 100644 --- a/README.md +++ b/README.md @@ -393,6 +393,33 @@ the Win32 GUI runner's dark look, the title bar is dark unless Windows shows the title bars, which it then keeps while the window is active, and the Export JUnit Report dialog, Windows' own, stays light. +**View > Theme > Dark Theme** chooses the dark look's colors: Carbon, built in, or a theme file of +your own, used whenever the look is dark, with Dark or with Use Windows Setting (high contrast keeps +its own colors). Theme files go in `%APPDATA%\ASWUnitTests\Themes`, which **Open Themes Folder** +opens, and are shared by every test app; the menu lists each one by its file name. The choice is +saved in the layout file with the theme, and a problem with a theme file, such as a misspelled role, +a color that isn't one or a missing file, is reported in the Log tab, with Carbon's colors used in +its place. + +The Win32 GUI runner's theme files are INI files, `.ini`, and **Save Theme Template...** saves +Carbon's colors as one to start from, every role listed under a comment on what its group colors: + +```ini +[Theme] +Base=Dark + +[Colors] +Window=#0F2238 +Panel=#1B3350 +``` + +`Base=Dark` is required. Each line under `[Colors]` sets a role's color as `#RRGGBB`, and a role +left out keeps Carbon's color. Choosing the theme again reads its file again, so a change shows at +once. The VCL GUI runner's theme files are VCL style files, `.vsf`, made with RAD Studio's +Bitmap Style Designer. Each needs a style name of its own, not that of a style the project already +has, such as Carbon. A style file is loaded once per session, so a change to it shows after a +restart. + The window also remembers which tests are checked, in `%APPDATA%\ASWUnitTests\.selection`, as long as the command line doesn't choose them itself (no `--run`, `--filter`, or partition options). What it saves is what Run Selected would run, the checked tests that are shown; the filter diff --git a/gui/src/ASWUnitTests_GUI_Commands.h b/gui/src/ASWUnitTests_GUI_Commands.h index 122a1ed..3889c77 100644 --- a/gui/src/ASWUnitTests_GUI_Commands.h +++ b/gui/src/ASWUnitTests_GUI_Commands.h @@ -59,7 +59,7 @@ struct TGUICommandContext // Whether each of a GUI runner's commands is enabled, one member per // command, named as in the VCL GUI runner's actions (e.g. RunSelected for // Act_RunSelected), plus the filter box. Theme is the GUI runners' View > -// Theme menu, its three commands together. +// Theme menu, its commands together, its Dark Theme submenu's included. ///////////////////////////////////////////////////////////////////////////// struct TGUICommandStates { diff --git a/gui/src/ASWUnitTests_GUI_INIDocument.cpp b/gui/src/ASWUnitTests_GUI_INIDocument.cpp index 01a6fbb..92e8292 100644 --- a/gui/src/ASWUnitTests_GUI_INIDocument.cpp +++ b/gui/src/ASWUnitTests_GUI_INIDocument.cpp @@ -167,6 +167,20 @@ TGUIINIDocument::TSection* TGUIINIDocument::FindSection(std::string const& secti return const_cast(static_cast(this)->FindSection(section)); } //--------------------------------------------------------------------------- +std::vector TGUIINIDocument::KeyNames(std::string const& section) const +{ + std::vector names; + TSection const* const found = FindSection(section); + + if (found != nullptr) + { + for (TKey const& key : found->Keys) + names.push_back(key.Name); + } + + return names; +} +//--------------------------------------------------------------------------- std::optional TGUIINIDocument::ReadString(std::string const& section, std::string const& key) const { TSection const* const found = FindSection(section); @@ -182,6 +196,16 @@ std::optional TGUIINIDocument::ReadString(std::string const& sectio return std::nullopt; } //--------------------------------------------------------------------------- +std::vector TGUIINIDocument::SectionNames() const +{ + std::vector names; + + for (TSection const& section : m_Sections) + names.push_back(section.Name); + + return names; +} +//--------------------------------------------------------------------------- std::string TGUIINIDocument::Text() const { std::string text; diff --git a/gui/src/ASWUnitTests_GUI_INIDocument.h b/gui/src/ASWUnitTests_GUI_INIDocument.h index ccdf769..f19d0df 100644 --- a/gui/src/ASWUnitTests_GUI_INIDocument.h +++ b/gui/src/ASWUnitTests_GUI_INIDocument.h @@ -81,8 +81,13 @@ class TGUIINIDocument // Removes the key, if there is one. Its section stays, even if it's then empty. void DeleteKey(std::string const& section, std::string const& key); + // The names of the section's keys, as written and in order, a repeated key's each time, as TMemIniFile's + // ReadSection() gives them; empty if the section doesn't exist. + std::vector KeyNames(std::string const& section) const; // The key's value, or empty if the section or key doesn't exist. std::optional ReadString(std::string const& section, std::string const& key) const; + // The names of the sections, as written and in order. + std::vector SectionNames() const; // The document's text, as described above; empty for an empty document. std::string Text() const; // Sets the key's value, adding the key at the end of its section, and the section at the end of the diff --git a/gui/src/ASWUnitTests_GUI_Theme.cpp b/gui/src/ASWUnitTests_GUI_Theme.cpp index b2d6fe5..5592b2d 100644 --- a/gui/src/ASWUnitTests_GUI_Theme.cpp +++ b/gui/src/ASWUnitTests_GUI_Theme.cpp @@ -25,21 +25,61 @@ limitations under the License. #include "ASWUnitTests_GUI_Theme.h" //--------------------------------------------------------------------------- #include +#include #include #include +#include //--------------------------------------------------------------------------- namespace { +char const BaseKey[] = "Base"; +char const ColorsSection[] = "Colors"; +char const DarkThemeKey[] = "DarkTheme"; char const ThemeKey[] = "Theme"; +char const ThemeSection[] = "Theme"; char const ViewSection[] = "View"; //--------------------------------------------------------------------------- +int HexDigitValue(char c); +bool LessText(std::string const& a, std::string const& b); bool SameText(std::string const& a, std::string const& b); char ToLowerASCII(char c); +//--------------------------------------------------------------------------- +/* + HexDigitValue + + The value of 'c' as a hexadecimal digit, in either case; -1 if it isn't one. +*/ +int HexDigitValue(char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + + return -1; +} +//--------------------------------------------------------------------------- +/* + LessText + + Whether 'a' sorts before 'b', ignoring ASCII case. +*/ +bool LessText(std::string const& a, std::string const& b) +{ + return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(), [](char x, char y) + { + return ToLowerASCII(x) < ToLowerASCII(y); + }); +} //--------------------------------------------------------------------------- /* SameText @@ -67,6 +107,22 @@ char ToLowerASCII(char c) namespace ASWUnitTests { +//--------------------------------------------------------------------------- +std::string FormatGUIThemeColor(std::uint32_t rgb) +{ + char const digits[] = "0123456789ABCDEF"; + std::string text = "#"; + + for (int shift = 20; shift >= 0; shift -= 4) + text += digits[(rgb >> shift) & 0xF]; + + return text; +} +//--------------------------------------------------------------------------- +std::string GUIDefaultDarkThemeName() +{ + return "Carbon"; +} //--------------------------------------------------------------------------- bool GUIThemeIsDark(EGUITheme theme, bool windowsAppsUseDark, bool highContrast) { @@ -85,6 +141,47 @@ bool GUIThemeIsDark(EGUITheme theme, bool windowsAppsUseDark, bool highContrast) return windowsAppsUseDark && !highContrast; } //--------------------------------------------------------------------------- +std::vector GUIThemeNames(std::vector const& fileNames, std::string const& extension) +{ + std::vector names; + + for (std::string const& fileName : fileNames) + { + if (fileName.size() <= extension.size() || + !SameText(fileName.substr(fileName.size() - extension.size()), extension)) + { + continue; + } + + std::string const name = fileName.substr(0, fileName.size() - extension.size()); + bool const listed = std::any_of(names.begin(), names.end(), [&name](std::string const& other) + { + return SameText(other, name); + }); + + if (!listed && !SameText(name, GUIDefaultDarkThemeName())) + names.push_back(name); + } + + std::stable_sort(names.begin(), names.end(), &LessText); + return names; +} +//--------------------------------------------------------------------------- +std::string GUIThemeProblemLine(std::string const& themeName, std::string const& problem) +{ + return "Dark theme \"" + themeName + "\": " + problem; +} +//--------------------------------------------------------------------------- +std::string LoadGUIDarkTheme(TGUIINIDocument const& ini) +{ + std::optional const name = ini.ReadString(ViewSection, DarkThemeKey); + + if (!name.has_value() || name->empty() || SameText(*name, GUIDefaultDarkThemeName())) + return GUIDefaultDarkThemeName(); + + return *name; +} +//--------------------------------------------------------------------------- EGUITheme LoadGUITheme(TGUIINIDocument const& ini) { std::optional const name = ini.ReadString(ViewSection, ThemeKey); @@ -101,6 +198,116 @@ EGUITheme LoadGUITheme(TGUIINIDocument const& ini) return EGUITheme::Windows; } //--------------------------------------------------------------------------- +std::optional ParseGUIThemeColor(std::string const& text) +{ + if (text.size() != 7 || text[0] != '#') + return std::nullopt; + + std::uint32_t rgb = 0; + + for (std::size_t i = 1; i < text.size(); ++i) + { + int const digit = HexDigitValue(text[i]); + + if (digit < 0) + return std::nullopt; + + rgb = (rgb << 4) | static_cast(digit); + } + + return rgb; +} +//--------------------------------------------------------------------------- +/* + ReadGUIThemeFile + + Every problem is reported, even in a file that can't be used, so one look at the log shows them all. +*/ +TGUIThemeFile ReadGUIThemeFile(std::string const& text, std::vector const& roles) +{ + TGUIINIDocument const ini(text); + TGUIThemeFile file; + + for (std::string const& section : ini.SectionNames()) + { + if (!SameText(section, ThemeSection) && !SameText(section, ColorsSection)) + file.Problems.push_back("[" + section + "] isn't a section of a theme file, so it's ignored."); + } + + for (std::string const& key : ini.KeyNames(ThemeSection)) + { + if (!SameText(key, BaseKey)) + file.Problems.push_back("[Theme] " + key + " isn't a setting of a theme file, so it's ignored."); + } + + std::optional const base = ini.ReadString(ThemeSection, BaseKey); + + if (!base.has_value()) + { + file.Problems.push_back("[Theme] has no Base=Dark, which a theme file needs, so " + GUIDefaultDarkThemeName() + + " is used instead."); + } + else if (!SameText(*base, "Dark")) + { + file.Problems.push_back("[Theme] Base=" + *base + " isn't supported, only Base=Dark, so " + + GUIDefaultDarkThemeName() + " is used instead."); + } + + // The roles met so far, as 'roles' names them, so a repeat is reported, even of an invalid color. + std::vector met; + + for (std::string const& key : ini.KeyNames(ColorsSection)) + { + std::vector::const_iterator const role = std::find_if(roles.begin(), roles.end(), + [&key](std::string const& candidate) + { + return SameText(candidate, key); + }); + + if (role == roles.end()) + { + file.Problems.push_back("[Colors] " + key + " isn't a role a theme can color, so it's ignored."); + continue; + } + + if (std::find(met.begin(), met.end(), *role) != met.end()) + { + file.Problems.push_back("[Colors] " + key + " is set more than once, so only the first is used."); + continue; + } + + met.push_back(*role); + + // A repeated key reads as its first value, as TMemIniFile reads it. + std::string const value = ini.ReadString(ColorsSection, key).value_or(std::string()); + std::optional const rgb = ParseGUIThemeColor(value); + + if (!rgb.has_value()) + { + file.Problems.push_back("[Colors] " + key + "=" + value + " isn't a color written as #RRGGBB, so it's " + "ignored."); + continue; + } + + TGUIThemeColor color; + color.RGB = *rgb; + color.Role = *role; + file.Colors.push_back(color); + } + + file.Usable = base.has_value() && SameText(*base, "Dark"); + + if (!file.Usable) + file.Colors.clear(); + + return file; +} +//--------------------------------------------------------------------------- +void SaveGUIDarkTheme(TGUIINIDocument& ini, std::string const& name) +{ + ini.WriteString(ViewSection, DarkThemeKey, name); +} +//--------------------------------------------------------------------------- void SaveGUITheme(TGUIINIDocument& ini, EGUITheme theme) { char const* name = "Windows"; diff --git a/gui/src/ASWUnitTests_GUI_Theme.h b/gui/src/ASWUnitTests_GUI_Theme.h index 6d22227..5b30429 100644 --- a/gui/src/ASWUnitTests_GUI_Theme.h +++ b/gui/src/ASWUnitTests_GUI_Theme.h @@ -22,6 +22,11 @@ limitations under the License. #ifndef ASWUnitTests_GUI_ThemeH #define ASWUnitTests_GUI_ThemeH //--------------------------------------------------------------------------- +#include +#include +#include +#include +//--------------------------------------------------------------------------- #include "ASWUnitTests_GUI_INIDocument.h" //--------------------------------------------------------------------------- @@ -42,15 +47,79 @@ enum class EGUITheme Dark }; + +///////////////////////////////////////////////////////////////////////////// +// TGUIThemeColor +// +// One color a theme file sets: the role it colors, as the GUI runner names +// it, and the color, as 0xRRGGBB. +///////////////////////////////////////////////////////////////////////////// +struct TGUIThemeColor +{ + std::uint32_t RGB = 0; + std::string Role; +}; + + +///////////////////////////////////////////////////////////////////////////// +// TGUIThemeFile +// +// A theme file as ReadGUIThemeFile() read it. A theme file is an INI file +// in the GUI runners' Themes folder, named for its theme, that sets the +// dark look's colors (the Win32 GUI runner's; the VCL GUI runner's dark +// themes are VCL style files instead): +// [Theme] +// Base=Dark +// [Colors] +// Window=#202020 +// Base=Dark is required, so that a later version can add light themes. Each +// [Colors] key is a role's name, ignoring (ASCII) case, and its value a +// color as #RRGGBB, in either case; a role left out keeps the built-in +// dark theme's color. +///////////////////////////////////////////////////////////////////////////// +struct TGUIThemeFile +{ + // The colors the file sets, in its order, each role once, named as the GUI runner names it. + std::vector Colors; + // What's wrong with the file, one sentence each, for the log (see GUIThemeProblemLine()): an unknown + // section, setting or role, a color that isn't one, a role set twice, or a missing or unknown Base. + std::vector Problems; + // Whether the file can be used, which it can't without Base=Dark; Colors is then empty. + bool Usable = false; +}; + //--------------------------------------------------------------------------- +// 'rgb', 0xRRGGBB, as a theme file writes a color: "#RRGGBB", in uppercase. +std::string FormatGUIThemeColor(std::uint32_t rgb); +// The built-in dark theme's name, "Carbon": the default for LoadGUIDarkTheme(), and a name GUIThemeNames() +// leaves out, so a theme file can't take it. +std::string GUIDefaultDarkThemeName(); // Whether 'theme' is dark: Light and Dark as chosen, and Windows as 'windowsAppsUseDark' (Windows' app mode // setting) says, except that it's light while 'highContrast' is on, so the window shows high contrast's own // colors. bool GUIThemeIsDark(EGUITheme theme, bool windowsAppsUseDark, bool highContrast); +// The names of the themes among 'fileNames', the files in the Themes folder: each one that ends in +// 'extension' (e.g. ".ini"), ignoring (ASCII) case, without it, sorted ignoring case, and each name once. +// Leaves out GUIDefaultDarkThemeName(), ignoring case, and a file named just 'extension'. +std::vector GUIThemeNames(std::vector const& fileNames, std::string const& extension); +// A line for the log about one of the dark theme 'themeName''s problems (e.g. one of TGUIThemeFile's), as both +// GUI runners word it: Dark theme "": +std::string GUIThemeProblemLine(std::string const& themeName, std::string const& problem); +// Reads the dark theme SaveGUIDarkTheme() wrote to 'ini', its [View] section's DarkTheme key: the name of a +// theme file, or GUIDefaultDarkThemeName() when it's missing or empty, or is that name in another case. +std::string LoadGUIDarkTheme(TGUIINIDocument const& ini); // Reads the theme SaveGUITheme() wrote to 'ini', its [View] section's Theme key, ignoring (ASCII) case. // Windows when it's missing or isn't a theme's name. EGUITheme LoadGUITheme(TGUIINIDocument const& ini); +// The color 'text' is, as a theme file writes it (see TGUIThemeFile), as 0xRRGGBB; nullopt if it isn't one. +std::optional ParseGUIThemeColor(std::string const& text); +// Reads a theme file's 'text' (see TGUIThemeFile), UTF-8, whose roles are 'roles', the names the GUI runner +// gives them. +TGUIThemeFile ReadGUIThemeFile(std::string const& text, std::vector const& roles); +// Writes 'name', a dark theme's (see LoadGUIDarkTheme()), to 'ini'. The file itself is written from the +// document's Text() afterward. +void SaveGUIDarkTheme(TGUIINIDocument& ini, std::string const& name); // Writes 'theme' to 'ini', as "Windows", "Light" or "Dark". The file itself is written from the document's // Text() afterward. void SaveGUITheme(TGUIINIDocument& ini, EGUITheme theme); diff --git a/gui/src/ASWUnitTests_GUI_View.cpp b/gui/src/ASWUnitTests_GUI_View.cpp index ad247e1..e2be257 100644 --- a/gui/src/ASWUnitTests_GUI_View.cpp +++ b/gui/src/ASWUnitTests_GUI_View.cpp @@ -161,6 +161,21 @@ std::string GUIFirstLine(std::string const& text) return text.substr(0, text.find('\n')); } //--------------------------------------------------------------------------- +std::string GUIMenuItemText(std::string const& text) +{ + std::string caption; + + for (char const c : text) + { + if (c == '&') + caption += '&'; + + caption += c; + } + + return caption; +} +//--------------------------------------------------------------------------- std::vector GUIStatusPanels(TGUITestList const& testList, size_t runFinishedCount, size_t runTotalCount, std::optional elapsedSeconds, std::string const& shuffleText, std::string const& message) { diff --git a/gui/src/ASWUnitTests_GUI_View.h b/gui/src/ASWUnitTests_GUI_View.h index f753c59..fcc90e9 100644 --- a/gui/src/ASWUnitTests_GUI_View.h +++ b/gui/src/ASWUnitTests_GUI_View.h @@ -100,6 +100,9 @@ std::string GUIDefaultJUnitReportFileName(std::string const& exeFileName); TestFilter GUIFilterBoxFilter(std::string const& text); // The first line of 'text', e.g. for the Failures & Skips list's Detail column. std::string GUIFirstLine(std::string const& text); +// 'text', e.g. a theme file's name, as a menu item's caption: each '&' doubled, so it shows rather than making +// the next letter the item's accelerator. +std::string GUIMenuItemText(std::string const& text); // The status bar's panels' text, in order: the checked and shown tests, the run's progress, the passed, failed // and skipped counts, the run's elapsed time (empty if no run has started), the shuffle setting ('shuffleText', // from TGUIShuffle::StatusText()), and 'message'. diff --git a/gui/tests/Test_ASWUnitTests_GUI_INIDocument.cpp b/gui/tests/Test_ASWUnitTests_GUI_INIDocument.cpp index 6ae64d8..367c7ac 100644 --- a/gui/tests/Test_ASWUnitTests_GUI_INIDocument.cpp +++ b/gui/tests/Test_ASWUnitTests_GUI_INIDocument.cpp @@ -26,12 +26,41 @@ limitations under the License. //--------------------------------------------------------------------------- #include #include +#include //--------------------------------------------------------------------------- #include "ASWUnitTests_Registry.h" //--------------------------------------------------------------------------- #include "ASWUnitTests_GUI_INIDocument.h" //--------------------------------------------------------------------------- +namespace +{ + +//--------------------------------------------------------------------------- + +std::string JoinNames(std::vector const& names); + +//--------------------------------------------------------------------------- +/* + JoinNames + + The names separated by commas, compared whole, so a failure shows the whole list. +*/ +std::string JoinNames(std::vector const& names) +{ + std::string text; + + for (std::string const& name : names) + text += (text.empty() ? "" : ",") + name; + + return text; +} +//--------------------------------------------------------------------------- + +} // namespace + +//--------------------------------------------------------------------------- + namespace ASWUnitTests { @@ -52,8 +81,12 @@ TTest_ASWUnitTests_GUI_INIDocument::TTest_ASWUnitTests_GUI_INIDocument() RegisterTest(&TTest_ASWUnitTests_GUI_INIDocument::Test_Constructor_SkipsLinesTMemIniFileSkips, "Constructor_SkipsLinesTMemIniFileSkips"); RegisterTest(&TTest_ASWUnitTests_GUI_INIDocument::Test_DeleteKey_RemovesOnlyThatKey, "DeleteKey_RemovesOnlyThatKey"); + RegisterTest(&TTest_ASWUnitTests_GUI_INIDocument::Test_KeyNames_ListsEachKeyAsWritten, + "KeyNames_ListsEachKeyAsWritten"); RegisterTest(&TTest_ASWUnitTests_GUI_INIDocument::Test_ReadString_IgnoresCase, "ReadString_IgnoresCase"); RegisterTest(&TTest_ASWUnitTests_GUI_INIDocument::Test_ReadString_MissingIsEmpty, "ReadString_MissingIsEmpty"); + RegisterTest(&TTest_ASWUnitTests_GUI_INIDocument::Test_SectionNames_ListsEachSectionOnce, + "SectionNames_ListsEachSectionOnce"); RegisterTest(&TTest_ASWUnitTests_GUI_INIDocument::Test_Text_EmptyDocumentIsEmpty, "Text_EmptyDocumentIsEmpty"); RegisterTest(&TTest_ASWUnitTests_GUI_INIDocument::Test_Text_RoundTripsWhatItRead, "Text_RoundTripsWhatItRead"); RegisterTest(&TTest_ASWUnitTests_GUI_INIDocument::Test_WriteString_AddsNewKeysAndSectionsAtTheEnd, @@ -166,6 +199,22 @@ void TTest_ASWUnitTests_GUI_INIDocument::Test_DeleteKey_RemovesOnlyThatKey() "a key is found ignoring case, and its emptied section stays, as with TMemIniFile"); } //--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_INIDocument::Test_KeyNames_ListsEachKeyAsWritten() +{ + // Arrange + TGUIINIDocument ini("[A]\r\n One = 1\r\nNoEquals\r\nTwo=2\r\nONE=again\r\n[B]\r\n[C]\r\nThree=3\r\n"); + + // Act and Assert + CheckEquals(std::string("One,Two,ONE"), JoinNames(ini.KeyNames("a")), __func__, __LINE__, + "trimmed, in order, a repeated key each time, found ignoring the section's case"); + CheckEmpty(JoinNames(ini.KeyNames("B")), __func__, __LINE__, "an empty section"); + CheckEmpty(JoinNames(ini.KeyNames("Missing")), __func__, __LINE__, "a missing section"); + + ini.WriteString("C", "Four", "4"); + CheckEquals(std::string("Three,Four"), JoinNames(ini.KeyNames("C")), __func__, __LINE__, + "with a key written since"); +} +//--------------------------------------------------------------------------- void TTest_ASWUnitTests_GUI_INIDocument::Test_ReadString_IgnoresCase() { // Arrange @@ -186,6 +235,21 @@ void TTest_ASWUnitTests_GUI_INIDocument::Test_ReadString_MissingIsEmpty() CheckFalse(ini.ReadString("Panels", "Height").has_value(), __func__, __LINE__, "a missing section"); } //--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_INIDocument::Test_SectionNames_ListsEachSectionOnce() +{ + // Arrange + TGUIINIDocument ini("Before=section\r\n[ Colors ]\r\nA=1\r\n[Theme]\r\n[colors]\r\nB=2\r\n"); + + // Act and Assert + CheckEquals(std::string("Colors,Theme"), JoinNames(ini.SectionNames()), __func__, __LINE__, + "trimmed, in order, a repeated one once, as it's skipped"); + CheckEmpty(JoinNames(TGUIINIDocument().SectionNames()), __func__, __LINE__, "an empty document"); + + ini.WriteString("View", "Theme", "Dark"); + CheckEquals(std::string("Colors,Theme,View"), JoinNames(ini.SectionNames()), __func__, __LINE__, + "with a section written since"); +} +//--------------------------------------------------------------------------- void TTest_ASWUnitTests_GUI_INIDocument::Test_Text_EmptyDocumentIsEmpty() { // Act and Assert diff --git a/gui/tests/Test_ASWUnitTests_GUI_INIDocument.h b/gui/tests/Test_ASWUnitTests_GUI_INIDocument.h index 892e062..7aa186f 100644 --- a/gui/tests/Test_ASWUnitTests_GUI_INIDocument.h +++ b/gui/tests/Test_ASWUnitTests_GUI_INIDocument.h @@ -47,8 +47,10 @@ class TTest_ASWUnitTests_GUI_INIDocument : public TTestGroupBase void Test_Constructor_ReadsTrimmedNamesAndValues(); void Test_Constructor_SkipsLinesTMemIniFileSkips(); void Test_DeleteKey_RemovesOnlyThatKey(); + void Test_KeyNames_ListsEachKeyAsWritten(); void Test_ReadString_IgnoresCase(); void Test_ReadString_MissingIsEmpty(); + void Test_SectionNames_ListsEachSectionOnce(); void Test_Text_EmptyDocumentIsEmpty(); void Test_Text_RoundTripsWhatItRead(); void Test_WriteString_AddsNewKeysAndSectionsAtTheEnd(); diff --git a/gui/tests/Test_ASWUnitTests_GUI_Theme.cpp b/gui/tests/Test_ASWUnitTests_GUI_Theme.cpp index a1a3614..89b9347 100644 --- a/gui/tests/Test_ASWUnitTests_GUI_Theme.cpp +++ b/gui/tests/Test_ASWUnitTests_GUI_Theme.cpp @@ -24,8 +24,10 @@ limitations under the License. // Module header #include "Test_ASWUnitTests_GUI_Theme.h" //--------------------------------------------------------------------------- +#include #include #include +#include //--------------------------------------------------------------------------- #include "ASWUnitTests_Registry.h" //--------------------------------------------------------------------------- @@ -33,6 +35,52 @@ limitations under the License. #include "ASWUnitTests_GUI_Theme.h" //--------------------------------------------------------------------------- +namespace +{ + +using namespace ASWUnitTests; + +//--------------------------------------------------------------------------- + +std::string ColorsText(TGUIThemeFile const& file); +std::string JoinLines(std::vector const& lines); + +//--------------------------------------------------------------------------- +/* + ColorsText + + The file's colors as "Role=#RRGGBB", separated by commas, compared whole, so a failure shows them all. +*/ +std::string ColorsText(TGUIThemeFile const& file) +{ + std::string text; + + for (TGUIThemeColor const& color : file.Colors) + text += (text.empty() ? "" : ",") + color.Role + "=" + FormatGUIThemeColor(color.RGB); + + return text; +} +//--------------------------------------------------------------------------- +/* + JoinLines + + The lines, each followed by a line break, compared whole, so a failure shows them all. +*/ +std::string JoinLines(std::vector const& lines) +{ + std::string text; + + for (std::string const& line : lines) + text += line + "\n"; + + return text; +} +//--------------------------------------------------------------------------- + +} // namespace + +//--------------------------------------------------------------------------- + namespace ASWUnitTests { @@ -44,11 +92,26 @@ namespace ASWUnitTests TTest_ASWUnitTests_GUI_Theme::TTest_ASWUnitTests_GUI_Theme() : inherited("ASWUnitTests_GUI_Theme_Tests") { + RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_FormatGUIThemeColor_RoundTripsThroughParse, + "FormatGUIThemeColor_RoundTripsThroughParse"); RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_GUIThemeIsDark_FollowsTheChoiceOrWindows, "GUIThemeIsDark_FollowsTheChoiceOrWindows"); + RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_GUIThemeNames_ListsTheFilesOfOneKindSorted, + "GUIThemeNames_ListsTheFilesOfOneKindSorted"); + RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_LoadGUIDarkTheme_DefaultsToCarbon, + "LoadGUIDarkTheme_DefaultsToCarbon"); RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_LoadGUITheme_DefaultsToWindows, "LoadGUITheme_DefaultsToWindows"); RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_LoadGUITheme_ReadsEachThemeIgnoringCase, "LoadGUITheme_ReadsEachThemeIgnoringCase"); + RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_ParseGUIThemeColor_RejectsOtherForms, + "ParseGUIThemeColor_RejectsOtherForms"); + RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_ReadGUIThemeFile_NeedsBaseDark, "ReadGUIThemeFile_NeedsBaseDark"); + RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_ReadGUIThemeFile_ReadsTheColorsItSets, + "ReadGUIThemeFile_ReadsTheColorsItSets"); + RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_ReadGUIThemeFile_ReportsEachProblem, + "ReadGUIThemeFile_ReportsEachProblem"); + RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_SaveGUIDarkTheme_RoundTripsBesideTheTheme, + "SaveGUIDarkTheme_RoundTripsBesideTheTheme"); RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_SaveGUITheme_KeepsTheLayout, "SaveGUITheme_KeepsTheLayout"); RegisterTest(&TTest_ASWUnitTests_GUI_Theme::Test_SaveGUITheme_RoundTrips, "SaveGUITheme_RoundTrips"); } @@ -76,6 +139,25 @@ void TTest_ASWUnitTests_GUI_Theme::TearDown_Test(ITestCase& /*testCase*/) // /////// Begin tests after this line /////////////////////// +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_Theme::Test_FormatGUIThemeColor_RoundTripsThroughParse() +{ + // Act and Assert + CheckEquals(std::string("#1A2B3C"), FormatGUIThemeColor(0x1A2B3C), __func__, __LINE__, "red, green, blue"); + CheckEquals(std::string("#000000"), FormatGUIThemeColor(0), __func__, __LINE__, "with leading zeros"); + CheckEquals(std::string("#FFFFFF"), FormatGUIThemeColor(0xFFFFFF), __func__, __LINE__, "in uppercase"); + CheckEquals(std::string("#ABCDEF"), FormatGUIThemeColor(0xFFABCDEF), __func__, __LINE__, + "ignoring bits above the blue, green and red bytes"); + + for (std::uint32_t const rgb : { 0x000000u, 0x000001u, 0x0F1E2Du, 0x808080u, 0xC0FFEEu, 0xFFFFFFu }) + { + CheckTrue(ParseGUIThemeColor(FormatGUIThemeColor(rgb)) == rgb, __func__, __LINE__, + "read back: " + FormatGUIThemeColor(rgb)); + } + + CheckTrue(ParseGUIThemeColor("#c0ffee") == 0xC0FFEEu, __func__, __LINE__, "lowercase digits"); + CheckTrue(ParseGUIThemeColor("#C0ffEE") == 0xC0FFEEu, __func__, __LINE__, "mixed case digits"); +} //--------------------------------------------------------------------------- void TTest_ASWUnitTests_GUI_Theme::Test_GUIThemeIsDark_FollowsTheChoiceOrWindows() { @@ -90,6 +172,40 @@ void TTest_ASWUnitTests_GUI_Theme::Test_GUIThemeIsDark_FollowsTheChoiceOrWindows "Windows, set to dark, with high contrast"); } //--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_Theme::Test_GUIThemeNames_ListsTheFilesOfOneKindSorted() +{ + // Arrange: the Themes folder's files, in the order a listing might give them. + std::vector const files = { + "ocean.ini", "Zest.ini", "Notes.txt", "Carbon.ini", "OCEAN.INI", ".ini", "apple.Ini", "carbon.INI", + "Ocean.ini.bak", "Forest.vsf", "ini", "Two Words.ini", + }; + + // Act and Assert + CheckEquals(std::string("apple\nocean\nTwo Words\nZest\n"), JoinLines(GUIThemeNames(files, ".ini")), __func__, + __LINE__, "the .ini files' names, sorted ignoring case, a name once, without Carbon or a bare extension"); + CheckEquals(std::string("Forest\n"), JoinLines(GUIThemeNames(files, ".vsf")), __func__, __LINE__, + "the VCL GUI runner's .vsf files"); + CheckEmpty(JoinLines(GUIThemeNames(std::vector(), ".ini")), __func__, __LINE__, + "an empty or missing folder"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_Theme::Test_LoadGUIDarkTheme_DefaultsToCarbon() +{ + // Act and Assert + CheckEquals(std::string("Carbon"), GUIDefaultDarkThemeName(), __func__, __LINE__, "the built-in dark theme"); + CheckEquals(std::string("Carbon"), LoadGUIDarkTheme(TGUIINIDocument()), __func__, __LINE__, "an empty file"); + CheckEquals(std::string("Carbon"), LoadGUIDarkTheme(TGUIINIDocument("[View]\r\nTheme=Dark\r\n")), __func__, + __LINE__, "a layout file from before theme files"); + CheckEquals(std::string("Carbon"), LoadGUIDarkTheme(TGUIINIDocument("[View]\r\nDarkTheme=\r\n")), __func__, + __LINE__, "an empty value"); + CheckEquals(std::string("Carbon"), LoadGUIDarkTheme(TGUIINIDocument("[View]\r\nDarkTheme=CARBON\r\n")), __func__, + __LINE__, "Carbon in another case is the built-in one"); + CheckEquals(std::string("Carbon"), LoadGUIDarkTheme(TGUIINIDocument("[Window]\r\nDarkTheme=Ocean\r\n")), + __func__, __LINE__, "a DarkTheme key in another section"); + CheckEquals(std::string("Deep Ocean"), LoadGUIDarkTheme(TGUIINIDocument("[view]\r\ndarktheme = Deep Ocean \r\n")), + __func__, __LINE__, "a theme file's name, as written, trimmed by the INI document"); +} +//--------------------------------------------------------------------------- void TTest_ASWUnitTests_GUI_Theme::Test_LoadGUITheme_DefaultsToWindows() { // Act and Assert @@ -123,6 +239,128 @@ void TTest_ASWUnitTests_GUI_Theme::Test_LoadGUITheme_ReadsEachThemeIgnoringCase( __LINE__, "spaces around the value, which the INI document trims"); } //--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_Theme::Test_ParseGUIThemeColor_RejectsOtherForms() +{ + // Act and Assert + for (char const* const text : { "", "#", "#12345", "#1234567", "123456", "0x123456", "#12345G", " #123456", + "#123456 ", "#-12345", "#+12345", "#12 456", "rgb(1,2,3)", "1, 2, 3", "#ABC" }) + { + CheckFalse(ParseGUIThemeColor(text).has_value(), __func__, __LINE__, std::string("not a color: \"") + text + + "\""); + } +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_Theme::Test_ReadGUIThemeFile_NeedsBaseDark() +{ + // Arrange + std::vector const roles = { "Window" }; + std::string const colors = "[Colors]\r\nWindow=#102030\r\n"; + + // Act + TGUIThemeFile const missing = ReadGUIThemeFile(colors, roles); + TGUIThemeFile const light = ReadGUIThemeFile("[Theme]\r\nBase=Light\r\n" + colors, roles); + TGUIThemeFile const empty = ReadGUIThemeFile(std::string(), roles); + TGUIThemeFile const dark = ReadGUIThemeFile("[theme]\r\nbase=DARK\r\n" + colors, roles); + + // Assert + CheckFalse(missing.Usable, __func__, __LINE__, "no Base"); + CheckEmpty(ColorsText(missing), __func__, __LINE__, "so no colors"); + CheckEquals(std::string("[Theme] has no Base=Dark, which a theme file needs, so Carbon is used instead.\n"), + JoinLines(missing.Problems), __func__, __LINE__, "the problem"); + + CheckFalse(light.Usable, __func__, __LINE__, "Base=Light"); + CheckEmpty(ColorsText(light), __func__, __LINE__, "so no colors either"); + CheckEquals(std::string("[Theme] Base=Light isn't supported, only Base=Dark, so Carbon is used instead.\n"), + JoinLines(light.Problems), __func__, __LINE__, "its problem"); + + CheckFalse(empty.Usable, __func__, __LINE__, "an empty file"); + CheckEquals(std::string("[Theme] has no Base=Dark, which a theme file needs, so Carbon is used instead.\n"), + JoinLines(empty.Problems), __func__, __LINE__, "its problem"); + + CheckTrue(dark.Usable, __func__, __LINE__, "Base=Dark, ignoring case"); + CheckEquals(std::string("Window=#102030"), ColorsText(dark), __func__, __LINE__, "its color"); + CheckEmpty(JoinLines(dark.Problems), __func__, __LINE__, "no problems"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_Theme::Test_ReadGUIThemeFile_ReadsTheColorsItSets() +{ + // Arrange: comments and blank lines, as the template has, a byte order mark from an editor, LF line breaks + // and roles written in another case. + std::vector const roles = { "Panel", "Window", "WindowText" }; + std::string const text = "\xEF\xBB\xBF; A theme\n" + "\n" + "[Theme]\n" + "Base=Dark\n" + "\n" + "[Colors]\n" + "; The window\n" + "window=#102030\n" + " PANEL = #a0B0c0 \n"; + + // Act + TGUIThemeFile const file = ReadGUIThemeFile(text, roles); + + // Assert + CheckTrue(file.Usable, __func__, __LINE__, "usable"); + CheckEquals(std::string("Window=#102030,Panel=#A0B0C0"), ColorsText(file), __func__, __LINE__, + "in the file's order, each role named as the GUI names it, and WindowText left out"); + CheckEmpty(JoinLines(file.Problems), __func__, __LINE__, "no problems"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_Theme::Test_ReadGUIThemeFile_ReportsEachProblem() +{ + // Arrange + std::vector const roles = { "Panel", "Window", "WindowText" }; + std::string const text = "[Theme]\r\n" + "Base=Dark\r\n" + "Name=Ocean\r\n" + "[Colours]\r\n" + "Window=#000000\r\n" + "[Colors]\r\n" + "Window=#102030\r\n" + "Windw=#111111\r\n" + "WindowText=white\r\n" + "Panel=#123456 ; a comment\r\n" + "WINDOW=#203040\r\n" + "windowtext=#FFFFFF\r\n"; + + // Act + TGUIThemeFile const file = ReadGUIThemeFile(text, roles); + + // Assert + CheckTrue(file.Usable, __func__, __LINE__, "still usable, with what it got right"); + CheckEquals(std::string("Window=#102030"), ColorsText(file), __func__, __LINE__, "the one valid color"); + CheckEquals(std::string("[Colours] isn't a section of a theme file, so it's ignored.\n" + "[Theme] Name isn't a setting of a theme file, so it's ignored.\n" + "[Colors] Windw isn't a role a theme can color, so it's ignored.\n" + "[Colors] WindowText=white isn't a color written as #RRGGBB, so it's ignored.\n" + "[Colors] Panel=#123456 ; a comment isn't a color written as #RRGGBB, so it's ignored.\n" + "[Colors] WINDOW is set more than once, so only the first is used.\n" + "[Colors] windowtext is set more than once, so only the first is used.\n"), JoinLines(file.Problems), __func__, + __LINE__, "each problem, sections first, then the settings, then the colors in the file's order"); + CheckEquals(std::string("Dark theme \"Ocean\": a problem."), GUIThemeProblemLine("Ocean", "a problem."), __func__, + __LINE__, "a problem as the log shows it"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_Theme::Test_SaveGUIDarkTheme_RoundTripsBesideTheTheme() +{ + // Arrange + TGUIINIDocument ini; + SaveGUITheme(ini, EGUITheme::Dark); + + // Act and Assert: each overwrites the one before, and the theme stays. + for (char const* const name : { "Ocean", "Two Words", "Carbon" }) + { + SaveGUIDarkTheme(ini, name); + TGUIINIDocument const readBack(ini.Text()); + CheckEquals(std::string(name), LoadGUIDarkTheme(readBack), __func__, __LINE__, "read back"); + CheckTrue(LoadGUITheme(readBack) == EGUITheme::Dark, __func__, __LINE__, "and the theme with it"); + } + + CheckEquals(std::string("[View]\r\nTheme=Dark\r\nDarkTheme=Carbon\r\n\r\n"), ini.Text(), __func__, __LINE__, + "both in [View]"); +} +//--------------------------------------------------------------------------- void TTest_ASWUnitTests_GUI_Theme::Test_SaveGUITheme_KeepsTheLayout() { // Arrange: the theme is saved as soon as it's chosen, and the layout when the window closes, each keeping diff --git a/gui/tests/Test_ASWUnitTests_GUI_Theme.h b/gui/tests/Test_ASWUnitTests_GUI_Theme.h index e765cc4..4ba4930 100644 --- a/gui/tests/Test_ASWUnitTests_GUI_Theme.h +++ b/gui/tests/Test_ASWUnitTests_GUI_Theme.h @@ -41,9 +41,17 @@ class TTest_ASWUnitTests_GUI_Theme : public TTestGroupBase typedef TTestGroupBase inherited; private: // Test methods + void Test_FormatGUIThemeColor_RoundTripsThroughParse(); void Test_GUIThemeIsDark_FollowsTheChoiceOrWindows(); + void Test_GUIThemeNames_ListsTheFilesOfOneKindSorted(); + void Test_LoadGUIDarkTheme_DefaultsToCarbon(); void Test_LoadGUITheme_DefaultsToWindows(); void Test_LoadGUITheme_ReadsEachThemeIgnoringCase(); + void Test_ParseGUIThemeColor_RejectsOtherForms(); + void Test_ReadGUIThemeFile_NeedsBaseDark(); + void Test_ReadGUIThemeFile_ReadsTheColorsItSets(); + void Test_ReadGUIThemeFile_ReportsEachProblem(); + void Test_SaveGUIDarkTheme_RoundTripsBesideTheTheme(); void Test_SaveGUITheme_KeepsTheLayout(); void Test_SaveGUITheme_RoundTrips(); diff --git a/gui/tests/Test_ASWUnitTests_GUI_View.cpp b/gui/tests/Test_ASWUnitTests_GUI_View.cpp index f8b2660..97dc302 100644 --- a/gui/tests/Test_ASWUnitTests_GUI_View.cpp +++ b/gui/tests/Test_ASWUnitTests_GUI_View.cpp @@ -59,6 +59,8 @@ TTest_ASWUnitTests_GUI_View::TTest_ASWUnitTests_GUI_View() "GUIFilterBoxFilter_MatchesAnywhereIgnoringCase"); RegisterTest(&TTest_ASWUnitTests_GUI_View::Test_GUIFirstLine_StopsAtTheFirstLineBreak, "GUIFirstLine_StopsAtTheFirstLineBreak"); + RegisterTest(&TTest_ASWUnitTests_GUI_View::Test_GUIMenuItemText_DoublesEachAmpersand, + "GUIMenuItemText_DoublesEachAmpersand"); RegisterTest(&TTest_ASWUnitTests_GUI_View::Test_GUIStatusPanels_ShowCountsAndMessage, "GUIStatusPanels_ShowCountsAndMessage"); RegisterTest(&TTest_ASWUnitTests_GUI_View::Test_GUIStatusPanelWidths_OnePerPanel, "GUIStatusPanelWidths_OnePerPanel"); @@ -215,6 +217,17 @@ void TTest_ASWUnitTests_GUI_View::Test_GUIFirstLine_StopsAtTheFirstLineBreak() CheckEmpty(GUIFirstLine("\nsecond"), __func__, __LINE__, "an empty first line"); } //--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_View::Test_GUIMenuItemText_DoublesEachAmpersand() +{ + // Act and Assert + CheckEquals(std::string("Ocean"), GUIMenuItemText("Ocean"), __func__, __LINE__, "plain text as it is"); + CheckEquals(std::string("Salt && Pepper"), GUIMenuItemText("Salt & Pepper"), __func__, __LINE__, "one '&'"); + CheckEquals(std::string("&&&&a&&"), GUIMenuItemText("&&a&"), __func__, __LINE__, "each of several"); + CheckEquals(std::string("\xC3\x89" "cume && co"), GUIMenuItemText("\xC3\x89" "cume & co"), __func__, __LINE__, + "UTF-8 left as it is"); + CheckEmpty(GUIMenuItemText(std::string()), __func__, __LINE__, "empty"); +} +//--------------------------------------------------------------------------- void TTest_ASWUnitTests_GUI_View::Test_GUIStatusPanels_ShowCountsAndMessage() { // Arrange: three tests, one hidden by the filter box, one unchecked, one passed and one failed. diff --git a/gui/tests/Test_ASWUnitTests_GUI_View.h b/gui/tests/Test_ASWUnitTests_GUI_View.h index 1b6b139..0b6099f 100644 --- a/gui/tests/Test_ASWUnitTests_GUI_View.h +++ b/gui/tests/Test_ASWUnitTests_GUI_View.h @@ -48,6 +48,7 @@ class TTest_ASWUnitTests_GUI_View : public TTestGroupBase void Test_GUIDefaultJUnitReportFileName_ReplacesTheExtension(); void Test_GUIFilterBoxFilter_MatchesAnywhereIgnoringCase(); void Test_GUIFirstLine_StopsAtTheFirstLineBreak(); + void Test_GUIMenuItemText_DoublesEachAmpersand(); void Test_GUIStatusPanels_ShowCountsAndMessage(); void Test_GUIStatusPanelWidths_OnePerPanel(); void Test_GUIUnhandledExceptionRecord_FailsTheRunningTest(); diff --git a/vcl/gui/rad370/ASWUnitTests_VCL_GUI.cpp b/vcl/gui/rad370/ASWUnitTests_VCL_GUI.cpp index dd1e6a8..ebe806b 100644 --- a/vcl/gui/rad370/ASWUnitTests_VCL_GUI.cpp +++ b/vcl/gui/rad370/ASWUnitTests_VCL_GUI.cpp @@ -27,6 +27,7 @@ limitations under the License. //--------------------------------------------------------------------------- #include #include +#include //--------------------------------------------------------------------------- #include "ASWUnitTests_GUI_CommandLine.h" #include "ASWUnitTests_GUI_TextDialog.h" @@ -54,10 +55,16 @@ int WINAPI _tWinMain(HINSTANCE, HINSTANCE, LPTSTR, int) ASWUnitTests::TGUICommandLine::Parse(ASWUnitTests::ProcessArguments(), options); // Before any form exists, so each one, the --help text's included, is created in its style rather than - // restyled. --layout-reset's deletion of the layout file, which also holds the theme, comes later. - ASWUnitTests::EGUITheme const theme = (options.LayoutIgnore || options.LayoutReset) ? - ASWUnitTests::EGUITheme::Windows : ASWUnitTests::LoadGUIThemeFromLayoutFile(); - ASWUnitTests::ApplyGUITheme(theme); + // restyled. --layout-reset's deletion of the layout file, which also holds the theme and the dark theme, + // comes later. + bool const savedTheme = !options.LayoutIgnore && !options.LayoutReset; + ASWUnitTests::EGUITheme const theme = savedTheme ? ASWUnitTests::LoadGUIThemeFromLayoutFile() : + ASWUnitTests::EGUITheme::Windows; + std::string const darkTheme = savedTheme ? ASWUnitTests::LoadGUIDarkThemeFromLayoutFile() : + ASWUnitTests::GUIDefaultDarkThemeName(); + std::vector themeProblems; + System::UnicodeString const darkStyle = ASWUnitTests::LoadGUIDarkStyle(darkTheme, themeProblems); + ASWUnitTests::ApplyGUITheme(theme, darkStyle); if (commandLine.EarlyExitCode.has_value()) { @@ -66,7 +73,7 @@ int WINAPI _tWinMain(HINSTANCE, HINSTANCE, LPTSTR, int) } Application->CreateForm(__classid(TFormASWUnitTestsGUIMain), &FormASWUnitTestsGUIMain); - FormASWUnitTestsGUIMain->Start(options, theme); + FormASWUnitTestsGUIMain->Start(options, theme, darkTheme, darkStyle, themeProblems); Application->Run(); // The form outlives Run(); Application frees it later, during shutdown. diff --git a/vcl/gui/src/ASWUnitTests_GUI_MainForm.cpp b/vcl/gui/src/ASWUnitTests_GUI_MainForm.cpp index 7253b61..cc99a31 100644 --- a/vcl/gui/src/ASWUnitTests_GUI_MainForm.cpp +++ b/vcl/gui/src/ASWUnitTests_GUI_MainForm.cpp @@ -484,6 +484,16 @@ void __fastcall TFormASWUnitTestsGUIMain::Act_FocusFilterExecute(TObject* /*Send }); } //--------------------------------------------------------------------------- +void __fastcall TFormASWUnitTestsGUIMain::Act_OpenThemesFolderExecute(TObject* /*Sender*/) +{ + RunGuarded("Theme > Dark Theme > Open Themes Folder", []() + { + System::UnicodeString const folder = GUIThemesFolderPath(); + System::Sysutils::ForceDirectories(folder); + OpenFolderInExplorer(folder); + }); +} +//--------------------------------------------------------------------------- void __fastcall TFormASWUnitTestsGUIMain::Act_ResetLayoutExecute(TObject* /*Sender*/) { RunGuarded("Reset Layout", [this]() @@ -691,7 +701,7 @@ void TFormASWUnitTestsGUIMain::ApplyTheme() } m_ThemeChangePending = false; - ApplyGUITheme(m_Theme); + ApplyGUITheme(m_Theme, m_DarkStyle); } //--------------------------------------------------------------------------- /* @@ -830,6 +840,27 @@ void __fastcall TFormASWUnitTestsGUIMain::Edt_FilterChange(TObject* /*Sender*/) }); } //--------------------------------------------------------------------------- +/* + TFormASWUnitTestsGUIMain::DarkThemeItemClick + + Not while the Theme actions are disabled, during a run, as for them. +*/ +void __fastcall TFormASWUnitTestsGUIMain::DarkThemeItemClick(TObject* Sender) +{ + RunGuarded("Theme > Dark Theme", [this, Sender]() + { + TMenuItem const* const item = dynamic_cast(Sender); + + if (item == nullptr || !Act_ThemeDark->Enabled || item->Tag < 0 || + static_cast(item->Tag) >= m_DarkThemeNames.size()) + { + return; + } + + SetDarkTheme(m_DarkThemeNames[static_cast(item->Tag)]); + }); +} +//--------------------------------------------------------------------------- int TFormASWUnitTestsGUIMain::ExitCode() const { return m_Options.ExitAfterRun ? m_LastRunExitCode : ExitCode_Success; @@ -855,6 +886,53 @@ void TFormASWUnitTestsGUIMain::FailRunOnUnhandledException(std::string const& de UpdateDetail(); } //--------------------------------------------------------------------------- +/* + TFormASWUnitTestsGUIMain::FillDarkThemeMenu + + Carbon first, then the VCL style files in GUIThemesFolderPath() (see GUIThemeNames()), so a file added or + removed since shows at once, above Open Themes Folder; the chosen one is checked. A chosen theme whose file is + missing is still listed, so choosing it again reports why it isn't used. +*/ +void TFormASWUnitTestsGUIMain::FillDarkThemeMenu() +{ + for (TMenuItem* const item : m_DarkThemeItems) + delete item; + + m_DarkThemeItems.clear(); + + std::vector names = GUIThemeNames(FileNamesInFolder(GUIThemesFolderPath()), ".vsf"); + names.insert(names.begin(), GUIDefaultDarkThemeName()); + + // Windows' file names ignore case, so a name saved in another case is still that file's. + std::vector::const_iterator chosen = std::find_if(names.begin(), names.end(), + [this](std::string const& name) + { + return System::Sysutils::AnsiSameText(FromUTF8(name), FromUTF8(m_DarkTheme)); + }); + + if (chosen == names.end()) + chosen = names.insert(names.end(), m_DarkTheme); + + size_t const chosenIndex = static_cast(chosen - names.begin()); + + for (size_t i = 0; i < names.size(); ++i) + { + TMenuItem* const item = new TMenuItem(this); + m_DarkThemeItems.push_back(item); + + // Carbon's C is its accelerator; a style file's name has none. + item->Caption = FromUTF8((i == 0 ? "&" : "") + GUIMenuItemText(names[i])); + item->Checked = i == chosenIndex; + item->Enabled = Act_ThemeDark->Enabled; + item->OnClick = DarkThemeItemClick; + item->RadioItem = true; + item->Tag = static_cast(i); + MI_ViewDarkTheme->Insert(static_cast(i), item); + } + + m_DarkThemeNames = names; +} +//--------------------------------------------------------------------------- void __fastcall TFormASWUnitTestsGUIMain::FormClose(TObject* /*Sender*/, TCloseAction& /*Action*/) { RunGuarded("closing the window", [this]() @@ -1058,6 +1136,19 @@ void __fastcall TFormASWUnitTestsGUIMain::LV_FailuresDblClick(TObject* /*Sender* }); } //--------------------------------------------------------------------------- +/* + TFormASWUnitTestsGUIMain::MI_ViewDarkThemeClick + + The VCL calls a parent item's OnClick as its submenu opens. +*/ +void __fastcall TFormASWUnitTestsGUIMain::MI_ViewDarkThemeClick(TObject* /*Sender*/) +{ + RunGuarded("the Dark Theme menu", [this]() + { + FillDarkThemeMenu(); + }); +} +//--------------------------------------------------------------------------- void TFormASWUnitTestsGUIMain::OnTestFinished(TTestCaseRecord const& record, std::string const& testLog) { std::optional const index = m_TestList.IndexOf(record.GroupName, record.TestName); @@ -1369,6 +1460,35 @@ void TFormASWUnitTestsGUIMain::SaveSelection() } } //--------------------------------------------------------------------------- +/* + TFormASWUnitTestsGUIMain::SetDarkTheme + + Applied at once while dark; while light, its file is still loaded, so its problems show in the log, and the + status bar says it shows once the theme is dark. A VCL style file already loaded isn't read again (see + LoadGUIDarkStyle()). +*/ +void TFormASWUnitTestsGUIMain::SetDarkTheme(std::string const& name) +{ + m_DarkTheme = name; + + if (!m_Options.LayoutIgnore) + SaveGUIDarkThemeToLayoutFile(name); + + std::vector problems; + m_DarkStyle = LoadGUIDarkStyle(name, problems); + ApplyTheme(); + + if (GUIThemeIsDark(m_Theme, WindowsAppsUseDarkTheme(), WindowsHighContrast())) + m_StatusMessage = "Dark theme: " + name + "."; + else + m_StatusMessage = "Dark theme: " + name + ", shown while the theme is dark."; + + for (std::string const& problem : problems) + AppendLog(problem + "\n"); + + UpdateStatusBar(); +} +//--------------------------------------------------------------------------- void TFormASWUnitTestsGUIMain::SetNodeImage(TTreeNode* node) { int const image = static_cast(GUINodeStatus(m_TestList, NodeRef(node->Data))); @@ -1441,10 +1561,13 @@ void TFormASWUnitTestsGUIMain::ShowThemeState() and --shuffle and --shuffle-seed to the Options menu. Also restores the window layout saved when it last closed. */ -void TFormASWUnitTestsGUIMain::Start(TGUIOptions const& options, EGUITheme theme) +void TFormASWUnitTestsGUIMain::Start(TGUIOptions const& options, EGUITheme theme, std::string const& darkTheme, + System::UnicodeString const& darkStyle, std::vector const& themeProblems) { m_Options = options; m_Theme = theme; + m_DarkTheme = darkTheme; + m_DarkStyle = darkStyle; ShowThemeState(); m_Observer.LogTextCallback = [this](std::string const& text) @@ -1489,6 +1612,9 @@ void TFormASWUnitTestsGUIMain::Start(TGUIOptions const& options, EGUITheme theme for (std::string const& note : GUICommandLineNotes(m_Options, "VCL forms or controls")) AppendLog(note + "\n"); + for (std::string const& problem : themeProblems) + AppendLog(problem + "\n"); + if (m_Options.LayoutReset) ResetSavedLayout(); @@ -1657,6 +1783,7 @@ void TFormASWUnitTestsGUIMain::UpdateCommands() Act_ShuffleOrder->Enabled = states.ShuffleOrder; Act_ShuffleSeed->Enabled = states.ShuffleSeed; Act_Stop->Enabled = states.Stop; + Act_OpenThemesFolder->Enabled = states.Theme; Act_ThemeDark->Enabled = states.Theme; Act_ThemeLight->Enabled = states.Theme; Act_ThemeWindows->Enabled = states.Theme; diff --git a/vcl/gui/src/ASWUnitTests_GUI_MainForm.dfm b/vcl/gui/src/ASWUnitTests_GUI_MainForm.dfm index d3845b9..6c0adc3 100644 --- a/vcl/gui/src/ASWUnitTests_GUI_MainForm.dfm +++ b/vcl/gui/src/ASWUnitTests_GUI_MainForm.dfm @@ -382,6 +382,13 @@ object FormASWUnitTestsGUIMain: TFormASWUnitTestsGUIMain Hint = 'Use the dark theme, whatever Windows is set to' OnExecute = Act_ThemeDarkExecute end + object Act_OpenThemesFolder: TAction + Caption = '&Open Themes Folder' + Hint = + 'Open the folder of the VCL style files View > Theme > Dark Theme' + + ' lists' + OnExecute = Act_OpenThemesFolderExecute + end end object IL_Status: TImageList Left = 120 @@ -466,6 +473,19 @@ object FormASWUnitTestsGUIMain: TFormASWUnitTestsGUIMain Action = Act_ThemeDark RadioItem = True end + object MI_ViewThemeSeparator1: TMenuItem + Caption = '-' + end + object MI_ViewDarkTheme: TMenuItem + Caption = 'Dark &Theme' + OnClick = MI_ViewDarkThemeClick + object MI_ViewDarkThemeSeparator1: TMenuItem + Caption = '-' + end + object MI_ViewDarkThemeOpenFolder: TMenuItem + Action = Act_OpenThemesFolder + end + end end end object MI_Options: TMenuItem diff --git a/vcl/gui/src/ASWUnitTests_GUI_MainForm.h b/vcl/gui/src/ASWUnitTests_GUI_MainForm.h index 2e2ffe2..1d4d504 100644 --- a/vcl/gui/src/ASWUnitTests_GUI_MainForm.h +++ b/vcl/gui/src/ASWUnitTests_GUI_MainForm.h @@ -74,6 +74,7 @@ class TFormASWUnitTestsGUIMain : public TForm TAction* Act_ExpandAll; TAction* Act_ExportJUnitReport; TAction* Act_FocusFilter; + TAction* Act_OpenThemesFolder; TAction* Act_ResetLayout; TAction* Act_RunFailed; TAction* Act_RunSelected; @@ -122,6 +123,9 @@ class TFormASWUnitTestsGUIMain : public TForm TMenuItem* MI_TestsSeparator1; TMenuItem* MI_View; TMenuItem* MI_ViewCollapseAll; + TMenuItem* MI_ViewDarkTheme; + TMenuItem* MI_ViewDarkThemeOpenFolder; + TMenuItem* MI_ViewDarkThemeSeparator1; TMenuItem* MI_ViewExpandAll; TMenuItem* MI_ViewResetLayout; TMenuItem* MI_ViewSeparator1; @@ -129,6 +133,7 @@ class TFormASWUnitTestsGUIMain : public TForm TMenuItem* MI_ViewTheme; TMenuItem* MI_ViewThemeDark; TMenuItem* MI_ViewThemeLight; + TMenuItem* MI_ViewThemeSeparator1; TMenuItem* MI_ViewThemeWindows; TMainMenu* MM_Main; TProgressBar* PB_Progress; @@ -151,6 +156,7 @@ class TFormASWUnitTestsGUIMain : public TForm void __fastcall Act_ExpandAllExecute(TObject* Sender); void __fastcall Act_ExportJUnitReportExecute(TObject* Sender); void __fastcall Act_FocusFilterExecute(TObject* Sender); + void __fastcall Act_OpenThemesFolderExecute(TObject* Sender); void __fastcall Act_ResetLayoutExecute(TObject* Sender); void __fastcall Act_RunFailedExecute(TObject* Sender); void __fastcall Act_RunSelectedExecute(TObject* Sender); @@ -169,6 +175,7 @@ class TFormASWUnitTestsGUIMain : public TForm void __fastcall LV_FailuresCustomDrawItem(TCustomListView* Sender, TListItem* Item, TCustomDrawState State, bool& DefaultDraw); void __fastcall LV_FailuresDblClick(TObject* Sender); + void __fastcall MI_ViewDarkThemeClick(TObject* Sender); void __fastcall TV_TestsChange(TObject* Sender, TTreeNode* Node); void __fastcall TV_TestsCheckStateChanging(TCustomTreeView* Sender, TTreeNode* Node, TNodeCheckState NewCheckState, TNodeCheckState OldCheckState, bool& AllowChange); @@ -183,6 +190,14 @@ class TFormASWUnitTestsGUIMain : public TForm bool m_CloseAfterRun; // The test that has started but not yet finished, if any. std::optional m_CurrentTestIndex; + // The VCL style m_DarkTheme is (see LoadGUIDarkStyle()), which ApplyTheme() uses while the theme is dark. + System::UnicodeString m_DarkStyle; + // The View > Theme > Dark Theme choice: GUIDefaultDarkThemeName() or a VCL style file's name. + std::string m_DarkTheme; + // The items FillDarkThemeMenu() added to View > Theme > Dark Theme, one per dark theme, by index into + // m_DarkThemeNames, which each one's Tag is. + std::vector m_DarkThemeItems; + std::vector m_DarkThemeNames; // The layout from the DFM, at 96 DPI, captured before the saved one is applied, for Reset Layout. ASWUnitTests::TGUILayout m_DefaultLayout; TWndMethod m_FormWindowProc; // The form's own WindowProc, which FormWindowProc() passes messages on to. @@ -239,8 +254,12 @@ class TFormASWUnitTestsGUIMain : public TForm void CreateStatusImages(); // The window's layout as it is now, for saving. ASWUnitTests::TGUILayout CurrentLayout(); + // Chooses the dark theme of the View > Theme > Dark Theme item 'Sender'. + void __fastcall DarkThemeItemClick(TObject* Sender); // Ends a run that an unhandled exception escaped from, described by 'description'. void FailRunOnUnhandledException(std::string const& description); + // Lists Carbon and the VCL style files in View > Theme > Dark Theme, as it opens; see the definition. + void FillDarkThemeMenu(); // Follows the theme as it changes; see the definition. void __fastcall FormWindowProc(TMessage& message); // Restores the layout SaveLayout() saved, if any; see the definition. @@ -266,6 +285,9 @@ class TFormASWUnitTestsGUIMain : public TForm void SaveLayout(); // Saves which tests are checked and shown for the next start, ignoring any error. void SaveSelection(); + // Chooses View > Theme > Dark Theme's 'name', saving it, unless --layout-ignore, and applies it; see the + // definition. + void SetDarkTheme(std::string const& name); // Sets a tree node's status image from m_TestList: a test's own status, or a group's or suite's, from its // tests. void SetNodeImage(TTreeNode* node); @@ -302,9 +324,11 @@ class TFormASWUnitTestsGUIMain : public TForm // The process exit code: with --exit, what the console runner would have returned for the --run run; // otherwise ExitCode_Success, since the GUI was used interactively. int ExitCode() const; - // Loads the registered tests and applies 'options' (see WinMain()), and shows 'theme', the theme WinMain() - // applied, in the View > Theme menu. Call once, right after creating the form. - void Start(ASWUnitTests::TGUIOptions const& options, ASWUnitTests::EGUITheme theme); + // Loads the registered tests and applies 'options' (see WinMain()), and shows 'theme' and 'darkTheme', the + // choices WinMain() applied, in the View > Theme menu, with 'darkStyle', the VCL style LoadGUIDarkStyle() gave + // for 'darkTheme', and the problems it found in the log. Call once, right after creating the form. + void Start(ASWUnitTests::TGUIOptions const& options, ASWUnitTests::EGUITheme theme, std::string const& darkTheme, + System::UnicodeString const& darkStyle, std::vector const& themeProblems); // True once a run has ended with a test that timed out or crashed. A timed-out test's abandoned thread may then // still be running, using its group, so WinMain() ends the process without destroying the groups, as the console // runner does. diff --git a/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.cpp b/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.cpp index 9ace4a8..5b4a62f 100644 --- a/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.cpp +++ b/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.cpp @@ -26,6 +26,7 @@ limitations under the License. //--------------------------------------------------------------------------- #include #include +#include //--------------------------------------------------------------------------- #include #include @@ -33,6 +34,7 @@ limitations under the License. #include // Links the unit that registers the engine loading VCL style resources, e.g. Carbon. #include #include +#include //--------------------------------------------------------------------------- #include "ASWUnitTests_GUI_INIDocument.h" #include "ASWUnitTests_GUI_Strings.h" @@ -48,10 +50,10 @@ namespace ASWUnitTests TrySetStyle() leaves the style as it is if the dark style's resource isn't linked in (the GUI project's Custom_Styles), as in the VCL console runner, or the Vcl.Styles unit isn't, which loads it. */ -bool ApplyGUITheme(EGUITheme theme) +bool ApplyGUITheme(EGUITheme theme, System::UnicodeString const& darkStyle) { bool const dark = GUIThemeIsDark(theme, WindowsAppsUseDarkTheme(), WindowsHighContrast()); - System::UnicodeString const styleName = dark ? "Carbon" : "Windows"; + System::UnicodeString const styleName = dark ? darkStyle : System::UnicodeString("Windows"); if (System::Sysutils::SameText(Vcl::Themes::TStyleManager::ActiveStyle->Name, styleName)) return false; @@ -59,6 +61,28 @@ bool ApplyGUITheme(EGUITheme theme) return Vcl::Themes::TStyleManager::TrySetStyle(styleName, false); } //--------------------------------------------------------------------------- +std::vector FileNamesInFolder(System::UnicodeString const& folderPath) +{ + std::vector names; + + try + { + if (!System::Sysutils::DirectoryExists(folderPath)) + return names; + + System::DynamicArray const files = System::Ioutils::TDirectory::GetFiles(folderPath); + + for (int i = 0; i < files.Length; ++i) + names.push_back(ToUTF8(System::Sysutils::ExtractFileName(files[i]))); + } + catch (...) + { + names.clear(); + } + + return names; +} +//--------------------------------------------------------------------------- System::UnicodeString GUILayoutFilePath() { // GetHomePath() is %APPDATA% on Windows. @@ -75,6 +99,104 @@ System::UnicodeString GUISelectionFilePath() return System::Sysutils::ChangeFileExt(GUILayoutFilePath(), ".selection"); } //--------------------------------------------------------------------------- +System::UnicodeString GUIThemesFolderPath() +{ + // GetHomePath() is %APPDATA% on Windows, as for GUILayoutFilePath(). + return System::Ioutils::TPath::Combine(System::Ioutils::TPath::Combine(System::Ioutils::TPath::GetHomePath(), + "ASWUnitTests"), "Themes"); +} +//--------------------------------------------------------------------------- +/* + LoadGUIDarkStyle + + A style, once loaded, stays registered for the process, under the name the file gives it, and can't be + loaded again, so a file loaded before gives the style it loaded, and a change to it shows after a restart. + A file whose style has the name of one already registered, e.g. Carbon's own file renamed, can't be loaded + either. Every error gives Carbon, with a line in 'problems'. +*/ +System::UnicodeString LoadGUIDarkStyle(std::string const& darkTheme, std::vector& problems) +{ + // Each file loaded so far, by its path in lowercase, and the name of its style. + static std::vector > loaded; + + System::UnicodeString const carbon = "Carbon"; + + if (darkTheme == GUIDefaultDarkThemeName()) + return carbon; + + std::string problem; + + try + { + System::UnicodeString const path = System::Ioutils::TPath::Combine(GUIThemesFolderPath(), + FromUTF8(darkTheme) + ".vsf"); + System::UnicodeString const key = System::Sysutils::AnsiLowerCase(path); + + for (std::pair const& entry : loaded) + { + if (entry.first == key) + return entry.second; + } + + Vcl::Themes::TStyleInfo info; + + if (!System::Sysutils::FileExists(path)) + { + problem = "There's no style file " + ToUTF8(path) + ", so " + GUIDefaultDarkThemeName() + + " is used instead."; + } + else if (!Vcl::Themes::TStyleManager::IsValidStyle(path, info)) + { + problem = "Its file isn't a VCL style file, so " + GUIDefaultDarkThemeName() + " is used instead."; + } + else + { + System::DynamicArray const names = Vcl::Themes::TStyleManager::StyleNames; + + for (int i = 0; i < names.Length && problem.empty(); ++i) + { + if (System::Sysutils::SameText(names[i], info.Name)) + { + problem = "Its style is named " + ToUTF8(info.Name) + ", as a style already loaded is, so " + + GUIDefaultDarkThemeName() + " is used instead. Give it a name of its own in RAD Studio's " + + "Bitmap Style Designer."; + } + } + + if (problem.empty()) + { + Vcl::Themes::TStyleManager::LoadFromFile(path); + loaded.emplace_back(key, info.Name); + return info.Name; + } + } + } + catch (System::Sysutils::Exception const& exception) + { + problem = "Its style file couldn't be loaded, so " + GUIDefaultDarkThemeName() + " is used instead: " + + ToUTF8(exception.Message); + } + + problems.push_back(GUIThemeProblemLine(darkTheme, problem)); + return carbon; +} +//--------------------------------------------------------------------------- +std::string LoadGUIDarkThemeFromLayoutFile() +{ + try + { + System::UnicodeString const path = GUILayoutFilePath(); + if (!System::Sysutils::FileExists(path)) + return GUIDefaultDarkThemeName(); + + return LoadGUIDarkTheme(TGUIINIDocument(ReadFileBytes(path))); + } + catch (...) + { + return GUIDefaultDarkThemeName(); + } +} +//--------------------------------------------------------------------------- EGUITheme LoadGUIThemeFromLayoutFile() { try @@ -91,6 +213,19 @@ EGUITheme LoadGUIThemeFromLayoutFile() } } //--------------------------------------------------------------------------- +/* + OpenFolderInExplorer + + ShellExecuteW() returns a value above 32 on success. +*/ +void OpenFolderInExplorer(System::UnicodeString const& folderPath) +{ + HINSTANCE const result = ::ShellExecuteW(nullptr, L"open", folderPath.c_str(), nullptr, nullptr, SW_SHOWNORMAL); + + if (reinterpret_cast(result) <= 32) + throw System::Sysutils::Exception(System::UnicodeString("Couldn't open the folder ") + folderPath); +} +//--------------------------------------------------------------------------- std::vector ProcessArguments() { std::vector args; @@ -111,6 +246,24 @@ std::string ReadFileBytes(System::UnicodeString const& path) return std::string(reinterpret_cast(&bytes[0]), static_cast(bytes.Length)); } //--------------------------------------------------------------------------- +void SaveGUIDarkThemeToLayoutFile(std::string const& name) +{ + try + { + System::UnicodeString const path = GUILayoutFilePath(); + System::Sysutils::ForceDirectories(System::Sysutils::ExtractFileDir(path)); + + // Starting from the file's current text keeps the layout and any other values in it. + TGUIINIDocument ini(System::Sysutils::FileExists(path) ? ReadFileBytes(path) : std::string()); + SaveGUIDarkTheme(ini, name); + WriteFileBytes(path, ini.Text()); + } + catch (...) + { + // Not saving it (e.g. a read-only profile) only means the next start uses the default dark theme. + } +} +//--------------------------------------------------------------------------- void SaveGUIThemeToLayoutFile(EGUITheme theme) { try diff --git a/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.h b/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.h index 6cfb0e3..1729728 100644 --- a/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.h +++ b/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.h @@ -36,25 +36,45 @@ limitations under the License. namespace ASWUnitTests { -// Sets the VCL style for 'theme' (see GUIThemeIsDark()): Carbon, which the GUI project links in, when it's dark, +// Sets the VCL style for 'theme' (see GUIThemeIsDark()): 'darkStyle' (see LoadGUIDarkStyle()) when it's dark, // otherwise the default unstyled look. Returns whether the active style changed. Call it on the main thread; // before any form exists, each form is created in that style, and afterward it restyles the open ones. -bool ApplyGUITheme(EGUITheme theme); +bool ApplyGUITheme(EGUITheme theme, System::UnicodeString const& darkStyle); +// The names of the files (not folders) in the folder, without the folder, converted to UTF-8; empty if the +// folder doesn't exist or can't be listed. +std::vector FileNamesInFolder(System::UnicodeString const& folderPath); // This executable's layout file, %APPDATA%\ASWUnitTests\.ini, so each test app built on the GUI // keeps its own layout, shared by all of its build configurations. System::UnicodeString GUILayoutFilePath(); // This executable's selection file, beside its layout file (see GUILayoutFilePath()): its name with a // ".selection" extension. System::UnicodeString GUISelectionFilePath(); +// The folder that holds the theme files both GUI runners share, whatever executable runs them: +// %APPDATA%\ASWUnitTests\Themes. The VCL GUI runner's are VCL style files, ".vsf". It may not exist yet. +System::UnicodeString GUIThemesFolderPath(); +// The VCL style the dark theme 'darkTheme' is, for ApplyGUITheme(): Carbon, which the GUI project links in, for +// GUIDefaultDarkThemeName(); otherwise the style in GUIThemesFolderPath()'s ".vsf", loaded the first +// time it's needed. Adds a line for the log to 'problems' (see GUIThemeProblemLine()), and gives Carbon, when +// the file is missing, isn't a VCL style, or has the name of a style already loaded from elsewhere. +System::UnicodeString LoadGUIDarkStyle(std::string const& darkTheme, std::vector& problems); +// The dark theme SaveGUIDarkThemeToLayoutFile() saved in the layout file (see GUILayoutFilePath()), or +// GUIDefaultDarkThemeName() if there's none or it can't be read. +std::string LoadGUIDarkThemeFromLayoutFile(); // The theme SaveGUIThemeToLayoutFile() saved in the layout file (see GUILayoutFilePath()), or Windows if there's // none or it can't be read. EGUITheme LoadGUIThemeFromLayoutFile(); +// Opens the folder in a File Explorer window. Throws an RTL exception if it can't, e.g. because the folder +// doesn't exist. +void OpenFolderInExplorer(System::UnicodeString const& folderPath); // This process's command-line arguments, without the program name, converted to UTF-8, for // TGUICommandLine::Parse(). std::vector ProcessArguments(); // The file's bytes, unchanged, e.g. UTF-8 text for TGUIINIDocument. Throws an RTL exception if it can't be // read. std::string ReadFileBytes(System::UnicodeString const& path); +// Saves 'name', the dark theme's, in the layout file (see GUILayoutFilePath()), keeping its other values, and +// creating it if needed. Ignores any error, which only means the next start uses the default dark theme. +void SaveGUIDarkThemeToLayoutFile(std::string const& name); // Saves 'theme' in the layout file (see GUILayoutFilePath()), keeping its other values, and creating it if needed. // Ignores any error, which only means the next start uses the default theme. void SaveGUIThemeToLayoutFile(EGUITheme theme); diff --git a/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.cpp b/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.cpp index a1acc0c..0fc6d83 100644 --- a/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.cpp +++ b/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.cpp @@ -24,6 +24,7 @@ limitations under the License. // Module header #include "Test_ASWUnitTests_GUI_VCLSystem.h" //--------------------------------------------------------------------------- +#include #include #include #include @@ -52,10 +53,16 @@ namespace ASWUnitTests TTest_ASWUnitTests_GUI_VCLSystem::TTest_ASWUnitTests_GUI_VCLSystem() : inherited("ASWUnitTests_GUI_VCLSystem_Tests") { + RegisterTest(&TTest_ASWUnitTests_GUI_VCLSystem::Test_FileNamesInFolder_ListsOnlyTheFiles, + "FileNamesInFolder_ListsOnlyTheFiles"); RegisterTest(&TTest_ASWUnitTests_GUI_VCLSystem::Test_GUILayoutFilePath_IsPerExeUnderAppData, "GUILayoutFilePath_IsPerExeUnderAppData"); RegisterTest(&TTest_ASWUnitTests_GUI_VCLSystem::Test_GUISelectionFilePath_IsBesideTheLayoutFile, "GUISelectionFilePath_IsBesideTheLayoutFile"); + RegisterTest(&TTest_ASWUnitTests_GUI_VCLSystem::Test_GUIThemesFolderPath_IsSharedBesideTheLayoutFiles, + "GUIThemesFolderPath_IsSharedBesideTheLayoutFiles"); + RegisterTest(&TTest_ASWUnitTests_GUI_VCLSystem::Test_LoadGUIDarkStyle_GivesCarbonForAnyProblem, + "LoadGUIDarkStyle_GivesCarbonForAnyProblem"); RegisterTest(&TTest_ASWUnitTests_GUI_VCLSystem::Test_LoadGUIThemeFromLayoutFile_ReadsWhatSaveWrote, "LoadGUIThemeFromLayoutFile_ReadsWhatSaveWrote"); RegisterTest(&TTest_ASWUnitTests_GUI_VCLSystem::Test_ProcessArguments_MatchesParamCount, "ProcessArguments_MatchesParamCount"); @@ -88,6 +95,57 @@ void TTest_ASWUnitTests_GUI_VCLSystem::TearDown_Test(ITestCase& /*testCase*/) // /////// Begin tests after this line /////////////////////// +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_VCLSystem::Test_FileNamesInFolder_ListsOnlyTheFiles() +{ + // Arrange: two files, one with a non-ASCII name (U+4E2D), and a folder named like a style file, in a folder + // deleted however the test ends. + System::UnicodeString const folder = System::Ioutils::TPath::Combine(System::Ioutils::TPath::GetTempPath(), + System::UnicodeString("ASWUnitTests_VCLSystem_") + System::Sysutils::IntToStr(static_cast( + GetCurrentProcessId()))); + + struct TDeleteFolder + { + System::UnicodeString const Folder; + + ~TDeleteFolder() + { + try + { + System::Ioutils::TDirectory::Delete(Folder, true); + } + catch (...) + { + } + } + } const deleteFolder{ folder }; + + System::Sysutils::ForceDirectories(System::Ioutils::TPath::Combine(folder, "Folder.vsf")); + WriteFileBytes(System::Ioutils::TPath::Combine(folder, "Ocean.vsf"), "x"); + WriteFileBytes(System::Ioutils::TPath::Combine(folder, System::UnicodeString(L"\x4E2D.vsf")), std::string()); + WriteFileBytes(System::Ioutils::TPath::Combine(folder, "Folder.vsf\\Inside.vsf"), "x"); + + // Act + std::vector names = FileNamesInFolder(folder); + std::vector const missing = FileNamesInFolder(System::Ioutils::TPath::Combine(folder, "Missing")); + + // Assert: both sorted the same way, as where a non-ASCII byte sorts depends on whether char is signed. + std::vector expected = { "Ocean.vsf", "\xE4\xB8\xAD.vsf" }; + std::sort(expected.begin(), expected.end()); + std::sort(names.begin(), names.end()); + std::string expectedText; + std::string listed; + + for (std::string const& name : expected) + expectedText += name + "\n"; + + for (std::string const& name : names) + listed += name + "\n"; + + CheckEquals(expectedText, listed, __func__, __LINE__, + "the files' names, in UTF-8, without the folder, its subfolder or the files in it"); + CheckTrue(missing.empty(), __func__, __LINE__, "none for a missing folder"); +} //--------------------------------------------------------------------------- void TTest_ASWUnitTests_GUI_VCLSystem::Test_GUILayoutFilePath_IsPerExeUnderAppData() { @@ -120,6 +178,63 @@ void TTest_ASWUnitTests_GUI_VCLSystem::Test_GUISelectionFilePath_IsBesideTheLayo __LINE__, "the layout file's path with a .selection extension"); } //--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_VCLSystem::Test_GUIThemesFolderPath_IsSharedBesideTheLayoutFiles() +{ + // Act and Assert: the same for every executable, unlike the layout file. + CheckEquals(ToUTF8(System::Ioutils::TPath::Combine(System::Sysutils::ExtractFileDir(GUILayoutFilePath()), + "Themes")), ToUTF8(GUIThemesFolderPath()), __func__, __LINE__, "a Themes folder beside the layout file"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_GUI_VCLSystem::Test_LoadGUIDarkStyle_GivesCarbonForAnyProblem() +{ + // Arrange: a missing theme, and a file that isn't a style, in the real Themes folder, which is put back as it + // was however the test ends. + std::string const suffix = std::to_string(GetCurrentProcessId()); + std::string const missingName = "ASWUnitTests_NoSuchTheme_" + suffix; + std::string const invalidName = "ASWUnitTests_NotAStyle_" + suffix; + System::UnicodeString const folder = GUIThemesFolderPath(); + System::UnicodeString const invalidPath = System::Ioutils::TPath::Combine(folder, FromUTF8(invalidName) + ".vsf"); + + struct TRestoreFolder + { + System::UnicodeString const Folder; + System::UnicodeString const File; + bool const Existed; + + ~TRestoreFolder() + { + System::Sysutils::DeleteFile(File); + + if (!Existed) + System::Sysutils::RemoveDir(Folder); + } + } const restoreFolder{ folder, invalidPath, System::Sysutils::DirectoryExists(folder) }; + + System::Sysutils::ForceDirectories(folder); + WriteFileBytes(invalidPath, "not a VCL style"); + + // Act + std::vector carbonProblems; + System::UnicodeString const carbon = LoadGUIDarkStyle("Carbon", carbonProblems); + std::vector missingProblems; + System::UnicodeString const missing = LoadGUIDarkStyle(missingName, missingProblems); + std::vector invalidProblems; + System::UnicodeString const invalid = LoadGUIDarkStyle(invalidName, invalidProblems); + + // Assert + CheckEquals(std::string("Carbon"), ToUTF8(carbon), __func__, __LINE__, "the built-in theme's style"); + CheckTrue(carbonProblems.empty(), __func__, __LINE__, "with no problems"); + CheckEquals(std::string("Carbon"), ToUTF8(missing), __func__, __LINE__, "Carbon for a missing file"); + AssertEquals(static_cast(1), missingProblems.size(), __func__, __LINE__, "with one problem"); + std::string const missingStart = "Dark theme \"" + missingName + "\": There's no style file "; + CheckEquals(missingStart, missingProblems.front().substr(0, missingStart.size()), __func__, __LINE__, + "saying so"); + CheckEquals(std::string("Carbon"), ToUTF8(invalid), __func__, __LINE__, "Carbon for a file that isn't a style"); + AssertEquals(static_cast(1), invalidProblems.size(), __func__, __LINE__, "with one problem"); + CheckEquals("Dark theme \"" + invalidName + "\": Its file isn't a VCL style file, so Carbon is used instead.", + invalidProblems.front(), __func__, __LINE__, "saying so"); +} +//--------------------------------------------------------------------------- void TTest_ASWUnitTests_GUI_VCLSystem::Test_LoadGUIThemeFromLayoutFile_ReadsWhatSaveWrote() { // Arrange: this runner's own layout file, which only a GUI would otherwise use, put back however the test @@ -152,6 +267,7 @@ void TTest_ASWUnitTests_GUI_VCLSystem::Test_LoadGUIThemeFromLayoutFile_ReadsWhat System::Sysutils::DeleteFile(path); EGUITheme const noFile = LoadGUIThemeFromLayoutFile(); + std::string const noFileDarkTheme = LoadGUIDarkThemeFromLayoutFile(); // Act SaveGUIThemeToLayoutFile(EGUITheme::Dark); @@ -160,14 +276,24 @@ void TTest_ASWUnitTests_GUI_VCLSystem::Test_LoadGUIThemeFromLayoutFile_ReadsWhat WriteFileBytes(path, "[Window]\r\nMaximized=1\r\n"); SaveGUIThemeToLayoutFile(EGUITheme::Light); EGUITheme const light = LoadGUIThemeFromLayoutFile(); + SaveGUIDarkThemeToLayoutFile("Deep \xC3\x89" "cume"); + std::string const darkTheme = LoadGUIDarkThemeFromLayoutFile(); std::string const text = ReadFileBytes(path); + System::Sysutils::DeleteFile(path); + SaveGUIDarkThemeToLayoutFile("Ocean"); + std::string const darkThemeInNewFile = LoadGUIDarkThemeFromLayoutFile(); + // Assert CheckTrue(noFile == EGUITheme::Windows, __func__, __LINE__, "Windows without a layout file"); + CheckEquals(std::string("Carbon"), noFileDarkTheme, __func__, __LINE__, "and the dark theme Carbon"); CheckTrue(dark == EGUITheme::Dark, __func__, __LINE__, "the theme saved in a new file"); CheckTrue(light == EGUITheme::Light, __func__, __LINE__, "the theme saved in an existing file"); - CheckEquals(std::string("[Window]\r\nMaximized=1\r\n\r\n[View]\r\nTheme=Light\r\n\r\n"), text, __func__, - __LINE__, "the layout stays"); + CheckEquals(std::string("Deep \xC3\x89" "cume"), darkTheme, __func__, __LINE__, + "the dark theme saved beside it, in UTF-8"); + CheckEquals(std::string("[Window]\r\nMaximized=1\r\n\r\n[View]\r\nTheme=Light\r\nDarkTheme=Deep \xC3\x89" "cume\r\n" + "\r\n"), text, __func__, __LINE__, "the layout stays"); + CheckEquals(std::string("Ocean"), darkThemeInNewFile, __func__, __LINE__, "the dark theme saved in a new file"); } //--------------------------------------------------------------------------- void TTest_ASWUnitTests_GUI_VCLSystem::Test_ProcessArguments_MatchesParamCount() diff --git a/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.h b/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.h index 7f3c30f..73e0af8 100644 --- a/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.h +++ b/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.h @@ -41,8 +41,11 @@ class TTest_ASWUnitTests_GUI_VCLSystem : public TTestGroupBase typedef TTestGroupBase inherited; private: // Test methods + void Test_FileNamesInFolder_ListsOnlyTheFiles(); void Test_GUILayoutFilePath_IsPerExeUnderAppData(); void Test_GUISelectionFilePath_IsBesideTheLayoutFile(); + void Test_GUIThemesFolderPath_IsSharedBesideTheLayoutFiles(); + void Test_LoadGUIDarkStyle_GivesCarbonForAnyProblem(); void Test_LoadGUIThemeFromLayoutFile_ReadsWhatSaveWrote(); void Test_ProcessArguments_MatchesParamCount(); void Test_ReadFileBytes_ReadsWhatWriteFileBytesWrote(); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp index 061a12e..3da549b 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp @@ -28,9 +28,12 @@ limitations under the License. #include #include #include +#include +#include #include #include #include +#include #include #include //--------------------------------------------------------------------------- @@ -39,6 +42,7 @@ limitations under the License. //--------------------------------------------------------------------------- #include "ASWUnitTests_GUI_INIDocument.h" #include "ASWUnitTests_GUI_Selection.h" +#include "ASWUnitTests_GUI_Theme.h" #include "ASWUnitTests_GUI_Tree.h" #include "ASWUnitTests_GUI_View.h" //--------------------------------------------------------------------------- @@ -55,7 +59,12 @@ namespace ASWUnitTests namespace { +// The command ID of View > Theme > Dark Theme's first theme, Carbon, after the EWin32GUICommand IDs; each of the +// others' is the next (see TWin32GUIMainWindow::m_DarkThemeNames), up to MaxDarkThemes of them. +WORD const FirstDarkThemeCommand = 2000; wchar_t const MainWindowClassName[] = L"ASWUnitTests_Win32GUI_Main"; +// The most themes View > Theme > Dark Theme lists, Carbon included. +std::size_t const MaxDarkThemes = 1000; // Posted by PostToUI(), so the message loop runs the queued actions. UINT const RunQueuedMessage = WM_APP + 1; // The title of the GUI's windows, as the VCL GUI runner's Application->Title. @@ -71,9 +80,11 @@ EWin32GUICommand const AllCommands[] = { EWin32GUICommand::ExpandAll, EWin32GUICommand::ExportJUnitReport, EWin32GUICommand::FocusFilter, + EWin32GUICommand::OpenThemesFolder, EWin32GUICommand::ResetLayout, EWin32GUICommand::RunFailed, EWin32GUICommand::RunSelected, + EWin32GUICommand::SaveThemeTemplate, EWin32GUICommand::SelectAll, EWin32GUICommand::SelectFailed, EWin32GUICommand::SelectNone, @@ -132,6 +143,8 @@ class TScopedFlag BOOL CALLBACK AddMonitorWorkArea(HMONITOR monitor, HDC context, LPRECT bounds, LPARAM workAreas); void AppendCommand(HMENU menu, EWin32GUICommand command, wchar_t const* caption); HMENU AppendSubmenu(HMENU menuBar, wchar_t const* caption); +std::optional AskSaveFilePath(HWND owner, wchar_t const* title, wchar_t const* filter, + wchar_t const* defaultExtension, std::string const& initialPath); char const* CommandName(EWin32GUICommand command); bool IsCommandEnabled(TGUICommandStates const& states, EWin32GUICommand command); COLORREF LogLineColor(std::string const& line, TWin32GUITheme const& theme); @@ -184,6 +197,54 @@ HMENU AppendSubmenu(HMENU menuBar, wchar_t const* caption) return submenu; } //--------------------------------------------------------------------------- +/* + AskSaveFilePath + + The path chosen in Windows' Save dialog, or nullopt if it was canceled. The dialog starts at 'initialPath', + given its folder and its file name separately, so a folder that doesn't exist (any more) only means it starts + in its default folder. Throws std::runtime_error if the dialog can't be shown. +*/ +std::optional AskSaveFilePath(HWND owner, wchar_t const* title, wchar_t const* filter, + wchar_t const* defaultExtension, std::string const& initialPath) +{ + std::wstring const initial = UTF8ToWide(initialPath); + std::size_t const separator = initial.find_last_of(L"\\/"); + std::wstring const initialFolder = separator == std::wstring::npos ? std::wstring() : + initial.substr(0, separator + 1); + std::wstring const initialName = separator == std::wstring::npos ? initial : initial.substr(separator + 1); + + // Room for the longest path Windows allows, plus the terminating null. + std::vector fileName(32768, L'\0'); + std::copy(initialName.begin(), initialName.begin() + + static_cast((std::min)(initialName.size(), fileName.size() - 1)), fileName.begin()); + + OPENFILENAMEW dialog = {}; + dialog.lStructSize = sizeof(dialog); + dialog.hwndOwner = owner; + dialog.lpstrFilter = filter; + dialog.lpstrFile = fileName.data(); + dialog.nMaxFile = static_cast(fileName.size()); + dialog.lpstrInitialDir = initialFolder.empty() ? nullptr : initialFolder.c_str(); + dialog.lpstrTitle = title; + dialog.Flags = OFN_ENABLESIZING | OFN_EXPLORER | OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST; + dialog.lpstrDefExt = defaultExtension; + + if (GetSaveFileNameW(&dialog) == 0) + { + DWORD const error = CommDlgExtendedError(); + + if (error != 0) + { + throw std::runtime_error("Couldn't show the " + WideToUTF8(title) + " dialog (error " + + std::to_string(error) + ")"); + } + + return std::nullopt; // Canceled. + } + + return WideToUTF8(fileName.data()); +} +//--------------------------------------------------------------------------- /* CommandName @@ -217,6 +278,9 @@ char const* CommandName(EWin32GUICommand command) case EWin32GUICommand::FocusFilter: return "Filter"; + case EWin32GUICommand::OpenThemesFolder: + return "Theme > Dark Theme > Open Themes Folder"; + case EWin32GUICommand::ResetLayout: return "Reset Layout"; @@ -226,6 +290,9 @@ char const* CommandName(EWin32GUICommand command) case EWin32GUICommand::RunSelected: return "Run Selected"; + case EWin32GUICommand::SaveThemeTemplate: + return "Theme > Dark Theme > Save Theme Template"; + case EWin32GUICommand::SelectAll: return "Select All"; @@ -285,6 +352,9 @@ bool IsCommandEnabled(TGUICommandStates const& states, EWin32GUICommand command) case EWin32GUICommand::FocusFilter: return states.FocusFilter; + case EWin32GUICommand::OpenThemesFolder: + return states.Theme; + case EWin32GUICommand::ResetLayout: return states.ResetLayout; @@ -294,6 +364,9 @@ bool IsCommandEnabled(TGUICommandStates const& states, EWin32GUICommand command) case EWin32GUICommand::RunSelected: return states.RunSelected; + case EWin32GUICommand::SaveThemeTemplate: + return states.Theme; + case EWin32GUICommand::SelectAll: return states.SelectAll; @@ -528,6 +601,7 @@ TWin32GUIMainWindow::TWin32GUIMainWindow(TWin32GUITheme& theme) m_Lbl_Filter(nullptr), m_LV_Failures(nullptr), m_Mem_Detail(nullptr), + m_MI_ViewDarkTheme(nullptr), m_MM_Main(nullptr), m_PB_Progress(nullptr), m_PC_Results(nullptr), @@ -538,6 +612,8 @@ TWin32GUIMainWindow::TWin32GUIMainWindow(TWin32GUITheme& theme) m_Accelerators(nullptr), m_CloseAfterRun(false), m_Closing(false), + m_DarkTheme(GUIDefaultDarkThemeName()), + m_DarkThemeNames(1, GUIDefaultDarkThemeName()), m_DetailHeight(TWin32GUIMainSizes::DefaultDetailHeight), m_DPI(USER_DEFAULT_SCREEN_DPI), m_FocusBeforeRun(nullptr), @@ -741,6 +817,8 @@ void TWin32GUIMainWindow::ApplyTheme() return; m_Theme.SetDark(dark); + // The tree's status dots are the dark theme's own colors while dark. + CreateStatusImages(); RecolorLog(); } //--------------------------------------------------------------------------- @@ -1042,6 +1120,13 @@ void TWin32GUIMainWindow::CreateMenuBar() AppendCommand(theme, EWin32GUICommand::ThemeWindows, L"Use &Windows Setting"); AppendCommand(theme, EWin32GUICommand::ThemeLight, L"&Light"); AppendCommand(theme, EWin32GUICommand::ThemeDark, L"&Dark"); + AppendMenuW(theme, MF_SEPARATOR, 0, nullptr); + // Filled with the theme files each time it opens; Carbon until then. + m_MI_ViewDarkTheme = AppendSubmenu(theme, L"Dark &Theme"); + AppendMenuW(m_MI_ViewDarkTheme, MF_STRING, FirstDarkThemeCommand, L"&Carbon"); + AppendMenuW(m_MI_ViewDarkTheme, MF_SEPARATOR, 0, nullptr); + AppendCommand(m_MI_ViewDarkTheme, EWin32GUICommand::SaveThemeTemplate, L"&Save Theme Template..."); + AppendCommand(m_MI_ViewDarkTheme, EWin32GUICommand::OpenThemesFolder, L"&Open Themes Folder"); HMENU const options = AppendSubmenu(m_MM_Main, L"&Options"); AppendCommand(options, EWin32GUICommand::ShuffleOrder, L"Run in &Shuffled Order"); @@ -1057,20 +1142,33 @@ void TWin32GUIMainWindow::CreateMenuBar() Draws one colored dot per TGUITestStatus into a new image list for the test tree, in the enum's order, so a status converts directly to its image index. Drawn at runtime, scaled to m_DPI, as the VCL GUI runner - draws its own, rather than stored as images. + draws its own, rather than stored as images. In the dark theme's colors while dark, and else in Carbon's, + which are the light look's too. */ void TWin32GUIMainWindow::CreateStatusImages() { + TWin32GUIPalette const palette = m_Theme.IsDark() ? m_Theme.Palette() : CarbonPalette(); + // In TGUITestStatus order: NotRun, Running, Passed, Failed, Skipped. COLORREF const colors[] = { - RGB(190, 190, 190), - RGB(0, 120, 215), - RGB(16, 160, 16), - RGB(215, 40, 40), - RGB(230, 165, 0), + palette.TreeStatusNotRun, + palette.TreeStatusRunning, + palette.TreeStatusPassed, + palette.TreeStatusFailed, + palette.TreeStatusSkipped, }; - // Each dot's background, left out by ImageList_AddMasked(). - COLORREF const transparent = RGB(255, 0, 255); + // Each dot's background, left out by ImageList_AddMasked(): a color none of the dots has, as a theme file may + // give one any color. + COLORREF transparent = RGB(255, 0, 255); + + for (COLORREF const candidate : { RGB(255, 0, 255), RGB(0, 255, 255), RGB(255, 255, 0), RGB(1, 2, 3), + RGB(254, 0, 254), RGB(0, 254, 254) }) + { + transparent = candidate; + + if (std::find(std::begin(colors), std::end(colors), candidate) == std::end(colors)) + break; + } int const size = ScaleForDPI(16, m_DPI); int const inset = ScaleForDPI(3, m_DPI); @@ -1263,6 +1361,11 @@ void TWin32GUIMainWindow::DoCommand(EWin32GUICommand command) SendMessageW(m_Edt_Filter, EM_SETSEL, 0, -1); break; + case EWin32GUICommand::OpenThemesFolder: + CreateFolderPath(GUIThemesFolderPath()); + OpenFolderInExplorer(m_Window, GUIThemesFolderPath()); + break; + case EWin32GUICommand::ResetLayout: ResetLayout(); break; @@ -1286,6 +1389,10 @@ void TWin32GUIMainWindow::DoCommand(EWin32GUICommand command) RunCheckedTests(); break; + case EWin32GUICommand::SaveThemeTemplate: + SaveThemeTemplate(); + break; + case EWin32GUICommand::SelectAll: m_TestList.SetAllChecked(true); SyncTreeChecks(); @@ -1345,9 +1452,7 @@ int TWin32GUIMainWindow::ExitCode() const TWin32GUIMainWindow::ExportJUnitReport Saves the latest run's JUnit report, the same one --report-junit writes, e.g. when --report-junit wasn't - given. The dialog starts at --report-junit's path if there is one, and then at the last one chosen. It's - given the folder and the file name separately, so a folder that doesn't exist (any more) only means it - starts in its default folder. + given. The dialog starts at --report-junit's path if there is one, and then at the last one chosen. */ void TWin32GUIMainWindow::ExportJUnitReport() { @@ -1364,43 +1469,14 @@ void TWin32GUIMainWindow::ExportJUnitReport() m_ExportJUnitReportPath = GUIDefaultJUnitReportFileName(ExeFileName()); } - std::wstring const initialPath = UTF8ToWide(m_ExportJUnitReportPath); - std::size_t const separator = initialPath.find_last_of(L"\\/"); - std::wstring const initialFolder = separator == std::wstring::npos ? std::wstring() : - initialPath.substr(0, separator + 1); - std::wstring const initialName = separator == std::wstring::npos ? initialPath : initialPath.substr(separator + 1); - - // Room for the longest path Windows allows, plus the terminating null. - std::vector fileName(32768, L'\0'); - std::copy(initialName.begin(), initialName.begin() + - static_cast((std::min)(initialName.size(), fileName.size() - 1)), fileName.begin()); - - // The .dfm's SD_JUnitReport: its filter, default extension, title and options. - OPENFILENAMEW dialog = {}; - dialog.lStructSize = sizeof(dialog); - dialog.hwndOwner = m_Window; - dialog.lpstrFilter = L"JUnit XML reports (*.xml)\0*.xml\0All files (*.*)\0*.*\0"; - dialog.lpstrFile = fileName.data(); - dialog.nMaxFile = static_cast(fileName.size()); - dialog.lpstrInitialDir = initialFolder.empty() ? nullptr : initialFolder.c_str(); - dialog.lpstrTitle = L"Export JUnit Report"; - dialog.Flags = OFN_ENABLESIZING | OFN_EXPLORER | OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST; - dialog.lpstrDefExt = L"xml"; - - if (GetSaveFileNameW(&dialog) == 0) - { - DWORD const error = CommDlgExtendedError(); - - if (error != 0) - { - throw std::runtime_error("Couldn't show the Export JUnit Report dialog (error " + std::to_string(error) + - ")"); - } + // The .dfm's SD_JUnitReport: its title, filter and default extension. + std::optional const path = AskSaveFilePath(m_Window, L"Export JUnit Report", + L"JUnit XML reports (*.xml)\0*.xml\0All files (*.*)\0*.*\0", L"xml", m_ExportJUnitReportPath); + if (!path.has_value()) return; // Canceled. - } - m_ExportJUnitReportPath = WideToUTF8(fileName.data()); + m_ExportJUnitReportPath = *path; std::string error; @@ -1436,6 +1512,70 @@ void TWin32GUIMainWindow::FailRunOnUnhandledException(std::string const& descrip UpdateDetail(); } //--------------------------------------------------------------------------- +/* + TWin32GUIMainWindow::FillDarkThemeMenu + + Carbon first, then the theme files in GUIThemesFolderPath() (see GUIThemeNames()), so a file added or removed + since shows at once, then Save Theme Template and Open Themes Folder; the chosen one has the radio check. A + chosen theme whose file is missing is still listed, so choosing it again reports why it isn't used. +*/ +void TWin32GUIMainWindow::FillDarkThemeMenu() +{ + std::vector names = GUIThemeNames(FileNamesInFolder(GUIThemesFolderPath()), ".ini"); + names.insert(names.begin(), GUIDefaultDarkThemeName()); + + // Windows' file names ignore case, so a name saved in another case is still that file's. + auto const sameName = [](std::string const& a, std::string const& b) + { + std::wstring const wideA = UTF8ToWide(a); + std::wstring const wideB = UTF8ToWide(b); + + return CompareStringOrdinal(wideA.c_str(), static_cast(wideA.size()), wideB.c_str(), + static_cast(wideB.size()), TRUE) == CSTR_EQUAL; + }; + + std::vector::const_iterator chosen = std::find_if(names.begin(), names.end(), + [this, &sameName](std::string const& name) + { + return sameName(name, m_DarkTheme); + }); + + if (chosen == names.end()) + chosen = names.insert(names.end(), m_DarkTheme); + + std::size_t const chosenIndex = static_cast(chosen - names.begin()); + + if (names.size() > MaxDarkThemes) + names.erase(names.begin() + static_cast(MaxDarkThemes), names.end()); + + while (GetMenuItemCount(m_MI_ViewDarkTheme) > 0) + DeleteMenu(m_MI_ViewDarkTheme, 0, MF_BYPOSITION); + + UINT const enabled = m_CommandStates.Theme ? MF_ENABLED : MF_GRAYED; + + for (std::size_t i = 0; i < names.size(); ++i) + { + // Carbon's C is its accelerator; a theme file's name has none. + std::wstring const caption = UTF8ToWide((i == 0 ? "&" : "") + GUIMenuItemText(names[i])); + AppendMenuW(m_MI_ViewDarkTheme, MF_STRING | enabled, FirstDarkThemeCommand + i, caption.c_str()); + } + + if (chosenIndex < names.size()) + { + CheckMenuRadioItem(m_MI_ViewDarkTheme, FirstDarkThemeCommand, static_cast(FirstDarkThemeCommand + + names.size() - 1), static_cast(FirstDarkThemeCommand + chosenIndex), MF_BYCOMMAND); + } + + AppendMenuW(m_MI_ViewDarkTheme, MF_SEPARATOR, 0, nullptr); + AppendMenuW(m_MI_ViewDarkTheme, MF_STRING | enabled, static_cast(EWin32GUICommand::SaveThemeTemplate), + L"&Save Theme Template..."); + AppendMenuW(m_MI_ViewDarkTheme, MF_STRING | enabled, static_cast(EWin32GUICommand::OpenThemesFolder), + L"&Open Themes Folder"); + + m_DarkThemeNames = names; + m_Theme.RefreshMenu(m_MI_ViewDarkTheme); +} +//--------------------------------------------------------------------------- HWND TWin32GUIMainWindow::Handle() const { return m_Window; @@ -1484,6 +1624,23 @@ LRESULT TWin32GUIMainWindow::HandleMessage(UINT message, WPARAM wParam, LPARAM l return 0; } + std::size_t const darkTheme = static_cast(id) - FirstDarkThemeCommand; + + if (id >= FirstDarkThemeCommand && darkTheme < m_DarkThemeNames.size()) + { + // As DoCommand() does, nothing while the Theme commands are disabled. + if (!m_CommandStates.Theme) + return 0; + + std::string const name = m_DarkThemeNames[darkTheme]; + + RunGuarded("Theme > Dark Theme", [this, &name]() + { + SetDarkTheme(name); + }); + return 0; + } + if (reinterpret_cast(lParam) == m_Edt_Filter && HIWORD(wParam) == EN_CHANGE) { if (m_SettingFilterText) @@ -1509,6 +1666,18 @@ LRESULT TWin32GUIMainWindow::HandleMessage(UINT message, WPARAM wParam, LPARAM l OnDPIChanged(HIWORD(wParam), *reinterpret_cast(lParam)); return 0; + case WM_INITMENUPOPUP: + if (reinterpret_cast(wParam) == m_MI_ViewDarkTheme) + { + RunGuarded("the Dark Theme menu", [this]() + { + FillDarkThemeMenu(); + }); + return 0; + } + + break; + case WM_NCDESTROY: m_Window = nullptr; break; @@ -2393,6 +2562,39 @@ void TWin32GUIMainWindow::SaveSelection() } } //--------------------------------------------------------------------------- +/* + TWin32GUIMainWindow::SaveThemeTemplate + + Writes CarbonThemeFileText() where the user chooses, starting in GUIThemesFolderPath(), created if needed, + where View > Theme > Dark Theme lists it once it's saved there. +*/ +void TWin32GUIMainWindow::SaveThemeTemplate() +{ + std::string const folder = GUIThemesFolderPath(); + CreateFolderPath(folder); + + std::optional const path = AskSaveFilePath(m_Window, L"Save Theme Template", + L"Theme files (*.ini)\0*.ini\0All files (*.*)\0*.*\0", L"ini", folder + "\\My Theme.ini"); + + if (!path.has_value()) + return; // Canceled. + + try + { + WriteFileBytes(*path, CarbonThemeFileText()); + } + catch (std::exception const& exception) + { + std::string const message = "The theme template couldn't be written to:\n" + *path + "\n\n" + + exception.what(); + ShowMessageDialog(m_Window, m_Theme, Title, message, EWin32GUIMessageIcon::Error); + return; + } + + m_StatusMessage = "Theme template saved."; + UpdateStatusBar(); +} +//--------------------------------------------------------------------------- void TWin32GUIMainWindow::SelectResultsPage(int page) { // Unlike a click on a tab, this sends no TCN_SELCHANGE. @@ -2405,6 +2607,39 @@ HTREEITEM TWin32GUIMainWindow::SelectedTreeItem() const return RelatedTreeItem(m_TV_Tests, nullptr, TVGN_CARET); } //--------------------------------------------------------------------------- +/* + TWin32GUIMainWindow::SetDarkTheme + + Read from its file again even if it's the current one, so a change to the file shows. Applied at once + while dark; while light, it's still read, so its problems show in the log, and the status bar says it shows + once the theme is dark. +*/ +void TWin32GUIMainWindow::SetDarkTheme(std::string const& name) +{ + m_DarkTheme = name; + + if (!m_Options.LayoutIgnore) + SaveGUIDarkThemeToLayoutFile(name); + + std::vector const problems = m_Theme.SetDarkTheme(name); + + if (m_Theme.IsDark()) + { + CreateStatusImages(); + RecolorLog(); + m_StatusMessage = "Dark theme: " + name + "."; + } + else + { + m_StatusMessage = "Dark theme: " + name + ", shown while the theme is dark."; + } + + for (std::string const& problem : problems) + AppendLog(problem + "\n"); + + UpdateStatusBar(); +} +//--------------------------------------------------------------------------- void TWin32GUIMainWindow::SetNodeImage(HTREEITEM item) { int const image = static_cast(GUINodeStatus(m_TestList, TreeItemRef(m_TV_Tests, item))); @@ -2549,14 +2784,19 @@ void TWin32GUIMainWindow::ShowThemeState() Loads the registered tests and applies the command line's options: --project-name to the caption, and --filter and the partition options to which tests start out checked, and --filter to the filter box, and --shuffle and --shuffle-seed to the Options menu. Also restores the window layout and the test - selection saved when it last closed. WinMain() set the theme up for 'theme' before any window existed, so - the --help dialog had it too. + selection saved when it last closed. WinMain() set the theme up for 'theme' and 'darkTheme' before any + window existed, so the --help dialog had it too, and its problems are noted in the log, where they stay + until the first run clears it. */ -void TWin32GUIMainWindow::Start(TGUIOptions const& options, EGUITheme theme) +void TWin32GUIMainWindow::Start(TGUIOptions const& options, EGUITheme theme, std::string const& darkTheme, + std::vector const& themeProblems) { m_Options = options; m_ThemeChoice = theme; + m_DarkTheme = darkTheme; ShowThemeState(); + // The dark theme's status dots, if WinMain() set up a theme file. + CreateStatusImages(); m_Observer.LogTextCallback = [this](std::string const& text) { @@ -2599,6 +2839,9 @@ void TWin32GUIMainWindow::Start(TGUIOptions const& options, EGUITheme theme) for (std::string const& note : GUICommandLineNotes(m_Options, "windows or controls")) AppendLog(note + "\n"); + for (std::string const& problem : themeProblems) + AppendLog(problem + "\n"); + if (m_Options.LayoutReset) ResetSavedLayout(); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.h b/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.h index 0a08677..f899be4 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.h @@ -78,9 +78,11 @@ enum class EWin32GUICommand : WORD ExpandAll, ExportJUnitReport, FocusFilter, + OpenThemesFolder, ResetLayout, RunFailed, RunSelected, + SaveThemeTemplate, SelectAll, SelectFailed, SelectNone, @@ -126,6 +128,8 @@ class TWin32GUIMainWindow HWND m_Lbl_Filter; HWND m_LV_Failures; HWND m_Mem_Detail; + // View > Theme > Dark Theme, which FillDarkThemeMenu() fills each time it opens. + HMENU m_MI_ViewDarkTheme; HMENU m_MM_Main; HWND m_PB_Progress; HWND m_PC_Results; @@ -146,6 +150,11 @@ class TWin32GUIMainWindow TGUICommandStates m_CommandStates; // The test that has started but not yet finished, if any. std::optional m_CurrentTestIndex; + // The View > Theme > Dark Theme choice: GUIDefaultDarkThemeName() or a theme file's name. + std::string m_DarkTheme; + // The dark themes View > Theme > Dark Theme lists, in its order, each item's command ID being + // FirstDarkThemeCommand plus its index here. + std::vector m_DarkThemeNames; // The detail pane's height and the test tree's width, in pixels at m_DPI. int m_DetailHeight; UINT m_DPI; @@ -244,6 +253,8 @@ class TWin32GUIMainWindow void ExportJUnitReport(); // Ends the run on an unhandled exception that escaped TTestHandler::Run(), described by 'description'. void FailRunOnUnhandledException(std::string const& description); + // Lists Carbon and the theme files in View > Theme > Dark Theme, as it opens; see the definition. + void FillDarkThemeMenu(); LRESULT HandleMessage(UINT message, WPARAM wParam, LPARAM lParam); bool IsToolbarButton(HWND control) const; void LayOut(); @@ -299,10 +310,14 @@ class TWin32GUIMainWindow // Saves the test selection to GUISelectionFilePath(). Not saving it only means the next start checks every // test. void SaveSelection(); + // Saves Carbon as a theme file to start from, where the user chooses; see the definition. + void SaveThemeTemplate(); // Shows the results page 'page' (0 Failures & Skips, 1 Log), as clicking its tab does. void SelectResultsPage(int page); // The test tree's selected node, or nullptr if none is. HTREEITEM SelectedTreeItem() const; + // Chooses View > Theme > Dark Theme's 'name', saving it, and applies it; see the definition. + void SetDarkTheme(std::string const& name); // Sets a test tree node's status image from m_TestList. void SetNodeImage(HTREEITEM item); // Sets the progress bar's position; see the definition. @@ -359,9 +374,11 @@ class TWin32GUIMainWindow // else was asked for. void Show(int showCommand); // Loads the registered tests, applies the command line's options, and restores the saved layout and selection; - // see the definition. 'theme' is the View > Theme choice the window's theme was set up for. Call after - // Create(), before Show(). - void Start(TGUIOptions const& options, EGUITheme theme); + // see the definition. 'theme' and 'darkTheme' are the View > Theme and Dark Theme choices the window's theme + // was set up for, and 'themeProblems' what TWin32GUITheme::SetDarkTheme() found wrong, noted in the log. Call + // after Create(), before Show(). + void Start(TGUIOptions const& options, EGUITheme theme, std::string const& darkTheme, + std::vector const& themeProblems); // Whether a run ended with a test that timed out or crashed. A timed-out test's abandoned thread may still be // running, using one of the test groups, and after a crash their state may be corrupted, so the caller should // then end the process, as the console runner does, without destroying this window and its tests. diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp index 4c313ba..1efe2ce 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp @@ -25,6 +25,44 @@ limitations under the License. #include "ASWUnitTests_Win32GUI_Palette.h" //--------------------------------------------------------------------------- #include +#include +#include +#include +//--------------------------------------------------------------------------- + +namespace +{ + +//--------------------------------------------------------------------------- + +COLORREF FromRGB(std::uint32_t rgb); +std::uint32_t ToRGB(COLORREF color); + +//--------------------------------------------------------------------------- +/* + FromRGB + + A theme file's color, 0xRRGGBB, as a COLORREF, 0x00BBGGRR. +*/ +COLORREF FromRGB(std::uint32_t rgb) +{ + return RGB((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF); +} +//--------------------------------------------------------------------------- +/* + ToRGB + + A COLORREF as a theme file's color, 0xRRGGBB. +*/ +std::uint32_t ToRGB(COLORREF color) +{ + return (static_cast(GetRValue(color)) << 16) | (static_cast(GetGValue(color)) << 8) | + static_cast(GetBValue(color)); +} +//--------------------------------------------------------------------------- + +} // namespace + //--------------------------------------------------------------------------- namespace ASWUnitTests @@ -130,10 +168,69 @@ TWin32GUIPalette CarbonPalette() // As the VCL GUI runner's on its dark window color. palette.FailureText = FailureTextColorOn(palette.Window); palette.SkipText = SkipTextColorOn(palette.Window); + // The Win32 GUI runner's own, which the light look has too. + palette.TreeStatusFailed = RGB(215, 40, 40); + palette.TreeStatusNotRun = RGB(190, 190, 190); + palette.TreeStatusPassed = RGB(16, 160, 16); + palette.TreeStatusRunning = RGB(0, 120, 215); + palette.TreeStatusSkipped = RGB(230, 165, 0); return palette; } //--------------------------------------------------------------------------- +/* + CarbonThemeFileText + + The roles' groups are separated by a blank line, each under its comment. +*/ +std::string CarbonThemeFileText() +{ + TWin32GUIPalette const palette = CarbonPalette(); + std::string text = "; An ASWUnitTests theme file: the colors of the Win32 GUI runner's dark look.\r\n" + ";\r\n" + "; In the Themes folder (View > Theme > Dark Theme > Open Themes Folder), it's listed in View > Theme >\r\n" + "; Dark Theme by its file name, and choosing it there uses it whenever the window is dark. Choosing it\r\n" + "; again reads it again, so a change shows at once.\r\n" + ";\r\n" + "; Each line under [Colors] sets a role's color, as #RRGGBB. These are Carbon's, the built-in dark\r\n" + "; theme's, and a role left out keeps Carbon's color, so the lines left as they are can be deleted.\r\n" + "; Problems, such as a misspelled role, are reported in the Log tab.\r\n" + "\r\n" + "[Theme]\r\n" + "Base=Dark\r\n" + "\r\n" + "[Colors]\r\n"; + bool first = true; + + for (TWin32GUIPaletteRole const& role : PaletteRoles()) + { + if (role.Group != nullptr) + { + if (!first) + text += "\r\n"; + + first = false; + std::string const group = role.Group; + std::size_t start = 0; + + while (start <= group.size()) + { + std::size_t end = group.find('\n', start); + + if (end == std::string::npos) + end = group.size(); + + text += "; " + group.substr(start, end - start) + "\r\n"; + start = end + 1; + } + } + + text += std::string(role.Name) + "=" + FormatGUIThemeColor(ToRGB(palette.*role.Color)) + "\r\n"; + } + + return text; +} +//--------------------------------------------------------------------------- COLORREF FailureTextColorOn(COLORREF background) { return IsDarkColor(background) ? RGB(255, 100, 100) : RGB(200, 0, 0); @@ -146,6 +243,154 @@ bool IsDarkColor(COLORREF color) return luma < 128; } //--------------------------------------------------------------------------- +std::vector PaletteRoleNames() +{ + std::vector names; + + for (TWin32GUIPaletteRole const& role : PaletteRoles()) + names.push_back(role.Name); + + return names; +} +//--------------------------------------------------------------------------- +/* + PaletteRoles + + Each role is named by the macro as its member, so a name can't differ from its member's. The groups follow + TWin32GUIPalette's comments. +*/ +std::vector const& PaletteRoles() +{ +#define ASWUNITTESTS_WIN32GUI_ROLE(member, group) \ + TWin32GUIPaletteRole{ &TWin32GUIPalette::member, group, #member } + + static std::vector const roles = { + ASWUNITTESTS_WIN32GUI_ROLE(ButtonBorder, + "Push buttons: the border, the default (or focused) button's line inside it, the face's gradient\n" + "(bottom and top), normal, hot (under the mouse), pressed and disabled, and the outline around them."), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonDefaultFrame, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonDisabledBorder, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonDisabledFaceBottom, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonDisabledFaceTop, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonDisabledOutline, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonFaceBottom, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonFaceTop, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonHotBorder, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonHotFaceBottom, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonHotFaceTop, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonOutline, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonPressedFaceBottom, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ButtonPressedFaceTop, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(CheckBoxBorder, + "Check boxes (the test tree's too) and radio buttons: the box's or circle's border, its inside, its\n" + "border under the mouse, and the mark (a cross, the partial state's square, a radio button's dot),\n" + "normal and disabled."), + ASWUNITTESTS_WIN32GUI_ROLE(CheckBoxFill, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(CheckBoxHotBorder, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(CheckMark, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(CheckMarkDisabled, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ControlText, + "The text of labels, buttons, radio buttons and check boxes, menus, the inactive tabs, the list's\n" + "header and the status bar."), + ASWUNITTESTS_WIN32GUI_ROLE(DisabledText, "Disabled text, but the menus' (see MenuBarDisabledText)."), + ASWUNITTESTS_WIN32GUI_ROLE(EditBorderInner, + "The two-pixel border of the edit boxes, the tree, the list and the log: inner and outer."), + ASWUNITTESTS_WIN32GUI_ROLE(EditBorderOuter, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(FailureText, "A failed test's text in the log and the Failures & Skips list."), + ASWUNITTESTS_WIN32GUI_ROLE(GripDot, "The status bar's size grip."), + ASWUNITTESTS_WIN32GUI_ROLE(HeaderBottom, + "The list's header: its gradient's bottom, the lines between columns, a column under the mouse, and\n" + "the gradient's top."), + ASWUNITTESTS_WIN32GUI_ROLE(HeaderDivider, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(HeaderHot, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(HeaderTop, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(Highlight, "The menu item under the mouse or the keyboard, and its frame."), + ASWUNITTESTS_WIN32GUI_ROLE(HighlightFrame, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(HotText, "Text over Highlight or Selection, and a hot or pressed button's."), + ASWUNITTESTS_WIN32GUI_ROLE(MenuBarDisabledText, "A disabled item's text, in the menu bar and in a menu."), + ASWUNITTESTS_WIN32GUI_ROLE(MenuDisabledText, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(MenuBackground, + "A menu's background, its two-pixel frame (inner and outer) and its separators."), + ASWUNITTESTS_WIN32GUI_ROLE(MenuBorderInner, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(MenuBorderOuter, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(MenuSeparator, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(Panel, + "The background of the window, the menu bar, the toolbar, the tab strip, the status bar, the splitters\n" + "and the dialogs."), + ASWUNITTESTS_WIN32GUI_ROLE(ProgressBorder, + "The progress bar: its border, its bar's gradient (bottom and top), normal and after a failure, and\n" + "its track."), + ASWUNITTESTS_WIN32GUI_ROLE(ProgressChunkBottom, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ProgressChunkTop, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ProgressFailedBottom, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ProgressFailedTop, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(ProgressTrack, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(Selection, "The selected node or row in the tree and the list, and its frame."), + ASWUNITTESTS_WIN32GUI_ROLE(SelectionFrame, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(SkipText, "A skipped test's text in the log and the Failures & Skips list."), + ASWUNITTESTS_WIN32GUI_ROLE(SpinArrow, + "The results tabs' scroll arrows, when the tabs don't fit: the arrow, normal and disabled, each\n" + "button's face gradient (bottom and top) and outline, disabled, normal and hot, and the edge a pressed\n" + "one shows at its top and left."), + ASWUNITTESTS_WIN32GUI_ROLE(SpinDisabledArrow, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(SpinDisabledFaceBottom, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(SpinDisabledFaceTop, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(SpinDisabledOutline, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(SpinFaceBottom, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(SpinFaceTop, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(SpinHotFaceBottom, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(SpinHotFaceTop, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(SpinOutline, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(SpinPressedEdge, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(StatusLine, "The line along the status bar's top."), + ASWUNITTESTS_WIN32GUI_ROLE(TabActiveBottom, + "The results tabs: the selected one's gradient (bottom and top), the borders, a tab under the mouse,\n" + "the other tabs and their borders, and the line around the strip and the pages."), + ASWUNITTESTS_WIN32GUI_ROLE(TabActiveTop, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(TabBorder, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(TabHot, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(TabInactive, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(TabInactiveBorder, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(TabOuter, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(TooltipBackground, "Tooltips."), + ASWUNITTESTS_WIN32GUI_ROLE(TooltipText, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(TreeLine, "The test tree's dotted lines between nodes."), + ASWUNITTESTS_WIN32GUI_ROLE(TreeStatusFailed, + "The test tree's status dots: failed, not run, passed, running and skipped. The light look keeps\n" + "Carbon's."), + ASWUNITTESTS_WIN32GUI_ROLE(TreeStatusNotRun, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(TreeStatusPassed, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(TreeStatusRunning, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(TreeStatusSkipped, nullptr), + ASWUNITTESTS_WIN32GUI_ROLE(Window, + "The background of the edit boxes, the detail pane, the tree, the list and the log, and their text."), + ASWUNITTESTS_WIN32GUI_ROLE(WindowText, nullptr), + }; + +#undef ASWUNITTESTS_WIN32GUI_ROLE + + return roles; +} +//--------------------------------------------------------------------------- +TWin32GUIPalette PaletteWithThemeColors(TWin32GUIPalette palette, std::vector const& colors) +{ + std::vector const& roles = PaletteRoles(); + + for (TGUIThemeColor const& color : colors) + { + std::vector::const_iterator const role = std::find_if(roles.begin(), roles.end(), + [&color](TWin32GUIPaletteRole const& candidate) + { + return color.Role == candidate.Name; + }); + + if (role != roles.end()) + palette.*(role->Color) = FromRGB(color.RGB); + } + + return palette; +} +//--------------------------------------------------------------------------- COLORREF SkipTextColorOn(COLORREF background) { return IsDarkColor(background) ? RGB(255, 190, 70) : RGB(190, 120, 0); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h index 1016a2c..557f0dd 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h @@ -29,6 +29,11 @@ limitations under the License. //--------------------------------------------------------------------------- #include "ASWUnitTests_Win32GUI_Windows.h" //--------------------------------------------------------------------------- +#include +#include +//--------------------------------------------------------------------------- +#include "ASWUnitTests_GUI_Theme.h" +//--------------------------------------------------------------------------- namespace ASWUnitTests { @@ -37,8 +42,11 @@ namespace ASWUnitTests // TWin32GUIPalette // // The colors the dark theme paints with, one per role, each named for what -// it colors. CarbonPalette() has Carbon's. The light theme is Windows' -// standard look, in the system colors, so it has no palette. +// it colors. CarbonPalette() has Carbon's, and a theme file sets any of +// them by name (see PaletteRoles()), so a member's name is part of that +// file's format: renaming one breaks the theme files that set it. The light +// theme is Windows' standard look, in the system colors, so it has no +// palette. ///////////////////////////////////////////////////////////////////////////// struct TWin32GUIPalette { @@ -141,11 +149,36 @@ struct TWin32GUIPalette COLORREF TooltipText; // The test tree's dotted lines between nodes. COLORREF TreeLine; + // The test tree's status dots: a test or group that failed, hasn't run, passed, is running, or was skipped. + // Light draws them in Carbon's colors. + COLORREF TreeStatusFailed; + COLORREF TreeStatusNotRun; + COLORREF TreeStatusPassed; + COLORREF TreeStatusRunning; + COLORREF TreeStatusSkipped; // The background of the edit boxes, the detail pane, the tree, the list and the log, and their text. COLORREF Window; COLORREF WindowText; }; + +///////////////////////////////////////////////////////////////////////////// +// TWin32GUIPaletteRole +// +// One of TWin32GUIPalette's colors, as a theme file sets it (see +// PaletteRoles()). +///////////////////////////////////////////////////////////////////////////// +struct TWin32GUIPaletteRole +{ + // The member it is. + COLORREF TWin32GUIPalette::* Color; + // What this role and the ones after it, up to the next one with a Group, color, one line or more, for + // CarbonThemeFileText()'s comments; nullptr for those after it. + char const* Group; + // The member's name, which the theme file uses. + char const* Name; +}; + //--------------------------------------------------------------------------- // 'from' mixed with 'to' in the proportion 'numerator' / 'denominator', each channel rounded to the nearest: @@ -155,12 +188,23 @@ COLORREF BlendColors(COLORREF from, COLORREF to, int numerator, int denominator) // The colors of RAD Studio's Carbon style, the VCL GUI runner's dark theme, read from its .vsf file and from // the VCL GUI runner as it draws under it. TWin32GUIPalette CarbonPalette(); +// A theme file (see TGUIThemeFile) setting each of PaletteRoles() to Carbon's color, in their order, with +// comments saying what the file is and what each group of roles colors, and CRLF line breaks: the template +// View > Theme > Dark Theme > Save Theme Template writes. +std::string CarbonThemeFileText(); // The color of a failed test's text on 'background': RGB(200, 0, 0), or RGB(255, 100, 100) on a dark // background (see IsDarkColor()), as the VCL GUI runner's. COLORREF FailureTextColorOn(COLORREF background); // Whether 'color' is dark, by its luma (Rec. 601 weights) being below 128, as the VCL GUI runner decides // whether a background needs lighter text colors. bool IsDarkColor(COLORREF color); +// The names of PaletteRoles(), in their order, for ReadGUIThemeFile(). +std::vector PaletteRoleNames(); +// Every member of TWin32GUIPalette, once, in the struct's order, with its name and group. +std::vector const& PaletteRoles(); +// 'palette' with each of 'colors' (a theme file's, see ReadGUIThemeFile()) in place of its role's color; one +// whose role isn't one of PaletteRoles() is left out. +TWin32GUIPalette PaletteWithThemeColors(TWin32GUIPalette palette, std::vector const& colors); // The color of a skipped test's text on 'background': RGB(190, 120, 0), or RGB(255, 190, 70) on a dark // background (see IsDarkColor()), as the VCL GUI runner's. COLORREF SkipTextColorOn(COLORREF background); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp index fa9fd97..f7677fb 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp @@ -241,6 +241,25 @@ bool FileExists(std::string const& path) return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0; } //--------------------------------------------------------------------------- +std::vector FileNamesInFolder(std::string const& folderPath) +{ + std::vector names; + WIN32_FIND_DATAW found = {}; + HANDLE const search = FindFirstFileW(UTF8ToWide(folderPath + "\\*").c_str(), &found); + + if (search == INVALID_HANDLE_VALUE) + return names; + + for (bool more = true; more; more = FindNextFileW(search, &found) != 0) + { + if ((found.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) + names.push_back(WideToUTF8(found.cFileName)); + } + + FindClose(search); + return names; +} +//--------------------------------------------------------------------------- std::string GUILayoutFilePath() { return GUISettingsFolderPath() + "\\" + WithExtension(ExeFileName(), ".ini"); @@ -263,6 +282,11 @@ std::string GUISettingsFolderPath() return WideToUTF8(appData) + "\\ASWUnitTests"; } //--------------------------------------------------------------------------- +std::string GUIThemesFolderPath() +{ + return GUISettingsFolderPath() + "\\Themes"; +} +//--------------------------------------------------------------------------- bool IsThemeSettingChange(WPARAM wParam, LPARAM lParam) { if (wParam == SPI_SETHIGHCONTRAST) @@ -273,6 +297,23 @@ bool IsThemeSettingChange(WPARAM wParam, LPARAM lParam) return area != nullptr && std::wcscmp(area, L"ImmersiveColorSet") == 0; } //--------------------------------------------------------------------------- +std::string LoadGUIDarkThemeFromLayoutFile() +{ + try + { + std::string const path = GUILayoutFilePath(); + + if (!FileExists(path)) + return GUIDefaultDarkThemeName(); + + return LoadGUIDarkTheme(TGUIINIDocument(ReadFileBytes(path))); + } + catch (...) + { + return GUIDefaultDarkThemeName(); + } +} +//--------------------------------------------------------------------------- EGUITheme LoadGUIThemeFromLayoutFile() { try @@ -295,6 +336,25 @@ HICON MainIcon() return LoadIconW(GetModuleHandleW(nullptr), L"MAINICON"); } //--------------------------------------------------------------------------- +/* + OpenFolderInExplorer + + ShellExecuteW() returns a value above 32 on success, and otherwise an error code that, as documented, isn't + always GetLastError()'s, so the error is described from GetLastError() only when it has one. +*/ +void OpenFolderInExplorer(HWND owner, std::string const& folderPath) +{ + SetLastError(ERROR_SUCCESS); + INT_PTR const result = reinterpret_cast(ShellExecuteW(owner, L"open", UTF8ToWide(folderPath).c_str(), + nullptr, nullptr, SW_SHOWNORMAL)); + + if (result <= 32) + { + DWORD const error = GetLastError(); + throw FileError("open the folder", folderPath, error != ERROR_SUCCESS ? error : ERROR_PATH_NOT_FOUND); + } +} +//--------------------------------------------------------------------------- std::vector ProcessArguments() { return CommandLineArguments(GetCommandLineW()); @@ -343,6 +403,24 @@ bool RemoveFile(std::string const& path) return DeleteFileW(UTF8ToWide(path).c_str()) != 0; } //--------------------------------------------------------------------------- +void SaveGUIDarkThemeToLayoutFile(std::string const& name) +{ + try + { + std::string const path = GUILayoutFilePath(); + CreateFolderPath(GUISettingsFolderPath()); + + // Starting from the file's current text keeps the layout and any other values in it. + TGUIINIDocument ini(FileExists(path) ? ReadFileBytes(path) : std::string()); + SaveGUIDarkTheme(ini, name); + WriteFileBytes(path, ini.Text()); + } + catch (...) + { + // Not saving it (e.g. a read-only profile) only means the next start uses the default dark theme. + } +} +//--------------------------------------------------------------------------- void SaveGUIThemeToLayoutFile(EGUITheme theme) { try diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_System.h b/win32/gui/src/ASWUnitTests_Win32GUI_System.h index 6c31829..2e03709 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_System.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_System.h @@ -57,6 +57,9 @@ std::string ExeFileName(); std::string ExpandFileName(std::string const& path); // Whether a file (not a folder) exists at the path. bool FileExists(std::string const& path); +// The names of the files (not folders) in the folder, without the folder, in the order Windows lists them; +// empty if the folder doesn't exist or can't be listed. +std::vector FileNamesInFolder(std::string const& folderPath); // This executable's layout file, %APPDATA%\ASWUnitTests\.ini, so each test app built on the GUI // keeps its own layout, shared by all of its build configurations. std::string GUILayoutFilePath(); @@ -66,15 +69,24 @@ std::string GUISelectionFilePath(); // The folder that holds the GUI runners' layout and selection files, %APPDATA%\ASWUnitTests. It may not exist // yet (see CreateFolderPath()). std::string GUISettingsFolderPath(); +// The folder that holds the theme files both GUI runners share (see TGUIThemeFile), whatever executable runs +// them: %APPDATA%\ASWUnitTests\Themes. It may not exist yet (see CreateFolderPath()). +std::string GUIThemesFolderPath(); // Whether a WM_SETTINGCHANGE message's parameters are about a setting that Use Windows Setting follows: the // app mode (Windows names its change "ImmersiveColorSet") or high contrast. bool IsThemeSettingChange(WPARAM wParam, LPARAM lParam); +// The dark theme saved in the layout file (see GUILayoutFilePath() and LoadGUIDarkTheme()); +// GUIDefaultDarkThemeName() when there's no file or it can't be read. +std::string LoadGUIDarkThemeFromLayoutFile(); // The theme saved in the layout file (see GUILayoutFilePath() and LoadGUITheme()); Windows when there's no file // or it can't be read. EGUITheme LoadGUIThemeFromLayoutFile(); // This executable's icon, its MAINICON resource (resource/ASWUnitTests_Icon.rc, or Icon_MainIcon in a RAD Studio // project), at the standard icon size; nullptr if it has none. Shared, so it's never destroyed. HICON MainIcon(); +// Opens the folder in a File Explorer window, for 'owner' (a window of this process, or nullptr). Throws +// std::runtime_error if it can't, e.g. because the folder doesn't exist. +void OpenFolderInExplorer(HWND owner, std::string const& folderPath); // This process's command-line arguments, without the program name, converted to UTF-8, for // TGUICommandLine::Parse(). std::vector ProcessArguments(); @@ -83,6 +95,10 @@ std::vector ProcessArguments(); std::string ReadFileBytes(std::string const& path); // Deletes the file. False if it couldn't be deleted, e.g. because it doesn't exist. bool RemoveFile(std::string const& path); +// Saves 'name', the dark theme's, in the layout file (see GUILayoutFilePath() and SaveGUIDarkTheme()), keeping +// the layout and any other values in it. Not saving it (e.g. a read-only profile) only means the next start uses +// the default. +void SaveGUIDarkThemeToLayoutFile(std::string const& name); // Saves 'theme' in the layout file (see GUILayoutFilePath() and SaveGUITheme()), keeping the layout and any // other values in it. Not saving it (e.g. a read-only profile) only means the next start uses the default. void SaveGUIThemeToLayoutFile(EGUITheme theme); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp index 58ec3d6..3bf3667 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -28,10 +28,16 @@ limitations under the License. #include #include #include +#include +#include +#include //--------------------------------------------------------------------------- #include //--------------------------------------------------------------------------- +#include "ASWUnitTests_GUI_Theme.h" +//--------------------------------------------------------------------------- #include "ASWUnitTests_Win32GUI_Glyphs.h" +#include "ASWUnitTests_Win32GUI_System.h" //--------------------------------------------------------------------------- // RAD Studio's Win32 (bcc32c) links its own Windows import libraries, which don't include SetWindowTheme()'s. @@ -968,11 +974,39 @@ LRESULT CALLBACK TWin32GUITheme::ControlProc(HWND control, UINT message, WPARAM } } //--------------------------------------------------------------------------- +/* + TWin32GUITheme::CreateDarkCheckImage + + Carbon's check box, 13 pixels at 96 DPI, in the middle of the tree's 16-pixel state image. +*/ +HBITMAP TWin32GUITheme::CreateDarkCheckImage(int state, UINT dpi) const +{ + int const cell = ScaleForDPI(16, dpi); + int const box = ScaleForDPI(13, dpi); + int const offset = (cell - box) / 2; + int const border = (std::max)(1, ScaleForDPI(1, dpi)); + + TGlyphCanvas canvas(cell, cell, m_Palette.Window); + + if (state > 0) + { + RECT const outside = { offset, offset, offset + box, offset + box }; + canvas.FillRect(outside, m_Palette.CheckBoxBorder); + canvas.FillRect(Inset(outside, border), m_Palette.CheckBoxFill); + + if (state == 2) + canvas.Blend(EWin32GUIGlyph::Cross, offset, offset, box, m_Palette.CheckMark); + else if (state == 3) + canvas.Blend(EWin32GUIGlyph::Square, offset, offset, box, m_Palette.CheckMark); + } + + return canvas.CreateBitmap(); +} +//--------------------------------------------------------------------------- /* TWin32GUITheme::DarkCheckImages - Carbon's check boxes, 13 pixels at 96 DPI, in the middle of the tree's 16-pixel state images, in the order - TVS_EX_PARTIALCHECKBOXES numbers them: none, unchecked, checked (a cross), partial (a square). + In the order TVS_EX_PARTIALCHECKBOXES numbers them: none, unchecked, checked (a cross), partial (a square). */ HIMAGELIST TWin32GUITheme::DarkCheckImages(UINT dpi) { @@ -983,10 +1017,6 @@ HIMAGELIST TWin32GUITheme::DarkCheckImages(UINT dpi) } int const cell = ScaleForDPI(16, dpi); - int const box = ScaleForDPI(13, dpi); - int const offset = (cell - box) / 2; - int const border = (std::max)(1, ScaleForDPI(1, dpi)); - HIMAGELIST const images = ImageList_Create(cell, cell, ILC_COLOR32, 4, 0); if (images == nullptr) @@ -994,21 +1024,7 @@ HIMAGELIST TWin32GUITheme::DarkCheckImages(UINT dpi) for (int state = 0; state < 4; ++state) { - TGlyphCanvas canvas(cell, cell, m_Palette.Window); - - if (state > 0) - { - RECT const outside = { offset, offset, offset + box, offset + box }; - canvas.FillRect(outside, m_Palette.CheckBoxBorder); - canvas.FillRect(Inset(outside, border), m_Palette.CheckBoxFill); - - if (state == 2) - canvas.Blend(EWin32GUIGlyph::Cross, offset, offset, box, m_Palette.CheckMark); - else if (state == 3) - canvas.Blend(EWin32GUIGlyph::Square, offset, offset, box, m_Palette.CheckMark); - } - - HBITMAP const bitmap = canvas.CreateBitmap(); + HBITMAP const bitmap = CreateDarkCheckImage(state, dpi); if (bitmap != nullptr) { @@ -2543,6 +2559,36 @@ void TWin32GUITheme::Refresh() RedrawWindow(window, nullptr, nullptr, RDW_ERASE | RDW_FRAME | RDW_INVALIDATE | RDW_ALLCHILDREN); } //--------------------------------------------------------------------------- +/* + TWin32GUITheme::RefreshMenu + + Only the items of 'menu' and its submenus are made again, and the old ones forgotten, so the items of the + menu bar and of the other menus keep theirs. +*/ +void TWin32GUITheme::RefreshMenu(HMENU menu) +{ + if (m_MenuBar == nullptr || menu == nullptr) + return; + + std::vector > items; + ApplyToMenu(menu, items); + + // The menus just walked: 'menu', and those of its submenus' new items. + std::vector walked = { menu }; + + for (std::unique_ptr const& item : items) + walked.push_back(item->Menu); + + m_MenuItems.erase(std::remove_if(m_MenuItems.begin(), m_MenuItems.end(), + [&walked](std::unique_ptr const& item) + { + return std::find(walked.begin(), walked.end(), item->Menu) != walked.end(); + }), m_MenuItems.end()); + + for (std::unique_ptr& item : items) + m_MenuItems.push_back(std::move(item)); +} +//--------------------------------------------------------------------------- void TWin32GUITheme::SetDark(bool dark) { if (dark == m_Dark) @@ -2552,6 +2598,87 @@ void TWin32GUITheme::SetDark(bool dark) Refresh(); } //--------------------------------------------------------------------------- +/* + TWin32GUITheme::SetDarkTheme + + A file that can't be read leaves Carbon's colors, as does one ReadGUIThemeFile() finds unusable. +*/ +std::vector TWin32GUITheme::SetDarkTheme(std::string const& name) +{ + std::vector problems; + TWin32GUIPalette palette = CarbonPalette(); + + if (name != GUIDefaultDarkThemeName()) + { + try + { + std::string const path = GUIThemesFolderPath() + "\\" + name + ".ini"; + + if (!FileExists(path)) + { + problems.push_back(GUIThemeProblemLine(name, "There's no theme file " + path + ", so " + + GUIDefaultDarkThemeName() + " is used instead.")); + } + else + { + TGUIThemeFile const file = ReadGUIThemeFile(ReadFileBytes(path), PaletteRoleNames()); + + for (std::string const& problem : file.Problems) + problems.push_back(GUIThemeProblemLine(name, problem)); + + palette = PaletteWithThemeColors(palette, file.Colors); + } + } + catch (std::exception const& exception) + { + problems.push_back(GUIThemeProblemLine(name, "Its theme file couldn't be read, so " + + GUIDefaultDarkThemeName() + " is used instead: " + exception.what())); + } + } + + SetPalette(palette); + return problems; +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::SetPalette + + The brushes are made again in the new colors, and the old ones deleted once the menus no longer use them. + The dark check boxes are drawn again into their image lists, which the trees keep. +*/ +void TWin32GUITheme::SetPalette(TWin32GUIPalette const& palette) +{ + m_Palette = palette; + + HBRUSH const oldBrushes[] = { m_MenuBrush, m_PanelBrush, m_WindowBrush }; + m_MenuBrush = CreateSolidBrush(m_Palette.MenuBackground); + m_PanelBrush = CreateSolidBrush(m_Palette.Panel); + m_WindowBrush = CreateSolidBrush(m_Palette.Window); + + for (std::pair const& entry : m_DarkCheckImages) + { + for (int state = 0; state < 4; ++state) + { + HBITMAP const bitmap = CreateDarkCheckImage(state, entry.first); + + if (bitmap != nullptr) + { + ImageList_Replace(entry.second, state, bitmap, nullptr); + DeleteObject(bitmap); + } + } + } + + if (m_Dark) + Refresh(); + + for (HBRUSH const brush : oldBrushes) + { + if (brush != nullptr) + DeleteObject(brush); + } +} +//--------------------------------------------------------------------------- COLORREF TWin32GUITheme::SkipTextColor() const { return m_Dark ? m_Palette.SkipText : SkipTextColorOn(GetSysColor(COLOR_WINDOW)); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h index db6d9ac..d3d72c1 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h @@ -78,8 +78,9 @@ enum class EWin32GUIThemedControl // TWin32GUITheme // // Whether the GUI is light (Windows' standard look, in the system colors) or -// dark (painted in CarbonPalette()'s colors), applied to the windows and -// controls attached to it, and switched live by SetDark(). Dark has: +// dark (painted in CarbonPalette()'s colors, or a theme file's; see +// SetDarkTheme()), applied to the windows and controls attached to it, and +// switched live by SetDark() and SetDarkTheme(). Dark has: // - the title bar Windows draws dark (DWMWA_USE_IMMERSIVE_DARK_MODE); // - owner-drawn menus, and the menus' frames painted through a hook on // this thread's menu windows; @@ -167,6 +168,9 @@ class TWin32GUITheme // Attaches the scroll arrows a tab control creates when its tabs don't fit, if it has created them and they // aren't attached yet. void AttachUpDown(HWND tabControl); + // One of the dark tree check boxes' images, in the palette's colors, at 'dpi'; nullptr if it can't be made. + // 'state' is its index, as DarkCheckImages() numbers them. + HBITMAP CreateDarkCheckImage(int state, UINT dpi) const; // The dark tree check boxes for 'dpi', created if needed. HIMAGELIST DarkCheckImages(UINT dpi); void DrawButton(NMCUSTOMDRAW const& draw) const; @@ -227,8 +231,18 @@ class TWin32GUITheme // Applies the current look again to everything attached, e.g. after a DPI change or a change of the system // colors. void Refresh(); + // Applies the current look to 'menu', one of the attached menu bar's menus, after its items were changed, + // e.g. in WM_INITMENUPOPUP, leaving the menu bar and the other menus, which may be open, as they are. + void RefreshMenu(HMENU menu); // Switches to the dark look or back to the light one, and redraws everything attached. void SetDark(bool dark); + // Makes 'name' the dark theme: GUIDefaultDarkThemeName(), Carbon, or a theme file in GUIThemesFolderPath(), + // ".ini", over Carbon's colors (see SetPalette()). Returns its problems as lines for the log (see + // GUIThemeProblemLine()): the file's (see ReadGUIThemeFile()), or that it's missing or can't be read, so + // Carbon is used. + std::vector SetDarkTheme(std::string const& name); + // Makes 'palette' the dark look's colors, redrawing everything attached while dark. + void SetPalette(TWin32GUIPalette const& palette); // A skipped test's text color on the window color (see WindowColor()). COLORREF SkipTextColor() const; // The background of edit boxes, lists and the log. diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp index 0f1e34b..4b23369 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp @@ -25,6 +25,7 @@ limitations under the License. //--------------------------------------------------------------------------- #include #include +#include //--------------------------------------------------------------------------- #include "ASWUnitTests_CLI.h" #include "ASWUnitTests_Utils.h" @@ -114,10 +115,15 @@ int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int showCommand) ASWUnitTests::TGUICommandLineResult const commandLine = ASWUnitTests::TGUICommandLine::Parse(ASWUnitTests::ProcessArguments(), options); - // The saved theme, before any window, so the --help dialog has it too: Use Windows Setting with - // --layout-ignore, which ignores the saved layout file, and --layout-reset, which deletes it. - ASWUnitTests::EGUITheme const themeChoice = options.LayoutIgnore || options.LayoutReset ? - ASWUnitTests::EGUITheme::Windows : ASWUnitTests::LoadGUIThemeFromLayoutFile(); + // The saved theme and dark theme, before any window, so the --help dialog has them too: Use Windows + // Setting and Carbon with --layout-ignore, which ignores the saved layout file, and --layout-reset, which + // deletes it. + bool const savedTheme = !options.LayoutIgnore && !options.LayoutReset; + ASWUnitTests::EGUITheme const themeChoice = savedTheme ? ASWUnitTests::LoadGUIThemeFromLayoutFile() : + ASWUnitTests::EGUITheme::Windows; + std::string const darkTheme = savedTheme ? ASWUnitTests::LoadGUIDarkThemeFromLayoutFile() : + ASWUnitTests::GUIDefaultDarkThemeName(); + std::vector const themeProblems = theme.SetDarkTheme(darkTheme); theme.SetDark(ASWUnitTests::GUIThemeIsDark(themeChoice, ASWUnitTests::WindowsAppsUseDarkTheme(), ASWUnitTests::WindowsHighContrast())); @@ -129,7 +135,7 @@ int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int showCommand) ASWUnitTests::TWin32GUIMainWindow mainWindow(theme); mainWindow.Create(); - mainWindow.Start(options, themeChoice); + mainWindow.Start(options, themeChoice, darkTheme, themeProblems); mainWindow.Show(showCommand); mainWindow.RunMessageLoop(); diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.cpp b/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.cpp index 1ba9ef1..a645820 100644 --- a/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.cpp +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.cpp @@ -26,8 +26,14 @@ limitations under the License. // Module header #include "Test_ASWUnitTests_Win32GUI_Palette.h" //--------------------------------------------------------------------------- +#include +#include +#include +#include +//--------------------------------------------------------------------------- #include "ASWUnitTests_Registry.h" //--------------------------------------------------------------------------- +#include "ASWUnitTests_GUI_Theme.h" #include "ASWUnitTests_Win32GUI_Palette.h" //--------------------------------------------------------------------------- @@ -46,9 +52,15 @@ TTest_ASWUnitTests_Win32GUI_Palette::TTest_ASWUnitTests_Win32GUI_Palette() "BlendColors_MixesEachChannelInProportion"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_Palette::Test_CarbonPalette_HasCarbonsColors, "CarbonPalette_HasCarbonsColors"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Palette::Test_CarbonThemeFileText_ReadsBackAsCarbon, + "CarbonThemeFileText_ReadsBackAsCarbon"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_Palette::Test_FailureAndSkipTextColorOn_AreLighterOnADarkBackground, "FailureAndSkipTextColorOn_AreLighterOnADarkBackground"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_Palette::Test_IsDarkColor_IsByTheLuma, "IsDarkColor_IsByTheLuma"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Palette::Test_PaletteRoles_ListEveryMemberOnce, + "PaletteRoles_ListEveryMemberOnce"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Palette::Test_PaletteWithThemeColors_SetsOnlyTheirRoles, + "PaletteWithThemeColors_SetsOnlyTheirRoles"); } //--------------------------------------------------------------------------- TTest_ASWUnitTests_Win32GUI_Palette::~TTest_ASWUnitTests_Win32GUI_Palette() @@ -109,6 +121,65 @@ void TTest_ASWUnitTests_Win32GUI_Palette::Test_CarbonPalette_HasCarbonsColors() CheckEquals(RGB(255, 190, 70), palette.SkipText, __func__, __LINE__, "a skip on the dark window"); CheckTrue(IsDarkColor(palette.Window) && IsDarkColor(palette.Panel) && !IsDarkColor(palette.WindowText) && !IsDarkColor(palette.ControlText), __func__, __LINE__, "dark backgrounds, light text"); + CheckEquals(RGB(215, 40, 40), palette.TreeStatusFailed, __func__, __LINE__, "the tree's failed dot, as light's"); + CheckEquals(RGB(16, 160, 16), palette.TreeStatusPassed, __func__, __LINE__, "the tree's passed dot, as light's"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Palette::Test_CarbonThemeFileText_ReadsBackAsCarbon() +{ + // Act + std::string const text = CarbonThemeFileText(); + TGUIThemeFile const file = ReadGUIThemeFile(text, PaletteRoleNames()); + TWin32GUIPalette const blank = {}; + TWin32GUIPalette const readBack = PaletteWithThemeColors(blank, file.Colors); + + // Assert + CheckTrue(file.Usable, __func__, __LINE__, "a usable theme file"); + CheckEquals(std::string(), std::string(file.Problems.empty() ? "" : file.Problems.front()), __func__, __LINE__, + "with no problems"); + CheckEquals(PaletteRoles().size(), file.Colors.size(), __func__, __LINE__, "setting every role"); + + TWin32GUIPalette const carbon = CarbonPalette(); + std::string differences; + + for (TWin32GUIPaletteRole const& role : PaletteRoles()) + { + if (readBack.*role.Color != carbon.*role.Color) + differences += std::string(differences.empty() ? "" : ", ") + role.Name; + } + + CheckEmpty(differences, __func__, __LINE__, "to Carbon's colors"); + + CheckTrue(text.compare(0, 30, "; An ASWUnitTests theme file: ") == 0, __func__, __LINE__, + "starting with what it is"); + CheckTrue(text.find("\r\n[Theme]\r\nBase=Dark\r\n\r\n[Colors]\r\n; Push buttons: ") != std::string::npos, + __func__, __LINE__, "Base=Dark, then the colors, under their groups' comments"); + CheckTrue(text.find("\r\n\r\n; Tooltips.\r\nTooltipBackground=#515151\r\nTooltipText=#EAEAEA\r\n\r\n") != + std::string::npos, __func__, __LINE__, "a group, with a blank line on each side"); + + bool lineBreaksAreCRLF = true; + bool ascii = true; + std::size_t longest = 0; + std::size_t lineStart = 0; + + for (std::size_t i = 0; i < text.size(); ++i) + { + if (static_cast(text[i]) > 0x7F) + ascii = false; + + if (text[i] == '\n' && (i == 0 || text[i - 1] != '\r')) + lineBreaksAreCRLF = false; + + if (text[i] == '\n') + { + longest = (std::max)(longest, i - lineStart - 1); + lineStart = i + 1; + } + } + + CheckTrue(lineBreaksAreCRLF, __func__, __LINE__, "CRLF line breaks"); + CheckTrue(ascii, __func__, __LINE__, "plain ASCII"); + CheckTrue(longest <= 110, __func__, __LINE__, "no line longer than 110: " + std::to_string(longest)); } //--------------------------------------------------------------------------- void TTest_ASWUnitTests_Win32GUI_Palette::Test_FailureAndSkipTextColorOn_AreLighterOnADarkBackground() @@ -135,6 +206,90 @@ void TTest_ASWUnitTests_Win32GUI_Palette::Test_IsDarkColor_IsByTheLuma() CheckFalse(IsDarkColor(RGB(255, 255, 0)), __func__, __LINE__, "yellow, luma 225"); } //--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Palette::Test_PaletteRoles_ListEveryMemberOnce() +{ + // Arrange + std::vector const& roles = PaletteRoles(); + + // Act: each role's member set to its own number, so two roles of one member would leave the first's wrong. + TWin32GUIPalette palette = {}; + + for (std::size_t i = 0; i < roles.size(); ++i) + palette.*roles[i].Color = static_cast(i + 1); + + // Assert + CheckEquals(sizeof(TWin32GUIPalette) / sizeof(COLORREF), roles.size(), __func__, __LINE__, + "as many roles as the palette has members"); + + std::string wrong; + + for (std::size_t i = 0; i < roles.size(); ++i) + { + if (palette.*roles[i].Color != static_cast(i + 1)) + wrong += std::string(wrong.empty() ? "" : ", ") + roles[i].Name; + } + + CheckEmpty(wrong, __func__, __LINE__, "each a member of its own"); + + std::vector const names = PaletteRoleNames(); + std::string repeated; + + for (std::size_t i = 0; i < names.size(); ++i) + { + for (std::size_t j = i + 1; j < names.size(); ++j) + { + // Theme files match them ignoring case. + bool const same = names[i].size() == names[j].size() && std::equal(names[i].begin(), names[i].end(), + names[j].begin(), [](char a, char b) + { + return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); + }); + + if (same) + repeated += names[j] + " "; + } + } + + CheckEmpty(repeated, __func__, __LINE__, "names that differ, ignoring case"); + CheckEquals(roles.size(), names.size(), __func__, __LINE__, "PaletteRoleNames(), one per role"); + CheckTrue(!roles.empty() && roles.front().Group != nullptr, __func__, __LINE__, "the first role starts a group"); + CheckEquals(std::string("ButtonBorder"), names.front(), __func__, __LINE__, "named as its member"); + CheckEquals(std::string("WindowText"), names.back(), __func__, __LINE__, "in the struct's order"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Palette::Test_PaletteWithThemeColors_SetsOnlyTheirRoles() +{ + // Arrange + TWin32GUIPalette const carbon = CarbonPalette(); + std::vector colors(3); + colors[0].Role = "Window"; + colors[0].RGB = 0x102030; + colors[1].Role = "NotARole"; + colors[1].RGB = 0xFFFFFF; + colors[2].Role = "TreeStatusPassed"; + colors[2].RGB = 0xA0B0C0; + + // Act + TWin32GUIPalette const palette = PaletteWithThemeColors(carbon, colors); + + // Assert + CheckEquals(RGB(0x10, 0x20, 0x30), palette.Window, __func__, __LINE__, "#RRGGBB as a COLORREF"); + CheckEquals(RGB(0xA0, 0xB0, 0xC0), palette.TreeStatusPassed, __func__, __LINE__, "a status dot"); + + std::string changed; + + for (TWin32GUIPaletteRole const& role : PaletteRoles()) + { + if (std::string(role.Name) != "Window" && std::string(role.Name) != "TreeStatusPassed" && + palette.*role.Color != carbon.*role.Color) + { + changed += std::string(changed.empty() ? "" : ", ") + role.Name; + } + } + + CheckEmpty(changed, __func__, __LINE__, "the others keep their colors, and an unknown role is left out"); +} +//--------------------------------------------------------------------------- } // namespace ASWUnitTests diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.h b/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.h index b194e15..779169f 100644 --- a/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.h +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.h @@ -43,8 +43,11 @@ class TTest_ASWUnitTests_Win32GUI_Palette : public TTestGroupBase private: // Test methods void Test_BlendColors_MixesEachChannelInProportion(); void Test_CarbonPalette_HasCarbonsColors(); + void Test_CarbonThemeFileText_ReadsBackAsCarbon(); void Test_FailureAndSkipTextColorOn_AreLighterOnADarkBackground(); void Test_IsDarkColor_IsByTheLuma(); + void Test_PaletteRoles_ListEveryMemberOnce(); + void Test_PaletteWithThemeColors_SetsOnlyTheirRoles(); public: TTest_ASWUnitTests_Win32GUI_Palette(); diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp b/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp index e0554a1..ccad8e0 100644 --- a/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp @@ -26,6 +26,7 @@ limitations under the License. // Module header #include "Test_ASWUnitTests_Win32GUI_System.h" //--------------------------------------------------------------------------- +#include #include #include #include @@ -116,10 +117,14 @@ TTest_ASWUnitTests_Win32GUI_System::TTest_ASWUnitTests_Win32GUI_System() RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_ExpandFileName_ResolvesDotFolders, "ExpandFileName_ResolvesDotFolders"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_FileExists_IsTrueOnlyForAFile, "FileExists_IsTrueOnlyForAFile"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_FileNamesInFolder_ListsOnlyTheFiles, + "FileNamesInFolder_ListsOnlyTheFiles"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_GUILayoutFilePath_IsPerExeUnderAppData, "GUILayoutFilePath_IsPerExeUnderAppData"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_GUISelectionFilePath_IsBesideTheLayoutFile, "GUISelectionFilePath_IsBesideTheLayoutFile"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_GUIThemesFolderPath_IsSharedBesideTheLayoutFiles, + "GUIThemesFolderPath_IsSharedBesideTheLayoutFiles"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_IsThemeSettingChange_IsTrueForTheAppModeAndHighContrast, "IsThemeSettingChange_IsTrueForTheAppModeAndHighContrast"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_LoadGUIThemeFromLayoutFile_ReadsWhatSaveWrote, @@ -289,6 +294,36 @@ void TTest_ASWUnitTests_Win32GUI_System::Test_FileExists_IsTrueOnlyForAFile() CheckFalse(FileExists(m_ScratchFolder), __func__, __LINE__, "a folder isn't a file"); } //--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_System::Test_FileNamesInFolder_ListsOnlyTheFiles() +{ + // Arrange: two files, one with a non-ASCII name (U+4E2D), and a folder named like a theme file. + WriteFileBytes(ScratchPath("Ocean.ini"), "x"); + WriteFileBytes(ScratchPath("\xE4\xB8\xAD.ini"), std::string()); + CreateFolderPath(ScratchPath("Folder.ini")); + WriteFileBytes(ScratchPath("Folder.ini\\Inside.ini"), "x"); + + // Act + std::vector names = FileNamesInFolder(m_ScratchFolder); + std::vector const missing = FileNamesInFolder(ScratchPath("Missing")); + + // Assert: both sorted the same way, as where a non-ASCII byte sorts depends on whether char is signed. + std::vector expected = { "Ocean.ini", "\xE4\xB8\xAD.ini" }; + std::sort(expected.begin(), expected.end()); + std::sort(names.begin(), names.end()); + std::string expectedText; + std::string listed; + + for (std::string const& name : expected) + expectedText += name + "\n"; + + for (std::string const& name : names) + listed += name + "\n"; + + CheckEquals(expectedText, listed, __func__, __LINE__, + "the files' names, in UTF-8, without the folder, its subfolder or the files in it"); + CheckTrue(missing.empty(), __func__, __LINE__, "none for a missing folder"); +} +//--------------------------------------------------------------------------- void TTest_ASWUnitTests_Win32GUI_System::Test_GUILayoutFilePath_IsPerExeUnderAppData() { // Arrange @@ -321,6 +356,13 @@ void TTest_ASWUnitTests_Win32GUI_System::Test_GUISelectionFilePath_IsBesideTheLa "the layout file's path with a .selection extension"); } //--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_System::Test_GUIThemesFolderPath_IsSharedBesideTheLayoutFiles() +{ + // Act and Assert: the same for every executable, unlike the layout file. + CheckEquals(GUISettingsFolderPath() + "\\Themes", GUIThemesFolderPath(), __func__, __LINE__, + "a Themes folder in the settings folder"); +} +//--------------------------------------------------------------------------- void TTest_ASWUnitTests_Win32GUI_System::Test_IsThemeSettingChange_IsTrueForTheAppModeAndHighContrast() { // Arrange: WM_SETTINGCHANGE's parameters, as Windows sends them. @@ -372,6 +414,7 @@ void TTest_ASWUnitTests_Win32GUI_System::Test_LoadGUIThemeFromLayoutFile_ReadsWh RemoveFile(path); EGUITheme const noFile = LoadGUIThemeFromLayoutFile(); + std::string const noFileDarkTheme = LoadGUIDarkThemeFromLayoutFile(); // Act SaveGUIThemeToLayoutFile(EGUITheme::Dark); @@ -380,14 +423,24 @@ void TTest_ASWUnitTests_Win32GUI_System::Test_LoadGUIThemeFromLayoutFile_ReadsWh WriteFileBytes(path, "[Window]\r\nMaximized=1\r\n"); SaveGUIThemeToLayoutFile(EGUITheme::Light); EGUITheme const light = LoadGUIThemeFromLayoutFile(); + SaveGUIDarkThemeToLayoutFile("Deep \xC3\x89" "cume"); + std::string const darkTheme = LoadGUIDarkThemeFromLayoutFile(); std::string const text = ReadFileBytes(path); + RemoveFile(path); + SaveGUIDarkThemeToLayoutFile("Ocean"); + std::string const darkThemeInNewFile = LoadGUIDarkThemeFromLayoutFile(); + // Assert CheckTrue(noFile == EGUITheme::Windows, __func__, __LINE__, "Windows without a layout file"); + CheckEquals(std::string("Carbon"), noFileDarkTheme, __func__, __LINE__, "and the dark theme Carbon"); CheckTrue(dark == EGUITheme::Dark, __func__, __LINE__, "the theme saved in a new file"); CheckTrue(light == EGUITheme::Light, __func__, __LINE__, "the theme saved in an existing file"); - CheckEquals(std::string("[Window]\r\nMaximized=1\r\n\r\n[View]\r\nTheme=Light\r\n\r\n"), text, __func__, - __LINE__, "the layout stays"); + CheckEquals(std::string("Deep \xC3\x89" "cume"), darkTheme, __func__, __LINE__, + "the dark theme saved beside it, in UTF-8"); + CheckEquals(std::string("[Window]\r\nMaximized=1\r\n\r\n[View]\r\nTheme=Light\r\nDarkTheme=Deep \xC3\x89" "cume\r\n" + "\r\n"), text, __func__, __LINE__, "the layout stays"); + CheckEquals(std::string("Ocean"), darkThemeInNewFile, __func__, __LINE__, "the dark theme saved in a new file"); } //--------------------------------------------------------------------------- void TTest_ASWUnitTests_Win32GUI_System::Test_MainIcon_IsThisExecutablesSharedIcon() diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_System.h b/win32/tests/Test_ASWUnitTests_Win32GUI_System.h index e0aaa97..0c83274 100644 --- a/win32/tests/Test_ASWUnitTests_Win32GUI_System.h +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_System.h @@ -59,8 +59,10 @@ class TTest_ASWUnitTests_Win32GUI_System : public TTestGroupBase void Test_ExpandFileName_MakesARelativePathAbsolute(); void Test_ExpandFileName_ResolvesDotFolders(); void Test_FileExists_IsTrueOnlyForAFile(); + void Test_FileNamesInFolder_ListsOnlyTheFiles(); void Test_GUILayoutFilePath_IsPerExeUnderAppData(); void Test_GUISelectionFilePath_IsBesideTheLayoutFile(); + void Test_GUIThemesFolderPath_IsSharedBesideTheLayoutFiles(); void Test_IsThemeSettingChange_IsTrueForTheAppModeAndHighContrast(); void Test_LoadGUIThemeFromLayoutFile_ReadsWhatSaveWrote(); void Test_MainIcon_IsThisExecutablesSharedIcon(); From b8c7730c2a4ea88b26b3f2a458a38bde04450dbe Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 20:41:55 -0700 Subject: [PATCH 2/3] Keep GUI tests out of the real layout and Themes files, and widen Win32 status message --- gui/src/ASWUnitTests_GUI_View.h | 2 +- vcl/gui/rad370/ASWUnitTests_VCL_GUI.cpp | 8 +- vcl/gui/src/ASWUnitTests_GUI_MainForm.cpp | 6 +- vcl/gui/src/ASWUnitTests_GUI_VCLSystem.cpp | 38 ++++---- vcl/gui/src/ASWUnitTests_GUI_VCLSystem.h | 35 +++---- vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.cpp | 91 +++++++++---------- .../src/ASWUnitTests_Win32GUI_MainWindow.cpp | 10 +- .../gui/src/ASWUnitTests_Win32GUI_System.cpp | 54 +++++++---- win32/gui/src/ASWUnitTests_Win32GUI_System.h | 25 ++--- .../gui/src/ASWUnitTests_Win32GUI_WinMain.cpp | 5 +- .../Test_ASWUnitTests_Win32GUI_System.cpp | 53 +++-------- 11 files changed, 160 insertions(+), 167 deletions(-) diff --git a/gui/src/ASWUnitTests_GUI_View.h b/gui/src/ASWUnitTests_GUI_View.h index fcc90e9..dc0595c 100644 --- a/gui/src/ASWUnitTests_GUI_View.h +++ b/gui/src/ASWUnitTests_GUI_View.h @@ -109,7 +109,7 @@ std::string GUIMenuItemText(std::string const& text); std::vector GUIStatusPanels(TGUITestList const& testList, size_t runFinishedCount, size_t runTotalCount, std::optional elapsedSeconds, std::string const& shuffleText, std::string const& message); // The status bar's panel widths at 96 DPI, in order, one per panel GUIStatusPanels() fills, as the VCL GUI -// runner's form has them. +// runner's form has them. The last panel, the status message's, takes the rest of the bar in both runners. std::vector GUIStatusPanelWidths(); // The result a test is given when an unhandled exception, described by 'description', ended the run while it // was running. diff --git a/vcl/gui/rad370/ASWUnitTests_VCL_GUI.cpp b/vcl/gui/rad370/ASWUnitTests_VCL_GUI.cpp index ebe806b..880d0e9 100644 --- a/vcl/gui/rad370/ASWUnitTests_VCL_GUI.cpp +++ b/vcl/gui/rad370/ASWUnitTests_VCL_GUI.cpp @@ -58,12 +58,14 @@ int WINAPI _tWinMain(HINSTANCE, HINSTANCE, LPTSTR, int) // restyled. --layout-reset's deletion of the layout file, which also holds the theme and the dark theme, // comes later. bool const savedTheme = !options.LayoutIgnore && !options.LayoutReset; - ASWUnitTests::EGUITheme const theme = savedTheme ? ASWUnitTests::LoadGUIThemeFromLayoutFile() : + System::UnicodeString const layoutPath = ASWUnitTests::GUILayoutFilePath(); + ASWUnitTests::EGUITheme const theme = savedTheme ? ASWUnitTests::LoadGUIThemeFromLayoutFile(layoutPath) : ASWUnitTests::EGUITheme::Windows; - std::string const darkTheme = savedTheme ? ASWUnitTests::LoadGUIDarkThemeFromLayoutFile() : + std::string const darkTheme = savedTheme ? ASWUnitTests::LoadGUIDarkThemeFromLayoutFile(layoutPath) : ASWUnitTests::GUIDefaultDarkThemeName(); std::vector themeProblems; - System::UnicodeString const darkStyle = ASWUnitTests::LoadGUIDarkStyle(darkTheme, themeProblems); + System::UnicodeString const darkStyle = ASWUnitTests::LoadGUIDarkStyle(ASWUnitTests::GUIThemesFolderPath(), + darkTheme, themeProblems); ASWUnitTests::ApplyGUITheme(theme, darkStyle); if (commandLine.EarlyExitCode.has_value()) diff --git a/vcl/gui/src/ASWUnitTests_GUI_MainForm.cpp b/vcl/gui/src/ASWUnitTests_GUI_MainForm.cpp index cc99a31..14588aa 100644 --- a/vcl/gui/src/ASWUnitTests_GUI_MainForm.cpp +++ b/vcl/gui/src/ASWUnitTests_GUI_MainForm.cpp @@ -1472,10 +1472,10 @@ void TFormASWUnitTestsGUIMain::SetDarkTheme(std::string const& name) m_DarkTheme = name; if (!m_Options.LayoutIgnore) - SaveGUIDarkThemeToLayoutFile(name); + SaveGUIDarkThemeToLayoutFile(GUILayoutFilePath(), name); std::vector problems; - m_DarkStyle = LoadGUIDarkStyle(name, problems); + m_DarkStyle = LoadGUIDarkStyle(GUIThemesFolderPath(), name, problems); ApplyTheme(); if (GUIThemeIsDark(m_Theme, WindowsAppsUseDarkTheme(), WindowsHighContrast())) @@ -1535,7 +1535,7 @@ void TFormASWUnitTestsGUIMain::SetTheme(EGUITheme theme) ShowThemeState(); if (!m_Options.LayoutIgnore) - SaveGUIThemeToLayoutFile(theme); + SaveGUIThemeToLayoutFile(GUILayoutFilePath(), theme); ApplyTheme(); } diff --git a/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.cpp b/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.cpp index 5b4a62f..62360cf 100644 --- a/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.cpp +++ b/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.cpp @@ -114,7 +114,8 @@ System::UnicodeString GUIThemesFolderPath() A file whose style has the name of one already registered, e.g. Carbon's own file renamed, can't be loaded either. Every error gives Carbon, with a line in 'problems'. */ -System::UnicodeString LoadGUIDarkStyle(std::string const& darkTheme, std::vector& problems) +System::UnicodeString LoadGUIDarkStyle(System::UnicodeString const& themesFolder, std::string const& darkTheme, + std::vector& problems) { // Each file loaded so far, by its path in lowercase, and the name of its style. static std::vector > loaded; @@ -128,8 +129,7 @@ System::UnicodeString LoadGUIDarkStyle(std::string const& darkTheme, std::vector try { - System::UnicodeString const path = System::Ioutils::TPath::Combine(GUIThemesFolderPath(), - FromUTF8(darkTheme) + ".vsf"); + System::UnicodeString const path = System::Ioutils::TPath::Combine(themesFolder, FromUTF8(darkTheme) + ".vsf"); System::UnicodeString const key = System::Sysutils::AnsiLowerCase(path); for (std::pair const& entry : loaded) @@ -181,15 +181,14 @@ System::UnicodeString LoadGUIDarkStyle(std::string const& darkTheme, std::vector return carbon; } //--------------------------------------------------------------------------- -std::string LoadGUIDarkThemeFromLayoutFile() +std::string LoadGUIDarkThemeFromLayoutFile(System::UnicodeString const& layoutPath) { try { - System::UnicodeString const path = GUILayoutFilePath(); - if (!System::Sysutils::FileExists(path)) + if (!System::Sysutils::FileExists(layoutPath)) return GUIDefaultDarkThemeName(); - return LoadGUIDarkTheme(TGUIINIDocument(ReadFileBytes(path))); + return LoadGUIDarkTheme(TGUIINIDocument(ReadFileBytes(layoutPath))); } catch (...) { @@ -197,15 +196,14 @@ std::string LoadGUIDarkThemeFromLayoutFile() } } //--------------------------------------------------------------------------- -EGUITheme LoadGUIThemeFromLayoutFile() +EGUITheme LoadGUIThemeFromLayoutFile(System::UnicodeString const& layoutPath) { try { - System::UnicodeString const path = GUILayoutFilePath(); - if (!System::Sysutils::FileExists(path)) + if (!System::Sysutils::FileExists(layoutPath)) return EGUITheme::Windows; - return LoadGUITheme(TGUIINIDocument(ReadFileBytes(path))); + return LoadGUITheme(TGUIINIDocument(ReadFileBytes(layoutPath))); } catch (...) { @@ -246,17 +244,16 @@ std::string ReadFileBytes(System::UnicodeString const& path) return std::string(reinterpret_cast(&bytes[0]), static_cast(bytes.Length)); } //--------------------------------------------------------------------------- -void SaveGUIDarkThemeToLayoutFile(std::string const& name) +void SaveGUIDarkThemeToLayoutFile(System::UnicodeString const& layoutPath, std::string const& name) { try { - System::UnicodeString const path = GUILayoutFilePath(); - System::Sysutils::ForceDirectories(System::Sysutils::ExtractFileDir(path)); + System::Sysutils::ForceDirectories(System::Sysutils::ExtractFileDir(layoutPath)); // Starting from the file's current text keeps the layout and any other values in it. - TGUIINIDocument ini(System::Sysutils::FileExists(path) ? ReadFileBytes(path) : std::string()); + TGUIINIDocument ini(System::Sysutils::FileExists(layoutPath) ? ReadFileBytes(layoutPath) : std::string()); SaveGUIDarkTheme(ini, name); - WriteFileBytes(path, ini.Text()); + WriteFileBytes(layoutPath, ini.Text()); } catch (...) { @@ -264,17 +261,16 @@ void SaveGUIDarkThemeToLayoutFile(std::string const& name) } } //--------------------------------------------------------------------------- -void SaveGUIThemeToLayoutFile(EGUITheme theme) +void SaveGUIThemeToLayoutFile(System::UnicodeString const& layoutPath, EGUITheme theme) { try { - System::UnicodeString const path = GUILayoutFilePath(); - System::Sysutils::ForceDirectories(System::Sysutils::ExtractFileDir(path)); + System::Sysutils::ForceDirectories(System::Sysutils::ExtractFileDir(layoutPath)); // Starting from the file's current text keeps the layout and any other values in it. - TGUIINIDocument ini(System::Sysutils::FileExists(path) ? ReadFileBytes(path) : std::string()); + TGUIINIDocument ini(System::Sysutils::FileExists(layoutPath) ? ReadFileBytes(layoutPath) : std::string()); SaveGUITheme(ini, theme); - WriteFileBytes(path, ini.Text()); + WriteFileBytes(layoutPath, ini.Text()); } catch (...) { diff --git a/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.h b/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.h index 1729728..8251f39 100644 --- a/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.h +++ b/vcl/gui/src/ASWUnitTests_GUI_VCLSystem.h @@ -53,16 +53,18 @@ System::UnicodeString GUISelectionFilePath(); // %APPDATA%\ASWUnitTests\Themes. The VCL GUI runner's are VCL style files, ".vsf". It may not exist yet. System::UnicodeString GUIThemesFolderPath(); // The VCL style the dark theme 'darkTheme' is, for ApplyGUITheme(): Carbon, which the GUI project links in, for -// GUIDefaultDarkThemeName(); otherwise the style in GUIThemesFolderPath()'s ".vsf", loaded the first -// time it's needed. Adds a line for the log to 'problems' (see GUIThemeProblemLine()), and gives Carbon, when -// the file is missing, isn't a VCL style, or has the name of a style already loaded from elsewhere. -System::UnicodeString LoadGUIDarkStyle(std::string const& darkTheme, std::vector& problems); -// The dark theme SaveGUIDarkThemeToLayoutFile() saved in the layout file (see GUILayoutFilePath()), or -// GUIDefaultDarkThemeName() if there's none or it can't be read. -std::string LoadGUIDarkThemeFromLayoutFile(); -// The theme SaveGUIThemeToLayoutFile() saved in the layout file (see GUILayoutFilePath()), or Windows if there's -// none or it can't be read. -EGUITheme LoadGUIThemeFromLayoutFile(); +// GUIDefaultDarkThemeName(); otherwise the style in ".vsf" in 'themesFolder', e.g. +// GUIThemesFolderPath(), loaded the first time it's needed. Adds a line for the log to 'problems' (see +// GUIThemeProblemLine()), and gives Carbon, when the file is missing, isn't a VCL style, or has the name of a +// style already loaded from elsewhere. +System::UnicodeString LoadGUIDarkStyle(System::UnicodeString const& themesFolder, std::string const& darkTheme, + std::vector& problems); +// The dark theme SaveGUIDarkThemeToLayoutFile() saved in the layout file at 'layoutPath', e.g. +// GUILayoutFilePath(), or GUIDefaultDarkThemeName() if there's none or it can't be read. +std::string LoadGUIDarkThemeFromLayoutFile(System::UnicodeString const& layoutPath); +// The theme SaveGUIThemeToLayoutFile() saved in the layout file at 'layoutPath', e.g. GUILayoutFilePath(), or +// Windows if there's none or it can't be read. +EGUITheme LoadGUIThemeFromLayoutFile(System::UnicodeString const& layoutPath); // Opens the folder in a File Explorer window. Throws an RTL exception if it can't, e.g. because the folder // doesn't exist. void OpenFolderInExplorer(System::UnicodeString const& folderPath); @@ -72,12 +74,13 @@ std::vector ProcessArguments(); // The file's bytes, unchanged, e.g. UTF-8 text for TGUIINIDocument. Throws an RTL exception if it can't be // read. std::string ReadFileBytes(System::UnicodeString const& path); -// Saves 'name', the dark theme's, in the layout file (see GUILayoutFilePath()), keeping its other values, and -// creating it if needed. Ignores any error, which only means the next start uses the default dark theme. -void SaveGUIDarkThemeToLayoutFile(std::string const& name); -// Saves 'theme' in the layout file (see GUILayoutFilePath()), keeping its other values, and creating it if needed. -// Ignores any error, which only means the next start uses the default theme. -void SaveGUIThemeToLayoutFile(EGUITheme theme); +// Saves 'name', the dark theme's, in the layout file at 'layoutPath', e.g. GUILayoutFilePath(), keeping its other +// values, and creating it and its folder if needed. Ignores any error, which only means the next start uses the +// default dark theme. +void SaveGUIDarkThemeToLayoutFile(System::UnicodeString const& layoutPath, std::string const& name); +// Saves 'theme' in the layout file at 'layoutPath', e.g. GUILayoutFilePath(), keeping its other values, and +// creating it and its folder if needed. Ignores any error, which only means the next start uses the default theme. +void SaveGUIThemeToLayoutFile(System::UnicodeString const& layoutPath, EGUITheme theme); // Whether Windows' app mode setting is dark; false if it can't be read, as on a Windows version without it. bool WindowsAppsUseDarkTheme(); // Whether Windows' high contrast is on. diff --git a/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.cpp b/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.cpp index 0fc6d83..7c6d49b 100644 --- a/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.cpp +++ b/vcl/tests/Test_ASWUnitTests_GUI_VCLSystem.cpp @@ -187,39 +187,41 @@ void TTest_ASWUnitTests_GUI_VCLSystem::Test_GUIThemesFolderPath_IsSharedBesideTh //--------------------------------------------------------------------------- void TTest_ASWUnitTests_GUI_VCLSystem::Test_LoadGUIDarkStyle_GivesCarbonForAnyProblem() { - // Arrange: a missing theme, and a file that isn't a style, in the real Themes folder, which is put back as it - // was however the test ends. + // Arrange: a missing theme, and a file that isn't a style, in a Themes folder of the test's own, not the real + // one, which a GUI running these tests lists. Each name is the process's, as a style file, once loaded, isn't + // read again. The folder is deleted however the test ends. std::string const suffix = std::to_string(GetCurrentProcessId()); - std::string const missingName = "ASWUnitTests_NoSuchTheme_" + suffix; - std::string const invalidName = "ASWUnitTests_NotAStyle_" + suffix; - System::UnicodeString const folder = GUIThemesFolderPath(); - System::UnicodeString const invalidPath = System::Ioutils::TPath::Combine(folder, FromUTF8(invalidName) + ".vsf"); + std::string const missingName = "NoSuchTheme_" + suffix; + std::string const invalidName = "NotAStyle_" + suffix; + System::UnicodeString const folder = System::Ioutils::TPath::Combine(System::Ioutils::TPath::GetTempPath(), + System::UnicodeString("ASWUnitTests_VCLSystem_Themes_") + FromUTF8(suffix)); - struct TRestoreFolder + struct TDeleteFolder { System::UnicodeString const Folder; - System::UnicodeString const File; - bool const Existed; - ~TRestoreFolder() + ~TDeleteFolder() { - System::Sysutils::DeleteFile(File); - - if (!Existed) - System::Sysutils::RemoveDir(Folder); + try + { + System::Ioutils::TDirectory::Delete(Folder, true); + } + catch (...) + { + } } - } const restoreFolder{ folder, invalidPath, System::Sysutils::DirectoryExists(folder) }; + } const deleteFolder{ folder }; System::Sysutils::ForceDirectories(folder); - WriteFileBytes(invalidPath, "not a VCL style"); + WriteFileBytes(System::Ioutils::TPath::Combine(folder, FromUTF8(invalidName) + ".vsf"), "not a VCL style"); // Act std::vector carbonProblems; - System::UnicodeString const carbon = LoadGUIDarkStyle("Carbon", carbonProblems); + System::UnicodeString const carbon = LoadGUIDarkStyle(folder, "Carbon", carbonProblems); std::vector missingProblems; - System::UnicodeString const missing = LoadGUIDarkStyle(missingName, missingProblems); + System::UnicodeString const missing = LoadGUIDarkStyle(folder, missingName, missingProblems); std::vector invalidProblems; - System::UnicodeString const invalid = LoadGUIDarkStyle(invalidName, invalidProblems); + System::UnicodeString const invalid = LoadGUIDarkStyle(folder, invalidName, invalidProblems); // Assert CheckEquals(std::string("Carbon"), ToUTF8(carbon), __func__, __LINE__, "the built-in theme's style"); @@ -237,57 +239,52 @@ void TTest_ASWUnitTests_GUI_VCLSystem::Test_LoadGUIDarkStyle_GivesCarbonForAnyPr //--------------------------------------------------------------------------- void TTest_ASWUnitTests_GUI_VCLSystem::Test_LoadGUIThemeFromLayoutFile_ReadsWhatSaveWrote() { - // Arrange: this runner's own layout file, which only a GUI would otherwise use, put back however the test - // ends. - System::UnicodeString const path = GUILayoutFilePath(); + // Arrange: a layout file in a folder that doesn't exist yet, not this runner's own, which a GUI running these + // tests is using. The folder is deleted however the test ends. + System::UnicodeString const folder = System::Ioutils::TPath::Combine(System::Ioutils::TPath::GetTempPath(), + System::UnicodeString("ASWUnitTests_VCLSystem_Layout_") + System::Sysutils::IntToStr(static_cast( + GetCurrentProcessId()))); + System::UnicodeString const path = System::Ioutils::TPath::Combine(System::Ioutils::TPath::Combine(folder, + "Settings"), "Layout.ini"); - struct TRestoreFile + struct TDeleteFolder { - System::UnicodeString const Path; - bool const Existed; - std::string const Bytes; + System::UnicodeString const Folder; - ~TRestoreFile() + ~TDeleteFolder() { try { - if (Existed) - WriteFileBytes(Path, Bytes); - else - System::Sysutils::DeleteFile(Path); + System::Ioutils::TDirectory::Delete(Folder, true); } catch (...) { } } - }; - - bool const existed = System::Sysutils::FileExists(path); - TRestoreFile const restoreFile{ path, existed, existed ? ReadFileBytes(path) : std::string() }; + } const deleteFolder{ folder }; - System::Sysutils::DeleteFile(path); - EGUITheme const noFile = LoadGUIThemeFromLayoutFile(); - std::string const noFileDarkTheme = LoadGUIDarkThemeFromLayoutFile(); + EGUITheme const noFile = LoadGUIThemeFromLayoutFile(path); + std::string const noFileDarkTheme = LoadGUIDarkThemeFromLayoutFile(path); // Act - SaveGUIThemeToLayoutFile(EGUITheme::Dark); - EGUITheme const dark = LoadGUIThemeFromLayoutFile(); + SaveGUIThemeToLayoutFile(path, EGUITheme::Dark); + EGUITheme const dark = LoadGUIThemeFromLayoutFile(path); WriteFileBytes(path, "[Window]\r\nMaximized=1\r\n"); - SaveGUIThemeToLayoutFile(EGUITheme::Light); - EGUITheme const light = LoadGUIThemeFromLayoutFile(); - SaveGUIDarkThemeToLayoutFile("Deep \xC3\x89" "cume"); - std::string const darkTheme = LoadGUIDarkThemeFromLayoutFile(); + SaveGUIThemeToLayoutFile(path, EGUITheme::Light); + EGUITheme const light = LoadGUIThemeFromLayoutFile(path); + SaveGUIDarkThemeToLayoutFile(path, "Deep \xC3\x89" "cume"); + std::string const darkTheme = LoadGUIDarkThemeFromLayoutFile(path); std::string const text = ReadFileBytes(path); System::Sysutils::DeleteFile(path); - SaveGUIDarkThemeToLayoutFile("Ocean"); - std::string const darkThemeInNewFile = LoadGUIDarkThemeFromLayoutFile(); + SaveGUIDarkThemeToLayoutFile(path, "Ocean"); + std::string const darkThemeInNewFile = LoadGUIDarkThemeFromLayoutFile(path); // Assert CheckTrue(noFile == EGUITheme::Windows, __func__, __LINE__, "Windows without a layout file"); CheckEquals(std::string("Carbon"), noFileDarkTheme, __func__, __LINE__, "and the dark theme Carbon"); - CheckTrue(dark == EGUITheme::Dark, __func__, __LINE__, "the theme saved in a new file"); + CheckTrue(dark == EGUITheme::Dark, __func__, __LINE__, "the theme saved in a new file, and its new folder"); CheckTrue(light == EGUITheme::Light, __func__, __LINE__, "the theme saved in an existing file"); CheckEquals(std::string("Deep \xC3\x89" "cume"), darkTheme, __func__, __LINE__, "the dark theme saved beside it, in UTF-8"); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp index 3da549b..5f524ea 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp @@ -743,7 +743,8 @@ void TWin32GUIMainWindow::ApplyDPI() // e.g. the dark tree's check boxes and the menus' sizes, for the new DPI. m_Theme.Refresh(); - // The status bar's parts' widths, from the .dfm's panels. + // The status bar's parts' widths, from the .dfm's panels; the last, the status message's, takes the rest of + // the bar, as VCL's TStatusBar gives its last panel. std::vector rightEdges; int right = 0; @@ -753,6 +754,9 @@ void TWin32GUIMainWindow::ApplyDPI() rightEdges.push_back(right); } + if (!rightEdges.empty()) + rightEdges.back() = -1; + SendMessageW(m_SB_Status, SB_SETPARTS, rightEdges.size(), reinterpret_cast(rightEdges.data())); // The Detail column's width is set by LayOut(), to fill the list. @@ -2619,7 +2623,7 @@ void TWin32GUIMainWindow::SetDarkTheme(std::string const& name) m_DarkTheme = name; if (!m_Options.LayoutIgnore) - SaveGUIDarkThemeToLayoutFile(name); + SaveGUIDarkThemeToLayoutFile(GUILayoutFilePath(), name); std::vector const problems = m_Theme.SetDarkTheme(name); @@ -2707,7 +2711,7 @@ void TWin32GUIMainWindow::SetTheme(EGUITheme theme) ShowThemeState(); if (!m_Options.LayoutIgnore) - SaveGUIThemeToLayoutFile(theme); + SaveGUIThemeToLayoutFile(GUILayoutFilePath(), theme); ApplyTheme(); } diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp index f7677fb..f29db6c 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp @@ -75,6 +75,7 @@ class TFileHandle //--------------------------------------------------------------------------- std::runtime_error FileError(std::string const& action, std::string const& path, DWORD error); +std::string FolderOf(std::string const& path); std::string WithExtension(std::string const& path, std::string const& extension); //--------------------------------------------------------------------------- @@ -88,6 +89,19 @@ std::runtime_error FileError(std::string const& action, std::string const& path, return std::runtime_error("Couldn't " + action + " " + path + ": " + SystemErrorText(error)); } //--------------------------------------------------------------------------- +/* + FolderOf + + The folder the path's file is in, without a trailing separator, e.g. "a\b" for "a\b\c.ini"; empty if the + path has no folder. +*/ +std::string FolderOf(std::string const& path) +{ + std::size_t const separator = path.find_last_of("\\/"); + + return separator == std::string::npos ? std::string() : path.substr(0, separator); +} +//--------------------------------------------------------------------------- /* WithExtension @@ -297,16 +311,14 @@ bool IsThemeSettingChange(WPARAM wParam, LPARAM lParam) return area != nullptr && std::wcscmp(area, L"ImmersiveColorSet") == 0; } //--------------------------------------------------------------------------- -std::string LoadGUIDarkThemeFromLayoutFile() +std::string LoadGUIDarkThemeFromLayoutFile(std::string const& layoutPath) { try { - std::string const path = GUILayoutFilePath(); - - if (!FileExists(path)) + if (!FileExists(layoutPath)) return GUIDefaultDarkThemeName(); - return LoadGUIDarkTheme(TGUIINIDocument(ReadFileBytes(path))); + return LoadGUIDarkTheme(TGUIINIDocument(ReadFileBytes(layoutPath))); } catch (...) { @@ -314,16 +326,14 @@ std::string LoadGUIDarkThemeFromLayoutFile() } } //--------------------------------------------------------------------------- -EGUITheme LoadGUIThemeFromLayoutFile() +EGUITheme LoadGUIThemeFromLayoutFile(std::string const& layoutPath) { try { - std::string const path = GUILayoutFilePath(); - - if (!FileExists(path)) + if (!FileExists(layoutPath)) return EGUITheme::Windows; - return LoadGUITheme(TGUIINIDocument(ReadFileBytes(path))); + return LoadGUITheme(TGUIINIDocument(ReadFileBytes(layoutPath))); } catch (...) { @@ -403,17 +413,19 @@ bool RemoveFile(std::string const& path) return DeleteFileW(UTF8ToWide(path).c_str()) != 0; } //--------------------------------------------------------------------------- -void SaveGUIDarkThemeToLayoutFile(std::string const& name) +void SaveGUIDarkThemeToLayoutFile(std::string const& layoutPath, std::string const& name) { try { - std::string const path = GUILayoutFilePath(); - CreateFolderPath(GUISettingsFolderPath()); + std::string const folder = FolderOf(layoutPath); + + if (!folder.empty()) + CreateFolderPath(folder); // Starting from the file's current text keeps the layout and any other values in it. - TGUIINIDocument ini(FileExists(path) ? ReadFileBytes(path) : std::string()); + TGUIINIDocument ini(FileExists(layoutPath) ? ReadFileBytes(layoutPath) : std::string()); SaveGUIDarkTheme(ini, name); - WriteFileBytes(path, ini.Text()); + WriteFileBytes(layoutPath, ini.Text()); } catch (...) { @@ -421,17 +433,19 @@ void SaveGUIDarkThemeToLayoutFile(std::string const& name) } } //--------------------------------------------------------------------------- -void SaveGUIThemeToLayoutFile(EGUITheme theme) +void SaveGUIThemeToLayoutFile(std::string const& layoutPath, EGUITheme theme) { try { - std::string const path = GUILayoutFilePath(); - CreateFolderPath(GUISettingsFolderPath()); + std::string const folder = FolderOf(layoutPath); + + if (!folder.empty()) + CreateFolderPath(folder); // Starting from the file's current text keeps the layout and any other values in it. - TGUIINIDocument ini(FileExists(path) ? ReadFileBytes(path) : std::string()); + TGUIINIDocument ini(FileExists(layoutPath) ? ReadFileBytes(layoutPath) : std::string()); SaveGUITheme(ini, theme); - WriteFileBytes(path, ini.Text()); + WriteFileBytes(layoutPath, ini.Text()); } catch (...) { diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_System.h b/win32/gui/src/ASWUnitTests_Win32GUI_System.h index 2e03709..17d5c46 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_System.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_System.h @@ -75,12 +75,12 @@ std::string GUIThemesFolderPath(); // Whether a WM_SETTINGCHANGE message's parameters are about a setting that Use Windows Setting follows: the // app mode (Windows names its change "ImmersiveColorSet") or high contrast. bool IsThemeSettingChange(WPARAM wParam, LPARAM lParam); -// The dark theme saved in the layout file (see GUILayoutFilePath() and LoadGUIDarkTheme()); +// The dark theme saved in the layout file at 'layoutPath', e.g. GUILayoutFilePath()'s (see LoadGUIDarkTheme()); // GUIDefaultDarkThemeName() when there's no file or it can't be read. -std::string LoadGUIDarkThemeFromLayoutFile(); -// The theme saved in the layout file (see GUILayoutFilePath() and LoadGUITheme()); Windows when there's no file -// or it can't be read. -EGUITheme LoadGUIThemeFromLayoutFile(); +std::string LoadGUIDarkThemeFromLayoutFile(std::string const& layoutPath); +// The theme saved in the layout file at 'layoutPath', e.g. GUILayoutFilePath()'s (see LoadGUITheme()); Windows +// when there's no file or it can't be read. +EGUITheme LoadGUIThemeFromLayoutFile(std::string const& layoutPath); // This executable's icon, its MAINICON resource (resource/ASWUnitTests_Icon.rc, or Icon_MainIcon in a RAD Studio // project), at the standard icon size; nullptr if it has none. Shared, so it's never destroyed. HICON MainIcon(); @@ -95,13 +95,14 @@ std::vector ProcessArguments(); std::string ReadFileBytes(std::string const& path); // Deletes the file. False if it couldn't be deleted, e.g. because it doesn't exist. bool RemoveFile(std::string const& path); -// Saves 'name', the dark theme's, in the layout file (see GUILayoutFilePath() and SaveGUIDarkTheme()), keeping -// the layout and any other values in it. Not saving it (e.g. a read-only profile) only means the next start uses -// the default. -void SaveGUIDarkThemeToLayoutFile(std::string const& name); -// Saves 'theme' in the layout file (see GUILayoutFilePath() and SaveGUITheme()), keeping the layout and any -// other values in it. Not saving it (e.g. a read-only profile) only means the next start uses the default. -void SaveGUIThemeToLayoutFile(EGUITheme theme); +// Saves 'name', the dark theme's, in the layout file at 'layoutPath', e.g. GUILayoutFilePath() (see +// SaveGUIDarkTheme()), keeping the layout and any other values in it, and creating the file and its folder if +// needed. Not saving it (e.g. a read-only profile) only means the next start uses the default. +void SaveGUIDarkThemeToLayoutFile(std::string const& layoutPath, std::string const& name); +// Saves 'theme' in the layout file at 'layoutPath', e.g. GUILayoutFilePath() (see SaveGUITheme()), keeping the +// layout and any other values in it, and creating the file and its folder if needed. Not saving it (e.g. a +// read-only profile) only means the next start uses the default. +void SaveGUIThemeToLayoutFile(std::string const& layoutPath, EGUITheme theme); // Replaces the clipboard's content with the text, for 'owner' (a window of this process). Throws // std::runtime_error if it can't, e.g. while another program has the clipboard open. void SetClipboardText(HWND owner, std::string const& text); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp index 4b23369..6043acd 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp @@ -119,9 +119,10 @@ int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int showCommand) // Setting and Carbon with --layout-ignore, which ignores the saved layout file, and --layout-reset, which // deletes it. bool const savedTheme = !options.LayoutIgnore && !options.LayoutReset; - ASWUnitTests::EGUITheme const themeChoice = savedTheme ? ASWUnitTests::LoadGUIThemeFromLayoutFile() : + std::string const layoutPath = ASWUnitTests::GUILayoutFilePath(); + ASWUnitTests::EGUITheme const themeChoice = savedTheme ? ASWUnitTests::LoadGUIThemeFromLayoutFile(layoutPath) : ASWUnitTests::EGUITheme::Windows; - std::string const darkTheme = savedTheme ? ASWUnitTests::LoadGUIDarkThemeFromLayoutFile() : + std::string const darkTheme = savedTheme ? ASWUnitTests::LoadGUIDarkThemeFromLayoutFile(layoutPath) : ASWUnitTests::GUIDefaultDarkThemeName(); std::vector const themeProblems = theme.SetDarkTheme(darkTheme); theme.SetDark(ASWUnitTests::GUIThemeIsDark(themeChoice, ASWUnitTests::WindowsAppsUseDarkTheme(), diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp b/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp index ccad8e0..1d2b453 100644 --- a/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp @@ -384,57 +384,32 @@ void TTest_ASWUnitTests_Win32GUI_System::Test_IsThemeSettingChange_IsTrueForTheA //--------------------------------------------------------------------------- void TTest_ASWUnitTests_Win32GUI_System::Test_LoadGUIThemeFromLayoutFile_ReadsWhatSaveWrote() { - // Arrange: this runner's own layout file, which only a GUI would otherwise use, put back however the test - // ends. - std::string const path = GUILayoutFilePath(); - - struct TRestoreFile - { - std::string const Path; - bool const Existed; - std::string const Bytes; - - ~TRestoreFile() - { - try - { - if (Existed) - WriteFileBytes(Path, Bytes); - else - RemoveFile(Path); - } - catch (...) - { - } - } - }; + // Arrange: a layout file in a folder that doesn't exist yet, not this runner's own, which a GUI running these + // tests is using. + std::string const path = ScratchPath("Settings\\Layout.ini"); - bool const existed = FileExists(path); - TRestoreFile const restoreFile{ path, existed, existed ? ReadFileBytes(path) : std::string() }; - - RemoveFile(path); - EGUITheme const noFile = LoadGUIThemeFromLayoutFile(); - std::string const noFileDarkTheme = LoadGUIDarkThemeFromLayoutFile(); + EGUITheme const noFile = LoadGUIThemeFromLayoutFile(path); + std::string const noFileDarkTheme = LoadGUIDarkThemeFromLayoutFile(path); // Act - SaveGUIThemeToLayoutFile(EGUITheme::Dark); - EGUITheme const dark = LoadGUIThemeFromLayoutFile(); + SaveGUIThemeToLayoutFile(path, EGUITheme::Dark); + EGUITheme const dark = LoadGUIThemeFromLayoutFile(path); WriteFileBytes(path, "[Window]\r\nMaximized=1\r\n"); - SaveGUIThemeToLayoutFile(EGUITheme::Light); - EGUITheme const light = LoadGUIThemeFromLayoutFile(); - SaveGUIDarkThemeToLayoutFile("Deep \xC3\x89" "cume"); - std::string const darkTheme = LoadGUIDarkThemeFromLayoutFile(); + SaveGUIThemeToLayoutFile(path, EGUITheme::Light); + EGUITheme const light = LoadGUIThemeFromLayoutFile(path); + SaveGUIDarkThemeToLayoutFile(path, "Deep \xC3\x89" "cume"); + std::string const darkTheme = LoadGUIDarkThemeFromLayoutFile(path); std::string const text = ReadFileBytes(path); RemoveFile(path); - SaveGUIDarkThemeToLayoutFile("Ocean"); - std::string const darkThemeInNewFile = LoadGUIDarkThemeFromLayoutFile(); + SaveGUIDarkThemeToLayoutFile(path, "Ocean"); + std::string const darkThemeInNewFile = LoadGUIDarkThemeFromLayoutFile(path); // Assert CheckTrue(noFile == EGUITheme::Windows, __func__, __LINE__, "Windows without a layout file"); CheckEquals(std::string("Carbon"), noFileDarkTheme, __func__, __LINE__, "and the dark theme Carbon"); - CheckTrue(dark == EGUITheme::Dark, __func__, __LINE__, "the theme saved in a new file"); + CheckTrue(dark == EGUITheme::Dark, __func__, __LINE__, "the theme saved in a new file, and its new folder"); CheckTrue(light == EGUITheme::Light, __func__, __LINE__, "the theme saved in an existing file"); CheckEquals(std::string("Deep \xC3\x89" "cume"), darkTheme, __func__, __LINE__, "the dark theme saved beside it, in UTF-8"); From 1c900029efe2eb9d51ed8f392b0bda62824755dd Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 21:24:41 -0700 Subject: [PATCH 3/3] Keep Win32 GUI's menus' captions when switching from dark to light --- win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp index 3bf3667..a8cdc49 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -775,22 +775,15 @@ void TWin32GUITheme::ApplyToMenu(HMENU menu, std::vector(position), MF_BYPOSITION); - InsertMenuItemW(menu, static_cast(position), TRUE, &change); - } - else - { - SetMenuItemInfoW(menu, static_cast(position), TRUE, &change); - } + // An item keeps what Windows worked out for it in the look it was last shown in, whatever changes: a menu + // bar item its width, and a menu's item, once only shown owner-drawn, has no caption in the standard look. + // So each is replaced by a new one. + change.fMask |= MIIM_ID | MIIM_STATE | MIIM_SUBMENU; + change.wID = info.wID; + change.fState = info.fState; + change.hSubMenu = info.hSubMenu; + RemoveMenu(menu, static_cast(position), MF_BYPOSITION); + InsertMenuItemW(menu, static_cast(position), TRUE, &change); if (info.hSubMenu != nullptr) {