Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<Name>.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, `<Name>.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\<exe name>.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
Expand Down
2 changes: 1 addition & 1 deletion gui/src/ASWUnitTests_GUI_Commands.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
24 changes: 24 additions & 0 deletions gui/src/ASWUnitTests_GUI_INIDocument.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,20 @@ TGUIINIDocument::TSection* TGUIINIDocument::FindSection(std::string const& secti
return const_cast<TSection*>(static_cast<TGUIINIDocument const*>(this)->FindSection(section));
}
//---------------------------------------------------------------------------
std::vector<std::string> TGUIINIDocument::KeyNames(std::string const& section) const
{
std::vector<std::string> 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<std::string> TGUIINIDocument::ReadString(std::string const& section, std::string const& key) const
{
TSection const* const found = FindSection(section);
Expand All @@ -182,6 +196,16 @@ std::optional<std::string> TGUIINIDocument::ReadString(std::string const& sectio
return std::nullopt;
}
//---------------------------------------------------------------------------
std::vector<std::string> TGUIINIDocument::SectionNames() const
{
std::vector<std::string> names;

for (TSection const& section : m_Sections)
names.push_back(section.Name);

return names;
}
//---------------------------------------------------------------------------
std::string TGUIINIDocument::Text() const
{
std::string text;
Expand Down
5 changes: 5 additions & 0 deletions gui/src/ASWUnitTests_GUI_INIDocument.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string> KeyNames(std::string const& section) const;
// The key's value, or empty if the section or key doesn't exist.
std::optional<std::string> ReadString(std::string const& section, std::string const& key) const;
// The names of the sections, as written and in order.
std::vector<std::string> 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
Expand Down
207 changes: 207 additions & 0 deletions gui/src/ASWUnitTests_GUI_Theme.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,61 @@ limitations under the License.
#include "ASWUnitTests_GUI_Theme.h"
//---------------------------------------------------------------------------
#include <algorithm>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
//---------------------------------------------------------------------------

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
Expand Down Expand Up @@ -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)
{
Expand All @@ -85,6 +141,47 @@ bool GUIThemeIsDark(EGUITheme theme, bool windowsAppsUseDark, bool highContrast)
return windowsAppsUseDark && !highContrast;
}
//---------------------------------------------------------------------------
std::vector<std::string> GUIThemeNames(std::vector<std::string> const& fileNames, std::string const& extension)
{
std::vector<std::string> 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<std::string> 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<std::string> const name = ini.ReadString(ViewSection, ThemeKey);
Expand All @@ -101,6 +198,116 @@ EGUITheme LoadGUITheme(TGUIINIDocument const& ini)
return EGUITheme::Windows;
}
//---------------------------------------------------------------------------
std::optional<std::uint32_t> 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<std::uint32_t>(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<std::string> 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<std::string> 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<std::string> met;

for (std::string const& key : ini.KeyNames(ColorsSection))
{
std::vector<std::string>::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<std::uint32_t> 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";
Expand Down
Loading
Loading