From 46ffff93c448d6ac653ab40883860be841c5c2f7 Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 06:18:08 -0700 Subject: [PATCH 1/9] Add a dark theme to the Win32 GUI runner, painted in Carbon's colors --- CHANGELOG.md | 13 +- Deploy.bat | 24 + README.md | 27 +- cmake/CMakeLists.txt | 6 + gui/src/ASWUnitTests_GUI_CommandLine.cpp | 9 +- gui/src/ASWUnitTests_GUI_Commands.h | 7 +- gui/src/ASWUnitTests_GUI_Theme.h | 7 +- rad370/ASWUnitTests.cbproj | 16 + .../gui/rad370/ASWUnitTests_Win32_GUI.cbproj | 24 + .../gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp | 169 ++ win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h | 73 + .../src/ASWUnitTests_Win32GUI_MainWindow.cpp | 284 ++- .../src/ASWUnitTests_Win32GUI_MainWindow.h | 33 +- .../ASWUnitTests_Win32GUI_MessageDialog.cpp | 355 +++ .../src/ASWUnitTests_Win32GUI_MessageDialog.h | 62 + .../gui/src/ASWUnitTests_Win32GUI_Palette.cpp | 144 ++ win32/gui/src/ASWUnitTests_Win32GUI_Palette.h | 157 ++ ...SWUnitTests_Win32GUI_ShuffleSeedDialog.cpp | 34 +- .../ASWUnitTests_Win32GUI_ShuffleSeedDialog.h | 6 +- .../src/ASWUnitTests_Win32GUI_Sources.cmake | 4 + .../gui/src/ASWUnitTests_Win32GUI_System.cpp | 69 + win32/gui/src/ASWUnitTests_Win32GUI_System.h | 16 + .../src/ASWUnitTests_Win32GUI_TextDialog.cpp | 18 +- .../src/ASWUnitTests_Win32GUI_TextDialog.h | 6 +- win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp | 2072 +++++++++++++++++ win32/gui/src/ASWUnitTests_Win32GUI_Theme.h | 229 ++ .../gui/src/ASWUnitTests_Win32GUI_WinMain.cpp | 31 +- .../Test_ASWUnitTests_Win32GUI_Glyphs.cpp | 250 ++ .../tests/Test_ASWUnitTests_Win32GUI_Glyphs.h | 65 + .../Test_ASWUnitTests_Win32GUI_Palette.cpp | 142 ++ .../Test_ASWUnitTests_Win32GUI_Palette.h | 62 + .../Test_ASWUnitTests_Win32GUI_System.cpp | 105 + .../tests/Test_ASWUnitTests_Win32GUI_System.h | 3 + 33 files changed, 4402 insertions(+), 120 deletions(-) create mode 100644 win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp create mode 100644 win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h create mode 100644 win32/gui/src/ASWUnitTests_Win32GUI_MessageDialog.cpp create mode 100644 win32/gui/src/ASWUnitTests_Win32GUI_MessageDialog.h create mode 100644 win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp create mode 100644 win32/gui/src/ASWUnitTests_Win32GUI_Palette.h create mode 100644 win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp create mode 100644 win32/gui/src/ASWUnitTests_Win32GUI_Theme.h create mode 100644 win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp create mode 100644 win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.h create mode 100644 win32/tests/Test_ASWUnitTests_Win32GUI_Palette.cpp create mode 100644 win32/tests/Test_ASWUnitTests_Win32GUI_Palette.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 4296f19..d872dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,13 +27,16 @@ versioned baseline. before. - `gui\src\ASWUnitTests_GUI_Sources.cmake`, which lists the GUI runners' shared units as `ASWUNITTESTS_GUI_SOURCES`. -- A dark theme in the VCL GUI runner, with **View > Theme** to choose it: **Use Windows Setting** +- A dark theme in both GUI runners, with **View > Theme** to choose it: **Use Windows Setting** (the default) follows Windows' app mode setting, switching as soon as it changes, and **Light** and **Dark** override it. The choice applies without a restart and is saved in the layout file; - `--layout-ignore` and `--layout-reset` cover it as they do the layout. Dark is RAD Studio's Carbon - VCL style, so a project of your own that runs its tests in the VCL GUI runner needs Carbon among - its custom styles (Project > Options > Application > Appearance) for it, and a `WinMain()` that - applies the saved theme as `vcl\gui\rad370\ASWUnitTests_VCL_GUI.cpp` does. + `--layout-ignore` and `--layout-reset` cover it as they do the layout. In the VCL GUI runner, dark + is RAD Studio's Carbon VCL style, so a project of your own that runs its tests in it needs Carbon + among its custom styles (Project > Options > Application > Appearance) for it, and a `WinMain()` + that applies the saved theme as `vcl\gui\rad370\ASWUnitTests_VCL_GUI.cpp` does. The Win32 GUI + 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. - 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/Deploy.bat b/Deploy.bat index c58f69c..b17efb9 100644 --- a/Deploy.bat +++ b/Deploy.bat @@ -357,6 +357,10 @@ call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_DPI.cpp" "win32\gui\src\ASWU if errorlevel 1 exit /b 1 call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_DPI.h" "win32\gui\src\ASWUnitTests_Win32GUI_DPI.h" if errorlevel 1 exit /b 1 +call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_Glyphs.cpp" "win32\gui\src\ASWUnitTests_Win32GUI_Glyphs.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_Glyphs.h" "win32\gui\src\ASWUnitTests_Win32GUI_Glyphs.h" +if errorlevel 1 exit /b 1 call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_MainLayout.cpp" "win32\gui\src\ASWUnitTests_Win32GUI_MainLayout.cpp" if errorlevel 1 exit /b 1 call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_MainLayout.h" "win32\gui\src\ASWUnitTests_Win32GUI_MainLayout.h" @@ -367,6 +371,14 @@ call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_MainWindow.h" "win32\gui\src if errorlevel 1 exit /b 1 call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_Manifest.rc" "win32\gui\src\ASWUnitTests_Win32GUI_Manifest.rc" if errorlevel 1 exit /b 1 +call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_MessageDialog.cpp" "win32\gui\src\ASWUnitTests_Win32GUI_MessageDialog.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_MessageDialog.h" "win32\gui\src\ASWUnitTests_Win32GUI_MessageDialog.h" +if errorlevel 1 exit /b 1 +call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_Palette.cpp" "win32\gui\src\ASWUnitTests_Win32GUI_Palette.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_Palette.h" "win32\gui\src\ASWUnitTests_Win32GUI_Palette.h" +if errorlevel 1 exit /b 1 call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_ShuffleSeedDialog.cpp" "win32\gui\src\ASWUnitTests_Win32GUI_ShuffleSeedDialog.cpp" if errorlevel 1 exit /b 1 call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_ShuffleSeedDialog.h" "win32\gui\src\ASWUnitTests_Win32GUI_ShuffleSeedDialog.h" @@ -385,6 +397,10 @@ call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_TextDialog.cpp" "win32\gui\s if errorlevel 1 exit /b 1 call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_TextDialog.h" "win32\gui\src\ASWUnitTests_Win32GUI_TextDialog.h" if errorlevel 1 exit /b 1 +call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_Theme.cpp" "win32\gui\src\ASWUnitTests_Win32GUI_Theme.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_Theme.h" "win32\gui\src\ASWUnitTests_Win32GUI_Theme.h" +if errorlevel 1 exit /b 1 call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_WinMain.cpp" "win32\gui\src\ASWUnitTests_Win32GUI_WinMain.cpp" if errorlevel 1 exit /b 1 call :CopyFile "win32\gui\src\ASWUnitTests_Win32GUI_Windows.h" "win32\gui\src\ASWUnitTests_Win32GUI_Windows.h" @@ -397,10 +413,18 @@ call :CopyFile "win32\tests\Test_ASWUnitTests_Win32GUI_DPI.cpp" "win32\tests\Tes if errorlevel 1 exit /b 1 call :CopyFile "win32\tests\Test_ASWUnitTests_Win32GUI_DPI.h" "win32\tests\Test_ASWUnitTests_Win32GUI_DPI.h" if errorlevel 1 exit /b 1 +call :CopyFile "win32\tests\Test_ASWUnitTests_Win32GUI_Glyphs.cpp" "win32\tests\Test_ASWUnitTests_Win32GUI_Glyphs.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "win32\tests\Test_ASWUnitTests_Win32GUI_Glyphs.h" "win32\tests\Test_ASWUnitTests_Win32GUI_Glyphs.h" +if errorlevel 1 exit /b 1 call :CopyFile "win32\tests\Test_ASWUnitTests_Win32GUI_MainLayout.cpp" "win32\tests\Test_ASWUnitTests_Win32GUI_MainLayout.cpp" if errorlevel 1 exit /b 1 call :CopyFile "win32\tests\Test_ASWUnitTests_Win32GUI_MainLayout.h" "win32\tests\Test_ASWUnitTests_Win32GUI_MainLayout.h" if errorlevel 1 exit /b 1 +call :CopyFile "win32\tests\Test_ASWUnitTests_Win32GUI_Palette.cpp" "win32\tests\Test_ASWUnitTests_Win32GUI_Palette.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "win32\tests\Test_ASWUnitTests_Win32GUI_Palette.h" "win32\tests\Test_ASWUnitTests_Win32GUI_Palette.h" +if errorlevel 1 exit /b 1 call :CopyFile "win32\tests\Test_ASWUnitTests_Win32GUI_System.cpp" "win32\tests\Test_ASWUnitTests_Win32GUI_System.cpp" if errorlevel 1 exit /b 1 call :CopyFile "win32\tests\Test_ASWUnitTests_Win32GUI_System.h" "win32\tests\Test_ASWUnitTests_Win32GUI_System.h" diff --git a/README.md b/README.md index a8c612a..7d3537f 100644 --- a/README.md +++ b/README.md @@ -381,14 +381,17 @@ Reset Layout** restores the default layout right away; `--layout-ignore` and `-- (below) control this from the command line. Since the file is named after the executable, the two GUI runners, and each project's own build of them, keep separate layouts. -In the VCL GUI runner, **View > Theme** chooses a light or dark look. **Use Windows Setting**, the +In both GUI runners, **View > Theme** chooses a light or dark look. **Use Windows Setting**, the default, follows Windows' app mode setting (Settings > Personalization > Colors), and switches as soon as it changes, but stays light while high contrast is on, so the window shows high contrast's -own colors. **Light** and **Dark** keep that look whatever Windows is set to. Dark is RAD Studio's -Carbon VCL style, and light the standard Windows look. A choice applies right away and is saved in -the layout file at once, so the next session starts with it; **View > Reset Layout** leaves it as -it is. The Theme commands are disabled during a run, and a change of the Windows setting during one -applies when it ends. The Win32 GUI runner always has the standard Windows look, for now. +own colors. **Light** and **Dark** keep that look whatever Windows is set to. Light is the standard +Windows look. Dark is RAD Studio's Carbon VCL style in the VCL GUI runner, and the Win32 GUI runner +paints the same colors itself. A choice applies right away and is saved in the layout file at once, +so the next session starts with it; **View > Reset Layout** leaves it as it is. The Theme commands +are disabled during a run, and a change of the Windows setting during one applies when it ends. In +the Win32 GUI runner's dark look, the title bar is dark unless Windows shows the accent color on +title bars, which it then keeps while the window is active, and the Export JUnit Report dialog, +Windows' own, stays light. 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 @@ -430,12 +433,12 @@ these differences: - `--exit` (GUI-only, requires `--run`) closes the window when that run finishes, and exits with the same code the console runner would; a run ended with Stop exits with `1`. Without `--exit`, the GUI always exits with `0`. -- `--layout-ignore` (GUI-only) neither loads nor saves the window layout, or the VCL GUI runner's - theme, so the window opens with the defaults and the saved ones are left as they are. A theme - chosen in the View menu then lasts only until the window closes. -- `--layout-reset` (GUI-only) deletes the saved window layout, and with it the VCL GUI runner's - theme, and opens with the defaults, which is a way to recover if a saved layout ever causes a - problem. The layout is saved again when the window closes. +- `--layout-ignore` (GUI-only) neither loads nor saves the window layout, or the theme, so the + window opens with the defaults and the saved ones are left as they are. A theme chosen in the View + menu then lasts only until the window closes. +- `--layout-reset` (GUI-only) deletes the saved window layout, and with it the theme, and opens with + the defaults, which is a way to recover if a saved layout ever causes a problem. The layout is + saved again when the window closes. A GUI process doesn't make a Windows command prompt wait for it, so a script that needs `--exit`'s exit code should start it with `start /wait` (or its own language's equivalent; Bash, including Git diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 9423691..038ee0f 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -81,11 +81,15 @@ if(WIN32) target_sources(ASWUnitTests PRIVATE ../win32/gui/src/ASWUnitTests_Win32GUI_DialogTemplate.cpp ../win32/gui/src/ASWUnitTests_Win32GUI_DPI.cpp + ../win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp ../win32/gui/src/ASWUnitTests_Win32GUI_MainLayout.cpp + ../win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp ../win32/gui/src/ASWUnitTests_Win32GUI_System.cpp ../win32/tests/Test_ASWUnitTests_Win32GUI_DialogTemplate.cpp ../win32/tests/Test_ASWUnitTests_Win32GUI_DPI.cpp + ../win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp ../win32/tests/Test_ASWUnitTests_Win32GUI_MainLayout.cpp + ../win32/tests/Test_ASWUnitTests_Win32GUI_Palette.cpp ../win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp ) target_include_directories(ASWUnitTests PRIVATE @@ -126,7 +130,9 @@ if(WIN32) ${ASWUNITTESTS_SELF_TEST_SOURCES} ../win32/tests/Test_ASWUnitTests_Win32GUI_DialogTemplate.cpp ../win32/tests/Test_ASWUnitTests_Win32GUI_DPI.cpp + ../win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp ../win32/tests/Test_ASWUnitTests_Win32GUI_MainLayout.cpp + ../win32/tests/Test_ASWUnitTests_Win32GUI_Palette.cpp ../win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp ) diff --git a/gui/src/ASWUnitTests_GUI_CommandLine.cpp b/gui/src/ASWUnitTests_GUI_CommandLine.cpp index 8e6c72f..f970480 100644 --- a/gui/src/ASWUnitTests_GUI_CommandLine.cpp +++ b/gui/src/ASWUnitTests_GUI_CommandLine.cpp @@ -45,11 +45,10 @@ std::string TGUICommandLine::GUIOptionsHelp() " returning the same exit code the console runner would.\n" " Requires --run.\n" " --layout-ignore Neither load nor save the window layout (its size,\n" - " position, and panel sizes) or the VCL GUI's theme;\n" - " open with the defaults.\n" - " --layout-reset Delete the saved window layout (and the VCL GUI's\n" - " theme) and open with the defaults; the layout is saved\n" - " again on closing.\n" + " position, and panel sizes) or the theme; open with the\n" + " defaults.\n" + " --layout-reset Delete the saved window layout (and the theme) and open\n" + " with the defaults; the layout is saved again on closing.\n" "\n" "Ignored by the GUI: --list, --pause, and the color options.\n"; } diff --git a/gui/src/ASWUnitTests_GUI_Commands.h b/gui/src/ASWUnitTests_GUI_Commands.h index f8dcd36..122a1ed 100644 --- a/gui/src/ASWUnitTests_GUI_Commands.h +++ b/gui/src/ASWUnitTests_GUI_Commands.h @@ -58,9 +58,8 @@ 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 VCL GUI runner's -// View > Theme menu, its three actions together; the Win32 GUI runner has no -// themes yet, so it has no command that uses it. +// Act_RunSelected), plus the filter box. Theme is the GUI runners' View > +// Theme menu, its three commands together. ///////////////////////////////////////////////////////////////////////////// struct TGUICommandStates { @@ -94,7 +93,7 @@ struct TGUICommandStates // modal dialog (About, Command Line Options, Shuffle Seed, Export JUnit Report) would hold up the run until // it closed; the run's tests and order are already decided, so checking tests and changing the shuffle // would only mislead; changing the filter would rebuild the tree the run is updating; and changing the theme -// would recreate the windows the run is updating. +// would restyle (in the VCL GUI runner, recreate) the windows the run is updating. // - Run Selected and Run Failed stay disabled once a run has been aborted. // - Run Failed and Select Failed need a failed test, Export JUnit Report a finished run's report, Copy Details a // selected node, and Expand All and Collapse All a tree with nodes. diff --git a/gui/src/ASWUnitTests_GUI_Theme.h b/gui/src/ASWUnitTests_GUI_Theme.h index cbb26b8..6d22227 100644 --- a/gui/src/ASWUnitTests_GUI_Theme.h +++ b/gui/src/ASWUnitTests_GUI_Theme.h @@ -31,10 +31,9 @@ namespace ASWUnitTests ///////////////////////////////////////////////////////////////////////////// // EGUITheme // -// A GUI runner's theme, as chosen in the VCL GUI runner's View > Theme menu -// and saved in its layout file. Contains no UI code, so it's unit tested -// from the console runners (see gui/tests/Test_ASWUnitTests_GUI_Theme.cpp). -// The Win32 GUI runner has no themes yet. +// A GUI runner's theme, as chosen in its View > Theme menu and saved in its +// layout file. Contains no UI code, so it's unit tested from the console +// runners (see gui/tests/Test_ASWUnitTests_GUI_Theme.cpp). ///////////////////////////////////////////////////////////////////////////// enum class EGUITheme { diff --git a/rad370/ASWUnitTests.cbproj b/rad370/ASWUnitTests.cbproj index 12e0a4d..3ad7c16 100644 --- a/rad370/ASWUnitTests.cbproj +++ b/rad370/ASWUnitTests.cbproj @@ -356,10 +356,18 @@ ..\win32\gui\src\ASWUnitTests_Win32GUI_DPI.h 50 + + ..\win32\gui\src\ASWUnitTests_Win32GUI_Glyphs.h + 60 + ..\win32\gui\src\ASWUnitTests_Win32GUI_MainLayout.h 52 + + ..\win32\gui\src\ASWUnitTests_Win32GUI_Palette.h + 62 + ..\win32\gui\src\ASWUnitTests_Win32GUI_System.h 48 @@ -372,10 +380,18 @@ ..\win32\tests\Test_ASWUnitTests_Win32GUI_DPI.h 51 + + ..\win32\tests\Test_ASWUnitTests_Win32GUI_Glyphs.h + 61 + ..\win32\tests\Test_ASWUnitTests_Win32GUI_MainLayout.h 53 + + ..\win32\tests\Test_ASWUnitTests_Win32GUI_Palette.h + 63 + ..\win32\tests\Test_ASWUnitTests_Win32GUI_System.h 49 diff --git a/win32/gui/rad370/ASWUnitTests_Win32_GUI.cbproj b/win32/gui/rad370/ASWUnitTests_Win32_GUI.cbproj index f43e523..e9937e1 100644 --- a/win32/gui/rad370/ASWUnitTests_Win32_GUI.cbproj +++ b/win32/gui/rad370/ASWUnitTests_Win32_GUI.cbproj @@ -340,10 +340,18 @@ ..\..\tests\Test_ASWUnitTests_Win32GUI_DialogTemplate.h 58 + + ..\..\tests\Test_ASWUnitTests_Win32GUI_Glyphs.h + 65 + ..\..\tests\Test_ASWUnitTests_Win32GUI_MainLayout.h 54 + + ..\..\tests\Test_ASWUnitTests_Win32GUI_Palette.h + 67 + ..\src\ASWUnitTests_Win32GUI_DialogTemplate.h 57 @@ -352,6 +360,10 @@ ..\src\ASWUnitTests_Win32GUI_DPI.h 50 + + ..\src\ASWUnitTests_Win32GUI_Glyphs.h + 64 + ..\src\ASWUnitTests_Win32GUI_MainLayout.h 53 @@ -360,6 +372,14 @@ ..\src\ASWUnitTests_Win32GUI_MainWindow.h 55 + + ..\src\ASWUnitTests_Win32GUI_MessageDialog.h + 68 + + + ..\src\ASWUnitTests_Win32GUI_Palette.h + 66 + ..\src\ASWUnitTests_Win32GUI_ShuffleSeedDialog.h 59 @@ -376,6 +396,10 @@ ..\src\ASWUnitTests_Win32GUI_TextDialog.h 51 + + ..\src\ASWUnitTests_Win32GUI_Theme.h + 69 + 47 diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp new file mode 100644 index 0000000..b490e73 --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp @@ -0,0 +1,169 @@ +/* ************************************************************************** +ASWUnitTests_Win32GUI_Glyphs.cpp +Author: Anthony S. West - ASW Software + +The small shapes the Win32 GUI runner's dark theme draws. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWUnitTests_Win32GUI_Glyphs.h" +//--------------------------------------------------------------------------- +#include +#include +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +namespace +{ + +// Each pixel is sampled at SamplesPerSide x SamplesPerSide points. +int const SamplesPerSide = 4; + +//--------------------------------------------------------------------------- + +bool Covers(EWin32GUIGlyph glyph, double x, double y); +bool InTriangle(double x, double y, double ax, double ay, double bx, double by, double cx, double cy); +bool NearSegment(double x, double y, double ax, double ay, double bx, double by, double halfWidth); + +//--------------------------------------------------------------------------- +/* + Covers + + Whether the point (x, y) of a cell one unit wide, from its top left, is inside 'glyph'. The shapes' + proportions come from Carbon's glyphs: its 13-pixel check box's cross spans pixels 3 to 9 in strokes about + 1.7 pixels wide, its partial square pixels 4 to 8, its radio dot about 4.6 pixels across, and its 16-pixel + submenu arrow is 4 pixels wide and 7 tall. +*/ +bool Covers(EWin32GUIGlyph glyph, double x, double y) +{ + double const dx = x - 0.5; + double const dy = y - 0.5; + double const squaredDistance = dx * dx + dy * dy; + + switch (glyph) + { + case EWin32GUIGlyph::ArrowRight: + return InTriangle(x, y, 0.375, 0.28, 0.375, 0.72, 0.625, 0.5); + + case EWin32GUIGlyph::Circle: + return squaredDistance <= 0.5 * 0.5; + + case EWin32GUIGlyph::Cross: + return NearSegment(x, y, 0.285, 0.285, 0.715, 0.715, 0.066) || NearSegment(x, y, 0.715, 0.285, 0.285, + 0.715, 0.066); + + case EWin32GUIGlyph::Dot: + return squaredDistance <= 0.18 * 0.18; + + case EWin32GUIGlyph::InnerCircle: + return squaredDistance <= (5.5 / 13) * (5.5 / 13); + + case EWin32GUIGlyph::Square: + return x >= 0.34 && x <= 0.66 && y >= 0.34 && y <= 0.66; + + case EWin32GUIGlyph::Tick: + return NearSegment(x, y, 0.25, 0.52, 0.42, 0.69, 0.07) || NearSegment(x, y, 0.42, 0.69, 0.76, 0.33, + 0.07); + } + + return false; +} +//--------------------------------------------------------------------------- +/* + InTriangle + + Whether (x, y) is inside the triangle a, b, c (in either winding), or on its edge. +*/ +bool InTriangle(double x, double y, double ax, double ay, double bx, double by, double cx, double cy) +{ + auto const side = [x, y](double x1, double y1, double x2, double y2) + { + return (x2 - x1) * (y - y1) - (y2 - y1) * (x - x1); + }; + + double const ab = side(ax, ay, bx, by); + double const bc = side(bx, by, cx, cy); + double const ca = side(cx, cy, ax, ay); + + return (ab >= 0 && bc >= 0 && ca >= 0) || (ab <= 0 && bc <= 0 && ca <= 0); +} +//--------------------------------------------------------------------------- +/* + NearSegment + + Whether (x, y) is within 'halfWidth' of the segment from a to b: inside a stroke along it with round ends. +*/ +bool NearSegment(double x, double y, double ax, double ay, double bx, double by, double halfWidth) +{ + double const lengthX = bx - ax; + double const lengthY = by - ay; + double const squaredLength = lengthX * lengthX + lengthY * lengthY; + double const along = squaredLength > 0 ? (std::max)(0.0, (std::min)(1.0, ((x - ax) * lengthX + (y - ay) * + lengthY) / squaredLength)) : 0.0; + double const offsetX = x - (ax + along * lengthX); + double const offsetY = y - (ay + along * lengthY); + + return offsetX * offsetX + offsetY * offsetY <= halfWidth * halfWidth; +} +//--------------------------------------------------------------------------- + +} // namespace + +//--------------------------------------------------------------------------- + +//--------------------------------------------------------------------------- +std::vector RenderWin32GUIGlyph(EWin32GUIGlyph glyph, int size) +{ + if (size <= 0) + return std::vector(); + + std::vector coverage(static_cast(size) * static_cast(size), 0); + int const samples = SamplesPerSide * SamplesPerSide; + + for (int row = 0; row < size; ++row) + { + for (int column = 0; column < size; ++column) + { + int inside = 0; + + for (int sampleY = 0; sampleY < SamplesPerSide; ++sampleY) + { + for (int sampleX = 0; sampleX < SamplesPerSide; ++sampleX) + { + // Each sample at the middle of its part of the pixel. + double const x = (column + (sampleX + 0.5) / SamplesPerSide) / size; + double const y = (row + (sampleY + 0.5) / SamplesPerSide) / size; + + if (Covers(glyph, x, y)) + ++inside; + } + } + + coverage[static_cast(row) * static_cast(size) + static_cast(column)] = + static_cast((inside * 255 + samples / 2) / samples); + } + } + + return coverage; +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h b/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h new file mode 100644 index 0000000..5207b46 --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h @@ -0,0 +1,73 @@ +/* ************************************************************************** +ASWUnitTests_Win32GUI_Glyphs.h +Author: Anthony S. West - ASW Software + +The small shapes the Win32 GUI runner's dark theme draws, such as a check +box's cross and a menu's submenu arrow, as Carbon (the VCL GUI runner's dark +style) has them, antialiased at any size. Contains no window code, so it's +unit tested from the Windows console runners (see +win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp). + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +#ifndef ASWUnitTests_Win32GUI_GlyphsH +#define ASWUnitTests_Win32GUI_GlyphsH +//--------------------------------------------------------------------------- +#include +#include +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +///////////////////////////////////////////////////////////////////////////// +// EWin32GUIGlyph +// +// A shape RenderWin32GUIGlyph() draws, in a square cell. The check box's +// and radio button's are Carbon's at 13 pixels; the menus' at 16. +///////////////////////////////////////////////////////////////////////////// +enum class EWin32GUIGlyph +{ + // A menu's submenu arrow: a triangle pointing right, in the cell's middle. + ArrowRight, + // A radio button's circle, filling the cell; drawn in the border color, with InnerCircle over it in the + // fill color. + Circle, + // A checked check box's mark: a cross. + Cross, + // A checked radio button's dot, and a checked radio menu item's. + Dot, + // The inside of a radio button's circle, one pixel in from Circle at 13 pixels. + InnerCircle, + // A check box's partial state: a square in the middle. + Square, + // A checked menu item's mark: a check mark. + Tick, +}; + +//--------------------------------------------------------------------------- + +// How much of each pixel 'glyph' covers in a 'size' x 'size' cell, row by row from the top left: 0 where it's +// absent, up to 255 where it covers the whole pixel, antialiased by sampling each pixel at 4 x 4 points. Empty +// if 'size' isn't positive. +std::vector RenderWin32GUIGlyph(EWin32GUIGlyph glyph, int size); + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef ASWUnitTests_Win32GUI_GlyphsH diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp index cb5dbd7..061a12e 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.cpp @@ -28,6 +28,7 @@ limitations under the License. #include #include #include +#include #include #include #include @@ -42,32 +43,21 @@ limitations under the License. #include "ASWUnitTests_GUI_View.h" //--------------------------------------------------------------------------- #include "ASWUnitTests_Win32GUI_MainLayout.h" +#include "ASWUnitTests_Win32GUI_MessageDialog.h" #include "ASWUnitTests_Win32GUI_ShuffleSeedDialog.h" #include "ASWUnitTests_Win32GUI_System.h" #include "ASWUnitTests_Win32GUI_TextDialog.h" //--------------------------------------------------------------------------- -// RAD Studio's Win32 (bcc32c) links its own Windows import libraries, which don't include SetWindowTheme()'s. -// CMake links uxtheme for the other compilers, and RAD Studio's Win64x links it already. -#if defined(__BORLANDC__) && !defined(_WIN64) -# pragma comment(lib, "uxtheme.lib") -#endif - -//--------------------------------------------------------------------------- - namespace ASWUnitTests { namespace { -// The log's color for failure lines, as the VCL GUI runner's. -COLORREF const FailureColor = RGB(200, 0, 0); wchar_t const MainWindowClassName[] = L"ASWUnitTests_Win32GUI_Main"; // Posted by PostToUI(), so the message loop runs the queued actions. UINT const RunQueuedMessage = WM_APP + 1; -// The log's color for skip lines, as the VCL GUI runner's. -COLORREF const SkipColor = RGB(190, 120, 0); // The title of the GUI's windows, as the VCL GUI runner's Application->Title. char const Title[] = "ASWUnitTests"; @@ -90,6 +80,9 @@ EWin32GUICommand const AllCommands[] = { EWin32GUICommand::ShuffleOrder, EWin32GUICommand::ShuffleSeed, EWin32GUICommand::Stop, + EWin32GUICommand::ThemeDark, + EWin32GUICommand::ThemeLight, + EWin32GUICommand::ThemeWindows, }; //--------------------------------------------------------------------------- @@ -141,7 +134,7 @@ void AppendCommand(HMENU menu, EWin32GUICommand command, wchar_t const* caption) HMENU AppendSubmenu(HMENU menuBar, wchar_t const* caption); char const* CommandName(EWin32GUICommand command); bool IsCommandEnabled(TGUICommandStates const& states, EWin32GUICommand command); -COLORREF LogLineColor(std::string const& line); +COLORREF LogLineColor(std::string const& line, TWin32GUITheme const& theme); std::vector MonitorWorkAreas(); HTREEITEM NextTreeItem(HWND tree, HTREEITEM item); MONITORINFO PrimaryMonitorInfo(); @@ -250,6 +243,15 @@ char const* CommandName(EWin32GUICommand command) case EWin32GUICommand::Stop: return "Stop"; + + case EWin32GUICommand::ThemeDark: + return "Theme > Dark"; + + case EWin32GUICommand::ThemeLight: + return "Theme > Light"; + + case EWin32GUICommand::ThemeWindows: + return "Theme > Use Windows Setting"; } return "a command"; @@ -309,6 +311,11 @@ bool IsCommandEnabled(TGUICommandStates const& states, EWin32GUICommand command) case EWin32GUICommand::Stop: return states.Stop; + + case EWin32GUICommand::ThemeDark: + case EWin32GUICommand::ThemeLight: + case EWin32GUICommand::ThemeWindows: + return states.Theme; } return false; @@ -317,23 +324,24 @@ bool IsCommandEnabled(TGUICommandStates const& states, EWin32GUICommand command) /* LogLineColor - The log's color for one line of the framework's log output; see ClassifyGUILogLine(). + The log's color for one line of the framework's log output (see ClassifyGUILogLine()) in 'theme', as the + VCL GUI runner's: normal lines in the text color, explicitly, so a dark log's aren't black. */ -COLORREF LogLineColor(std::string const& line) +COLORREF LogLineColor(std::string const& line, TWin32GUITheme const& theme) { switch (ClassifyGUILogLine(line)) { case TGUILogLineKind::Failure: - return FailureColor; + return theme.FailureTextColor(); case TGUILogLineKind::Skip: - return SkipColor; + return theme.SkipTextColor(); case TGUILogLineKind::Normal: break; } - return GetSysColor(COLOR_WINDOWTEXT); + return theme.WindowTextColor(); } //--------------------------------------------------------------------------- /* @@ -508,7 +516,7 @@ TWin32GUIMainWindow::TToolbarButton const TWin32GUIMainWindow::ToolbarButtons[7] }; //--------------------------------------------------------------------------- -TWin32GUIMainWindow::TWin32GUIMainWindow() +TWin32GUIMainWindow::TWin32GUIMainWindow(TWin32GUITheme& theme) : m_Btn_CopyDetails(nullptr), m_Btn_RunFailed(nullptr), m_Btn_RunSelected(nullptr), @@ -547,6 +555,9 @@ TWin32GUIMainWindow::TWin32GUIMainWindow() m_StatusMessage("Ready."), m_SyncingChecks(false), m_TestTreeWidth(TWin32GUIMainSizes::DefaultTestTreeWidth), + m_Theme(theme), + m_ThemeChangePending(false), + m_ThemeChoice(EGUITheme::Windows), m_Window(nullptr) { } @@ -628,7 +639,7 @@ void TWin32GUIMainWindow::AppendLog(std::string const& text) CHARFORMAT2W format = {}; format.cbSize = sizeof(format); format.dwMask = CFM_COLOR; - format.crTextColor = LogLineColor(line); + format.crTextColor = LogLineColor(line, m_Theme); SendMessageW(m_RE_Log, EM_SETCHARFORMAT, SCF_SELECTION, reinterpret_cast(&format)); SendMessageW(m_RE_Log, EM_REPLACESEL, FALSE, reinterpret_cast(UTF8ToWide(line + @@ -653,6 +664,8 @@ void TWin32GUIMainWindow::ApplyDPI() CreateStatusImages(); SendMessageW(m_TV_Tests, TVM_SETINDENT, static_cast(ScaleForDPI(19, m_DPI)), 0); + // 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. std::vector rightEdges; @@ -706,6 +719,31 @@ void TWin32GUIMainWindow::ApplyPanelSizes(TGUILayout const& layout) LayOut(); } //--------------------------------------------------------------------------- +/* + TWin32GUIMainWindow::ApplyTheme + + Dark or light as View > Theme says, or with Use Windows Setting, as Windows' app mode does, but light while + a high contrast theme is on (see GUIThemeIsDark()). Not during a run, as in the VCL GUI runner; SetRunning() + calls it again once the run has ended. +*/ +void TWin32GUIMainWindow::ApplyTheme() +{ + if (m_Running) + { + m_ThemeChangePending = true; + return; + } + + m_ThemeChangePending = false; + bool const dark = GUIThemeIsDark(m_ThemeChoice, WindowsAppsUseDarkTheme(), WindowsHighContrast()); + + if (dark == m_Theme.IsDark()) + return; + + m_Theme.SetDark(dark); + RecolorLog(); +} +//--------------------------------------------------------------------------- /* TWin32GUIMainWindow::BuildTestTree @@ -829,6 +867,7 @@ void TWin32GUIMainWindow::Create() throw std::runtime_error("Couldn't create the main window"); } + m_Theme.AttachWindow(m_Window, m_MM_Main); m_DPI = WindowDPI(m_Window); m_TestTreeWidth = ScaleForDPI(TWin32GUIMainSizes::DefaultTestTreeWidth, m_DPI); m_DetailHeight = ScaleForDPI(TWin32GUIMainSizes::DefaultDetailHeight, m_DPI); @@ -887,35 +926,40 @@ void TWin32GUIMainWindow::CreateControls() m_Lbl_Filter = CreateChild(0, L"STATIC", L"F&ilter:", SS_LEFT); m_Edt_Filter = CreateChild(WS_EX_CLIENTEDGE, L"EDIT", L"", WS_TABSTOP | ES_AUTOHSCROLL); SendMessageW(m_Edt_Filter, EM_SETCUEBANNER, FALSE, reinterpret_cast(L"e.g. String or *Handler*.Run_*")); + m_Theme.Attach(m_Edt_Filter, EWin32GUIThemedControl::Edit); m_TV_Tests = CreateChild(WS_EX_CLIENTEDGE, WC_TREEVIEWW, L"", WS_TABSTOP | TVS_HASBUTTONS | TVS_HASLINES | TVS_LINESATROOT | TVS_SHOWSELALWAYS); // The partial state, for a group or suite with only some of its tests checked, has to be asked for before // the check boxes, which create the check box images. The check boxes only once the tree exists, as - // TVS_CHECKBOXES' documentation says, or they may not show their state. - SendMessageW(m_TV_Tests, TVM_SETEXTENDEDSTYLE, TVS_EX_PARTIALCHECKBOXES, TVS_EX_PARTIALCHECKBOXES); + // TVS_CHECKBOXES' documentation says, or they may not show their state. Painted through a buffer, so it + // doesn't flicker. + SendMessageW(m_TV_Tests, TVM_SETEXTENDEDSTYLE, TVS_EX_PARTIALCHECKBOXES | TVS_EX_DOUBLEBUFFER, + TVS_EX_PARTIALCHECKBOXES | TVS_EX_DOUBLEBUFFER); SetWindowLongPtrW(m_TV_Tests, GWL_STYLE, GetWindowLongPtrW(m_TV_Tests, GWL_STYLE) | TVS_CHECKBOXES); - // Drawn like File Explorer's tree (arrows to expand, a light selection), as the VCL draws its trees. - SetWindowTheme(m_TV_Tests, L"Explorer", nullptr); + // Drawn like File Explorer's tree (arrows to expand, a light selection) while light, as the VCL draws its + // trees; see TWin32GUITheme. + m_Theme.Attach(m_TV_Tests, EWin32GUIThemedControl::TreeView); m_Spl_Tests.Create(m_Window, TWin32GUISplitter::EOrientation::Vertical, [this](int position) { OnTestsSplitterDrag(position); }); + m_Theme.Attach(m_Spl_Tests.Handle(), EWin32GUIThemedControl::Splitter); m_Mem_Detail = CreateChild(WS_EX_CLIENTEDGE, L"EDIT", L"", WS_TABSTOP | WS_VSCROLL | ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL); SendMessageW(m_Mem_Detail, EM_SETLIMITTEXT, 0, 0); // Otherwise 32K characters at most. + m_Theme.Attach(m_Mem_Detail, EWin32GUIThemedControl::Edit); m_Spl_Detail.Create(m_Window, TWin32GUISplitter::EOrientation::Horizontal, [this](int position) { OnDetailSplitterDrag(position); }); + m_Theme.Attach(m_Spl_Detail.Handle(), EWin32GUIThemedControl::Splitter); m_LV_Failures = CreateChild(WS_EX_CLIENTEDGE, WC_LISTVIEWW, L"", WS_TABSTOP | LVS_REPORT | LVS_SINGLESEL | LVS_SHOWSELALWAYS); SendMessageW(m_LV_Failures, LVM_SETEXTENDEDLISTVIEWSTYLE, LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER, LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER); - // As the VCL draws its list views; it also keeps a selected row's text in its result's color. - SetWindowTheme(m_LV_Failures, L"Explorer", nullptr); wchar_t const* const columns[] = { L"Result", L"Test", L"Detail" }; @@ -927,11 +971,15 @@ void TWin32GUIMainWindow::CreateControls() SendMessageW(m_LV_Failures, LVM_INSERTCOLUMNW, static_cast(i), reinterpret_cast(&column)); } + // As the VCL draws its list views while light; it also keeps a selected row's text in its result's color. + m_Theme.Attach(m_LV_Failures, EWin32GUIThemedControl::ListView); + m_RE_Log = CreateChild(WS_EX_CLIENTEDGE, MSFTEDIT_CLASS, L"", WS_TABSTOP | WS_VSCROLL | WS_HSCROLL | ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | ES_AUTOHSCROLL | ES_NOHIDESEL); SendMessageW(m_RE_Log, EM_EXLIMITTEXT, 0, 0x7FFFFFFE); // Otherwise 32K characters at most. SendMessageW(m_RE_Log, EM_SETTARGETDEVICE, 0, 1); // No word wrap. ShowWindow(m_RE_Log, SW_HIDE); + m_Theme.Attach(m_RE_Log, EWin32GUIThemedControl::RichEdit); m_PC_Results = CreateChild(0, WC_TABCONTROLW, L"", WS_TABSTOP | WS_CLIPSIBLINGS); // "&&" for a literal '&', as in the .dfm: a tab control takes '&' as a hot key's prefix. @@ -945,9 +993,13 @@ void TWin32GUIMainWindow::CreateControls() SendMessageW(m_PC_Results, TCM_INSERTITEMW, static_cast(i), reinterpret_cast(&item)); } + m_Theme.Attach(m_PC_Results, EWin32GUIThemedControl::TabControl); + m_PB_Progress = CreateChild(0, PROGRESS_CLASSW, L"", 0); + m_Theme.Attach(m_PB_Progress, EWin32GUIThemedControl::ProgressBar); // Placed and sized by LayOut(), at the .dfm's height, rather than by itself at its font's. m_SB_Status = CreateChild(0, STATUSCLASSNAMEW, L"", SBARS_SIZEGRIP | CCS_NORESIZE | CCS_NOPARENTALIGN); + m_Theme.Attach(m_SB_Status, EWin32GUIThemedControl::StatusBar); } //--------------------------------------------------------------------------- /* @@ -985,6 +1037,11 @@ void TWin32GUIMainWindow::CreateMenuBar() AppendCommand(view, EWin32GUICommand::CollapseAll, L"&Collapse All"); AppendMenuW(view, MF_SEPARATOR, 0, nullptr); AppendCommand(view, EWin32GUICommand::ResetLayout, L"&Reset Layout"); + AppendMenuW(view, MF_SEPARATOR, 0, nullptr); + HMENU const theme = AppendSubmenu(view, L"&Theme"); + AppendCommand(theme, EWin32GUICommand::ThemeWindows, L"Use &Windows Setting"); + AppendCommand(theme, EWin32GUICommand::ThemeLight, L"&Light"); + AppendCommand(theme, EWin32GUICommand::ThemeDark, L"&Dark"); HMENU const options = AppendSubmenu(m_MM_Main, L"&Options"); AppendCommand(options, EWin32GUICommand::ShuffleOrder, L"Run in &Shuffled Order"); @@ -1090,6 +1147,8 @@ void TWin32GUIMainWindow::CreateTooltips() if (m_TT_Hints == nullptr) throw std::runtime_error("Couldn't create the tooltips"); + m_Theme.Attach(m_TT_Hints, EWin32GUIThemedControl::Tooltip); + auto const addTool = [this](HWND control, wchar_t const* text) { TTTOOLINFOW tool = {}; @@ -1160,7 +1219,7 @@ void TWin32GUIMainWindow::DoCommand(EWin32GUICommand command) switch (command) { case EWin32GUICommand::About: - ShowTextDialog(m_Window, std::string("About ") + Title, GUIAboutText("Win32 GUI runner")); + ShowTextDialog(m_Window, m_Theme, std::string("About ") + Title, GUIAboutText("Win32 GUI runner")); break; case EWin32GUICommand::CollapseAll: @@ -1170,7 +1229,8 @@ void TWin32GUIMainWindow::DoCommand(EWin32GUICommand command) break; case EWin32GUICommand::CommandLineOptions: - ShowTextDialog(m_Window, std::string(Title) + " - Command Line Options", TGUICommandLine::HelpText()); + ShowTextDialog(m_Window, m_Theme, std::string(Title) + " - Command Line Options", + TGUICommandLine::HelpText()); break; case EWin32GUICommand::CopyDetails: @@ -1247,7 +1307,7 @@ void TWin32GUIMainWindow::DoCommand(EWin32GUICommand command) break; case EWin32GUICommand::ShuffleSeed: - if (ShowShuffleSeedDialog(m_Window, m_Shuffle)) + if (ShowShuffleSeedDialog(m_Window, m_Theme, m_Shuffle)) ShowShuffleState(); break; @@ -1258,6 +1318,18 @@ void TWin32GUIMainWindow::DoCommand(EWin32GUICommand command) UpdateStatusBar(); break; + case EWin32GUICommand::ThemeDark: + SetTheme(EGUITheme::Dark); + break; + + case EWin32GUICommand::ThemeLight: + SetTheme(EGUITheme::Light); + break; + + case EWin32GUICommand::ThemeWindows: + SetTheme(EGUITheme::Windows); + break; + default: break; } @@ -1336,7 +1408,7 @@ void TWin32GUIMainWindow::ExportJUnitReport() { std::string const message = "The JUnit report couldn't be written to:\n" + m_ExportJUnitReportPath + "\n\n" + error; - MessageBoxW(m_Window, UTF8ToWide(message).c_str(), UTF8ToWide(Title).c_str(), MB_OK | MB_ICONERROR); + ShowMessageDialog(m_Window, m_Theme, Title, message, EWin32GUIMessageIcon::Error); return; } @@ -1405,7 +1477,8 @@ LRESULT TWin32GUIMainWindow::HandleMessage(UINT message, WPARAM wParam, LPARAM l { WORD const id = LOWORD(wParam); - if (id >= static_cast(EWin32GUICommand::About) && id <= static_cast(EWin32GUICommand::Stop)) + if (id >= static_cast(EWin32GUICommand::About) && + id <= static_cast(EWin32GUICommand::ThemeWindows)) { DoCommand(static_cast(id)); return 0; @@ -1426,18 +1499,6 @@ LRESULT TWin32GUIMainWindow::HandleMessage(UINT message, WPARAM wParam, LPARAM l break; } - case WM_CTLCOLORSTATIC: - // The read-only detail pane is drawn like an editable one, as the VCL GUI runner's is, rather than gray. - if (reinterpret_cast(lParam) == m_Mem_Detail) - { - HDC const context = reinterpret_cast(wParam); - SetTextColor(context, GetSysColor(COLOR_WINDOWTEXT)); - SetBkColor(context, GetSysColor(COLOR_WINDOW)); - return reinterpret_cast(GetSysColorBrush(COLOR_WINDOW)); - } - - break; - case WM_DESTROY: if (m_Closing) PostQuitMessage(0); @@ -1497,12 +1558,36 @@ LRESULT TWin32GUIMainWindow::HandleMessage(UINT message, WPARAM wParam, LPARAM l break; } + case WM_SETTINGCHANGE: + // With Use Windows Setting, Windows' app mode or high contrast changing changes the theme. + if (IsThemeSettingChange(wParam, lParam)) + { + RunGuarded("following Windows' theme", [this]() + { + ApplyTheme(); + }); + } + + break; + case WM_SIZE: if (wParam != SIZE_MINIMIZED) LayOut(); return 0; + case WM_SYSCOLORCHANGE: + // The common controls need it passed on, and the light theme's colors are the system's. + for (HWND child = GetWindow(m_Window, GW_CHILD); child != nullptr; child = GetWindow(child, GW_HWNDNEXT)) + SendMessageW(child, WM_SYSCOLORCHANGE, wParam, lParam); + + RunGuarded("the system colors' change", [this]() + { + m_Theme.Refresh(); + RecolorLog(); + }); + return 0; + default: if (message == RunQueuedMessage) { @@ -1791,22 +1876,25 @@ void TWin32GUIMainWindow::OnDPIChanged(UINT dpi, RECT const& suggested) /* TWin32GUIMainWindow::OnFailuresCustomDraw - A row it can't match to a test is simply drawn in the default color. + A row it can't match to a test is simply drawn in the text color. The theme draws the row; see + TWin32GUITheme::OnListCustomDraw(). */ LRESULT TWin32GUIMainWindow::OnFailuresCustomDraw(NMLVCUSTOMDRAW& draw) const { - if (draw.nmcd.dwDrawStage == CDDS_PREPAINT) - return CDRF_NOTIFYITEMDRAW; + COLORREF color = m_Theme.WindowTextColor(); if (draw.nmcd.dwDrawStage == CDDS_ITEMPREPAINT) { std::size_t const index = static_cast(draw.nmcd.lItemlParam); if (index < m_TestList.Count()) - draw.clrText = m_TestList.Status(index) == TGUITestStatus::Failed ? FailureColor : SkipColor; + { + color = m_TestList.Status(index) == TGUITestStatus::Failed ? m_Theme.FailureTextColor() : + m_Theme.SkipTextColor(); + } } - return CDRF_DODEFAULT; + return m_Theme.OnListCustomDraw(draw, color); } //--------------------------------------------------------------------------- /* @@ -2026,6 +2114,62 @@ void TWin32GUIMainWindow::ProcessMessages() } } //--------------------------------------------------------------------------- +/* + TWin32GUIMainWindow::RecolorLog + + AppendLog() colored each line for the theme then. Every line is given the text color first, in one step, + then each failure and skip line its own color, as the VCL GUI runner's does. The selection and the scroll + position are kept. +*/ +void TWin32GUIMainWindow::RecolorLog() +{ + CHARRANGE selection = {}; + SendMessageW(m_RE_Log, EM_EXGETSEL, 0, reinterpret_cast(&selection)); + int const firstVisible = static_cast(SendMessageW(m_RE_Log, EM_GETFIRSTVISIBLELINE, 0, 0)); + + // Only while it's showing: WM_SETREDRAW's TRUE shows a hidden window, e.g. the log behind the Failures & + // Skips page. (bcc32c's std::optional can't hold a TRedrawScope, which has no default constructor.) + std::unique_ptr const updating = IsWindowVisible(m_RE_Log) != FALSE ? + std::make_unique(m_RE_Log) : nullptr; + + CHARFORMAT2W format = {}; + format.cbSize = sizeof(format); + format.dwMask = CFM_COLOR; + format.crTextColor = m_Theme.WindowTextColor(); + SendMessageW(m_RE_Log, EM_SETCHARFORMAT, SCF_ALL, reinterpret_cast(&format)); + + int const lineCount = static_cast(SendMessageW(m_RE_Log, EM_GETLINECOUNT, 0, 0)); + + for (int i = 0; i < lineCount; ++i) + { + int const start = static_cast(SendMessageW(m_RE_Log, EM_LINEINDEX, static_cast(i), 0)); + int const length = static_cast(SendMessageW(m_RE_Log, EM_LINELENGTH, static_cast(start), 0)); + + if (start < 0 || length <= 0) + continue; + + // EM_GETLINE's buffer starts with its size, in characters. + std::wstring text(static_cast(length) + 1, L' '); + text[0] = static_cast((std::min)(length + 1, 0xFFFF)); + text.resize(static_cast(SendMessageW(m_RE_Log, EM_GETLINE, static_cast(i), + reinterpret_cast(&text[0])))); + + std::string const line = WideToUTF8(text); + + if (ClassifyGUILogLine(line) == TGUILogLineKind::Normal) + continue; + + CHARRANGE range = { start, start + length }; + SendMessageW(m_RE_Log, EM_EXSETSEL, 0, reinterpret_cast(&range)); + format.crTextColor = LogLineColor(line, m_Theme); + SendMessageW(m_RE_Log, EM_SETCHARFORMAT, SCF_SELECTION, reinterpret_cast(&format)); + } + + SendMessageW(m_RE_Log, EM_EXSETSEL, 0, reinterpret_cast(&selection)); + int const nowVisible = static_cast(SendMessageW(m_RE_Log, EM_GETFIRSTVISIBLELINE, 0, 0)); + SendMessageW(m_RE_Log, EM_LINESCROLL, 0, firstVisible - nowVisible); +} +//--------------------------------------------------------------------------- void TWin32GUIMainWindow::RefreshAllNodeImages() { TRedrawScope const updating(m_TV_Tests); @@ -2040,7 +2184,7 @@ void TWin32GUIMainWindow::RefreshAllNodeImages() void TWin32GUIMainWindow::ReportUnexpectedError(std::string const& where, std::string const& description) { std::string const message = "An unexpected error occurred in " + where + ": " + description; - MessageBoxW(m_Window, UTF8ToWide(message).c_str(), UTF8ToWide(Title).c_str(), MB_OK | MB_ICONERROR); + ShowMessageDialog(m_Window, m_Theme, Title, message, EWin32GUIMessageIcon::Error); } //--------------------------------------------------------------------------- void TWin32GUIMainWindow::ResetLayout() @@ -2309,6 +2453,28 @@ void TWin32GUIMainWindow::SetRunning(bool running) { SetFocus(m_FocusBeforeRun); } + + // Windows' setting changed during the run; see ApplyTheme(). + if (!running && m_ThemeChangePending) + ApplyTheme(); +} +//--------------------------------------------------------------------------- +/* + TWin32GUIMainWindow::SetTheme + + Saved at once, rather than with the layout as the window closes, so the choice is kept even if the window + doesn't get to close normally; but not with --layout-ignore, which saves nothing. Reset Layout leaves it + alone. +*/ +void TWin32GUIMainWindow::SetTheme(EGUITheme theme) +{ + m_ThemeChoice = theme; + ShowThemeState(); + + if (!m_Options.LayoutIgnore) + SaveGUIThemeToLayoutFile(theme); + + ApplyTheme(); } //--------------------------------------------------------------------------- void TWin32GUIMainWindow::SetTreeExpanded(bool expanded) @@ -2364,17 +2530,33 @@ void TWin32GUIMainWindow::ShowShuffleState() UpdateStatusBar(); } //--------------------------------------------------------------------------- +void TWin32GUIMainWindow::ShowThemeState() +{ + EWin32GUICommand checked = EWin32GUICommand::ThemeWindows; + + if (m_ThemeChoice == EGUITheme::Dark) + checked = EWin32GUICommand::ThemeDark; + else if (m_ThemeChoice == EGUITheme::Light) + checked = EWin32GUICommand::ThemeLight; + + CheckMenuRadioItem(m_MM_Main, static_cast(EWin32GUICommand::ThemeDark), + static_cast(EWin32GUICommand::ThemeWindows), static_cast(checked), MF_BYCOMMAND); +} +//--------------------------------------------------------------------------- /* TWin32GUIMainWindow::Start 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. + selection saved when it last closed. WinMain() set the theme up for 'theme' before any window existed, so + the --help dialog had it too. */ -void TWin32GUIMainWindow::Start(TGUIOptions const& options) +void TWin32GUIMainWindow::Start(TGUIOptions const& options, EGUITheme theme) { m_Options = options; + m_ThemeChoice = theme; + ShowThemeState(); m_Observer.LogTextCallback = [this](std::string const& text) { diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.h b/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.h index e27752b..0a08677 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_MainWindow.h @@ -49,9 +49,11 @@ limitations under the License. #include "ASWUnitTests_GUI_RunObserver.h" #include "ASWUnitTests_GUI_Shuffle.h" #include "ASWUnitTests_GUI_TestList.h" +#include "ASWUnitTests_GUI_Theme.h" //--------------------------------------------------------------------------- #include "ASWUnitTests_Win32GUI_DPI.h" #include "ASWUnitTests_Win32GUI_Splitter.h" +#include "ASWUnitTests_Win32GUI_Theme.h" //--------------------------------------------------------------------------- namespace ASWUnitTests @@ -61,8 +63,10 @@ namespace ASWUnitTests // EWin32GUICommand // // The main window's commands, one per VCL GUI runner action, named as in -// TGUICommandStates (e.g. RunSelected for Act_RunSelected). Each is also the -// control ID of its menu item and toolbar button. +// TGUICommandStates (e.g. RunSelected for Act_RunSelected), the theme ones +// each a View > Theme item (TGUICommandStates::Theme). Each is also the +// control ID of its menu item and toolbar button; the Theme ones are +// consecutive, for CheckMenuRadioItem(). ///////////////////////////////////////////////////////////////////////////// enum class EWin32GUICommand : WORD { @@ -83,6 +87,9 @@ enum class EWin32GUICommand : WORD ShuffleOrder, ShuffleSeed, Stop, + ThemeDark, + ThemeLight, + ThemeWindows, }; @@ -194,6 +201,12 @@ class TWin32GUIMainWindow // BuildTestTree(); a Win32 tree's nodes stay valid until they're deleted. std::vector m_TestNodes; int m_TestTreeWidth; + // The look of the window, its controls and dialogs, light or dark; see ApplyTheme(). + TWin32GUITheme& m_Theme; + // Set when the theme should change during a run, which then changes it once the run has ended. + bool m_ThemeChangePending; + // The View > Theme choice. + EGUITheme m_ThemeChoice; TWin32GUIFont m_UIFont; HWND m_Window; @@ -213,6 +226,8 @@ class TWin32GUIMainWindow // Sets the test tree's width and the detail pane's height that 'layout' has, within what the splitters // allow. void ApplyPanelSizes(TGUILayout const& layout); + // Makes the window light or dark as m_ThemeChoice and Windows' settings say; see the definition. + void ApplyTheme(); // Rebuilds the test tree from m_TestList's visible tests, fully expanded; see the definition. void BuildTestTree(); void CenterOnMonitor(); @@ -261,6 +276,8 @@ class TWin32GUIMainWindow // Handles the messages waiting in the queue, so the window stays responsive while tests run; see the // definition. void ProcessMessages(); + // Colors the log's text again for the current theme; see the definition. + void RecolorLog(); // Sets every test tree node's status image from m_TestList. void RefreshAllNodeImages(); // Reports an error that escaped from 'where' in a message box. @@ -292,12 +309,16 @@ class TWin32GUIMainWindow void SetProgressPosition(int position); // Enables or disables the commands for a run starting or ending; see the definition. void SetRunning(bool running); + // Chooses View > Theme's 'theme', saving it, and applies it; see the definition. + void SetTheme(EGUITheme theme); // Expands or collapses every node of the test tree. void SetTreeExpanded(bool expanded); // Shows the results page whose tab is selected; see the definition. void ShowResultsPage(); // Shows whether shuffling is on, in the Options menu's check mark and the status bar. void ShowShuffleState(); + // Shows m_ThemeChoice in View > Theme's radio check. + void ShowThemeState(); // Sets every test tree node's check box from m_TestList; see the definition. void SyncTreeChecks(); // A test's tree node, or nullptr while the filter box hides it. @@ -315,7 +336,8 @@ class TWin32GUIMainWindow bool WriteJUnitReport(std::string const& path, std::string& error); public: - TWin32GUIMainWindow(); + // A window in 'theme''s look, which must outlive it. + explicit TWin32GUIMainWindow(TWin32GUITheme& theme); ~TWin32GUIMainWindow(); TWin32GUIMainWindow(TWin32GUIMainWindow const&) = delete; @@ -337,8 +359,9 @@ 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. Call after Create(), before Show(). - void Start(TGUIOptions const& options); + // 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); // 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_MessageDialog.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_MessageDialog.cpp new file mode 100644 index 0000000..3e269e7 --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_MessageDialog.cpp @@ -0,0 +1,355 @@ +/* ************************************************************************** +ASWUnitTests_Win32GUI_MessageDialog.cpp +Author: Anthony S. West - ASW Software + +The Win32 GUI runner's message box. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWUnitTests_Win32GUI_MessageDialog.h" +//--------------------------------------------------------------------------- +#include +#include +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Utils.h" +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_DialogTemplate.h" +#include "ASWUnitTests_Win32GUI_DPI.h" +#include "ASWUnitTests_Win32GUI_System.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +namespace +{ + +// Posted to the dialog when it moves to a monitor with another DPI, so its icon is reloaded and it's laid out +// again after Windows has rescaled it and its controls' fonts. +UINT const ApplyDPIMessage = WM_APP + 1; +// The controls' IDs; the OK button's is IDOK. +WORD const IconID = 100; +WORD const MessageID = 101; +// The OK button's subclass, for Ctrl+C. +UINT_PTR const OKButtonSubclassID = 1; + +///////////////////////////////////////////////////////////////////////////// +// TMessageDialogState +// +// What the dialog shows, and the icon it loads for its DPI. +///////////////////////////////////////////////////////////////////////////// +struct TMessageDialogState +{ + std::wstring Caption; + HICON Icon = nullptr; + EWin32GUIMessageIcon IconKind = EWin32GUIMessageIcon::Error; + std::wstring Text; + TWin32GUITheme* Theme = nullptr; +}; + +//--------------------------------------------------------------------------- + +void ApplyDPI(HWND dialog); +TWin32GUIDialogTemplate BuildDialogTemplate(TMessageDialogState const& state); +void CenterOnOwner(HWND dialog); +INT_PTR CALLBACK DialogProc(HWND dialog, UINT message, WPARAM wParam, LPARAM lParam); +INT_PTR HandleDialogMessage(HWND dialog, UINT message, WPARAM wParam, LPARAM lParam); +void LayOutDialog(HWND dialog); +LRESULT CALLBACK OKButtonProc(HWND button, UINT message, WPARAM wParam, LPARAM lParam, UINT_PTR id, + DWORD_PTR state); +UINT SoundOf(EWin32GUIMessageIcon icon); + +//--------------------------------------------------------------------------- +/* + ApplyDPI + + Loads the icon at the size Windows' icons have at the dialog's DPI, scaled down from a larger one if + needed, then lays the dialog out again. +*/ +void ApplyDPI(HWND dialog) +{ + TMessageDialogState* const state = reinterpret_cast(GetWindowLongPtrW(dialog, DWLP_USER)); + UINT const dpi = WindowDPI(dialog); + int const size = GetSystemMetricsForDpi(SM_CXICON, dpi); + + // IDI_ERROR and IDI_WARNING; the W versions of those macros need UNICODE. + HICON icon = nullptr; + LoadIconWithScaleDown(nullptr, MAKEINTRESOURCEW(state->IconKind == EWin32GUIMessageIcon::Error ? 32513 : 32515), + size, size, &icon); + + if (icon != nullptr) + { + SendDlgItemMessageW(dialog, IconID, STM_SETICON, reinterpret_cast(icon), 0); + + if (state->Icon != nullptr) + DestroyIcon(state->Icon); + + state->Icon = icon; + } + + LayOutDialog(dialog); +} +//--------------------------------------------------------------------------- +/* + BuildDialogTemplate + + LayOutDialog() sets the positions and sizes. +*/ +TWin32GUIDialogTemplate BuildDialogTemplate(TMessageDialogState const& state) +{ + TWin32GUIDialogTemplate dialogTemplate(WS_POPUP | WS_CAPTION | WS_SYSMENU | DS_MODALFRAME, state.Caption); + + dialogTemplate.AddControl(EWin32GUIDialogControlClass::Static, IconID, WS_CHILD | WS_VISIBLE | SS_ICON | + SS_REALSIZECONTROL, 0, std::wstring()); + dialogTemplate.AddControl(EWin32GUIDialogControlClass::Static, MessageID, WS_CHILD | WS_VISIBLE | SS_LEFT | + SS_NOPREFIX, 0, state.Text); + dialogTemplate.AddControl(EWin32GUIDialogControlClass::Button, IDOK, WS_CHILD | WS_VISIBLE | WS_TABSTOP | + BS_DEFPUSHBUTTON, 0, L"OK"); + + return dialogTemplate; +} +//--------------------------------------------------------------------------- +/* + CenterOnOwner + + As MessageBoxW() places itself: centered on its owner, or on the work area of its monitor if it has none, + kept within that work area. +*/ +void CenterOnOwner(HWND dialog) +{ + HWND const owner = GetWindow(dialog, GW_OWNER); + MONITORINFO monitor = {}; + monitor.cbSize = sizeof(monitor); + GetMonitorInfoW(MonitorFromWindow(owner != nullptr ? owner : dialog, MONITOR_DEFAULTTONEAREST), &monitor); + + RECT const& work = monitor.rcWork; + RECT around = work; + + if (owner != nullptr) + GetWindowRect(owner, &around); + + RECT bounds; + GetWindowRect(dialog, &bounds); + int const width = bounds.right - bounds.left; + int const height = bounds.bottom - bounds.top; + + int const left = (std::max)(static_cast(work.left), (std::min)(static_cast(work.right) - width, + static_cast(around.left + (around.right - around.left - width) / 2))); + int const top = (std::max)(static_cast(work.top), (std::min)(static_cast(work.bottom) - height, + static_cast(around.top + (around.bottom - around.top - height) / 2))); + + SetWindowPos(dialog, nullptr, left, top, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); +} +//--------------------------------------------------------------------------- +/* + DialogProc + + No exception may leave a dialog procedure, since unwinding through Windows' own frames is undefined. One + that would ends the dialog with -1, which ShowMessageDialog() answers with a MessageBoxW(). +*/ +INT_PTR CALLBACK DialogProc(HWND dialog, UINT message, WPARAM wParam, LPARAM lParam) +{ + try + { + return HandleDialogMessage(dialog, message, wParam, lParam); + } + catch (...) + { + EndDialog(dialog, -1); + return TRUE; + } +} +//--------------------------------------------------------------------------- +INT_PTR HandleDialogMessage(HWND dialog, UINT message, WPARAM wParam, LPARAM lParam) +{ + switch (message) + { + case WM_INITDIALOG: + { + SetWindowLongPtrW(dialog, DWLP_USER, lParam); + TMessageDialogState* const state = reinterpret_cast(lParam); + + state->Theme->AttachWindow(dialog); + SetWindowSubclass(GetDlgItem(dialog, IDOK), &OKButtonProc, OKButtonSubclassID, + reinterpret_cast(state)); + + // Shown before the main window exists, it has a taskbar button of its own. + if (GetWindow(dialog, GW_OWNER) == nullptr) + SendMessageW(dialog, WM_SETICON, ICON_BIG, reinterpret_cast(MainIcon())); + + ApplyDPI(dialog); + CenterOnOwner(dialog); + MessageBeep(SoundOf(state->IconKind)); + + SetFocus(GetDlgItem(dialog, IDOK)); + return FALSE; // The focus is set. + } + + case WM_COMMAND: + if (LOWORD(wParam) == IDOK || LOWORD(wParam) == IDCANCEL) + { + EndDialog(dialog, IDOK); + return TRUE; + } + + return FALSE; + + case WM_DPICHANGED: + PostMessageW(dialog, ApplyDPIMessage, 0, 0); + return FALSE; // Windows moves and rescales the dialog. + + default: + if (message == ApplyDPIMessage) + { + ApplyDPI(dialog); + return TRUE; + } + + return FALSE; + } +} +//--------------------------------------------------------------------------- +/* + LayOutDialog + + As a MessageBoxW() at the dialog's DPI: the icon at the top left, the message to its right, wrapped at 400 + pixels at 96 DPI and centered on the icon if it's shorter, and the OK button centered below both, as the + VCL GUI runner's message dialogs have it. Then sizes the dialog to fit, keeping its position. +*/ +void LayOutDialog(HWND dialog) +{ + UINT const dpi = WindowDPI(dialog); + int const margin = ScaleForDPI(12, dpi); + int const iconSize = GetSystemMetricsForDpi(SM_CXICON, dpi); + HWND const messageLabel = GetDlgItem(dialog, MessageID); + + std::wstring const text = UTF8ToWide(WindowText(messageLabel)); + RECT bounds = { 0, 0, ScaleForDPI(400, dpi), 0 }; + HDC const context = GetDC(messageLabel); + + if (context == nullptr) + throw std::runtime_error("Couldn't measure the message"); + + HGDIOBJ const oldFont = SelectObject(context, reinterpret_cast(SendMessageW(messageLabel, WM_GETFONT, 0, + 0))); + DrawTextW(context, text.c_str(), -1, &bounds, DT_CALCRECT | DT_WORDBREAK | DT_NOPREFIX | DT_EXPANDTABS); + SelectObject(context, oldFont); + ReleaseDC(messageLabel, context); + + int const textWidth = bounds.right; + int const textHeight = bounds.bottom; + int const textLeft = margin + iconSize + margin; + int const textTop = margin + (std::max)(0, (iconSize - textHeight) / 2); + + MoveWindow(GetDlgItem(dialog, IconID), margin, margin, iconSize, iconSize, TRUE); + MoveWindow(messageLabel, textLeft, textTop, textWidth, textHeight, TRUE); + + int const buttonWidth = ScaleForDPI(80, dpi); + int const buttonHeight = ScaleForDPI(26, dpi); + int const width = (std::max)(textLeft + textWidth + margin, buttonWidth + 2 * margin); + int const buttonTop = (std::max)(margin + iconSize, textTop + textHeight) + margin + ScaleForDPI(4, dpi); + MoveWindow(GetDlgItem(dialog, IDOK), (width - buttonWidth) / 2, buttonTop, buttonWidth, buttonHeight, TRUE); + + RECT frame = { 0, 0, width, buttonTop + buttonHeight + margin }; + AdjustWindowRectExForDpi(&frame, static_cast(GetWindowLongPtrW(dialog, GWL_STYLE)), FALSE, + static_cast(GetWindowLongPtrW(dialog, GWL_EXSTYLE)), dpi); + SetWindowPos(dialog, nullptr, 0, 0, frame.right - frame.left, frame.bottom - frame.top, + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); +} +//--------------------------------------------------------------------------- +/* + OKButtonProc + + The OK button, which has the focus, copies the dialog as text on Ctrl+C, laid out as a MessageBoxW() + copies itself. No exception may leave a window procedure; a failure to copy is ignored, as it would be in + a MessageBoxW(). +*/ +LRESULT CALLBACK OKButtonProc(HWND button, UINT message, WPARAM wParam, LPARAM lParam, UINT_PTR id, DWORD_PTR state) +{ + if (message == WM_NCDESTROY) + { + RemoveWindowSubclass(button, &OKButtonProc, id); + } + else if (message == WM_KEYDOWN && (wParam == 'C' || wParam == VK_INSERT) && + (GetKeyState(VK_CONTROL) & 0x8000) != 0) + { + try + { + TMessageDialogState const* const dialog = reinterpret_cast(state); + std::string const line = "---------------------------\r\n"; + SetClipboardText(button, line + WideToUTF8(dialog->Caption) + "\r\n" + line + WideToUTF8(dialog->Text) + + "\r\n" + line + "OK\r\n" + line); + } + catch (...) + { + } + + return 0; + } + + return DefSubclassProc(button, message, wParam, lParam); +} +//--------------------------------------------------------------------------- +UINT SoundOf(EWin32GUIMessageIcon icon) +{ + return icon == EWin32GUIMessageIcon::Error ? MB_ICONERROR : MB_ICONWARNING; +} +//--------------------------------------------------------------------------- + +} // namespace + +//--------------------------------------------------------------------------- + +//--------------------------------------------------------------------------- +void ShowMessageDialog(HWND owner, TWin32GUITheme& theme, std::string const& caption, std::string const& text, + EWin32GUIMessageIcon icon) +{ + try + { + TMessageDialogState state; + state.Caption = UTF8ToWide(caption); + state.IconKind = icon; + state.Text = UTF8ToWide(AdjustLineBreaks(text)); + state.Theme = &theme; + + TWin32GUIDialogTemplate const dialogTemplate = BuildDialogTemplate(state); + INT_PTR const result = DialogBoxIndirectParamW(GetModuleHandleW(nullptr), dialogTemplate.Get(), owner, + &DialogProc, reinterpret_cast(&state)); + + if (state.Icon != nullptr) + DestroyIcon(state.Icon); + + if (result != -1) + return; + } + catch (...) + { + } + + try + { + MessageBoxW(owner, UTF8ToWide(text).c_str(), UTF8ToWide(caption).c_str(), MB_OK | SoundOf(icon)); + } + catch (...) + { + } +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_MessageDialog.h b/win32/gui/src/ASWUnitTests_Win32GUI_MessageDialog.h new file mode 100644 index 0000000..17717c9 --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_MessageDialog.h @@ -0,0 +1,62 @@ +/* ************************************************************************** +ASWUnitTests_Win32GUI_MessageDialog.h +Author: Anthony S. West - ASW Software + +The Win32 GUI runner's message box: an icon, a message and an OK button, as +MessageBoxW() shows them, but in the GUI's theme. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +#ifndef ASWUnitTests_Win32GUI_MessageDialogH +#define ASWUnitTests_Win32GUI_MessageDialogH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_Windows.h" +//--------------------------------------------------------------------------- +#include +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_Theme.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +///////////////////////////////////////////////////////////////////////////// +// EWin32GUIMessageIcon +// +// The icon ShowMessageDialog() shows, and the sound it plays, as +// MessageBoxW()'s MB_ICONERROR and MB_ICONWARNING. +///////////////////////////////////////////////////////////////////////////// +enum class EWin32GUIMessageIcon +{ + Error, + Warning, +}; + +//--------------------------------------------------------------------------- + +// Shows 'text' (UTF-8) in a modal dialog titled 'caption', owned by 'owner' (nullptr for none), with 'icon' and +// an OK button, in 'theme''s look, and waits until it's closed. As in a MessageBoxW(), its sound plays, the +// message wraps, and Ctrl+C copies the caption, the message and the button's text. Throws nothing: if the +// dialog can't be shown, a MessageBoxW() is. +void ShowMessageDialog(HWND owner, TWin32GUITheme& theme, std::string const& caption, std::string const& text, + EWin32GUIMessageIcon icon); + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef ASWUnitTests_Win32GUI_MessageDialogH diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp new file mode 100644 index 0000000..fcd4fd8 --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp @@ -0,0 +1,144 @@ +/* ************************************************************************** +ASWUnitTests_Win32GUI_Palette.cpp +Author: Anthony S. West - ASW Software + +The colors of the Win32 GUI runner's dark theme. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWUnitTests_Win32GUI_Palette.h" +//--------------------------------------------------------------------------- +#include +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +//--------------------------------------------------------------------------- +COLORREF BlendColors(COLORREF from, COLORREF to, int numerator, int denominator) +{ + if (denominator <= 0) + return from; + + int const part = (std::max)(0, (std::min)(numerator, denominator)); + auto const channel = [part, denominator](int a, int b) + { + return static_cast((a * (denominator - part) + b * part + denominator / 2) / denominator); + }; + + return RGB(channel(GetRValue(from), GetRValue(to)), channel(GetGValue(from), GetGValue(to)), + channel(GetBValue(from), GetBValue(to))); +} +//--------------------------------------------------------------------------- +/* + CarbonPalette + + Most colors are Carbon.vsf's own, from TStyleManager::ActiveStyle's system, style and font colors and + from pixels of the elements it draws; the selection, the tree's lines, the failed progress bar and the + edit border are sampled from the VCL GUI runner under Carbon (plans/1.5/theme-win32-handoff.md). +*/ +TWin32GUIPalette CarbonPalette() +{ + TWin32GUIPalette palette; + + palette.ButtonBorder = RGB(0x49, 0x49, 0x49); + palette.ButtonDefaultFrame = RGB(0x24, 0x61, 0x7A); + palette.ButtonDisabledBorder = RGB(0x3A, 0x3A, 0x3A); + palette.ButtonDisabledFaceBottom = RGB(0x30, 0x30, 0x30); + palette.ButtonDisabledFaceTop = RGB(0x3C, 0x3C, 0x3C); + palette.ButtonDisabledOutline = RGB(0x2C, 0x2C, 0x2C); + palette.ButtonFaceBottom = RGB(0x31, 0x31, 0x31); + palette.ButtonFaceTop = RGB(0x43, 0x43, 0x43); + palette.ButtonHotBorder = RGB(0x57, 0x57, 0x57); + palette.ButtonHotFaceBottom = RGB(0x35, 0x35, 0x35); + palette.ButtonHotFaceTop = RGB(0x47, 0x47, 0x47); + palette.ButtonOutline = RGB(0x19, 0x19, 0x19); + palette.ButtonPressedFaceBottom = RGB(0x48, 0x48, 0x48); + palette.ButtonPressedFaceTop = RGB(0x37, 0x37, 0x37); + palette.CheckBoxBorder = RGB(0x47, 0x47, 0x47); + palette.CheckBoxFill = RGB(0x19, 0x19, 0x19); + palette.CheckBoxHotBorder = RGB(0x5F, 0x5F, 0x5F); + palette.CheckMark = RGB(0x84, 0x84, 0x84); + palette.CheckMarkDisabled = RGB(0x53, 0x53, 0x53); + palette.ControlText = RGB(0xC0, 0xC0, 0xC0); + palette.DisabledText = RGB(0x80, 0x80, 0x80); + palette.EditBorderInner = RGB(0x4C, 0x4C, 0x4C); + palette.EditBorderOuter = RGB(0x2E, 0x2E, 0x2E); + palette.GripDot = RGB(0x1A, 0x1A, 0x1A); + palette.HeaderBottom = RGB(0x35, 0x35, 0x35); + palette.HeaderDivider = RGB(0x2E, 0x2E, 0x2E); + palette.HeaderHot = RGB(0x4C, 0x4C, 0x4C); + palette.HeaderTop = RGB(0x3D, 0x3D, 0x3D); + palette.Highlight = RGB(0x26, 0x5A, 0x73); + palette.HighlightFrame = RGB(0x20, 0x20, 0x20); + palette.HotText = RGB(0xFF, 0xFF, 0xFF); + palette.MenuBackground = RGB(0x19, 0x19, 0x19); + palette.MenuBarDisabledText = RGB(0x59, 0x59, 0x59); + palette.MenuBorderInner = RGB(0x41, 0x41, 0x41); + palette.MenuBorderOuter = RGB(0x2E, 0x2E, 0x2E); + palette.MenuDisabledText = RGB(0x4F, 0x4F, 0x4F); + palette.MenuSeparator = RGB(0x84, 0x84, 0x84); + palette.Panel = RGB(0x33, 0x33, 0x33); + palette.ProgressBorder = RGB(0x2B, 0x2B, 0x2B); + palette.ProgressChunkBottom = RGB(0x27, 0x54, 0x6A); + palette.ProgressChunkTop = RGB(0x24, 0x61, 0x7A); + palette.ProgressFailedBottom = RGB(0x95, 0x29, 0x34); + palette.ProgressFailedTop = RGB(0x94, 0x30, 0x3C); + palette.ProgressTrack = RGB(0x19, 0x19, 0x19); + palette.Selection = RGB(0x62, 0x62, 0x62); + palette.SelectionFrame = RGB(0x38, 0x38, 0x38); + palette.StatusLine = RGB(0x2E, 0x2E, 0x2E); + palette.TabActiveBottom = RGB(0x35, 0x35, 0x35); + palette.TabActiveTop = RGB(0x3C, 0x3C, 0x3C); + palette.TabBorder = RGB(0x41, 0x41, 0x41); + palette.TabHot = RGB(0x33, 0x33, 0x33); + palette.TabInactive = RGB(0x27, 0x27, 0x27); + palette.TabInactiveBorder = RGB(0x2B, 0x2B, 0x2B); + palette.TabOuter = RGB(0x14, 0x14, 0x14); + palette.TooltipBackground = RGB(0x51, 0x51, 0x51); + palette.TooltipText = RGB(0xEA, 0xEA, 0xEA); + palette.TreeLine = RGB(0x6D, 0x6D, 0x6D); + palette.Window = RGB(0x19, 0x19, 0x19); + palette.WindowText = RGB(0xEA, 0xEA, 0xEA); + // As the VCL GUI runner's on its dark window color. + palette.FailureText = FailureTextColorOn(palette.Window); + palette.SkipText = SkipTextColorOn(palette.Window); + + return palette; +} +//--------------------------------------------------------------------------- +COLORREF FailureTextColorOn(COLORREF background) +{ + return IsDarkColor(background) ? RGB(255, 100, 100) : RGB(200, 0, 0); +} +//--------------------------------------------------------------------------- +bool IsDarkColor(COLORREF color) +{ + int const luma = (GetRValue(color) * 299 + GetGValue(color) * 587 + GetBValue(color) * 114) / 1000; + + return luma < 128; +} +//--------------------------------------------------------------------------- +COLORREF SkipTextColorOn(COLORREF background) +{ + return IsDarkColor(background) ? RGB(255, 190, 70) : RGB(190, 120, 0); +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h new file mode 100644 index 0000000..881417d --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h @@ -0,0 +1,157 @@ +/* ************************************************************************** +ASWUnitTests_Win32GUI_Palette.h +Author: Anthony S. West - ASW Software + +The colors of the Win32 GUI runner's dark theme, one per role, as RAD +Studio's Carbon style (the VCL GUI runner's dark theme) has them. Contains +no window code, so it's unit tested from the Windows console runners (see +win32/tests/Test_ASWUnitTests_Win32GUI_Palette.cpp). + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +#ifndef ASWUnitTests_Win32GUI_PaletteH +#define ASWUnitTests_Win32GUI_PaletteH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_Windows.h" +//--------------------------------------------------------------------------- + +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. +///////////////////////////////////////////////////////////////////////////// +struct TWin32GUIPalette +{ + // Push buttons: the outline around them, the border, and the face's gradient from top to bottom, normal, + // hot (under the mouse), pressed and disabled; the default button's (and the focused one's) line inside + // its border. + COLORREF ButtonBorder; + COLORREF ButtonDefaultFrame; + COLORREF ButtonDisabledBorder; + COLORREF ButtonDisabledFaceBottom; + COLORREF ButtonDisabledFaceTop; + COLORREF ButtonDisabledOutline; + COLORREF ButtonFaceBottom; + COLORREF ButtonFaceTop; + COLORREF ButtonHotBorder; + COLORREF ButtonHotFaceBottom; + COLORREF ButtonHotFaceTop; + COLORREF ButtonOutline; + COLORREF ButtonPressedFaceBottom; + COLORREF ButtonPressedFaceTop; + // Check boxes (the test tree's too) and radio buttons: the box's or circle's border, its inside, and the + // mark (a cross, the partial state's square, the radio button's dot); the border under the mouse, and the + // mark of a disabled one. + COLORREF CheckBoxBorder; + COLORREF CheckBoxFill; + COLORREF CheckBoxHotBorder; + COLORREF CheckMark; + COLORREF CheckMarkDisabled; + // The text of labels, buttons, radio buttons and check boxes, menus, the inactive tab, the list's header + // and the status bar. + COLORREF ControlText; + // Disabled text, but the menus' (see MenuBarDisabledText and MenuDisabledText). + COLORREF DisabledText; + // The two-pixel border of the edit boxes, the tree, the list and the log: outer and inner. + COLORREF EditBorderInner; + COLORREF EditBorderOuter; + // A failed and a skipped test's text in the log and the Failures & Skips list. + COLORREF FailureText; + // The status bar's size grip. + COLORREF GripDot; + // The list's header: its gradient from top to bottom, under the mouse, and the lines between columns. + COLORREF HeaderBottom; + COLORREF HeaderDivider; + COLORREF HeaderHot; + COLORREF HeaderTop; + // The menu item under the mouse or the keyboard, and its frame. + COLORREF Highlight; + COLORREF HighlightFrame; + // Text over Highlight or Selection, and a hot or pressed button's. + COLORREF HotText; + // A disabled item's text, in the menu bar and in a menu. + COLORREF MenuBarDisabledText; + COLORREF MenuDisabledText; + // A menu's background, its two-pixel frame (outer and inner) and its separators. + COLORREF MenuBackground; + COLORREF MenuBorderInner; + COLORREF MenuBorderOuter; + COLORREF MenuSeparator; + // The window's background, and that of the menu bar, the toolbar, the tab strip, the status bar, the + // splitters and the dialogs. + COLORREF Panel; + // The progress bar: its border, its track, and its bar's gradient from top to bottom, normal and failed. + COLORREF ProgressBorder; + COLORREF ProgressChunkBottom; + COLORREF ProgressChunkTop; + COLORREF ProgressFailedBottom; + COLORREF ProgressFailedTop; + COLORREF ProgressTrack; + // The selected node or row in the tree and the list, and its frame. + COLORREF Selection; + COLORREF SelectionFrame; + COLORREF SkipText; + // The line along the status bar's top. + COLORREF StatusLine; + // The results tabs: the selected one's gradient from top to bottom, the others' color, one under the + // mouse, their borders, and the line around the strip and the pages. + COLORREF TabActiveBottom; + COLORREF TabActiveTop; + COLORREF TabBorder; + COLORREF TabHot; + COLORREF TabInactive; + COLORREF TabInactiveBorder; + COLORREF TabOuter; + // Tooltips. + COLORREF TooltipBackground; + COLORREF TooltipText; + // The test tree's dotted lines between nodes. + COLORREF TreeLine; + // The background of the edit boxes, the detail pane, the tree, the list and the log, and their text. + COLORREF Window; + COLORREF WindowText; +}; + +//--------------------------------------------------------------------------- + +// 'from' mixed with 'to' in the proportion 'numerator' / 'denominator', each channel rounded to the nearest: +// 'from' at 0, 'to' at 'denominator', e.g. one row of a gradient. 'from' if 'denominator' isn't positive; the +// proportion is kept within 0 and 1. +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(); +// 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 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); + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef ASWUnitTests_Win32GUI_PaletteH diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_ShuffleSeedDialog.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_ShuffleSeedDialog.cpp index 31316eb..1295d79 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_ShuffleSeedDialog.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_ShuffleSeedDialog.cpp @@ -34,6 +34,7 @@ limitations under the License. //--------------------------------------------------------------------------- #include "ASWUnitTests_Win32GUI_DialogTemplate.h" #include "ASWUnitTests_Win32GUI_DPI.h" +#include "ASWUnitTests_Win32GUI_MessageDialog.h" #include "ASWUnitTests_Win32GUI_System.h" //--------------------------------------------------------------------------- @@ -56,6 +57,17 @@ WORD const LastSeedLabelID = 104; WORD const NewSeedID = 101; WORD const SeedBoxID = 103; +///////////////////////////////////////////////////////////////////////////// +// TShuffleSeedDialogState +// +// The choice the dialog edits, and the theme it's shown in. +///////////////////////////////////////////////////////////////////////////// +struct TShuffleSeedDialogState +{ + TGUIShuffle* Shuffle = nullptr; + TWin32GUITheme* Theme = nullptr; +}; + //--------------------------------------------------------------------------- void ApplyChoice(HWND dialog); @@ -76,13 +88,15 @@ int TextHeight(HWND control, int width); */ void ApplyChoice(HWND dialog) { - TGUIShuffle* const shuffle = reinterpret_cast(GetWindowLongPtrW(dialog, DWLP_USER)); + TShuffleSeedDialogState const* const state = reinterpret_cast( + GetWindowLongPtrW(dialog, DWLP_USER)); HWND const seedBox = GetDlgItem(dialog, SeedBoxID); std::string error; - if (!shuffle->ApplySeedChoice(IsDlgButtonChecked(dialog, NewSeedID) == BST_CHECKED, WindowText(seedBox), error)) + if (!state->Shuffle->ApplySeedChoice(IsDlgButtonChecked(dialog, NewSeedID) == BST_CHECKED, WindowText(seedBox), + error)) { - MessageBoxW(dialog, UTF8ToWide(error).c_str(), DialogCaption, MB_OK | MB_ICONWARNING); + ShowMessageDialog(dialog, *state->Theme, WideToUTF8(DialogCaption), error, EWin32GUIMessageIcon::Warning); // Focuses the seed box as tabbing to it does, which also selects its text. SendMessageW(dialog, WM_NEXTDLGCTL, reinterpret_cast(seedBox), TRUE); return; @@ -189,12 +203,14 @@ INT_PTR HandleDialogMessage(HWND dialog, UINT message, WPARAM wParam, LPARAM lPa case WM_INITDIALOG: { SetWindowLongPtrW(dialog, DWLP_USER, lParam); - TGUIShuffle const* const shuffle = reinterpret_cast(lParam); + TShuffleSeedDialogState const* const state = reinterpret_cast(lParam); HWND const seedBox = GetDlgItem(dialog, SeedBoxID); SendMessageW(seedBox, EM_SETLIMITTEXT, 10, 0); // 4294967295, the largest seed. + state->Theme->AttachWindow(dialog); + state->Theme->Attach(seedBox, EWin32GUIThemedControl::Edit); - bool const fixedSeed = shuffle->FixedSeed().has_value(); + bool const fixedSeed = state->Shuffle->FixedSeed().has_value(); SelectSeedChoice(dialog, fixedSeed ? FixedSeedID : NewSeedID); LayOutDialog(dialog); @@ -350,12 +366,16 @@ int TextHeight(HWND control, int width) //--------------------------------------------------------------------------- //--------------------------------------------------------------------------- -bool ShowShuffleSeedDialog(HWND owner, TGUIShuffle& shuffle) +bool ShowShuffleSeedDialog(HWND owner, TWin32GUITheme& theme, TGUIShuffle& shuffle) { TWin32GUIDialogTemplate const dialogTemplate = BuildDialogTemplate(shuffle); + TShuffleSeedDialogState state; + state.Shuffle = &shuffle; + state.Theme = &theme; + INT_PTR const result = DialogBoxIndirectParamW(GetModuleHandleW(nullptr), dialogTemplate.Get(), owner, - &DialogProc, reinterpret_cast(&shuffle)); + &DialogProc, reinterpret_cast(&state)); if (result == -1) throw std::runtime_error("Couldn't show the \"Shuffle Seed\" dialog"); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_ShuffleSeedDialog.h b/win32/gui/src/ASWUnitTests_Win32GUI_ShuffleSeedDialog.h index a5ab093..8690c26 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_ShuffleSeedDialog.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_ShuffleSeedDialog.h @@ -29,6 +29,8 @@ limitations under the License. //--------------------------------------------------------------------------- #include "ASWUnitTests_GUI_Shuffle.h" //--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_Theme.h" +//--------------------------------------------------------------------------- namespace ASWUnitTests { @@ -36,8 +38,8 @@ namespace ASWUnitTests // Shows a modal dialog, owned by 'owner', for choosing between a new random seed for each shuffled run and one // seed for every run, starting from 'shuffle's current choice. Clicking OK applies the choice to 'shuffle', which // also turns shuffling on, and returns true; Cancel changes nothing and returns false. Throws std::runtime_error -// if the dialog can't be shown. -bool ShowShuffleSeedDialog(HWND owner, TGUIShuffle& shuffle); +// if the dialog can't be shown. The dialog has 'theme''s look. +bool ShowShuffleSeedDialog(HWND owner, TWin32GUITheme& theme, TGUIShuffle& shuffle); } // namespace ASWUnitTests diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Sources.cmake b/win32/gui/src/ASWUnitTests_Win32GUI_Sources.cmake index 7eb6f4d..8624ca6 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Sources.cmake +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Sources.cmake @@ -64,12 +64,16 @@ set(ASWUNITTESTS_WIN32_GUI_SOURCES ${ASWUNITTESTS_GUI_SOURCES} "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_DialogTemplate.cpp" "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_DPI.cpp" + "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_Glyphs.cpp" "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_MainLayout.cpp" "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_MainWindow.cpp" + "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_MessageDialog.cpp" + "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_Palette.cpp" "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_ShuffleSeedDialog.cpp" "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_Splitter.cpp" "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_System.cpp" "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_TextDialog.cpp" + "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_Theme.cpp" "${ASWUNITTESTS_WIN32_GUI_SOURCE_DIR}/ASWUnitTests_Win32GUI_WinMain.cpp" "${ASWUNITTESTS_RESOURCE_DIR}/ASWUnitTests_Icon.rc" ) diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp index 1c1e7ee..fa9fd97 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_System.cpp @@ -31,11 +31,14 @@ limitations under the License. //--------------------------------------------------------------------------- #include #include +#include #include #include //--------------------------------------------------------------------------- #include "ASWUnitTests_Utils.h" //--------------------------------------------------------------------------- +#include "ASWUnitTests_GUI_INIDocument.h" +//--------------------------------------------------------------------------- namespace ASWUnitTests { @@ -260,6 +263,33 @@ std::string GUISettingsFolderPath() return WideToUTF8(appData) + "\\ASWUnitTests"; } //--------------------------------------------------------------------------- +bool IsThemeSettingChange(WPARAM wParam, LPARAM lParam) +{ + if (wParam == SPI_SETHIGHCONTRAST) + return true; + + wchar_t const* const area = reinterpret_cast(lParam); + + return area != nullptr && std::wcscmp(area, L"ImmersiveColorSet") == 0; +} +//--------------------------------------------------------------------------- +EGUITheme LoadGUIThemeFromLayoutFile() +{ + try + { + std::string const path = GUILayoutFilePath(); + + if (!FileExists(path)) + return EGUITheme::Windows; + + return LoadGUITheme(TGUIINIDocument(ReadFileBytes(path))); + } + catch (...) + { + return EGUITheme::Windows; + } +} +//--------------------------------------------------------------------------- HICON MainIcon() { return LoadIconW(GetModuleHandleW(nullptr), L"MAINICON"); @@ -313,6 +343,24 @@ bool RemoveFile(std::string const& path) return DeleteFileW(UTF8ToWide(path).c_str()) != 0; } //--------------------------------------------------------------------------- +void SaveGUIThemeToLayoutFile(EGUITheme theme) +{ + 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()); + SaveGUITheme(ini, theme); + WriteFileBytes(path, ini.Text()); + } + catch (...) + { + // Not saving it (e.g. a read-only profile) only means the next start uses the default theme. + } +} +//--------------------------------------------------------------------------- /* SetClipboardText @@ -366,6 +414,27 @@ std::string WindowText(HWND window) return WideToUTF8(text); } //--------------------------------------------------------------------------- +bool WindowsAppsUseDarkTheme() +{ + DWORD value = 1; + DWORD size = sizeof(value); + + LSTATUS const status = RegGetValueW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", L"AppsUseLightTheme", RRF_RT_REG_DWORD, + nullptr, &value, &size); + + return status == ERROR_SUCCESS && value == 0; +} +//--------------------------------------------------------------------------- +bool WindowsHighContrast() +{ + HIGHCONTRASTW highContrast = {}; + highContrast.cbSize = sizeof(highContrast); + + return SystemParametersInfoW(SPI_GETHIGHCONTRAST, sizeof(highContrast), &highContrast, 0) != 0 && + (highContrast.dwFlags & HCF_HIGHCONTRASTON) != 0; +} +//--------------------------------------------------------------------------- void WriteFileBytes(std::string const& path, std::string const& bytes) { TFileHandle const file(CreateFileW(UTF8ToWide(path).c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_System.h b/win32/gui/src/ASWUnitTests_Win32GUI_System.h index fb29896..6c31829 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_System.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_System.h @@ -37,6 +37,8 @@ limitations under the License. #include #include //--------------------------------------------------------------------------- +#include "ASWUnitTests_GUI_Theme.h" +//--------------------------------------------------------------------------- namespace ASWUnitTests { @@ -64,6 +66,12 @@ 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(); +// 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 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(); @@ -75,11 +83,19 @@ 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 '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); // 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); // The window's text, e.g. a control's, converted to UTF-8. std::string WindowText(HWND window); +// Whether Windows' setting for apps is dark: AppsUseLightTheme is 0 under +// HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize. False if it can't be read. +bool WindowsAppsUseDarkTheme(); +// Whether a high contrast theme is on. +bool WindowsHighContrast(); // Replaces the file's content with 'bytes', unchanged, creating the file if needed (but not its folder). // Throws std::runtime_error if it can't be written. void WriteFileBytes(std::string const& path, std::string const& bytes); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.cpp index 46df46b..dc38c8b 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.cpp @@ -55,6 +55,7 @@ struct TTextDialogState { std::wstring Text; TWin32GUIFont MonospaceFont; + TWin32GUITheme* Theme = nullptr; }; //--------------------------------------------------------------------------- @@ -132,6 +133,8 @@ INT_PTR HandleDialogMessage(HWND dialog, UINT message, WPARAM wParam, LPARAM lPa // Without a limit, an EDIT control holds only 32K characters. HWND const textBox = GetDlgItem(dialog, TextBoxID); + state->Theme->AttachWindow(dialog); + state->Theme->Attach(textBox, EWin32GUIThemedControl::Edit); SendMessageW(textBox, EM_SETLIMITTEXT, 0, 0); SetWindowTextW(textBox, state->Text.c_str()); @@ -155,18 +158,6 @@ INT_PTR HandleDialogMessage(HWND dialog, UINT message, WPARAM wParam, LPARAM lPa return FALSE; - case WM_CTLCOLORSTATIC: - // A read-only text box is drawn like an editable one, as the VCL GUI runner's is, rather than gray. - if (reinterpret_cast(lParam) == GetDlgItem(dialog, TextBoxID)) - { - HDC const context = reinterpret_cast(wParam); - SetTextColor(context, GetSysColor(COLOR_WINDOWTEXT)); - SetBkColor(context, GetSysColor(COLOR_WINDOW)); - return reinterpret_cast(GetSysColorBrush(COLOR_WINDOW)); - } - - return FALSE; - case WM_DPICHANGED: PostMessageW(dialog, ApplyDPIMessage, 0, 0); return FALSE; // Windows moves and rescales the dialog. @@ -247,10 +238,11 @@ void SizeAndCenterDialog(HWND dialog) //--------------------------------------------------------------------------- //--------------------------------------------------------------------------- -void ShowTextDialog(HWND owner, std::string const& caption, std::string const& text) +void ShowTextDialog(HWND owner, TWin32GUITheme& theme, std::string const& caption, std::string const& text) { TTextDialogState state; state.Text = UTF8ToWide(AdjustLineBreaks(text)); + state.Theme = &theme; TWin32GUIDialogTemplate const dialogTemplate = BuildDialogTemplate(UTF8ToWide(caption)); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.h b/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.h index c0645e3..2dc263e 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.h @@ -29,6 +29,8 @@ limitations under the License. //--------------------------------------------------------------------------- #include //--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_Theme.h" +//--------------------------------------------------------------------------- namespace ASWUnitTests { @@ -36,8 +38,8 @@ namespace ASWUnitTests // Shows 'text' (UTF-8) in a modal, resizable dialog with a read-only, scrollable, monospace text box, for // console-style output such as --help, whose aligned columns a regular message box's proportional font would // scramble. 'caption' (UTF-8) is the dialog's title. 'owner' may be nullptr, e.g. before the main window -// exists. Throws std::runtime_error if the dialog can't be shown. -void ShowTextDialog(HWND owner, std::string const& caption, std::string const& text); +// exists. The dialog has 'theme''s look. Throws std::runtime_error if the dialog can't be shown. +void ShowTextDialog(HWND owner, TWin32GUITheme& theme, std::string const& caption, std::string const& text); } // namespace ASWUnitTests diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp new file mode 100644 index 0000000..6ec5814 --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -0,0 +1,2072 @@ +/* ************************************************************************** +ASWUnitTests_Win32GUI_Theme.cpp +Author: Anthony S. West - ASW Software + +The Win32 GUI runner's light and dark looks. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWUnitTests_Win32GUI_Theme.h" +//--------------------------------------------------------------------------- +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_Glyphs.h" +//--------------------------------------------------------------------------- + +// RAD Studio's Win32 (bcc32c) links its own Windows import libraries, which don't include SetWindowTheme()'s. +// CMake links uxtheme for the other compilers, and RAD Studio's Win64x links it already. +#if defined(__BORLANDC__) && !defined(_WIN64) +# pragma comment(lib, "uxtheme.lib") +#endif + +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +namespace +{ + +// The theme Windows' own dark controls (scroll bars, the tree's expand arrows) are drawn from. Not documented, +// but used widely; where it's missing, the controls keep their light theme. +wchar_t const DarkExplorerTheme[] = L"DarkMode_Explorer"; +// DwmSetWindowAttribute()'s attribute for a dark title bar: 20 from Windows 10 version 20H1 on, 19 before. +DWORD const DarkTitleBarAttribute = 20; +DWORD const DarkTitleBarAttributeBefore20H1 = 19; +// The theme the tree and the list have while light, as File Explorer draws them, as the VCL draws its own. +wchar_t const ExplorerTheme[] = L"Explorer"; +// The window property that holds which tab is under the mouse, plus one (0 for none), while dark. +wchar_t const HotTabProperty[] = L"ASWUnitTests_Win32GUI_HotTab"; +// The menu windows' class, documented as a system class. +wchar_t const MenuClassName[] = L"#32768"; +// The subclass IDs: an attached control's is ControlSubclassBase plus its kind. +UINT_PTR const ControlSubclassBase = 100; +UINT_PTR const MenuFrameSubclassID = 2; +UINT_PTR const WindowSubclassID = 1; + +///////////////////////////////////////////////////////////////////////////// +// TGlyphCanvas +// +// A small image built pixel by pixel, e.g. a check box from its border, its +// inside and its mark, then drawn or made into a bitmap. +///////////////////////////////////////////////////////////////////////////// +class TGlyphCanvas +{ +private: + int const m_Height; + // Top-down 32-bit pixels, 0xAARRGGBB, fully opaque. + std::vector m_Pixels; + int const m_Width; + +private: + static std::uint32_t ToPixel(COLORREF color); + +private: + BITMAPINFO BitmapInfo() const; + +public: + TGlyphCanvas(int width, int height, COLORREF background); + + // Mixes 'color' into the pixels 'glyph' covers in the square at 'left', 'top' of side 'size'. + void Blend(EWin32GUIGlyph glyph, int left, int top, int size, COLORREF color); + // A bitmap of the canvas, which the caller deletes; nullptr if it can't be created. + HBITMAP CreateBitmap() const; + void Draw(HDC context, int left, int top) const; + void FillRect(RECT const& rect, COLORREF color); +}; + +///////////////////////////////////////////////////////////////////////////// +// TPaintBuffer +// +// Paints through a bitmap the size of a rectangle, copied to the target in +// one step when this object is destroyed, so nothing painted in between is +// seen, and so nothing flickers. If the bitmap can't be created, painting +// goes straight to the target. +///////////////////////////////////////////////////////////////////////////// +class TPaintBuffer +{ +private: + HBITMAP m_Bitmap; + HDC m_Context; + HGDIOBJ m_OldBitmap; + RECT const m_Rect; + HDC const m_Target; + +public: + TPaintBuffer(HDC target, RECT const& rect); + ~TPaintBuffer(); + + TPaintBuffer(TPaintBuffer const&) = delete; + TPaintBuffer& operator=(TPaintBuffer const&) = delete; + + // The context to paint into, in the target's coordinates. + HDC Get() const; +}; + +//--------------------------------------------------------------------------- + +bool ClassNameIs(HWND window, wchar_t const* className); +void FillGradient(HDC context, RECT const& rect, COLORREF top, COLORREF bottom); +void FillSolid(HDC context, RECT const& rect, COLORREF color); +void FrameSolid(HDC context, RECT const& rect, COLORREF color); +bool HidesAccelerators(HWND control); +bool HidesFocus(HWND control); +RECT Inset(RECT rect, int by); +void SetImmersiveDarkMode(HWND window, bool dark); +void SplitShortcut(std::wstring const& text, std::wstring& caption, std::wstring& shortcut); +SIZE TextSize(HDC context, std::wstring const& text, UINT format); +std::wstring WideWindowText(HWND window); + +//--------------------------------------------------------------------------- +bool ClassNameIs(HWND window, wchar_t const* className) +{ + wchar_t name[64] = {}; + GetClassNameW(window, name, 64); + + return lstrcmpiW(name, className) == 0; +} +//--------------------------------------------------------------------------- +/* + FillGradient + + A vertical gradient, one row at a time: 'top' at the first row and 'bottom' at the last. +*/ +void FillGradient(HDC context, RECT const& rect, COLORREF top, COLORREF bottom) +{ + int const height = rect.bottom - rect.top; + + for (int y = 0; y < height; ++y) + { + RECT const row = { rect.left, rect.top + y, rect.right, rect.top + y + 1 }; + FillSolid(context, row, BlendColors(top, bottom, y, height - 1)); + } +} +//--------------------------------------------------------------------------- +void FillSolid(HDC context, RECT const& rect, COLORREF color) +{ + COLORREF const old = SetDCBrushColor(context, color); + FillRect(context, &rect, static_cast(GetStockObject(DC_BRUSH))); + SetDCBrushColor(context, old); +} +//--------------------------------------------------------------------------- +void FrameSolid(HDC context, RECT const& rect, COLORREF color) +{ + COLORREF const old = SetDCBrushColor(context, color); + FrameRect(context, &rect, static_cast(GetStockObject(DC_BRUSH))); + SetDCBrushColor(context, old); +} +//--------------------------------------------------------------------------- +bool HidesAccelerators(HWND control) +{ + return (SendMessageW(control, WM_QUERYUISTATE, 0, 0) & UISF_HIDEACCEL) != 0; +} +//--------------------------------------------------------------------------- +bool HidesFocus(HWND control) +{ + return (SendMessageW(control, WM_QUERYUISTATE, 0, 0) & UISF_HIDEFOCUS) != 0; +} +//--------------------------------------------------------------------------- +RECT Inset(RECT rect, int by) +{ + InflateRect(&rect, -by, -by); + return rect; +} +//--------------------------------------------------------------------------- +/* + SetImmersiveDarkMode + + DwmSetWindowAttribute() is loaded from dwmapi.dll as it's needed, rather than linked, so the GUI needs no + import library of it (RAD Studio's Win32 and Win64x have different ones, and CMake would need it listed for + every project that builds the GUI). Windows before 10 version 1809 has neither attribute, and keeps the + title bar light. +*/ +void SetImmersiveDarkMode(HWND window, bool dark) +{ + using TDwmSetWindowAttribute = HRESULT(WINAPI*)(HWND, DWORD, LPCVOID, DWORD); + + static HMODULE const library = LoadLibraryW(L"dwmapi.dll"); + static TDwmSetWindowAttribute const setAttribute = library == nullptr ? nullptr : + reinterpret_cast(reinterpret_cast(GetProcAddress(library, + "DwmSetWindowAttribute"))); + + if (setAttribute == nullptr) + return; + + BOOL const value = dark ? TRUE : FALSE; + + if (FAILED(setAttribute(window, DarkTitleBarAttribute, &value, sizeof(value)))) + setAttribute(window, DarkTitleBarAttributeBefore20H1, &value, sizeof(value)); +} +//--------------------------------------------------------------------------- +/* + SplitShortcut + + A menu item's text, e.g. "&Run Selected\tF9", as its caption and its shortcut, which is empty if it has + none. +*/ +void SplitShortcut(std::wstring const& text, std::wstring& caption, std::wstring& shortcut) +{ + std::size_t const tab = text.find(L'\t'); + caption = text.substr(0, tab); + shortcut = tab == std::wstring::npos ? std::wstring() : text.substr(tab + 1); +} +//--------------------------------------------------------------------------- +SIZE TextSize(HDC context, std::wstring const& text, UINT format) +{ + RECT bounds = { 0, 0, 0, 0 }; + DrawTextW(context, text.c_str(), static_cast(text.size()), &bounds, format | DT_CALCRECT | DT_SINGLELINE); + + return { bounds.right - bounds.left, bounds.bottom - bounds.top }; +} +//--------------------------------------------------------------------------- +std::wstring WideWindowText(HWND window) +{ + std::wstring text(static_cast(GetWindowTextLengthW(window)) + 1, L'\0'); + text.resize(static_cast(GetWindowTextW(window, &text[0], static_cast(text.size())))); + + return text; +} +//--------------------------------------------------------------------------- + + +///////////////////////////////////////////////////////////////////////////// +// TGlyphCanvas +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TGlyphCanvas::TGlyphCanvas(int width, int height, COLORREF background) + : m_Height((std::max)(height, 1)), + m_Pixels(static_cast((std::max)(width, 1)) * static_cast((std::max)(height, 1)), + ToPixel(background)), + m_Width((std::max)(width, 1)) +{ +} +//--------------------------------------------------------------------------- +BITMAPINFO TGlyphCanvas::BitmapInfo() const +{ + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(info.bmiHeader); + info.bmiHeader.biWidth = m_Width; + info.bmiHeader.biHeight = -m_Height; // Top-down. + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + + return info; +} +//--------------------------------------------------------------------------- +void TGlyphCanvas::Blend(EWin32GUIGlyph glyph, int left, int top, int size, COLORREF color) +{ + std::vector const coverage = RenderWin32GUIGlyph(glyph, size); + + for (int y = 0; y < size; ++y) + { + for (int x = 0; x < size; ++x) + { + int const canvasX = left + x; + int const canvasY = top + y; + std::uint8_t const amount = coverage[static_cast(y) * static_cast(size) + + static_cast(x)]; + + if (amount == 0 || canvasX < 0 || canvasY < 0 || canvasX >= m_Width || canvasY >= m_Height) + continue; + + std::uint32_t& pixel = m_Pixels[static_cast(canvasY) * static_cast(m_Width) + + static_cast(canvasX)]; + COLORREF const under = RGB((pixel >> 16) & 0xFF, (pixel >> 8) & 0xFF, pixel & 0xFF); + pixel = ToPixel(BlendColors(under, color, amount, 255)); + } + } +} +//--------------------------------------------------------------------------- +HBITMAP TGlyphCanvas::CreateBitmap() const +{ + BITMAPINFO const info = BitmapInfo(); + void* bits = nullptr; + HBITMAP const bitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + + if (bitmap != nullptr && bits != nullptr) + std::copy(m_Pixels.begin(), m_Pixels.end(), static_cast(bits)); + + return bitmap; +} +//--------------------------------------------------------------------------- +void TGlyphCanvas::Draw(HDC context, int left, int top) const +{ + BITMAPINFO const info = BitmapInfo(); + SetDIBitsToDevice(context, left, top, static_cast(m_Width), static_cast(m_Height), 0, 0, 0, + static_cast(m_Height), m_Pixels.data(), &info, DIB_RGB_COLORS); +} +//--------------------------------------------------------------------------- +void TGlyphCanvas::FillRect(RECT const& rect, COLORREF color) +{ + std::uint32_t const pixel = ToPixel(color); + + for (int y = (std::max)(0, static_cast(rect.top)); y < (std::min)(m_Height, static_cast(rect.bottom)); + ++y) + { + for (int x = (std::max)(0, static_cast(rect.left)); x < (std::min)(m_Width, static_cast(rect.right)); + ++x) + { + m_Pixels[static_cast(y) * static_cast(m_Width) + static_cast(x)] = + pixel; + } + } +} +//--------------------------------------------------------------------------- +std::uint32_t TGlyphCanvas::ToPixel(COLORREF color) +{ + return 0xFF000000u | (static_cast(GetRValue(color)) << 16) | + (static_cast(GetGValue(color)) << 8) | static_cast(GetBValue(color)); +} +//--------------------------------------------------------------------------- + + +///////////////////////////////////////////////////////////////////////////// +// TPaintBuffer +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TPaintBuffer::TPaintBuffer(HDC target, RECT const& rect) + : m_Bitmap(nullptr), + m_Context(nullptr), + m_OldBitmap(nullptr), + m_Rect(rect), + m_Target(target) +{ + int const width = rect.right - rect.left; + int const height = rect.bottom - rect.top; + + if (width <= 0 || height <= 0) + return; + + m_Context = CreateCompatibleDC(target); + m_Bitmap = m_Context != nullptr ? CreateCompatibleBitmap(target, width, height) : nullptr; + + if (m_Bitmap == nullptr) + { + if (m_Context != nullptr) + DeleteDC(m_Context); + + m_Context = nullptr; + return; + } + + m_OldBitmap = SelectObject(m_Context, m_Bitmap); + SetViewportOrgEx(m_Context, -rect.left, -rect.top, nullptr); +} +//--------------------------------------------------------------------------- +TPaintBuffer::~TPaintBuffer() +{ + if (m_Context == nullptr) + return; + + SetViewportOrgEx(m_Context, 0, 0, nullptr); + BitBlt(m_Target, m_Rect.left, m_Rect.top, m_Rect.right - m_Rect.left, m_Rect.bottom - m_Rect.top, m_Context, 0, 0, + SRCCOPY); + SelectObject(m_Context, m_OldBitmap); + DeleteObject(m_Bitmap); + DeleteDC(m_Context); +} +//--------------------------------------------------------------------------- +HDC TPaintBuffer::Get() const +{ + return m_Context != nullptr ? m_Context : m_Target; +} +//--------------------------------------------------------------------------- + +} // namespace + +//--------------------------------------------------------------------------- + + +///////////////////////////////////////////////////////////////////////////// +// TWin32GUITheme +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TWin32GUITheme* TWin32GUITheme::s_Instance = nullptr; + +//--------------------------------------------------------------------------- +TWin32GUITheme::TWin32GUITheme() + : m_Dark(false), + m_MenuBar(nullptr), + m_MenuBarWindow(nullptr), + m_MenuBrush(nullptr), + m_MenuFontDPI(0), + m_MenuHook(nullptr), + m_Palette(CarbonPalette()), + m_PanelBrush(nullptr), + m_WindowBrush(nullptr) +{ + m_MenuBrush = CreateSolidBrush(m_Palette.MenuBackground); + m_PanelBrush = CreateSolidBrush(m_Palette.Panel); + m_WindowBrush = CreateSolidBrush(m_Palette.Window); + + // Without the hook, the menus' frames stay light. + s_Instance = this; + m_MenuHook = SetWindowsHookExW(WH_CALLWNDPROC, &MenuHookProc, nullptr, GetCurrentThreadId()); +} +//--------------------------------------------------------------------------- +TWin32GUITheme::~TWin32GUITheme() +{ + if (m_MenuHook != nullptr) + UnhookWindowsHookEx(m_MenuHook); + + if (s_Instance == this) + s_Instance = nullptr; + + for (std::pair const& themed : m_ThemedCheckImages) + ImageList_Destroy(themed.second); + + for (std::pair const& dark : m_DarkCheckImages) + ImageList_Destroy(dark.second); + + for (HBRUSH const brush : { m_MenuBrush, m_PanelBrush, m_WindowBrush }) + { + if (brush != nullptr) + DeleteObject(brush); + } +} +//--------------------------------------------------------------------------- +void TWin32GUITheme::ApplyToControl(TAttachedControl const& attached) +{ + HWND const control = attached.Control; + wchar_t const* const darkOrDefault = m_Dark ? DarkExplorerTheme : nullptr; + + switch (attached.Kind) + { + case EWin32GUIThemedControl::Edit: + // Its scroll bars; its colors come from WM_CTLCOLOREDIT and WM_CTLCOLORSTATIC. + SetWindowTheme(control, darkOrDefault, nullptr); + break; + + case EWin32GUIThemedControl::ListView: + { + SetWindowTheme(control, m_Dark ? DarkExplorerTheme : ExplorerTheme, nullptr); + HWND const header = reinterpret_cast(SendMessageW(control, LVM_GETHEADER, 0, 0)); + + if (header != nullptr) + SetWindowTheme(header, m_Dark ? L"DarkMode_ItemsView" : nullptr, nullptr); + + COLORREF const background = WindowColor(); + SendMessageW(control, LVM_SETBKCOLOR, 0, static_cast(background)); + SendMessageW(control, LVM_SETTEXTBKCOLOR, 0, static_cast(background)); + SendMessageW(control, LVM_SETTEXTCOLOR, 0, static_cast(WindowTextColor())); + break; + } + + case EWin32GUIThemedControl::RichEdit: + SetWindowTheme(control, darkOrDefault, nullptr); + SendMessageW(control, EM_SETBKGNDCOLOR, m_Dark ? FALSE : TRUE, static_cast(m_Palette.Window)); + break; + + case EWin32GUIThemedControl::Tooltip: + if (m_Dark) + { + // Without visual styles, so it takes the colors given. + SetWindowTheme(control, L" ", L" "); + SendMessageW(control, TTM_SETTIPBKCOLOR, static_cast(m_Palette.TooltipBackground), 0); + SendMessageW(control, TTM_SETTIPTEXTCOLOR, static_cast(m_Palette.TooltipText), 0); + } + else + { + SetWindowTheme(control, nullptr, nullptr); + SendMessageW(control, TTM_SETTIPBKCOLOR, static_cast(GetSysColor(COLOR_INFOBK)), 0); + SendMessageW(control, TTM_SETTIPTEXTCOLOR, static_cast(GetSysColor(COLOR_INFOTEXT)), 0); + } + + return; // A tooltip is drawn when it next shows. + + case EWin32GUIThemedControl::TreeView: + ApplyToTree(control); + break; + + case EWin32GUIThemedControl::ProgressBar: + case EWin32GUIThemedControl::Splitter: + case EWin32GUIThemedControl::StatusBar: + case EWin32GUIThemedControl::TabControl: + break; // Painted by ControlProc() while dark. + } + + RedrawWindow(control, nullptr, nullptr, RDW_ERASE | RDW_FRAME | RDW_INVALIDATE | RDW_ALLCHILDREN); +} +//--------------------------------------------------------------------------- +void TWin32GUITheme::ApplyToMenu(HMENU menu, std::vector >& items) +{ + int const count = GetMenuItemCount(menu); + + for (int position = 0; position < count; ++position) + { + wchar_t text[256] = {}; + MENUITEMINFOW info = {}; + info.cbSize = sizeof(info); + info.fMask = MIIM_DATA | MIIM_FTYPE | MIIM_ID | MIIM_STATE | MIIM_STRING | MIIM_SUBMENU; + info.dwTypeData = text; + info.cch = 256; + + if (GetMenuItemInfoW(menu, static_cast(position), TRUE, &info) == 0) + continue; + + // An owner-drawn item's string can't be read back reliably, so it's taken from its item. + TMenuItem const* const current = FindMenuItem(info.dwItemData); + + if (current != nullptr) + text[current->Text.copy(text, 255)] = L'\0'; + + // Its string is set again too, which an owner-drawn item may have lost. + MENUITEMINFOW change = {}; + change.cbSize = sizeof(change); + change.fMask = MIIM_FTYPE | MIIM_DATA | ((info.fType & MFT_SEPARATOR) != 0 ? 0 : MIIM_STRING); + change.fType = info.fType & ~static_cast(MFT_OWNERDRAW); + change.dwTypeData = text; + + if (m_Dark) + { + std::unique_ptr item = std::make_unique(); + item->Menu = menu; + item->Position = static_cast(position); + item->Text = text; + item->Separator = (info.fType & MFT_SEPARATOR) != 0; + item->Submenu = info.hSubMenu != nullptr; + item->TopLevel = menu == m_MenuBar; + + // Its string stays, so Windows still finds its & letter. + change.fType |= MFT_OWNERDRAW; + change.dwItemData = reinterpret_cast(item.get()); + items.push_back(std::move(item)); + } + + if (menu == m_MenuBar) + { + // A menu bar item keeps the width it was first measured at, whatever changes, e.g. an owner-drawn + // width, too narrow for the standard look, so it's replaced by a new one. A menu measures its items + // each time it opens. + 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); + } + else + { + SetMenuItemInfoW(menu, static_cast(position), TRUE, &change); + } + + if (info.hSubMenu != nullptr) + { + MENUINFO background = {}; + background.cbSize = sizeof(background); + background.fMask = MIM_BACKGROUND; + background.hbrBack = m_Dark ? m_MenuBrush : nullptr; + SetMenuInfo(info.hSubMenu, &background); + + ApplyToMenu(info.hSubMenu, items); + } + } +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::ApplyToMenus + + The new items replace the old ones only once every menu item points to its new one. +*/ +void TWin32GUITheme::ApplyToMenus() +{ + if (m_MenuBar == nullptr) + return; + + std::vector > items; + ApplyToMenu(m_MenuBar, items); + m_MenuItems.swap(items); + + // The menu bar's empty part, right of its items. + MENUINFO background = {}; + background.cbSize = sizeof(background); + background.fMask = MIM_BACKGROUND; + background.hbrBack = m_Dark ? m_PanelBrush : nullptr; + SetMenuInfo(m_MenuBar, &background); + + DrawMenuBar(m_MenuBarWindow); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::ApplyToTitleBar + + A visible window's frame is redrawn, so a live switch shows on its title bar too. +*/ +void TWin32GUITheme::ApplyToTitleBar(HWND window) const +{ + SetImmersiveDarkMode(window, m_Dark); + + if (IsWindowVisible(window) != FALSE) + { + SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | + SWP_FRAMECHANGED); + } +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::ApplyToTree + + A tree with check boxes shows Carbon's while dark, keeping its own to give back when it turns light. Its own + are given back before its theme changes, and replaced after, since a theme change redraws the images of the + list it has. +*/ +void TWin32GUITheme::ApplyToTree(HWND tree) +{ + bool const checkBoxes = (GetWindowLongPtrW(tree, GWL_STYLE) & TVS_CHECKBOXES) != 0; + std::vector >::iterator const themed = std::find_if(m_ThemedCheckImages.begin(), + m_ThemedCheckImages.end(), [tree](std::pair const& entry) + { + return entry.first == tree; + }); + + if (!m_Dark && themed != m_ThemedCheckImages.end()) + { + SendMessageW(tree, TVM_SETIMAGELIST, TVSIL_STATE, reinterpret_cast(themed->second)); + m_ThemedCheckImages.erase(themed); + } + + SetWindowTheme(tree, m_Dark ? DarkExplorerTheme : ExplorerTheme, nullptr); + SendMessageW(tree, TVM_SETBKCOLOR, 0, m_Dark ? static_cast(m_Palette.Window) : -1); + SendMessageW(tree, TVM_SETTEXTCOLOR, 0, m_Dark ? static_cast(m_Palette.WindowText) : -1); + SendMessageW(tree, TVM_SETLINECOLOR, 0, m_Dark ? static_cast(m_Palette.TreeLine) : + static_cast(CLR_DEFAULT)); + + if (m_Dark && checkBoxes) + { + HIMAGELIST const previous = reinterpret_cast(SendMessageW(tree, TVM_SETIMAGELIST, TVSIL_STATE, + reinterpret_cast(DarkCheckImages(WindowDPI(tree))))); + bool const previousIsDark = std::any_of(m_DarkCheckImages.begin(), m_DarkCheckImages.end(), + [previous](std::pair const& entry) + { + return entry.second == previous; + }); + + if (previous != nullptr && !previousIsDark) + m_ThemedCheckImages.emplace_back(tree, previous); + } +} +//--------------------------------------------------------------------------- +void TWin32GUITheme::Attach(HWND control, EWin32GUIThemedControl kind) +{ + if (control == nullptr) + return; + + m_Controls.push_back({ control, kind }); + SetWindowSubclass(control, &ControlProc, ControlSubclassBase + static_cast(kind), + reinterpret_cast(this)); + ApplyToControl(m_Controls.back()); +} +//--------------------------------------------------------------------------- +void TWin32GUITheme::AttachWindow(HWND window, HMENU menuBar) +{ + if (window == nullptr) + return; + + m_Windows.push_back(window); + SetWindowSubclass(window, &WindowProc, WindowSubclassID, reinterpret_cast(this)); + + if (menuBar != nullptr) + { + m_MenuBar = menuBar; + m_MenuBarWindow = window; + ApplyToMenus(); + } + + ApplyToTitleBar(window); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::ControlProc + + An attached control's subclass. No exception may leave a window procedure; one that would leaves the + message to the control. +*/ +LRESULT CALLBACK TWin32GUITheme::ControlProc(HWND control, UINT message, WPARAM wParam, LPARAM lParam, UINT_PTR id, + DWORD_PTR theme) +{ + try + { + EWin32GUIThemedControl const kind = static_cast(id - ControlSubclassBase); + + if (message == WM_NCDESTROY) + { + reinterpret_cast(theme)->Unattach(control); + RemovePropW(control, HotTabProperty); + RemoveWindowSubclass(control, &ControlProc, id); + return DefSubclassProc(control, message, wParam, lParam); + } + + return reinterpret_cast(theme)->HandleControlMessage(control, kind, message, wParam, lParam); + } + catch (...) + { + return DefSubclassProc(control, message, wParam, lParam); + } +} +//--------------------------------------------------------------------------- +/* + 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). +*/ +HIMAGELIST TWin32GUITheme::DarkCheckImages(UINT dpi) +{ + for (std::pair const& entry : m_DarkCheckImages) + { + if (entry.first == dpi) + return entry.second; + } + + 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) + return nullptr; + + 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(); + + if (bitmap != nullptr) + { + ImageList_Add(images, bitmap, nullptr); + DeleteObject(bitmap); + } + } + + m_DarkCheckImages.emplace_back(dpi, images); + return images; +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::DrawButton + + As Carbon draws a push button: an outline, a border, and a face shaded from top to bottom, its border lighter + under the mouse, and with a line inside it for the default button or the focused one, which is how the + focus shows, as in the VCL GUI runner. +*/ +void TWin32GUITheme::DrawButton(NMCUSTOMDRAW const& draw) const +{ + HWND const button = draw.hdr.hwndFrom; + HDC const context = draw.hdc; + UINT const dpi = WindowDPI(button); + int const line = (std::max)(1, ScaleForDPI(1, dpi)); + + bool const disabled = (draw.uItemState & CDIS_DISABLED) != 0; + bool const hot = (draw.uItemState & CDIS_HOT) != 0; + bool const pressed = (draw.uItemState & CDIS_SELECTED) != 0; + bool const emphasized = !disabled && ((draw.uItemState & (CDIS_DEFAULT | CDIS_FOCUS)) != 0 || + (GetWindowLongPtrW(button, GWL_STYLE) & BS_TYPEMASK) == BS_DEFPUSHBUTTON); + + RECT const outline = draw.rc; + RECT const border = Inset(outline, line); + RECT const face = Inset(border, line); + + FillSolid(context, outline, disabled ? m_Palette.ButtonDisabledOutline : m_Palette.ButtonOutline); + FillSolid(context, border, disabled ? m_Palette.ButtonDisabledBorder : hot || emphasized ? + m_Palette.ButtonHotBorder : m_Palette.ButtonBorder); + + if (disabled) + FillGradient(context, face, m_Palette.ButtonDisabledFaceTop, m_Palette.ButtonDisabledFaceBottom); + else if (pressed) + FillGradient(context, face, m_Palette.ButtonPressedFaceTop, m_Palette.ButtonPressedFaceBottom); + else if (hot) + FillGradient(context, face, m_Palette.ButtonHotFaceTop, m_Palette.ButtonHotFaceBottom); + else + FillGradient(context, face, m_Palette.ButtonFaceTop, m_Palette.ButtonFaceBottom); + + if (emphasized) + FrameSolid(context, face, m_Palette.ButtonDefaultFrame); + + std::wstring const text = WideWindowText(button); + RECT textRect = face; + int const oldMode = SetBkMode(context, TRANSPARENT); + COLORREF const oldColor = SetTextColor(context, disabled ? m_Palette.DisabledText : hot || pressed ? + m_Palette.HotText : m_Palette.ControlText); + DrawTextW(context, text.c_str(), -1, &textRect, DT_CENTER | DT_VCENTER | DT_SINGLELINE | + (HidesAccelerators(button) ? DT_HIDEPREFIX : 0)); + SetTextColor(context, oldColor); + SetBkMode(context, oldMode); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::DrawCheckButton + + As Carbon draws a radio button or a check box: its 13-pixel circle or box at the left, middle, and its text + after it, with a focus rectangle around the text when it has the focus and the keyboard is being used. +*/ +void TWin32GUITheme::DrawCheckButton(NMCUSTOMDRAW const& draw, bool radio) const +{ + HWND const button = draw.hdr.hwndFrom; + HDC const context = draw.hdc; + UINT const dpi = WindowDPI(button); + RECT const rect = draw.rc; + + bool const disabled = (draw.uItemState & CDIS_DISABLED) != 0; + bool const hot = (draw.uItemState & CDIS_HOT) != 0 || (draw.uItemState & CDIS_SELECTED) != 0; + LRESULT const check = SendMessageW(button, BM_GETCHECK, 0, 0); + + FillSolid(context, rect, m_Palette.Panel); + + int const box = ScaleForDPI(13, dpi); + int const top = (rect.top + rect.bottom - box) / 2; + COLORREF const borderColor = hot && !disabled ? m_Palette.CheckBoxHotBorder : m_Palette.CheckBoxBorder; + COLORREF const markColor = disabled ? m_Palette.CheckMarkDisabled : m_Palette.CheckMark; + TGlyphCanvas canvas(box, box, m_Palette.Panel); + + if (radio) + { + canvas.Blend(EWin32GUIGlyph::Circle, 0, 0, box, borderColor); + canvas.Blend(EWin32GUIGlyph::InnerCircle, 0, 0, box, m_Palette.CheckBoxFill); + + if (check == BST_CHECKED) + canvas.Blend(EWin32GUIGlyph::Dot, 0, 0, box, markColor); + } + else + { + RECT const outside = { 0, 0, box, box }; + canvas.FillRect(outside, borderColor); + canvas.FillRect(Inset(outside, (std::max)(1, ScaleForDPI(1, dpi))), m_Palette.CheckBoxFill); + + if (check == BST_CHECKED) + canvas.Blend(EWin32GUIGlyph::Cross, 0, 0, box, markColor); + else if (check == BST_INDETERMINATE) + canvas.Blend(EWin32GUIGlyph::Square, 0, 0, box, markColor); + } + + canvas.Draw(context, rect.left, top); + + std::wstring const text = WideWindowText(button); + UINT const format = DT_LEFT | DT_VCENTER | DT_SINGLELINE | (HidesAccelerators(button) ? DT_HIDEPREFIX : 0); + RECT textRect = { rect.left + box + ScaleForDPI(4, dpi), rect.top, rect.right, rect.bottom }; + int const oldMode = SetBkMode(context, TRANSPARENT); + COLORREF const oldColor = SetTextColor(context, disabled ? m_Palette.DisabledText : m_Palette.ControlText); + DrawTextW(context, text.c_str(), -1, &textRect, format); + SetTextColor(context, oldColor); + SetBkMode(context, oldMode); + + if ((draw.uItemState & CDIS_FOCUS) != 0 && !HidesFocus(button)) + { + SIZE const size = TextSize(context, text, format & ~static_cast(DT_VCENTER)); + RECT focus = { textRect.left - 1, (rect.top + rect.bottom - size.cy) / 2 - 1, textRect.left + size.cx + 1, + (rect.top + rect.bottom + size.cy) / 2 + 1 }; + DrawFocusRect(context, &focus); + } +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::DrawListRow + + The row's background, its selection as the tree shows one, then each column's text, clipped with an + ellipsis, and a focus rectangle around the focused row while the list has the focus and the keyboard is + being used. +*/ +void TWin32GUITheme::DrawListRow(NMLVCUSTOMDRAW const& draw, COLORREF textColor) const +{ + HWND const list = draw.nmcd.hdr.hwndFrom; + HDC const context = draw.nmcd.hdc; + UINT const dpi = WindowDPI(list); + WPARAM const index = static_cast(draw.nmcd.dwItemSpec); + UINT const state = static_cast(SendMessageW(list, LVM_GETITEMSTATE, index, LVIS_SELECTED | LVIS_FOCUSED)); + + RECT row = {}; + row.left = LVIR_BOUNDS; + SendMessageW(list, LVM_GETITEMRECT, index, reinterpret_cast(&row)); + + if ((state & LVIS_SELECTED) != 0) + { + FillSolid(context, row, m_Palette.Selection); + FrameSolid(context, row, m_Palette.SelectionFrame); + } + else + { + FillSolid(context, row, m_Palette.Window); + } + + int const oldMode = SetBkMode(context, TRANSPARENT); + COLORREF const oldColor = SetTextColor(context, textColor); + HWND const header = reinterpret_cast(SendMessageW(list, LVM_GETHEADER, 0, 0)); + int const columns = header != nullptr ? static_cast(SendMessageW(header, HDM_GETITEMCOUNT, 0, 0)) : 1; + + for (int column = 0; column < columns; ++column) + { + RECT cell = {}; + + if (column == 0) + { + cell.left = LVIR_LABEL; + SendMessageW(list, LVM_GETITEMRECT, index, reinterpret_cast(&cell)); + } + else + { + cell.top = column; + cell.left = LVIR_BOUNDS; + SendMessageW(list, LVM_GETSUBITEMRECT, index, reinterpret_cast(&cell)); + } + + std::wstring text(1024, L'\0'); + LVITEMW item = {}; + item.iSubItem = column; + item.pszText = &text[0]; + item.cchTextMax = static_cast(text.size()); + text.resize(static_cast(SendMessageW(list, LVM_GETITEMTEXTW, index, + reinterpret_cast(&item)))); + + // As the list's own text is placed: 2 pixels into the first column's label, 6 into the others. + cell.left += ScaleForDPI(column == 0 ? 2 : 6, dpi); + cell.right -= ScaleForDPI(6, dpi); + DrawTextW(context, text.c_str(), static_cast(text.size()), &cell, DT_LEFT | DT_VCENTER | DT_SINGLELINE | + DT_END_ELLIPSIS | DT_NOPREFIX); + } + + SetTextColor(context, oldColor); + SetBkMode(context, oldMode); + + if ((state & LVIS_FOCUSED) != 0 && GetFocus() == list && !HidesFocus(list)) + { + RECT focus = Inset(row, 1); + DrawFocusRect(context, &focus); + } +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::DrawMenuItem + + As Carbon draws the menus: the item under the mouse or the keyboard in the highlight, with a frame; a + separator as a line; a menu item's caption after a gutter for its check mark (a tick, or a dot for a radio + item), its shortcut at the right, and a submenu's arrow. The & letters are underlined only once the + keyboard has been used (ODS_NOACCEL). Windows draws its own submenu arrow after WM_DRAWITEM, unless the + item is clipped out of the context. +*/ +void TWin32GUITheme::DrawMenuItem(DRAWITEMSTRUCT const& draw) +{ + TMenuItem const* const item = FindMenuItem(draw.itemData); + + if (item == nullptr) + return; + + HDC const context = draw.hDC; + RECT const rect = draw.rcItem; + UINT const dpi = m_MenuBarWindow != nullptr ? WindowDPI(m_MenuBarWindow) : USER_DEFAULT_SCREEN_DPI; + bool const selected = (draw.itemState & (ODS_SELECTED | ODS_HOTLIGHT)) != 0; + bool const disabled = (draw.itemState & (ODS_DISABLED | ODS_GRAYED)) != 0; + COLORREF const background = selected ? m_Palette.Highlight : item->TopLevel ? m_Palette.Panel : + m_Palette.MenuBackground; + + FillSolid(context, rect, background); + + if (selected) + FrameSolid(context, rect, m_Palette.HighlightFrame); + + if (item->Separator) + { + int const middle = (rect.top + rect.bottom) / 2; + RECT const separator = { rect.left + ScaleForDPI(4, dpi), middle, rect.right - ScaleForDPI(4, dpi), middle + + (std::max)(1, ScaleForDPI(1, dpi)) }; + FillSolid(context, separator, m_Palette.MenuSeparator); + return; + } + + COLORREF const textColor = disabled ? (item->TopLevel ? m_Palette.MenuBarDisabledText : + m_Palette.MenuDisabledText) : selected ? m_Palette.HotText : m_Palette.ControlText; + UINT const format = DT_SINGLELINE | DT_VCENTER | ((draw.itemState & ODS_NOACCEL) != 0 ? DT_HIDEPREFIX : 0); + std::wstring caption; + std::wstring shortcut; + SplitShortcut(item->Text, caption, shortcut); + + HGDIOBJ const oldFont = SelectObject(context, MenuFont(dpi)); + int const oldMode = SetBkMode(context, TRANSPARENT); + COLORREF const oldColor = SetTextColor(context, textColor); + + if (item->TopLevel) + { + RECT textRect = rect; + DrawTextW(context, caption.c_str(), -1, &textRect, format | DT_CENTER); + } + else + { + int const gutter = ScaleForDPI(28, dpi); + int const arrowWidth = ScaleForDPI(22, dpi); + RECT textRect = { rect.left + gutter, rect.top, rect.right - arrowWidth, rect.bottom }; + DrawTextW(context, caption.c_str(), -1, &textRect, format | DT_LEFT); + + if (!shortcut.empty()) + DrawTextW(context, shortcut.c_str(), -1, &textRect, format | DT_RIGHT); + + int const glyph = ScaleForDPI(16, dpi); + int const glyphTop = (rect.top + rect.bottom - glyph) / 2; + + if ((draw.itemState & ODS_CHECKED) != 0) + { + MENUITEMINFOW info = {}; + info.cbSize = sizeof(info); + info.fMask = MIIM_FTYPE; + GetMenuItemInfoW(item->Menu, item->Position, TRUE, &info); + + TGlyphCanvas canvas(glyph, glyph, background); + canvas.Blend((info.fType & MFT_RADIOCHECK) != 0 ? EWin32GUIGlyph::Dot : EWin32GUIGlyph::Tick, 0, 0, glyph, + textColor); + canvas.Draw(context, rect.left + (gutter - glyph) / 2, glyphTop); + } + + if (item->Submenu) + { + TGlyphCanvas canvas(glyph, glyph, background); + canvas.Blend(EWin32GUIGlyph::ArrowRight, 0, 0, glyph, textColor); + canvas.Draw(context, rect.right - arrowWidth + (arrowWidth - glyph) / 2, glyphTop); + ExcludeClipRect(context, rect.left, rect.top, rect.right, rect.bottom); + } + } + + SetTextColor(context, oldColor); + SetBkMode(context, oldMode); + SelectObject(context, oldFont); +} +//--------------------------------------------------------------------------- +COLORREF TWin32GUITheme::FailureTextColor() const +{ + return m_Dark ? m_Palette.FailureText : FailureTextColorOn(GetSysColor(COLOR_WINDOW)); +} +//--------------------------------------------------------------------------- +TWin32GUITheme::TMenuItem const* TWin32GUITheme::FindMenuItem(ULONG_PTR itemData) const +{ + for (std::unique_ptr const& item : m_MenuItems) + { + if (reinterpret_cast(item.get()) == itemData) + return item.get(); + } + + return nullptr; +} +//--------------------------------------------------------------------------- +LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedControl kind, UINT message, WPARAM wParam, + LPARAM lParam) +{ + bool const painted = kind == EWin32GUIThemedControl::ProgressBar || kind == EWin32GUIThemedControl::StatusBar || + kind == EWin32GUIThemedControl::TabControl; + + if (!m_Dark) + { + // The control paints itself, through a buffer too, so it doesn't flicker while the window is resized: + // erasing and painting it straight onto the screen would show its background in between. Not the status + // bar, whose WM_PRINTCLIENT leaves out its background and text. + bool const buffered = kind == EWin32GUIThemedControl::ProgressBar || kind == EWin32GUIThemedControl::TabControl; + + if (buffered && message == WM_ERASEBKGND) + return 1; + + if (buffered && message == WM_PAINT) + { + PAINTSTRUCT paint = {}; + HDC const target = BeginPaint(control, &paint); + + { + TPaintBuffer const buffer(target, paint.rcPaint); + DefSubclassProc(control, WM_PRINTCLIENT, reinterpret_cast(buffer.Get()), PRF_CLIENT | + PRF_ERASEBKGND); + } + + EndPaint(control, &paint); + return 0; + } + + return DefSubclassProc(control, message, wParam, lParam); + } + + bool const bordered = kind == EWin32GUIThemedControl::Edit || kind == EWin32GUIThemedControl::ListView || + kind == EWin32GUIThemedControl::RichEdit || kind == EWin32GUIThemedControl::TreeView; + + switch (message) + { + case WM_ERASEBKGND: + if (kind == EWin32GUIThemedControl::Splitter) + { + RECT client; + GetClientRect(control, &client); + FillSolid(reinterpret_cast(wParam), client, m_Palette.Panel); + return 1; + } + + if (painted) + return 1; // WM_PAINT paints it all. + + break; + + case WM_MOUSELEAVE: + case WM_MOUSEMOVE: + if (kind == EWin32GUIThemedControl::TabControl) + { + // The tab under the mouse is drawn lighter. + TCHITTESTINFO hit = {}; + + if (message == WM_MOUSEMOVE) + { + hit.pt.x = static_cast(LOWORD(lParam)); + hit.pt.y = static_cast(HIWORD(lParam)); + } + else + { + hit.pt.x = -1; + hit.pt.y = -1; + } + + int const hot = static_cast(SendMessageW(control, TCM_HITTEST, 0, reinterpret_cast(&hit))) + + 1; + + if (reinterpret_cast(GetPropW(control, HotTabProperty)) != hot) + { + SetPropW(control, HotTabProperty, reinterpret_cast(static_cast(hot))); + InvalidateRect(control, nullptr, FALSE); + } + + if (message == WM_MOUSEMOVE) + { + TRACKMOUSEEVENT track = {}; + track.cbSize = sizeof(track); + track.dwFlags = TME_LEAVE; + track.hwndTrack = control; + TrackMouseEvent(&track); + } + } + + break; + + case WM_NCPAINT: + if (bordered) + { + LRESULT const result = DefSubclassProc(control, message, wParam, lParam); + PaintBorder(control); + return result; + } + + break; + + case WM_NOTIFY: + if (kind == EWin32GUIThemedControl::ListView) + { + NMHDR const* const header = reinterpret_cast(lParam); + + if (header->code == NM_CUSTOMDRAW && + header->hwndFrom == reinterpret_cast(SendMessageW(control, LVM_GETHEADER, 0, 0))) + { + return OnHeaderCustomDraw(*reinterpret_cast(lParam)); + } + } + + break; + + case WM_PAINT: + case WM_PRINTCLIENT: + if (painted) + { + PAINTSTRUCT paint = {}; + HDC const target = message == WM_PAINT ? BeginPaint(control, &paint) : reinterpret_cast(wParam); + RECT client; + GetClientRect(control, &client); + + { + TPaintBuffer const buffer(target, client); + + if (kind == EWin32GUIThemedControl::ProgressBar) + PaintProgressBar(control, buffer.Get()); + else if (kind == EWin32GUIThemedControl::StatusBar) + PaintStatusBar(control, buffer.Get()); + else + PaintTabControl(control, buffer.Get()); + } + + if (message == WM_PAINT) + EndPaint(control, &paint); + + return 0; + } + + break; + + case WM_SIZE: + if (painted) + InvalidateRect(control, nullptr, FALSE); // e.g. the status bar's size grip, at its right. + + break; + + case WM_TIMER: + case PBM_DELTAPOS: + case PBM_SETPOS: + case PBM_SETRANGE: + case PBM_SETRANGE32: + case PBM_SETSTATE: + case PBM_STEPIT: + if (kind == EWin32GUIThemedControl::ProgressBar) + { + // The themed bar animates toward a new position by itself; this one shows it at once. + LRESULT const result = DefSubclassProc(control, message, wParam, lParam); + InvalidateRect(control, nullptr, FALSE); + return result; + } + + break; + + default: + break; + } + + return DefSubclassProc(control, message, wParam, lParam); +} +//--------------------------------------------------------------------------- +LRESULT TWin32GUITheme::HandleWindowMessage(HWND window, UINT message, WPARAM wParam, LPARAM lParam) +{ + switch (message) + { + case WM_CTLCOLORBTN: + case WM_CTLCOLORDLG: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORSTATIC: + { + LRESULT result = 0; + + if (OnCtlColor(message, reinterpret_cast(wParam), reinterpret_cast(lParam), result)) + return result; + + break; + } + + case WM_DRAWITEM: + { + DRAWITEMSTRUCT const* const draw = reinterpret_cast(lParam); + + if (draw->CtlType == ODT_MENU && FindMenuItem(draw->itemData) != nullptr) + { + DrawMenuItem(*draw); + return TRUE; + } + + break; + } + + case WM_ERASEBKGND: + if (m_Dark) + { + // Only between the children, which a window with WS_CLIPCHILDREN leaves out of the context. + RECT client; + GetClientRect(window, &client); + FillSolid(reinterpret_cast(wParam), client, m_Palette.Panel); + return 1; + } + + break; + + case WM_MEASUREITEM: + { + MEASUREITEMSTRUCT* const measure = reinterpret_cast(lParam); + + if (measure->CtlType == ODT_MENU && FindMenuItem(measure->itemData) != nullptr) + { + MeasureMenuItem(window, *measure); + return TRUE; + } + + break; + } + + case WM_NCACTIVATE: + case WM_NCPAINT: + if (m_Dark && window == m_MenuBarWindow) + { + LRESULT const result = DefSubclassProc(window, message, wParam, lParam); + PaintMenuBarLine(window); + return result; + } + + break; + + case WM_NOTIFY: + if (m_Dark) + { + NMHDR const* const header = reinterpret_cast(lParam); + + if (header->code == NM_CUSTOMDRAW && ClassNameIs(header->hwndFrom, WC_BUTTONW)) + return OnButtonCustomDraw(*reinterpret_cast(lParam)); + + if (header->code == NM_CUSTOMDRAW && ClassNameIs(header->hwndFrom, WC_TREEVIEWW)) + return OnTreeCustomDraw(*reinterpret_cast(lParam)); + } + + break; + + default: + break; + } + + return DefSubclassProc(window, message, wParam, lParam); +} +//--------------------------------------------------------------------------- +bool TWin32GUITheme::IsDark() const +{ + return m_Dark; +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::MeasureMenuItem + + Carbon's sizes: a menu item 21 pixels high at 96 DPI and a separator 9, with room for the check mark + gutter, the caption, the shortcut and the submenu arrow; a menu bar item its caption plus 6 pixels each + side, as tall as the menu bar. Windows adds the width of a check mark to a menu bar item's, which is taken + off here. +*/ +void TWin32GUITheme::MeasureMenuItem(HWND window, MEASUREITEMSTRUCT& measure) +{ + TMenuItem const* const item = FindMenuItem(measure.itemData); + UINT const dpi = WindowDPI(window); + + if (item->Separator) + { + measure.itemWidth = static_cast(ScaleForDPI(10, dpi)); + measure.itemHeight = static_cast(ScaleForDPI(9, dpi)); + return; + } + + std::wstring caption; + std::wstring shortcut; + SplitShortcut(item->Text, caption, shortcut); + + HDC const context = GetDC(window); + HGDIOBJ const oldFont = SelectObject(context, MenuFont(dpi)); + SIZE const captionSize = TextSize(context, caption, 0); + SIZE const shortcutSize = shortcut.empty() ? SIZE { 0, 0 } : TextSize(context, shortcut, 0); + SelectObject(context, oldFont); + ReleaseDC(window, context); + + if (item->TopLevel) + { + int const captionWidth = static_cast(captionSize.cx); + int const added = GetSystemMetricsForDpi(SM_CXMENUCHECK, dpi) - 1; + measure.itemWidth = static_cast((std::max)(captionWidth, captionWidth + ScaleForDPI(12, dpi) - added)); + measure.itemHeight = static_cast(GetSystemMetricsForDpi(SM_CYMENU, dpi)); + } + else + { + measure.itemWidth = static_cast(ScaleForDPI(28, dpi) + captionSize.cx + (shortcut.empty() ? 0 : + ScaleForDPI(32, dpi) + shortcutSize.cx) + ScaleForDPI(22, dpi)); + measure.itemHeight = static_cast(ScaleForDPI(21, dpi)); + } +} +//--------------------------------------------------------------------------- +HFONT TWin32GUITheme::MenuFont(UINT dpi) +{ + if (m_MenuFont.Get() == nullptr || m_MenuFontDPI != dpi) + { + NONCLIENTMETRICSW metrics = {}; + metrics.cbSize = sizeof(metrics); + + if (SystemParametersInfoForDpi(SPI_GETNONCLIENTMETRICS, sizeof(metrics), &metrics, 0, dpi) != 0) + { + m_MenuFont = TWin32GUIFont(CreateFontIndirectW(&metrics.lfMenuFont)); + m_MenuFontDPI = dpi; + } + } + + return m_MenuFont.Get() != nullptr ? m_MenuFont.Get() : static_cast(GetStockObject(DEFAULT_GUI_FONT)); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::MenuFrameProc + + A menu window's subclass while it exists: its frame (its non-client area) painted in Carbon's colors while + dark, also into the context WM_PRINT gives it for the menu's fade animation. +*/ +LRESULT CALLBACK TWin32GUITheme::MenuFrameProc(HWND menu, UINT message, WPARAM wParam, LPARAM lParam, UINT_PTR id, + DWORD_PTR theme) +{ + try + { + TWin32GUITheme const* const owner = reinterpret_cast(theme); + + switch (message) + { + case WM_NCDESTROY: + RemoveWindowSubclass(menu, &MenuFrameProc, id); + break; + + case WM_NCPAINT: + if (owner->m_Dark) + { + owner->PaintMenuFrame(menu, nullptr); + return 0; + } + + break; + + case WM_PRINT: + if (owner->m_Dark && (lParam & PRF_NONCLIENT) != 0) + { + LRESULT const result = DefSubclassProc(menu, message, wParam, lParam); + owner->PaintMenuFrame(menu, reinterpret_cast(wParam)); + return result; + } + + break; + + default: + break; + } + } + catch (...) + { + } + + return DefSubclassProc(menu, message, wParam, lParam); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::MenuHookProc + + This thread's WH_CALLWNDPROC hook: subclasses each menu window as it's created (see MenuFrameProc()). +*/ +LRESULT CALLBACK TWin32GUITheme::MenuHookProc(int code, WPARAM wParam, LPARAM lParam) +{ + TWin32GUITheme* const theme = s_Instance; + + if (code == HC_ACTION && theme != nullptr) + { + CWPSTRUCT const* const call = reinterpret_cast(lParam); + + if (call->message == WM_CREATE && ClassNameIs(call->hwnd, MenuClassName)) + SetWindowSubclass(call->hwnd, &MenuFrameProc, MenuFrameSubclassID, reinterpret_cast(theme)); + } + + return CallNextHookEx(theme != nullptr ? theme->m_MenuHook : nullptr, code, wParam, lParam); +} +//--------------------------------------------------------------------------- +LRESULT TWin32GUITheme::OnButtonCustomDraw(NMCUSTOMDRAW const& draw) const +{ + if (draw.dwDrawStage != CDDS_PREPAINT) + return CDRF_DODEFAULT; + + switch (GetWindowLongPtrW(draw.hdr.hwndFrom, GWL_STYLE) & BS_TYPEMASK) + { + case BS_DEFPUSHBUTTON: + case BS_PUSHBUTTON: + DrawButton(draw); + return CDRF_SKIPDEFAULT; + + case BS_AUTORADIOBUTTON: + case BS_RADIOBUTTON: + DrawCheckButton(draw, true); + return CDRF_SKIPDEFAULT; + + case BS_3STATE: + case BS_AUTO3STATE: + case BS_AUTOCHECKBOX: + case BS_CHECKBOX: + DrawCheckButton(draw, false); + return CDRF_SKIPDEFAULT; + + default: + return CDRF_DODEFAULT; + } +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::OnCtlColor + + A read-only edit box (WM_CTLCOLORSTATIC) is drawn like an editable one, in both looks, as the VCL GUI + runner's are, rather than gray. While dark, everything else gets the palette's colors; while light, the + system's. +*/ +bool TWin32GUITheme::OnCtlColor(UINT message, HDC context, HWND control, LRESULT& result) const +{ + bool const edit = ClassNameIs(control, WC_EDITW); + bool const enabled = IsWindowEnabled(control) != FALSE; + + if (message == WM_CTLCOLORSTATIC && edit && enabled) + { + SetTextColor(context, WindowTextColor()); + SetBkColor(context, WindowColor()); + result = reinterpret_cast(m_Dark ? m_WindowBrush : GetSysColorBrush(COLOR_WINDOW)); + return true; + } + + if (!m_Dark) + return false; + + switch (message) + { + case WM_CTLCOLOREDIT: + SetTextColor(context, m_Palette.WindowText); + SetBkColor(context, m_Palette.Window); + result = reinterpret_cast(m_WindowBrush); + return true; + + case WM_CTLCOLORSTATIC: + if (edit) + { + // A disabled edit box, e.g. the filter box during a run. + SetTextColor(context, m_Palette.DisabledText); + SetBkColor(context, m_Palette.Window); + result = reinterpret_cast(m_WindowBrush); + return true; + } + + SetTextColor(context, enabled ? m_Palette.ControlText : m_Palette.DisabledText); + SetBkColor(context, m_Palette.Panel); + result = reinterpret_cast(m_PanelBrush); + return true; + + case WM_CTLCOLORBTN: + case WM_CTLCOLORDLG: + result = reinterpret_cast(m_PanelBrush); + return true; + + default: + return false; + } +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::OnHeaderCustomDraw + + As Carbon draws a header: each column shaded from top to bottom (lighter under the mouse), with a line at + its right and along the bottom, and the part right of the last column the same. +*/ +LRESULT TWin32GUITheme::OnHeaderCustomDraw(NMCUSTOMDRAW const& draw) const +{ + HWND const header = draw.hdr.hwndFrom; + HDC const context = draw.hdc; + + if (draw.dwDrawStage == CDDS_PREPAINT) + return CDRF_NOTIFYITEMDRAW | CDRF_NOTIFYPOSTPAINT; + + if (draw.dwDrawStage == CDDS_ITEMPREPAINT) + { + RECT rect = draw.rc; + + if ((draw.uItemState & CDIS_HOT) != 0) + FillSolid(context, rect, m_Palette.HeaderHot); + else + FillGradient(context, rect, m_Palette.HeaderTop, m_Palette.HeaderBottom); + + RECT const divider = { rect.right - 1, rect.top, rect.right, rect.bottom }; + RECT const bottom = { rect.left, rect.bottom - 1, rect.right, rect.bottom }; + FillSolid(context, divider, m_Palette.HeaderDivider); + FillSolid(context, bottom, m_Palette.HeaderDivider); + + wchar_t text[256] = {}; + HDITEMW item = {}; + item.mask = HDI_TEXT; + item.pszText = text; + item.cchTextMax = 256; + SendMessageW(header, HDM_GETITEMW, draw.dwItemSpec, reinterpret_cast(&item)); + + rect.left += ScaleForDPI(6, WindowDPI(header)); + int const oldMode = SetBkMode(context, TRANSPARENT); + COLORREF const oldColor = SetTextColor(context, m_Palette.ControlText); + DrawTextW(context, text, -1, &rect, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX); + SetTextColor(context, oldColor); + SetBkMode(context, oldMode); + return CDRF_SKIPDEFAULT; + } + + if (draw.dwDrawStage == CDDS_POSTPAINT) + { + int const count = static_cast(SendMessageW(header, HDM_GETITEMCOUNT, 0, 0)); + RECT last = {}; + + if (count > 0) + SendMessageW(header, HDM_GETITEMRECT, static_cast(count - 1), reinterpret_cast(&last)); + + RECT rest; + GetClientRect(header, &rest); + rest.left = last.right; + + if (rest.left < rest.right) + { + FillGradient(context, rest, m_Palette.HeaderTop, m_Palette.HeaderBottom); + RECT const bottom = { rest.left, rest.bottom - 1, rest.right, rest.bottom }; + FillSolid(context, bottom, m_Palette.HeaderDivider); + } + } + + return CDRF_DODEFAULT; +} +//--------------------------------------------------------------------------- +LRESULT TWin32GUITheme::OnListCustomDraw(NMLVCUSTOMDRAW& draw, COLORREF rowTextColor) const +{ + if (draw.nmcd.dwDrawStage == CDDS_PREPAINT) + return CDRF_NOTIFYITEMDRAW; + + if (draw.nmcd.dwDrawStage != CDDS_ITEMPREPAINT) + return CDRF_DODEFAULT; + + if (m_Dark) + { + DrawListRow(draw, rowTextColor); + return CDRF_SKIPDEFAULT; + } + + draw.clrText = rowTextColor; + return CDRF_DODEFAULT; +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::OnTreeCustomDraw + + The selected node as the VCL GUI runner shows it under Carbon: white text in a gray box with a darker frame, + whether the tree has the focus or not. Without the selected, hot and focus states, the theme draws neither + its own selection nor its highlight under the mouse. +*/ +LRESULT TWin32GUITheme::OnTreeCustomDraw(NMTVCUSTOMDRAW& draw) const +{ + if (draw.nmcd.dwDrawStage == CDDS_PREPAINT) + return CDRF_NOTIFYITEMDRAW; + + HTREEITEM const item = reinterpret_cast(draw.nmcd.dwItemSpec); + bool const selected = SendMessageW(draw.nmcd.hdr.hwndFrom, TVM_GETNEXTITEM, TVGN_CARET, 0) == + static_cast(draw.nmcd.dwItemSpec); + + if (draw.nmcd.dwDrawStage == CDDS_ITEMPREPAINT) + { + draw.nmcd.uItemState &= ~static_cast(CDIS_SELECTED | CDIS_HOT | CDIS_FOCUS); + draw.clrText = selected ? m_Palette.HotText : m_Palette.WindowText; + draw.clrTextBk = selected ? m_Palette.Selection : m_Palette.Window; + + return selected ? CDRF_NOTIFYPOSTPAINT : CDRF_DODEFAULT; + } + + if (draw.nmcd.dwDrawStage == CDDS_ITEMPOSTPAINT && selected) + { + // TVM_GETITEMRECT takes the item in the rectangle and gives back its text's bounds (wParam TRUE). + RECT text = {}; + std::memcpy(&text, &item, sizeof(item)); + + if (SendMessageW(draw.nmcd.hdr.hwndFrom, TVM_GETITEMRECT, TRUE, reinterpret_cast(&text)) != 0) + FrameSolid(draw.nmcd.hdc, text, m_Palette.SelectionFrame); + } + + return CDRF_DODEFAULT; +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::PaintBorder + + WS_EX_CLIENTEDGE's border, which stays light under a dark theme, painted over in Carbon's two edit border + colors, the outer one a pixel wide. +*/ +void TWin32GUITheme::PaintBorder(HWND control) const +{ + if ((GetWindowLongPtrW(control, GWL_EXSTYLE) & WS_EX_CLIENTEDGE) == 0) + return; + + RECT rect; + GetWindowRect(control, &rect); + OffsetRect(&rect, -rect.left, -rect.top); + + HDC const context = GetWindowDC(control); + + if (context == nullptr) + return; + + int const edge = (std::max)(1, GetSystemMetricsForDpi(SM_CXEDGE, WindowDPI(control))); + FrameSolid(context, rect, m_Palette.EditBorderOuter); + + for (int i = 1; i < edge; ++i) + FrameSolid(context, Inset(rect, i), m_Palette.EditBorderInner); + + ReleaseDC(control, context); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::PaintMenuBarLine + + The line DefWindowProc() draws under the menu bar, in a light system color, painted over in the + window's background. +*/ +void TWin32GUITheme::PaintMenuBarLine(HWND window) const +{ + MENUBARINFO bar = {}; + bar.cbSize = sizeof(bar); + + if (GetMenuBarInfo(window, OBJID_MENU, 0, &bar) == 0) + return; + + RECT windowRect; + GetWindowRect(window, &windowRect); + RECT line = bar.rcBar; + OffsetRect(&line, -windowRect.left, -windowRect.top); + line.top = line.bottom; + line.bottom = line.top + 1; + + HDC const context = GetWindowDC(window); + + if (context == nullptr) + return; + + FillSolid(context, line, m_Palette.Panel); + ReleaseDC(window, context); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::PaintMenuFrame + + A menu window's non-client area, its frame, as Carbon's: two one-pixel lines, then the menu's background. + Into 'given' if it isn't nullptr (WM_PRINT's context), or else the window's own. +*/ +void TWin32GUITheme::PaintMenuFrame(HWND menu, HDC given) const +{ + RECT window; + GetWindowRect(menu, &window); + RECT client; + GetClientRect(menu, &client); + MapWindowPoints(menu, nullptr, reinterpret_cast(&client), 2); + OffsetRect(&client, -window.left, -window.top); + OffsetRect(&window, -window.left, -window.top); + + HDC const context = given != nullptr ? given : GetWindowDC(menu); + + if (context == nullptr) + return; + + int const saved = SaveDC(context); + ExcludeClipRect(context, client.left, client.top, client.right, client.bottom); + FillSolid(context, window, m_Palette.MenuBackground); + FrameSolid(context, window, m_Palette.MenuBorderOuter); + FrameSolid(context, Inset(window, 1), m_Palette.MenuBorderInner); + RestoreDC(context, saved); + + if (given == nullptr) + ReleaseDC(menu, context); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::PaintProgressBar + + As Carbon draws it: a border around a dark track, and the bar shaded from top to bottom, red once a test + has failed (PBST_ERROR). +*/ +void TWin32GUITheme::PaintProgressBar(HWND progressBar, HDC context) const +{ + RECT client; + GetClientRect(progressBar, &client); + int const line = (std::max)(1, ScaleForDPI(1, WindowDPI(progressBar))); + + FillSolid(context, client, m_Palette.ProgressBorder); + RECT const track = Inset(client, line); + FillSolid(context, track, m_Palette.ProgressTrack); + + PBRANGE range = {}; + SendMessageW(progressBar, PBM_GETRANGE, TRUE, reinterpret_cast(&range)); + int const position = static_cast(SendMessageW(progressBar, PBM_GETPOS, 0, 0)); + int const span = range.iHigh - range.iLow; + + if (span <= 0) + return; + + RECT bar = track; + int const done = (std::max)(0, (std::min)(span, position - range.iLow)); + bar.right = track.left + MulDiv(track.right - track.left, done, span); + + if (bar.right <= bar.left) + return; + + bool const failed = SendMessageW(progressBar, PBM_GETSTATE, 0, 0) == PBST_ERROR; + FillGradient(context, bar, failed ? m_Palette.ProgressFailedTop : m_Palette.ProgressChunkTop, + failed ? m_Palette.ProgressFailedBottom : m_Palette.ProgressChunkBottom); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::PaintStatusBar + + As Carbon draws it: a line along the top, each part's text, and the size grip's dots at the bottom right + (unless its window is maximized), where Carbon's 16-pixel grip has them. +*/ +void TWin32GUITheme::PaintStatusBar(HWND statusBar, HDC context) const +{ + RECT client; + GetClientRect(statusBar, &client); + UINT const dpi = WindowDPI(statusBar); + int const line = (std::max)(1, ScaleForDPI(1, dpi)); + + FillSolid(context, client, m_Palette.Panel); + RECT const top = { client.left, client.top, client.right, client.top + line }; + FillSolid(context, top, m_Palette.StatusLine); + + HGDIOBJ const oldFont = SelectObject(context, reinterpret_cast(SendMessageW(statusBar, WM_GETFONT, 0, 0))); + int const oldMode = SetBkMode(context, TRANSPARENT); + COLORREF const oldColor = SetTextColor(context, m_Palette.ControlText); + int const parts = static_cast(SendMessageW(statusBar, SB_GETPARTS, 0, 0)); + + for (int part = 0; part < parts; ++part) + { + RECT rect = {}; + SendMessageW(statusBar, SB_GETRECT, static_cast(part), reinterpret_cast(&rect)); + + std::wstring text(static_cast(LOWORD(SendMessageW(statusBar, SB_GETTEXTLENGTHW, + static_cast(part), 0))) + 1, L'\0'); + text.resize(static_cast(LOWORD(SendMessageW(statusBar, SB_GETTEXTW, static_cast(part), + reinterpret_cast(&text[0]))))); + + rect.left += ScaleForDPI(4, dpi); + DrawTextW(context, text.c_str(), static_cast(text.size()), &rect, DT_LEFT | DT_VCENTER | DT_SINGLELINE | + DT_END_ELLIPSIS | DT_NOPREFIX); + } + + SetTextColor(context, oldColor); + SetBkMode(context, oldMode); + SelectObject(context, oldFont); + + HWND const parent = GetParent(statusBar); + bool const maximized = parent != nullptr && IsZoomed(parent) != FALSE; + + if ((GetWindowLongPtrW(statusBar, GWL_STYLE) & SBARS_SIZEGRIP) == 0 || maximized) + return; + + // Carbon's dots, at 96 DPI in its 16-pixel grip: a triangle of four rows, two pixels apart. + int const cell = ScaleForDPI(16, dpi); + int const left = client.right - cell; + int const gripTop = client.bottom - cell; + + for (int row = 0; row < 4; ++row) + { + for (int column = 0; column <= row; ++column) + { + int const x = left + ScaleForDPI(10 - 2 * column, dpi); + int const y = gripTop + ScaleForDPI(7 + 2 * row, dpi); + RECT const dot = { x, y, x + line, y + line }; + FillSolid(context, dot, m_Palette.GripDot); + } + } +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::PaintTabControl + + As Carbon draws the results' tabs: the strip in the window's background, the pages framed by two lines, + the selected tab shaded and joined to them, the others darker (lighter under the mouse), the selected tab's + text brighter. The pages themselves are separate windows above the tab control. +*/ +void TWin32GUITheme::PaintTabControl(HWND tabControl, HDC context) const +{ + RECT client; + GetClientRect(tabControl, &client); + UINT const dpi = WindowDPI(tabControl); + int const line = (std::max)(1, ScaleForDPI(1, dpi)); + + FillSolid(context, client, m_Palette.Panel); + + RECT pages = client; + SendMessageW(tabControl, TCM_ADJUSTRECT, FALSE, reinterpret_cast(&pages)); + pages = Inset(pages, -2 * line); + FillSolid(context, pages, m_Palette.TabOuter); + FillSolid(context, Inset(pages, line), m_Palette.TabBorder); + FillSolid(context, Inset(pages, 2 * line), m_Palette.Panel); + + int const count = static_cast(SendMessageW(tabControl, TCM_GETITEMCOUNT, 0, 0)); + int const selected = static_cast(SendMessageW(tabControl, TCM_GETCURSEL, 0, 0)); + int const hot = static_cast(reinterpret_cast(GetPropW(tabControl, HotTabProperty))) - 1; + bool const showFocus = GetFocus() == tabControl && !HidesFocus(tabControl); + UINT const format = DT_CENTER | DT_VCENTER | DT_SINGLELINE | (HidesAccelerators(tabControl) ? DT_HIDEPREFIX : 0); + + HGDIOBJ const oldFont = SelectObject(context, reinterpret_cast(SendMessageW(tabControl, WM_GETFONT, 0, + 0))); + int const oldMode = SetBkMode(context, TRANSPARENT); + + for (int i = 0; i < count; ++i) + { + RECT tab = {}; + SendMessageW(tabControl, TCM_GETITEMRECT, static_cast(i), reinterpret_cast(&tab)); + + if (i == selected) + { + // Raised and widened, and down over the pages' frame, which it opens into. + tab.left -= 2 * line; + tab.right += 2 * line; + tab.top -= 2 * line; + tab.bottom += 2 * line; + FillSolid(context, tab, m_Palette.TabOuter); + RECT const inside = { tab.left + line, tab.top + line, tab.right - line, tab.bottom }; + FillSolid(context, inside, m_Palette.TabBorder); + RECT const face = { inside.left + line, inside.top + line, inside.right - line, inside.bottom }; + FillGradient(context, face, m_Palette.TabActiveTop, m_Palette.TabActiveBottom); + } + else + { + FillSolid(context, tab, m_Palette.TabInactiveBorder); + FillSolid(context, Inset(tab, line), i == hot ? m_Palette.TabHot : m_Palette.TabInactive); + } + + wchar_t text[128] = {}; + TCITEMW item = {}; + item.mask = TCIF_TEXT; + item.pszText = text; + item.cchTextMax = 128; + SendMessageW(tabControl, TCM_GETITEMW, static_cast(i), reinterpret_cast(&item)); + + RECT textRect = tab; + + if (i == selected) + textRect.bottom -= 2 * line; + + COLORREF const oldColor = SetTextColor(context, i == selected ? m_Palette.WindowText : m_Palette.ControlText); + DrawTextW(context, text, -1, &textRect, format); + SetTextColor(context, oldColor); + + if (i == selected && showFocus) + { + RECT focus = Inset(textRect, 3 * line); + DrawFocusRect(context, &focus); + } + } + + SetBkMode(context, oldMode); + SelectObject(context, oldFont); +} +//--------------------------------------------------------------------------- +TWin32GUIPalette const& TWin32GUITheme::Palette() const +{ + return m_Palette; +} +//--------------------------------------------------------------------------- +void TWin32GUITheme::Refresh() +{ + ApplyToMenus(); + + for (HWND const window : m_Windows) + ApplyToTitleBar(window); + + for (TAttachedControl const& attached : m_Controls) + ApplyToControl(attached); + + for (HWND const window : m_Windows) + RedrawWindow(window, nullptr, nullptr, RDW_ERASE | RDW_FRAME | RDW_INVALIDATE | RDW_ALLCHILDREN); +} +//--------------------------------------------------------------------------- +void TWin32GUITheme::SetDark(bool dark) +{ + if (dark == m_Dark) + return; + + m_Dark = dark; + Refresh(); +} +//--------------------------------------------------------------------------- +COLORREF TWin32GUITheme::SkipTextColor() const +{ + return m_Dark ? m_Palette.SkipText : SkipTextColorOn(GetSysColor(COLOR_WINDOW)); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::Unattach + + A tree's own check box images, kept while it was dark, are destroyed with it, as a tree with check boxes + leaves them to its owner. +*/ +void TWin32GUITheme::Unattach(HWND window) +{ + m_Controls.erase(std::remove_if(m_Controls.begin(), m_Controls.end(), [window](TAttachedControl const& attached) + { + return attached.Control == window; + }), m_Controls.end()); + m_Windows.erase(std::remove(m_Windows.begin(), m_Windows.end(), window), m_Windows.end()); + + for (std::vector >::iterator themed = m_ThemedCheckImages.begin(); + themed != m_ThemedCheckImages.end();) + { + if (themed->first == window) + { + ImageList_Destroy(themed->second); + themed = m_ThemedCheckImages.erase(themed); + } + else + { + ++themed; + } + } + + if (window == m_MenuBarWindow) + { + // The menu bar is destroyed with its window. + m_MenuBar = nullptr; + m_MenuBarWindow = nullptr; + m_MenuItems.clear(); + } +} +//--------------------------------------------------------------------------- +COLORREF TWin32GUITheme::WindowColor() const +{ + return m_Dark ? m_Palette.Window : GetSysColor(COLOR_WINDOW); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::WindowProc + + An attached window's subclass. No exception may leave a window procedure; one that would leaves the + message to the window. +*/ +LRESULT CALLBACK TWin32GUITheme::WindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam, UINT_PTR id, + DWORD_PTR theme) +{ + try + { + if (message == WM_NCDESTROY) + { + reinterpret_cast(theme)->Unattach(window); + RemoveWindowSubclass(window, &WindowProc, id); + return DefSubclassProc(window, message, wParam, lParam); + } + + return reinterpret_cast(theme)->HandleWindowMessage(window, message, wParam, lParam); + } + catch (...) + { + return DefSubclassProc(window, message, wParam, lParam); + } +} +//--------------------------------------------------------------------------- +COLORREF TWin32GUITheme::WindowTextColor() const +{ + return m_Dark ? m_Palette.WindowText : GetSysColor(COLOR_WINDOWTEXT); +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h new file mode 100644 index 0000000..ec34bea --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h @@ -0,0 +1,229 @@ +/* ************************************************************************** +ASWUnitTests_Win32GUI_Theme.h +Author: Anthony S. West - ASW Software + +The Win32 GUI runner's light and dark looks: Windows' standard one, and a +dark one painted in Carbon's colors (the VCL GUI runner's dark style), +drawing what Windows can't make dark itself. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +#ifndef ASWUnitTests_Win32GUI_ThemeH +#define ASWUnitTests_Win32GUI_ThemeH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_Windows.h" +//--------------------------------------------------------------------------- +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_DPI.h" +#include "ASWUnitTests_Win32GUI_Palette.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +///////////////////////////////////////////////////////////////////////////// +// EWin32GUIThemedControl +// +// The kinds of child control TWin32GUITheme::Attach() themes, each its own +// way. Buttons, radio buttons, check boxes and labels need no attaching: +// their window does it (see TWin32GUITheme::AttachWindow()). +///////////////////////////////////////////////////////////////////////////// +enum class EWin32GUIThemedControl +{ + // An EDIT control, single-line or multiline, read-only or not. + Edit, + // A WC_LISTVIEWW control in report view, with its header. + ListView, + // A PROGRESS_CLASSW control. + ProgressBar, + // An MSFTEDIT_CLASS rich edit control. Its text keeps the colors it's given (see TWin32GUITheme's + // WindowTextColor()). + RichEdit, + // A window that only fills itself with its parent's background, e.g. a splitter. + Splitter, + // A STATUSCLASSNAMEW control. + StatusBar, + // A WC_TABCONTROLW control, whose pages are separate windows above it. + TabControl, + // A TOOLTIPS_CLASSW window. + Tooltip, + // A WC_TREEVIEWW control, with or without TVS_CHECKBOXES (and TVS_EX_PARTIALCHECKBOXES). + TreeView, +}; + + +///////////////////////////////////////////////////////////////////////////// +// 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: +// - 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; +// - buttons, radio buttons and check boxes custom-drawn by their window; +// - the tab control, status bar and progress bar painted whole, through +// a buffer, so they don't flicker; +// - the tree's and list's items and the list's header drawn by us, and +// their scroll bars Windows' own dark ones ("DarkMode_Explorer"); +// - the edit boxes', tree's, list's and log's borders painted over. +// Make one per process, on the GUI's thread, before any window, and destroy +// it after the windows; dialogs attach themselves while they're open. +///////////////////////////////////////////////////////////////////////////// +class TWin32GUITheme +{ +private: + // An attached control and its kind. + struct TAttachedControl + { + HWND Control; + EWin32GUIThemedControl Kind; + }; + + // An item of an attached window's menu bar, or of one of its menus, while they're owner-drawn: the item's + // address is its MENUITEMINFOW::dwItemData. + struct TMenuItem + { + HMENU Menu; + UINT Position; + // Its caption, with any "\t" and shortcut. + std::wstring Text; + bool Separator; + bool Submenu; + bool TopLevel; + }; + +private: + // The theme whose hook paints the menus' frames; see MenuHookProc(). + static TWin32GUITheme* s_Instance; + +private: + std::vector m_Controls; + bool m_Dark; + // The dark tree check boxes, one image list per DPI they've been needed at, shared by the attached trees + // while dark. Kept until this object is destroyed, so a tree never shows a destroyed list. + std::vector > m_DarkCheckImages; + // The menu bar whose items, and its menus', are owner-drawn while dark, and the window it belongs to. + HMENU m_MenuBar; + HWND m_MenuBarWindow; + HBRUSH m_MenuBrush; + // The font for the menus' items, for m_MenuFontDPI. + TWin32GUIFont m_MenuFont; + UINT m_MenuFontDPI; + HHOOK m_MenuHook; + std::vector > m_MenuItems; + TWin32GUIPalette m_Palette; + HBRUSH m_PanelBrush; + // Each attached tree's own check box images, put back when it turns light. + std::vector > m_ThemedCheckImages; + HBRUSH m_WindowBrush; + // The windows attached by AttachWindow(). + std::vector m_Windows; + +private: + static LRESULT CALLBACK ControlProc(HWND control, UINT message, WPARAM wParam, LPARAM lParam, UINT_PTR id, + DWORD_PTR theme); + static LRESULT CALLBACK MenuFrameProc(HWND menu, UINT message, WPARAM wParam, LPARAM lParam, UINT_PTR id, + DWORD_PTR theme); + static LRESULT CALLBACK MenuHookProc(int code, WPARAM wParam, LPARAM lParam); + static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam, UINT_PTR id, + DWORD_PTR theme); + +private: + // Applies the current look to one attached control. + void ApplyToControl(TAttachedControl const& attached); + // Makes 'menu''s items, and its submenus', owner-drawn while dark, adding each to 'items', or standard while + // light. + void ApplyToMenu(HMENU menu, std::vector >& items); + // Makes the menu bar's items and its menus' owner-drawn while dark, or standard while light. + void ApplyToMenus(); + void ApplyToTitleBar(HWND window) const; + void ApplyToTree(HWND tree); + // The dark tree check boxes for 'dpi', created if needed. + HIMAGELIST DarkCheckImages(UINT dpi); + void DrawButton(NMCUSTOMDRAW const& draw) const; + void DrawCheckButton(NMCUSTOMDRAW const& draw, bool radio) const; + // OnListCustomDraw()'s item prepaint while dark. + void DrawListRow(NMLVCUSTOMDRAW const& draw, COLORREF textColor) const; + void DrawMenuItem(DRAWITEMSTRUCT const& draw); + // The owner-drawn menu item whose MENUITEMINFOW::dwItemData is 'itemData', or nullptr if it isn't one. + TMenuItem const* FindMenuItem(ULONG_PTR itemData) const; + LRESULT HandleControlMessage(HWND control, EWin32GUIThemedControl kind, UINT message, WPARAM wParam, + LPARAM lParam); + LRESULT HandleWindowMessage(HWND window, UINT message, WPARAM wParam, LPARAM lParam); + void MeasureMenuItem(HWND window, MEASUREITEMSTRUCT& measure); + // The font for the menus at 'dpi', created if needed. + HFONT MenuFont(UINT dpi); + // The custom draw of a button, radio button or check box, by its window; see AttachWindow(). + LRESULT OnButtonCustomDraw(NMCUSTOMDRAW const& draw) const; + // A WM_CTLCOLOR* message to an attached window; false leaves it to the window. + bool OnCtlColor(UINT message, HDC context, HWND control, LRESULT& result) const; + LRESULT OnHeaderCustomDraw(NMCUSTOMDRAW const& draw) const; + LRESULT OnTreeCustomDraw(NMTVCUSTOMDRAW& draw) const; + void PaintBorder(HWND control) const; + void PaintMenuBarLine(HWND window) const; + void PaintMenuFrame(HWND menu, HDC given) const; + void PaintProgressBar(HWND progressBar, HDC context) const; + void PaintStatusBar(HWND statusBar, HDC context) const; + void PaintTabControl(HWND tabControl, HDC context) const; + // Forgets a control or window that's being destroyed. + void Unattach(HWND window); + +public: + // Light, with nothing attached. Hooks this thread's menu windows. + TWin32GUITheme(); + ~TWin32GUITheme(); + + TWin32GUITheme(TWin32GUITheme const&) = delete; + TWin32GUITheme& operator=(TWin32GUITheme const&) = delete; + + // Themes 'control', of 'kind', a child of an attached window, until it's destroyed. + void Attach(HWND control, EWin32GUIThemedControl kind); + // Themes 'window', a top-level window or dialog, until it's destroyed: its title bar and background, its + // labels, edit boxes' and read-only edit boxes' colors (WM_CTLCOLOR*), its buttons, radio buttons and check + // boxes (their NM_CUSTOMDRAW), and 'menuBar', its menu bar, if it has one. + void AttachWindow(HWND window, HMENU menuBar = nullptr); + // A failed test's text color on the window color (see WindowColor()). + COLORREF FailureTextColor() const; + bool IsDark() const; + // An attached list view's NM_CUSTOMDRAW, for its parent to return: each row drawn whole while dark, or in its + // default look while light, with its text in 'rowTextColor', which the item prepaint stage uses. + LRESULT OnListCustomDraw(NMLVCUSTOMDRAW& draw, COLORREF rowTextColor) const; + // The dark palette's colors. + TWin32GUIPalette const& Palette() const; + // Applies the current look again to everything attached, e.g. after a DPI change or a change of the system + // colors. + void Refresh(); + // Switches to the dark look or back to the light one, and redraws everything attached. + void SetDark(bool dark); + // A skipped test's text color on the window color (see WindowColor()). + COLORREF SkipTextColor() const; + // The background of edit boxes, lists and the log. + COLORREF WindowColor() const; + // The text color of edit boxes, lists and the log. + COLORREF WindowTextColor() const; +}; + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef ASWUnitTests_Win32GUI_ThemeH diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp index be7d616..0f1e34b 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_WinMain.cpp @@ -30,10 +30,12 @@ limitations under the License. #include "ASWUnitTests_Utils.h" //--------------------------------------------------------------------------- #include "ASWUnitTests_GUI_CommandLine.h" +#include "ASWUnitTests_GUI_Theme.h" //--------------------------------------------------------------------------- #include "ASWUnitTests_Win32GUI_MainWindow.h" #include "ASWUnitTests_Win32GUI_System.h" #include "ASWUnitTests_Win32GUI_TextDialog.h" +#include "ASWUnitTests_Win32GUI_Theme.h" //--------------------------------------------------------------------------- #if defined(__MINGW32__) @@ -57,16 +59,16 @@ char const ApplicationTitle[] = "ASWUnitTests"; //--------------------------------------------------------------------------- -void ShowUnhandledException(std::string const& description); +void ShowUnhandledException(ASWUnitTests::TWin32GUITheme& theme, std::string const& description); //--------------------------------------------------------------------------- /* ShowUnhandledException - Reports an exception that escaped to WinMain(). Called from its catch blocks, so it throws nothing: if the - text dialog can't be shown, a message box is. + Reports an exception that escaped to WinMain(), in the GUI's theme. Called from its catch blocks, so it + throws nothing: if the text dialog can't be shown, a message box is. */ -void ShowUnhandledException(std::string const& description) +void ShowUnhandledException(ASWUnitTests::TWin32GUITheme& theme, std::string const& description) { try { @@ -74,7 +76,7 @@ void ShowUnhandledException(std::string const& description) try { - ASWUnitTests::ShowTextDialog(nullptr, ApplicationTitle, text); + ASWUnitTests::ShowTextDialog(nullptr, theme, ApplicationTitle, text); } catch (...) { @@ -95,6 +97,8 @@ void ShowUnhandledException(std::string const& description) int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int showCommand) { int exitCode = ASWUnitTests::ExitCode_Success; + // Before any window, and destroyed after them. + ASWUnitTests::TWin32GUITheme theme; try { @@ -110,15 +114,22 @@ 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(); + theme.SetDark(ASWUnitTests::GUIThemeIsDark(themeChoice, ASWUnitTests::WindowsAppsUseDarkTheme(), + ASWUnitTests::WindowsHighContrast())); + if (commandLine.EarlyExitCode.has_value()) { - ASWUnitTests::ShowTextDialog(nullptr, ApplicationTitle, commandLine.Message); + ASWUnitTests::ShowTextDialog(nullptr, theme, ApplicationTitle, commandLine.Message); return *commandLine.EarlyExitCode; } - ASWUnitTests::TWin32GUIMainWindow mainWindow; + ASWUnitTests::TWin32GUIMainWindow mainWindow(theme); mainWindow.Create(); - mainWindow.Start(options); + mainWindow.Start(options, themeChoice); mainWindow.Show(showCommand); mainWindow.RunMessageLoop(); @@ -132,12 +143,12 @@ int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int showCommand) } catch (std::exception const& exception) { - ShowUnhandledException(exception.what()); + ShowUnhandledException(theme, exception.what()); exitCode = ASWUnitTests::ExitCode_UnhandledException; } catch (...) { - ShowUnhandledException("Unknown"); + ShowUnhandledException(theme, "Unknown"); exitCode = ASWUnitTests::ExitCode_UnhandledExceptionUnknown; } diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp b/win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp new file mode 100644 index 0000000..16a6cdb --- /dev/null +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp @@ -0,0 +1,250 @@ +/* ************************************************************************** +Test_ASWUnitTests_Win32GUI_Glyphs.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "Test_ASWUnitTests_Win32GUI_Glyphs.h" +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Registry.h" +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_Glyphs.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +namespace +{ + +//--------------------------------------------------------------------------- + +int At(std::vector const& coverage, int size, int x, int y); +bool IsMirrored(std::vector const& coverage, int size, bool leftToRight); +int Total(std::vector const& coverage); + +//--------------------------------------------------------------------------- +int At(std::vector const& coverage, int size, int x, int y) +{ + return coverage[static_cast(y) * static_cast(size) + static_cast(x)]; +} +//--------------------------------------------------------------------------- +/* + IsMirrored + + Whether the glyph is the same mirrored left to right (or else top to bottom). +*/ +bool IsMirrored(std::vector const& coverage, int size, bool leftToRight) +{ + for (int y = 0; y < size; ++y) + { + for (int x = 0; x < size; ++x) + { + int const mirrored = leftToRight ? At(coverage, size, size - 1 - x, y) : At(coverage, size, x, size - 1 - y); + + if (At(coverage, size, x, y) != mirrored) + return false; + } + } + + return true; +} +//--------------------------------------------------------------------------- +int Total(std::vector const& coverage) +{ + int total = 0; + + for (std::uint8_t const value : coverage) + total += value; + + return total; +} +//--------------------------------------------------------------------------- + +} // namespace + +//--------------------------------------------------------------------------- + + +///////////////////////////////////////////////////////////////////////////// +// TTest_ASWUnitTests_Win32GUI_Glyphs +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TTest_ASWUnitTests_Win32GUI_Glyphs::TTest_ASWUnitTests_Win32GUI_Glyphs() + : inherited("ASWUnitTests_Win32GUI_Glyphs_Tests") +{ + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_ArrowPointsRight, + "RenderWin32GUIGlyph_ArrowPointsRight"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_CirclesAreRoundAndNested, + "RenderWin32GUIGlyph_CirclesAreRoundAndNested"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_CoversOnePixelPerValue, + "RenderWin32GUIGlyph_CoversOnePixelPerValue"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_CrossIsCarbonsCheckMark, + "RenderWin32GUIGlyph_CrossIsCarbonsCheckMark"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_IsEmptyWithoutASize, + "RenderWin32GUIGlyph_IsEmptyWithoutASize"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_ScalesWithTheSize, + "RenderWin32GUIGlyph_ScalesWithTheSize"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_SquareAndDotAreInTheMiddle, + "RenderWin32GUIGlyph_SquareAndDotAreInTheMiddle"); +} +//--------------------------------------------------------------------------- +TTest_ASWUnitTests_Win32GUI_Glyphs::~TTest_ASWUnitTests_Win32GUI_Glyphs() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Glyphs::SetUp_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Glyphs::SetUp_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Glyphs::TearDown_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Glyphs::TearDown_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- + +// /////// Begin tests after this line /////////////////////// + +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_ArrowPointsRight() +{ + // Act: the menus' 16-pixel cell, the triangle's flat side at pixel 6 and its point at 10. + std::vector const arrow = RenderWin32GUIGlyph(EWin32GUIGlyph::ArrowRight, 16); + + // Assert + CheckTrue(IsMirrored(arrow, 16, false), __func__, __LINE__, "the same above and below its middle"); + CheckEquals(255, At(arrow, 16, 6, 8), __func__, __LINE__, "full at its flat side, in the middle"); + CheckEquals(0, At(arrow, 16, 5, 8), __func__, __LINE__, "nothing left of it"); + CheckEquals(0, At(arrow, 16, 11, 8), __func__, __LINE__, "nothing right of its point"); + CheckTrue(At(arrow, 16, 6, 5) > At(arrow, 16, 9, 5), __func__, __LINE__, "narrowing to the right"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_CirclesAreRoundAndNested() +{ + // Act: a radio button's circle and its inside, at Carbon's 13 pixels. + std::vector const circle = RenderWin32GUIGlyph(EWin32GUIGlyph::Circle, 13); + std::vector const inner = RenderWin32GUIGlyph(EWin32GUIGlyph::InnerCircle, 13); + + bool nested = true; + + for (std::size_t i = 0; i < inner.size(); ++i) + nested = nested && inner[i] <= circle[i]; + + // Assert + CheckTrue(IsMirrored(circle, 13, true) && IsMirrored(circle, 13, false), __func__, __LINE__, + "the circle is symmetric"); + CheckEquals(255, At(circle, 13, 6, 6), __func__, __LINE__, "the circle's middle"); + CheckEquals(255, At(circle, 13, 0, 6), __func__, __LINE__, "the circle reaches the cell's edge"); + CheckEquals(0, At(circle, 13, 0, 0), __func__, __LINE__, "the circle leaves the corners"); + CheckEquals(0, At(inner, 13, 0, 6), __func__, __LINE__, "the inside is a pixel in from the edge"); + CheckEquals(255, At(inner, 13, 1, 6), __func__, __LINE__, "the inside starts a pixel in"); + CheckTrue(nested, __func__, __LINE__, "the inside is within the circle"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_CoversOnePixelPerValue() +{ + // Arrange + EWin32GUIGlyph const glyphs[] = { + EWin32GUIGlyph::ArrowRight, EWin32GUIGlyph::Circle, EWin32GUIGlyph::Cross, EWin32GUIGlyph::Dot, + EWin32GUIGlyph::InnerCircle, EWin32GUIGlyph::Square, EWin32GUIGlyph::Tick, + }; + + for (EWin32GUIGlyph const glyph : glyphs) + { + // Act + std::vector const coverage = RenderWin32GUIGlyph(glyph, 13); + + // Assert + std::string const name = "glyph " + std::to_string(static_cast(glyph)); + CheckEquals(static_cast(13 * 13), coverage.size(), __func__, __LINE__, name + ": 13 x 13 values"); + CheckTrue(Total(coverage) > 0, __func__, __LINE__, name + ": covers something"); + CheckTrue(Total(coverage) < 13 * 13 * 255, __func__, __LINE__, name + ": doesn't cover everything"); + } +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_CrossIsCarbonsCheckMark() +{ + // Act: Carbon's 13-pixel check box's cross, whose arms end at pixels 3 and 9. + std::vector const cross = RenderWin32GUIGlyph(EWin32GUIGlyph::Cross, 13); + + // Assert + CheckTrue(IsMirrored(cross, 13, true) && IsMirrored(cross, 13, false), __func__, __LINE__, "symmetric"); + CheckEquals(255, At(cross, 13, 6, 6), __func__, __LINE__, "full where its arms cross"); + CheckTrue(At(cross, 13, 3, 3) >= 200, __func__, __LINE__, "nearly full at an arm's end"); + CheckEquals(0, At(cross, 13, 6, 3), __func__, __LINE__, "nothing between the upper arms"); + CheckEquals(0, At(cross, 13, 1, 1), __func__, __LINE__, "nothing past an arm's end"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_IsEmptyWithoutASize() +{ + // Act, Assert + CheckTrue(RenderWin32GUIGlyph(EWin32GUIGlyph::Cross, 0).empty(), __func__, __LINE__, "size 0"); + CheckTrue(RenderWin32GUIGlyph(EWin32GUIGlyph::Cross, -1).empty(), __func__, __LINE__, "a negative size"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_ScalesWithTheSize() +{ + // Act: 96 and 192 DPI. + int const at13 = Total(RenderWin32GUIGlyph(EWin32GUIGlyph::Cross, 13)); + int const at26 = Total(RenderWin32GUIGlyph(EWin32GUIGlyph::Cross, 26)); + + // Assert: twice the size, four times the area, within what the pixel grid changes. + CheckTrue(at26 > at13 * 7 / 2 && at26 < at13 * 9 / 2, __func__, __LINE__, "covers about four times as much"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_SquareAndDotAreInTheMiddle() +{ + // Act: the partial check box's square and the radio button's dot, at 13 pixels. + std::vector const square = RenderWin32GUIGlyph(EWin32GUIGlyph::Square, 13); + std::vector const dot = RenderWin32GUIGlyph(EWin32GUIGlyph::Dot, 13); + + // Assert + for (std::vector const* const glyph : { &square, &dot }) + { + std::string const name = glyph == &square ? "the square" : "the dot"; + CheckTrue(IsMirrored(*glyph, 13, true) && IsMirrored(*glyph, 13, false), __func__, __LINE__, + name + " is symmetric"); + CheckEquals(255, At(*glyph, 13, 6, 6), __func__, __LINE__, name + " is full in the middle"); + CheckEquals(0, At(*glyph, 13, 6, 2), __func__, __LINE__, name + " leaves the edges"); + } + + CheckTrue(At(dot, 13, 4, 4) < At(square, 13, 4, 4), __func__, __LINE__, "the dot is round where the square isn't"); +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +ASW_REGISTER_TEST_GROUP(ASWUnitTests::TTest_ASWUnitTests_Win32GUI_Glyphs) diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.h b/win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.h new file mode 100644 index 0000000..24757d6 --- /dev/null +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.h @@ -0,0 +1,65 @@ +/* ************************************************************************** +Test_ASWUnitTests_Win32GUI_Glyphs.h +Author: Anthony S. West - ASW Software + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +#ifndef Test_ASWUnitTests_Win32GUI_GlyphsH +#define Test_ASWUnitTests_Win32GUI_GlyphsH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_TestBase.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +///////////////////////////////////////////////////////////////////////////// +// TTest_ASWUnitTests_Win32GUI_Glyphs +// +// Exercises the Win32 GUI runner's dark theme shapes +// (win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h). Compiled by the Windows +// console runners as well as the Win32 GUI, so it runs without the GUI. +///////////////////////////////////////////////////////////////////////////// +class TTest_ASWUnitTests_Win32GUI_Glyphs : public TTestGroupBase +{ +private: + typedef TTestGroupBase inherited; + +private: // Test methods + void Test_RenderWin32GUIGlyph_ArrowPointsRight(); + void Test_RenderWin32GUIGlyph_CirclesAreRoundAndNested(); + void Test_RenderWin32GUIGlyph_CoversOnePixelPerValue(); + void Test_RenderWin32GUIGlyph_CrossIsCarbonsCheckMark(); + void Test_RenderWin32GUIGlyph_IsEmptyWithoutASize(); + void Test_RenderWin32GUIGlyph_ScalesWithTheSize(); + void Test_RenderWin32GUIGlyph_SquareAndDotAreInTheMiddle(); + +public: + TTest_ASWUnitTests_Win32GUI_Glyphs(); + ~TTest_ASWUnitTests_Win32GUI_Glyphs() override; + + void SetUp_Group() override; + void SetUp_Test(ITestCase& testCase) override; + void TearDown_Group() override; + void TearDown_Test(ITestCase& testCase) override; +}; + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef Test_ASWUnitTests_Win32GUI_GlyphsH diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.cpp b/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.cpp new file mode 100644 index 0000000..1ba9ef1 --- /dev/null +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.cpp @@ -0,0 +1,142 @@ +/* ************************************************************************** +Test_ASWUnitTests_Win32GUI_Palette.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_Windows.h" +//--------------------------------------------------------------------------- +// Module header +#include "Test_ASWUnitTests_Win32GUI_Palette.h" +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Registry.h" +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Win32GUI_Palette.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +///////////////////////////////////////////////////////////////////////////// +// TTest_ASWUnitTests_Win32GUI_Palette +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TTest_ASWUnitTests_Win32GUI_Palette::TTest_ASWUnitTests_Win32GUI_Palette() + : inherited("ASWUnitTests_Win32GUI_Palette_Tests") +{ + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Palette::Test_BlendColors_MixesEachChannelInProportion, + "BlendColors_MixesEachChannelInProportion"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Palette::Test_CarbonPalette_HasCarbonsColors, + "CarbonPalette_HasCarbonsColors"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Palette::Test_FailureAndSkipTextColorOn_AreLighterOnADarkBackground, + "FailureAndSkipTextColorOn_AreLighterOnADarkBackground"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_Palette::Test_IsDarkColor_IsByTheLuma, "IsDarkColor_IsByTheLuma"); +} +//--------------------------------------------------------------------------- +TTest_ASWUnitTests_Win32GUI_Palette::~TTest_ASWUnitTests_Win32GUI_Palette() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Palette::SetUp_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Palette::SetUp_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Palette::TearDown_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Palette::TearDown_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- + +// /////// Begin tests after this line /////////////////////// + +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Palette::Test_BlendColors_MixesEachChannelInProportion() +{ + // Arrange + COLORREF const black = RGB(0, 0, 0); + COLORREF const white = RGB(255, 255, 255); + + // Act, Assert + CheckEquals(black, BlendColors(black, white, 0, 4), __func__, __LINE__, "'from' at 0"); + CheckEquals(white, BlendColors(black, white, 4, 4), __func__, __LINE__, "'to' at the denominator"); + CheckEquals(RGB(128, 128, 128), BlendColors(black, white, 1, 2), __func__, __LINE__, "halfway, rounded"); + CheckEquals(RGB(20, 40, 60), BlendColors(RGB(10, 20, 30), RGB(30, 60, 90), 1, 2), __func__, __LINE__, + "each channel on its own"); + CheckEquals(RGB(64, 64, 64), BlendColors(black, white, 1, 4), __func__, __LINE__, "a quarter, rounded"); + CheckEquals(white, BlendColors(black, white, 5, 4), __func__, __LINE__, "past the denominator is 'to'"); + CheckEquals(black, BlendColors(black, white, -1, 4), __func__, __LINE__, "below 0 is 'from'"); + CheckEquals(black, BlendColors(black, white, 1, 0), __func__, __LINE__, "'from' with no denominator"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Palette::Test_CarbonPalette_HasCarbonsColors() +{ + // Act + TWin32GUIPalette const palette = CarbonPalette(); + + // Assert: a few of Carbon.vsf's own colors, and the VCL GUI runner's text colors on its window color. + CheckEquals(RGB(0x19, 0x19, 0x19), palette.Window, __func__, __LINE__, "clWindow"); + CheckEquals(RGB(0xEA, 0xEA, 0xEA), palette.WindowText, __func__, __LINE__, "clWindowText"); + CheckEquals(RGB(0x33, 0x33, 0x33), palette.Panel, __func__, __LINE__, "clBtnFace"); + CheckEquals(RGB(0xC0, 0xC0, 0xC0), palette.ControlText, __func__, __LINE__, "sfButtonTextNormal"); + CheckEquals(RGB(0x26, 0x5A, 0x73), palette.Highlight, __func__, __LINE__, "the hot menu item"); + CheckEquals(RGB(0x19, 0x19, 0x19), palette.MenuBackground, __func__, __LINE__, "the menus' background"); + CheckEquals(RGB(255, 100, 100), palette.FailureText, __func__, __LINE__, "a failure on the dark window"); + 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"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Palette::Test_FailureAndSkipTextColorOn_AreLighterOnADarkBackground() +{ + // Act, Assert: as the VCL GUI runner's colors, by the background's luma, so a dark high contrast theme's + // window color also gets the lighter ones. + CheckEquals(RGB(200, 0, 0), FailureTextColorOn(RGB(255, 255, 255)), __func__, __LINE__, "failure on white"); + CheckEquals(RGB(255, 100, 100), FailureTextColorOn(RGB(0x19, 0x19, 0x19)), __func__, __LINE__, + "failure on Carbon's window color"); + CheckEquals(RGB(255, 100, 100), FailureTextColorOn(RGB(0, 0, 0)), __func__, __LINE__, "failure on black"); + CheckEquals(RGB(190, 120, 0), SkipTextColorOn(RGB(255, 255, 255)), __func__, __LINE__, "skip on white"); + CheckEquals(RGB(255, 190, 70), SkipTextColorOn(RGB(0x19, 0x19, 0x19)), __func__, __LINE__, + "skip on Carbon's window color"); +} +//--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_Palette::Test_IsDarkColor_IsByTheLuma() +{ + // Act, Assert: Rec. 601 luma, (299 R + 587 G + 114 B) / 1000, below 128. + CheckTrue(IsDarkColor(RGB(127, 127, 127)), __func__, __LINE__, "gray 127"); + CheckFalse(IsDarkColor(RGB(128, 128, 128)), __func__, __LINE__, "gray 128"); + CheckTrue(IsDarkColor(RGB(0, 0, 255)), __func__, __LINE__, "blue, luma 29"); + CheckFalse(IsDarkColor(RGB(0, 255, 0)), __func__, __LINE__, "green, luma 149"); + CheckTrue(IsDarkColor(RGB(255, 0, 0)), __func__, __LINE__, "red, luma 76"); + CheckFalse(IsDarkColor(RGB(255, 255, 0)), __func__, __LINE__, "yellow, luma 225"); +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +ASW_REGISTER_TEST_GROUP(ASWUnitTests::TTest_ASWUnitTests_Win32GUI_Palette) diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.h b/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.h new file mode 100644 index 0000000..b194e15 --- /dev/null +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_Palette.h @@ -0,0 +1,62 @@ +/* ************************************************************************** +Test_ASWUnitTests_Win32GUI_Palette.h +Author: Anthony S. West - ASW Software + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +#ifndef Test_ASWUnitTests_Win32GUI_PaletteH +#define Test_ASWUnitTests_Win32GUI_PaletteH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_TestBase.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +///////////////////////////////////////////////////////////////////////////// +// TTest_ASWUnitTests_Win32GUI_Palette +// +// Exercises the Win32 GUI runner's dark theme colors +// (win32/gui/src/ASWUnitTests_Win32GUI_Palette.h). Compiled by the Windows +// console runners as well as the Win32 GUI, so it runs without the GUI. +///////////////////////////////////////////////////////////////////////////// +class TTest_ASWUnitTests_Win32GUI_Palette : public TTestGroupBase +{ +private: + typedef TTestGroupBase inherited; + +private: // Test methods + void Test_BlendColors_MixesEachChannelInProportion(); + void Test_CarbonPalette_HasCarbonsColors(); + void Test_FailureAndSkipTextColorOn_AreLighterOnADarkBackground(); + void Test_IsDarkColor_IsByTheLuma(); + +public: + TTest_ASWUnitTests_Win32GUI_Palette(); + ~TTest_ASWUnitTests_Win32GUI_Palette() override; + + void SetUp_Group() override; + void SetUp_Test(ITestCase& testCase) override; + void TearDown_Group() override; + void TearDown_Test(ITestCase& testCase) override; +}; + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef Test_ASWUnitTests_Win32GUI_PaletteH diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp b/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp index 514e842..e0554a1 100644 --- a/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_System.cpp @@ -120,6 +120,10 @@ TTest_ASWUnitTests_Win32GUI_System::TTest_ASWUnitTests_Win32GUI_System() "GUILayoutFilePath_IsPerExeUnderAppData"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_GUISelectionFilePath_IsBesideTheLayoutFile, "GUISelectionFilePath_IsBesideTheLayoutFile"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_IsThemeSettingChange_IsTrueForTheAppModeAndHighContrast, + "IsThemeSettingChange_IsTrueForTheAppModeAndHighContrast"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_LoadGUIThemeFromLayoutFile_ReadsWhatSaveWrote, + "LoadGUIThemeFromLayoutFile_ReadsWhatSaveWrote"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_MainIcon_IsThisExecutablesSharedIcon, "MainIcon_IsThisExecutablesSharedIcon"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_ReadFileBytes_ReadsWhatWriteFileBytesWrote, @@ -130,6 +134,8 @@ TTest_ASWUnitTests_Win32GUI_System::TTest_ASWUnitTests_Win32GUI_System() "RemoveFile_DeletesOnlyAnExistingFile"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_WindowText_ConvertsNonASCIIToUTF8, "WindowText_ConvertsNonASCIIToUTF8"); + RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_WindowsAppsUseDarkTheme_MatchesTheRegistry, + "WindowsAppsUseDarkTheme_MatchesTheRegistry"); RegisterTest(&TTest_ASWUnitTests_Win32GUI_System::Test_WriteFileBytes_ThrowsWhenTheFolderIsMissing, "WriteFileBytes_ThrowsWhenTheFolderIsMissing"); } @@ -315,6 +321,75 @@ void TTest_ASWUnitTests_Win32GUI_System::Test_GUISelectionFilePath_IsBesideTheLa "the layout file's path with a .selection extension"); } //--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_System::Test_IsThemeSettingChange_IsTrueForTheAppModeAndHighContrast() +{ + // Arrange: WM_SETTINGCHANGE's parameters, as Windows sends them. + wchar_t const appMode[] = L"ImmersiveColorSet"; + wchar_t const other[] = L"Policy"; + + // Act + bool const forAppMode = IsThemeSettingChange(0, reinterpret_cast(appMode)); + bool const forHighContrast = IsThemeSettingChange(SPI_SETHIGHCONTRAST, 0); + bool const forOther = IsThemeSettingChange(0, reinterpret_cast(other)); + bool const forOtherSetting = IsThemeSettingChange(SPI_SETWORKAREA, 0); + + // Assert + CheckTrue(forAppMode, __func__, __LINE__, "the app mode's change"); + CheckTrue(forHighContrast, __func__, __LINE__, "high contrast's change"); + CheckFalse(forOther, __func__, __LINE__, "another area's change"); + CheckFalse(forOtherSetting, __func__, __LINE__, "another setting's change, with no area"); +} +//--------------------------------------------------------------------------- +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 (...) + { + } + } + }; + + bool const existed = FileExists(path); + TRestoreFile const restoreFile{ path, existed, existed ? ReadFileBytes(path) : std::string() }; + + RemoveFile(path); + EGUITheme const noFile = LoadGUIThemeFromLayoutFile(); + + // Act + SaveGUIThemeToLayoutFile(EGUITheme::Dark); + EGUITheme const dark = LoadGUIThemeFromLayoutFile(); + + WriteFileBytes(path, "[Window]\r\nMaximized=1\r\n"); + SaveGUIThemeToLayoutFile(EGUITheme::Light); + EGUITheme const light = LoadGUIThemeFromLayoutFile(); + std::string const text = ReadFileBytes(path); + + // Assert + CheckTrue(noFile == EGUITheme::Windows, __func__, __LINE__, "Windows without a layout file"); + 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"); +} +//--------------------------------------------------------------------------- void TTest_ASWUnitTests_Win32GUI_System::Test_MainIcon_IsThisExecutablesSharedIcon() { // Act: every runner that compiles this test embeds the ASWUnitTests icon. @@ -400,6 +475,36 @@ void TTest_ASWUnitTests_Win32GUI_System::Test_WindowText_ConvertsNonASCIIToUTF8( CheckEquals(std::string(), untitledText, __func__, __LINE__, "no text"); } //--------------------------------------------------------------------------- +void TTest_ASWUnitTests_Win32GUI_System::Test_WindowsAppsUseDarkTheme_MatchesTheRegistry() +{ + // Arrange: the setting read another way, with RegQueryValueExW(). This machine's setting can't be changed + // here, so only agreement with it can be checked. + bool expected = false; + HKEY key = nullptr; + + if (RegOpenKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", 0, + KEY_READ, &key) == ERROR_SUCCESS) + { + DWORD value = 1; + DWORD size = sizeof(value); + DWORD type = 0; + + if (RegQueryValueExW(key, L"AppsUseLightTheme", nullptr, &type, reinterpret_cast(&value), &size) == + ERROR_SUCCESS && type == REG_DWORD) + { + expected = value == 0; + } + + RegCloseKey(key); + } + + // Act + bool const dark = WindowsAppsUseDarkTheme(); + + // Assert + CheckEquals(expected, dark, __func__, __LINE__, "dark when AppsUseLightTheme is 0"); +} +//--------------------------------------------------------------------------- void TTest_ASWUnitTests_Win32GUI_System::Test_WriteFileBytes_ThrowsWhenTheFolderIsMissing() { // Act, Assert diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_System.h b/win32/tests/Test_ASWUnitTests_Win32GUI_System.h index f2e0185..e0aaa97 100644 --- a/win32/tests/Test_ASWUnitTests_Win32GUI_System.h +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_System.h @@ -61,11 +61,14 @@ class TTest_ASWUnitTests_Win32GUI_System : public TTestGroupBase void Test_FileExists_IsTrueOnlyForAFile(); void Test_GUILayoutFilePath_IsPerExeUnderAppData(); void Test_GUISelectionFilePath_IsBesideTheLayoutFile(); + void Test_IsThemeSettingChange_IsTrueForTheAppModeAndHighContrast(); + void Test_LoadGUIThemeFromLayoutFile_ReadsWhatSaveWrote(); void Test_MainIcon_IsThisExecutablesSharedIcon(); void Test_ReadFileBytes_ReadsWhatWriteFileBytesWrote(); void Test_ReadFileBytes_ThrowsForAMissingFile(); void Test_RemoveFile_DeletesOnlyAnExistingFile(); void Test_WindowText_ConvertsNonASCIIToUTF8(); + void Test_WindowsAppsUseDarkTheme_MatchesTheRegistry(); void Test_WriteFileBytes_ThrowsWhenTheFolderIsMissing(); public: From 208775937235701a1245dfea50b9a847579b9775 Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 06:24:09 -0700 Subject: [PATCH 2/9] Leave disabled dark menu items unhighlighted, give text dialog the icon, and widen status bar's 1st panel --- CHANGELOG.md | 2 ++ gui/src/ASWUnitTests_GUI_View.cpp | 2 +- vcl/gui/src/ASWUnitTests_GUI_MainForm.dfm | 2 +- win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.cpp | 6 +++--- win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp | 13 +++++++------ 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d872dfb..d685c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,6 +135,8 @@ versioned baseline. full. It now shows every finished test, and starts each run empty. - The VCL GUI runner's tooltips for Run Selected and Copy Details showed their keyboard shortcut twice, e.g. "(F9) (F9)". +- The VCL GUI runner's status bar cut off its first panel's text, e.g. "Checked: 44 / 44 show", and + now has room for up to "Checked: 9999 / 9999 shown". - With `--catch-crashes` and `--test-timeout-seconds` together, a crash that came while a test was logging or recording a result, such as one in a run observer's `OnLog()`, left the run waiting forever at the next test. The run now continues with the next test, as after any other crash. diff --git a/gui/src/ASWUnitTests_GUI_View.cpp b/gui/src/ASWUnitTests_GUI_View.cpp index f1a1fa0..ad247e1 100644 --- a/gui/src/ASWUnitTests_GUI_View.cpp +++ b/gui/src/ASWUnitTests_GUI_View.cpp @@ -184,7 +184,7 @@ std::vector GUIStatusPanels(TGUITestList const& testList, size_t ru //--------------------------------------------------------------------------- std::vector GUIStatusPanelWidths() { - return { 120, 100, 90, 90, 90, 70, 190, 300 }; + return { 170, 100, 90, 90, 90, 70, 190, 300 }; } //--------------------------------------------------------------------------- TTestCaseRecord GUIUnhandledExceptionRecord(TTestId const& test, std::string const& description) diff --git a/vcl/gui/src/ASWUnitTests_GUI_MainForm.dfm b/vcl/gui/src/ASWUnitTests_GUI_MainForm.dfm index ac7b892..d3845b9 100644 --- a/vcl/gui/src/ASWUnitTests_GUI_MainForm.dfm +++ b/vcl/gui/src/ASWUnitTests_GUI_MainForm.dfm @@ -238,7 +238,7 @@ object FormASWUnitTestsGUIMain: TFormASWUnitTestsGUIMain Height = 19 Panels = < item - Width = 120 + Width = 170 end item Width = 100 diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.cpp index dc38c8b..bce48b3 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_TextDialog.cpp @@ -138,9 +138,9 @@ INT_PTR HandleDialogMessage(HWND dialog, UINT message, WPARAM wParam, LPARAM lPa SendMessageW(textBox, EM_SETLIMITTEXT, 0, 0); SetWindowTextW(textBox, state->Text.c_str()); - // Shown before the main window exists (e.g. for --help), it has a taskbar button of its own. - if (GetWindow(dialog, GW_OWNER) == nullptr) - SendMessageW(dialog, WM_SETICON, ICON_BIG, reinterpret_cast(MainIcon())); + // In its title bar, as the VCL GUI runner's sizeable text dialog has the application's icon, and on its + // own taskbar button when it's shown before the main window exists (e.g. for --help). + SendMessageW(dialog, WM_SETICON, ICON_BIG, reinterpret_cast(MainIcon())); SizeAndCenterDialog(dialog); ApplyDPI(dialog); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp index 6ec5814..0a91b31 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -974,11 +974,11 @@ void TWin32GUITheme::DrawListRow(NMLVCUSTOMDRAW const& draw, COLORREF textColor) /* TWin32GUITheme::DrawMenuItem - As Carbon draws the menus: the item under the mouse or the keyboard in the highlight, with a frame; a - separator as a line; a menu item's caption after a gutter for its check mark (a tick, or a dot for a radio - item), its shortcut at the right, and a submenu's arrow. The & letters are underlined only once the - keyboard has been used (ODS_NOACCEL). Windows draws its own submenu arrow after WM_DRAWITEM, unless the - item is clipped out of the context. + As Carbon draws the menus: the item under the mouse or the keyboard in the highlight, with a frame, unless + it's disabled; a separator as a line; a menu item's caption after a gutter for its check mark (a tick, or a + dot for a radio item), its shortcut at the right, and a submenu's arrow. The & letters are underlined only + once the keyboard has been used (ODS_NOACCEL). Windows draws its own submenu arrow after WM_DRAWITEM, + unless the item is clipped out of the context. */ void TWin32GUITheme::DrawMenuItem(DRAWITEMSTRUCT const& draw) { @@ -990,8 +990,9 @@ void TWin32GUITheme::DrawMenuItem(DRAWITEMSTRUCT const& draw) HDC const context = draw.hDC; RECT const rect = draw.rcItem; UINT const dpi = m_MenuBarWindow != nullptr ? WindowDPI(m_MenuBarWindow) : USER_DEFAULT_SCREEN_DPI; - bool const selected = (draw.itemState & (ODS_SELECTED | ODS_HOTLIGHT)) != 0; bool const disabled = (draw.itemState & (ODS_DISABLED | ODS_GRAYED)) != 0; + // As Carbon: a disabled item under the mouse or the keyboard looks as it does otherwise. + bool const selected = !disabled && (draw.itemState & (ODS_SELECTED | ODS_HOTLIGHT)) != 0; COLORREF const background = selected ? m_Palette.Highlight : item->TopLevel ? m_Palette.Panel : m_Palette.MenuBackground; From e905b2ec5ece87dd7d277a73c6bbfd2f6f7969d1 Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 06:28:14 -0700 Subject: [PATCH 3/9] Paint the results tabs' scroll arrows in the dark theme --- .../gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp | 5 +- win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h | 8 +- .../gui/src/ASWUnitTests_Win32GUI_Palette.cpp | 11 ++ win32/gui/src/ASWUnitTests_Win32GUI_Palette.h | 14 ++ win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp | 180 +++++++++++++++++- win32/gui/src/ASWUnitTests_Win32GUI_Theme.h | 7 + .../Test_ASWUnitTests_Win32GUI_Glyphs.cpp | 13 +- 7 files changed, 231 insertions(+), 7 deletions(-) diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp index b490e73..481294a 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp @@ -50,7 +50,7 @@ bool NearSegment(double x, double y, double ax, double ay, double bx, double by, Whether the point (x, y) of a cell one unit wide, from its top left, is inside 'glyph'. The shapes' proportions come from Carbon's glyphs: its 13-pixel check box's cross spans pixels 3 to 9 in strokes about 1.7 pixels wide, its partial square pixels 4 to 8, its radio dot about 4.6 pixels across, and its 16-pixel - submenu arrow is 4 pixels wide and 7 tall. + submenu and spin button arrows are 4 pixels wide and 7 tall. */ bool Covers(EWin32GUIGlyph glyph, double x, double y) { @@ -60,6 +60,9 @@ bool Covers(EWin32GUIGlyph glyph, double x, double y) switch (glyph) { + case EWin32GUIGlyph::ArrowLeft: + return InTriangle(x, y, 0.625, 0.28, 0.625, 0.72, 0.375, 0.5); + case EWin32GUIGlyph::ArrowRight: return InTriangle(x, y, 0.375, 0.28, 0.375, 0.72, 0.625, 0.5); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h b/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h index 5207b46..564e5d2 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h @@ -39,11 +39,15 @@ namespace ASWUnitTests // EWin32GUIGlyph // // A shape RenderWin32GUIGlyph() draws, in a square cell. The check box's -// and radio button's are Carbon's at 13 pixels; the menus' at 16. +// and radio button's are Carbon's at 13 pixels; the menus' and the spin +// buttons' arrows at 16. ///////////////////////////////////////////////////////////////////////////// enum class EWin32GUIGlyph { - // A menu's submenu arrow: a triangle pointing right, in the cell's middle. + // A spin button's arrow pointing left: ArrowRight mirrored. + ArrowLeft, + // A menu's submenu arrow, and a spin button's pointing right: a triangle pointing right, in the cell's + // middle. ArrowRight, // A radio button's circle, filling the cell; drawn in the border color, with InnerCircle over it in the // fill color. diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp index fcd4fd8..4c313ba 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp @@ -103,6 +103,17 @@ TWin32GUIPalette CarbonPalette() palette.ProgressTrack = RGB(0x19, 0x19, 0x19); palette.Selection = RGB(0x62, 0x62, 0x62); palette.SelectionFrame = RGB(0x38, 0x38, 0x38); + palette.SpinArrow = RGB(0x1C, 0x1C, 0x1C); + palette.SpinDisabledArrow = RGB(0x2A, 0x2A, 0x2A); + palette.SpinDisabledFaceBottom = RGB(0x36, 0x38, 0x37); + palette.SpinDisabledFaceTop = RGB(0x42, 0x42, 0x42); + palette.SpinDisabledOutline = RGB(0x14, 0x14, 0x14); + palette.SpinFaceBottom = RGB(0x4B, 0x4D, 0x4C); + palette.SpinFaceTop = RGB(0x5B, 0x5B, 0x5B); + palette.SpinHotFaceBottom = RGB(0x5D, 0x5E, 0x5E); + palette.SpinHotFaceTop = RGB(0x6B, 0x6B, 0x6B); + palette.SpinOutline = RGB(0x1B, 0x1B, 0x1B); + palette.SpinPressedEdge = RGB(0x45, 0x45, 0x45); palette.StatusLine = RGB(0x2E, 0x2E, 0x2E); palette.TabActiveBottom = RGB(0x35, 0x35, 0x35); palette.TabActiveTop = RGB(0x3C, 0x3C, 0x3C); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h index 881417d..1016a2c 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h @@ -111,6 +111,20 @@ struct TWin32GUIPalette COLORREF Selection; COLORREF SelectionFrame; COLORREF SkipText; + // Spin buttons, such as the results tabs' scroll arrows when the tabs don't fit: the outline around each, its + // face's gradient from top to bottom, normal, hot and disabled, the edge a pressed one shows at its top and + // left, and its arrow, normal and disabled. + COLORREF SpinArrow; + COLORREF SpinDisabledArrow; + COLORREF SpinDisabledFaceBottom; + COLORREF SpinDisabledFaceTop; + COLORREF SpinDisabledOutline; + COLORREF SpinFaceBottom; + COLORREF SpinFaceTop; + COLORREF SpinHotFaceBottom; + COLORREF SpinHotFaceTop; + COLORREF SpinOutline; + COLORREF SpinPressedEdge; // The line along the status bar's top. COLORREF StatusLine; // The results tabs: the selected one's gradient from top to bottom, the others' color, one under the diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp index 0a91b31..ee3907f 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -88,6 +88,9 @@ class TGlyphCanvas // Mixes 'color' into the pixels 'glyph' covers in the square at 'left', 'top' of side 'size'. void Blend(EWin32GUIGlyph glyph, int left, int top, int size, COLORREF color); + // Takes the pixels at 'left', 'top' of 'context' as the canvas's own, e.g. a gradient a glyph is then drawn + // over. Leaves the canvas as it was if they can't be read. + void CopyFrom(HDC context, int left, int top); // A bitmap of the canvas, which the caller deletes; nullptr if it can't be created. HBITMAP CreateBitmap() const; void Draw(HDC context, int left, int top) const; @@ -298,6 +301,36 @@ void TGlyphCanvas::Blend(EWin32GUIGlyph glyph, int left, int top, int size, COLO } } //--------------------------------------------------------------------------- +void TGlyphCanvas::CopyFrom(HDC context, int left, int top) +{ + BITMAPINFO const info = BitmapInfo(); + void* bits = nullptr; + HBITMAP const bitmap = CreateDIBSection(context, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + HDC const copy = CreateCompatibleDC(context); + + if (bitmap != nullptr && bits != nullptr && copy != nullptr) + { + HGDIOBJ const old = SelectObject(copy, bitmap); + + if (BitBlt(copy, 0, 0, m_Width, m_Height, context, left, top, SRCCOPY) != 0) + { + GdiFlush(); + std::uint32_t const* const pixels = static_cast(bits); + + for (std::size_t i = 0; i < m_Pixels.size(); ++i) + m_Pixels[i] = 0xFF000000u | pixels[i]; + } + + SelectObject(copy, old); + } + + if (copy != nullptr) + DeleteDC(copy); + + if (bitmap != nullptr) + DeleteObject(bitmap); +} +//--------------------------------------------------------------------------- HBITMAP TGlyphCanvas::CreateBitmap() const { BITMAPINFO const info = BitmapInfo(); @@ -505,6 +538,7 @@ void TWin32GUITheme::ApplyToControl(TAttachedControl const& attached) case EWin32GUIThemedControl::Splitter: case EWin32GUIThemedControl::StatusBar: case EWin32GUIThemedControl::TabControl: + case EWin32GUIThemedControl::UpDown: break; // Painted by ControlProc() while dark. } @@ -678,6 +712,32 @@ void TWin32GUITheme::Attach(HWND control, EWin32GUIThemedControl kind) SetWindowSubclass(control, &ControlProc, ControlSubclassBase + static_cast(kind), reinterpret_cast(this)); ApplyToControl(m_Controls.back()); + + if (kind == EWin32GUIThemedControl::TabControl) + AttachUpDown(control); +} +//--------------------------------------------------------------------------- +/* + TWin32GUITheme::AttachUpDown + + A tab control creates its scroll arrows, a child up-down control, the first time its tabs don't fit, and + keeps them, hidden, once they fit again. It's looked for when the tab control is attached, created + (WM_PARENTNOTIFY) and resized, in case it doesn't notify. +*/ +void TWin32GUITheme::AttachUpDown(HWND tabControl) +{ + HWND const upDown = FindWindowExW(tabControl, nullptr, UPDOWN_CLASSW, nullptr); + + if (upDown == nullptr) + return; + + bool const attached = std::any_of(m_Controls.begin(), m_Controls.end(), [upDown](TAttachedControl const& entry) + { + return entry.Control == upDown; + }); + + if (!attached) + Attach(upDown, EWin32GUIThemedControl::UpDown); } //--------------------------------------------------------------------------- void TWin32GUITheme::AttachWindow(HWND window, HMENU menuBar) @@ -1086,7 +1146,21 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro LPARAM lParam) { bool const painted = kind == EWin32GUIThemedControl::ProgressBar || kind == EWin32GUIThemedControl::StatusBar || - kind == EWin32GUIThemedControl::TabControl; + kind == EWin32GUIThemedControl::TabControl || kind == EWin32GUIThemedControl::UpDown; + + bool const mayCreateArrows = message == WM_SIZE || (message == WM_PARENTNOTIFY && LOWORD(wParam) == WM_CREATE); + + if (kind == EWin32GUIThemedControl::TabControl && mayCreateArrows) + { + // Its scroll arrows, which it creates when it's too narrow for its tabs. + LRESULT const result = DefSubclassProc(control, message, wParam, lParam); + AttachUpDown(control); + + if (m_Dark && message == WM_SIZE) + InvalidateRect(control, nullptr, FALSE); + + return result; + } if (!m_Dark) { @@ -1172,6 +1246,34 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro } } + if (kind == EWin32GUIThemedControl::UpDown) + { + // The arrow under the mouse is drawn lighter. + InvalidateRect(control, nullptr, FALSE); + + if (message == WM_MOUSEMOVE) + { + TRACKMOUSEEVENT track = {}; + track.cbSize = sizeof(track); + track.dwFlags = TME_LEAVE; + track.hwndTrack = control; + TrackMouseEvent(&track); + } + } + + break; + + case WM_CAPTURECHANGED: + case WM_LBUTTONDOWN: + case WM_LBUTTONUP: + if (kind == EWin32GUIThemedControl::UpDown) + { + // Pressed, or not any more. + LRESULT const result = DefSubclassProc(control, message, wParam, lParam); + InvalidateRect(control, nullptr, FALSE); + return result; + } + break; case WM_NCPAINT: @@ -1214,8 +1316,10 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro PaintProgressBar(control, buffer.Get()); else if (kind == EWin32GUIThemedControl::StatusBar) PaintStatusBar(control, buffer.Get()); - else + else if (kind == EWin32GUIThemedControl::TabControl) PaintTabControl(control, buffer.Get()); + else + PaintUpDown(control, buffer.Get()); } if (message == WM_PAINT) @@ -1963,6 +2067,71 @@ void TWin32GUITheme::PaintTabControl(HWND tabControl, HDC context) const SelectObject(context, oldFont); } //--------------------------------------------------------------------------- +/* + TWin32GUITheme::PaintUpDown + + As Carbon draws a pair of horizontal spin buttons, e.g. the results tabs' scroll arrows: each an outline + around a face shaded from top to bottom, lighter under the mouse, with a darker edge at its top and left + while pressed, and an arrow pointing left (decrease) or right (increase). +*/ +void TWin32GUITheme::PaintUpDown(HWND upDown, HDC context) const +{ + RECT client; + GetClientRect(upDown, &client); + UINT const dpi = WindowDPI(upDown); + int const line = (std::max)(1, ScaleForDPI(1, dpi)); + bool const disabled = IsWindowEnabled(upDown) == FALSE; + + POINT cursor = {}; + GetCursorPos(&cursor); + bool const underMouse = WindowFromPoint(cursor) == upDown; + ScreenToClient(upDown, &cursor); + bool const pressing = GetCapture() == upDown && (GetKeyState(VK_LBUTTON) & 0x8000) != 0; + + int const middle = (client.left + client.right) / 2; + RECT const halves[] = { + { client.left, client.top, middle, client.bottom }, + { middle, client.top, client.right, client.bottom }, + }; + + for (int i = 0; i < 2; ++i) + { + RECT const& half = halves[i]; + bool const hot = !disabled && underMouse && PtInRect(&half, cursor) != FALSE; + + FillSolid(context, half, disabled ? m_Palette.SpinDisabledOutline : m_Palette.SpinOutline); + RECT const face = Inset(half, line); + + if (disabled) + FillGradient(context, face, m_Palette.SpinDisabledFaceTop, m_Palette.SpinDisabledFaceBottom); + else if (hot) + FillGradient(context, face, m_Palette.SpinHotFaceTop, m_Palette.SpinHotFaceBottom); + else + FillGradient(context, face, m_Palette.SpinFaceTop, m_Palette.SpinFaceBottom); + + if (hot && pressing) + { + RECT const top = { face.left, face.top, face.right, face.top + line }; + RECT const left = { face.left, face.top, face.left + line, face.bottom }; + FillSolid(context, top, m_Palette.SpinPressedEdge); + FillSolid(context, left, m_Palette.SpinPressedEdge); + } + + int const size = (std::min)(face.right - face.left, face.bottom - face.top); + + if (size <= 0) + continue; + + int const left = face.left + (face.right - face.left - size) / 2; + int const top = face.top + (face.bottom - face.top - size) / 2; + TGlyphCanvas canvas(size, size, m_Palette.SpinFaceTop); + canvas.CopyFrom(context, left, top); + canvas.Blend(i == 0 ? EWin32GUIGlyph::ArrowLeft : EWin32GUIGlyph::ArrowRight, 0, 0, size, disabled ? + m_Palette.SpinDisabledArrow : m_Palette.SpinArrow); + canvas.Draw(context, left, top); + } +} +//--------------------------------------------------------------------------- TWin32GUIPalette const& TWin32GUITheme::Palette() const { return m_Palette; @@ -1975,8 +2144,13 @@ void TWin32GUITheme::Refresh() for (HWND const window : m_Windows) ApplyToTitleBar(window); - for (TAttachedControl const& attached : m_Controls) + // By index, on a copy: applying the look can resize a tab control, which may then create and attach its + // scroll arrows. + for (std::size_t i = 0; i < m_Controls.size(); ++i) + { + TAttachedControl const attached = m_Controls[i]; ApplyToControl(attached); + } for (HWND const window : m_Windows) RedrawWindow(window, nullptr, nullptr, RDW_ERASE | RDW_FRAME | RDW_INVALIDATE | RDW_ALLCHILDREN); diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h index ec34bea..3f3efcd 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h @@ -68,6 +68,9 @@ enum class EWin32GUIThemedControl Tooltip, // A WC_TREEVIEWW control, with or without TVS_CHECKBOXES (and TVS_EX_PARTIALCHECKBOXES). TreeView, + // An UPDOWN_CLASSW control with UDS_HORZ, such as a tab control's scroll arrows, which attaching the tab + // control attaches as it creates them. + UpDown, }; @@ -158,6 +161,9 @@ class TWin32GUITheme void ApplyToMenus(); void ApplyToTitleBar(HWND window) const; void ApplyToTree(HWND tree); + // 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); // The dark tree check boxes for 'dpi', created if needed. HIMAGELIST DarkCheckImages(UINT dpi); void DrawButton(NMCUSTOMDRAW const& draw) const; @@ -185,6 +191,7 @@ class TWin32GUITheme void PaintProgressBar(HWND progressBar, HDC context) const; void PaintStatusBar(HWND statusBar, HDC context) const; void PaintTabControl(HWND tabControl, HDC context) const; + void PaintUpDown(HWND upDown, HDC context) const; // Forgets a control or window that's being destroyed. void Unattach(HWND window); diff --git a/win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp b/win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp index 16a6cdb..361b4d1 100644 --- a/win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp @@ -143,12 +143,23 @@ void TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_ArrowPointsRig // Act: the menus' 16-pixel cell, the triangle's flat side at pixel 6 and its point at 10. std::vector const arrow = RenderWin32GUIGlyph(EWin32GUIGlyph::ArrowRight, 16); + std::vector const left = RenderWin32GUIGlyph(EWin32GUIGlyph::ArrowLeft, 16); + + bool mirrored = true; + + for (int y = 0; y < 16; ++y) + { + for (int x = 0; x < 16; ++x) + mirrored = mirrored && At(left, 16, x, y) == At(arrow, 16, 15 - x, y); + } + // Assert CheckTrue(IsMirrored(arrow, 16, false), __func__, __LINE__, "the same above and below its middle"); CheckEquals(255, At(arrow, 16, 6, 8), __func__, __LINE__, "full at its flat side, in the middle"); CheckEquals(0, At(arrow, 16, 5, 8), __func__, __LINE__, "nothing left of it"); CheckEquals(0, At(arrow, 16, 11, 8), __func__, __LINE__, "nothing right of its point"); CheckTrue(At(arrow, 16, 6, 5) > At(arrow, 16, 9, 5), __func__, __LINE__, "narrowing to the right"); + CheckTrue(mirrored, __func__, __LINE__, "the left arrow is the right one mirrored"); } //--------------------------------------------------------------------------- void TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_CirclesAreRoundAndNested() @@ -177,7 +188,7 @@ void TTest_ASWUnitTests_Win32GUI_Glyphs::Test_RenderWin32GUIGlyph_CoversOnePixel { // Arrange EWin32GUIGlyph const glyphs[] = { - EWin32GUIGlyph::ArrowRight, EWin32GUIGlyph::Circle, EWin32GUIGlyph::Cross, EWin32GUIGlyph::Dot, + EWin32GUIGlyph::ArrowLeft, EWin32GUIGlyph::ArrowRight, EWin32GUIGlyph::Circle, EWin32GUIGlyph::Cross, EWin32GUIGlyph::Dot, EWin32GUIGlyph::InnerCircle, EWin32GUIGlyph::Square, EWin32GUIGlyph::Tick, }; From dcdb2679c27811b166b66ce1e7fcb044f6ec259b Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 06:36:36 -0700 Subject: [PATCH 4/9] Dim disabled scroll bars' arrows in the dark theme --- win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp | 149 ++++++++++++++++++ win32/gui/src/ASWUnitTests_Win32GUI_Theme.h | 2 + 2 files changed, 151 insertions(+) diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp index ee3907f..ede80e0 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -29,6 +29,8 @@ limitations under the License. #include #include //--------------------------------------------------------------------------- +#include +//--------------------------------------------------------------------------- #include "ASWUnitTests_Win32GUI_Glyphs.h" //--------------------------------------------------------------------------- @@ -94,7 +96,11 @@ class TGlyphCanvas // A bitmap of the canvas, which the caller deletes; nullptr if it can't be created. HBITMAP CreateBitmap() const; void Draw(HDC context, int left, int top) const; + // Mixes every pixel toward 'toward' in the proportion 'numerator' / 'denominator' (see BlendColors()). + void Fade(COLORREF toward, int numerator, int denominator); void FillRect(RECT const& rect, COLORREF color); + // The pixel at 'x', 'y', or black outside the canvas. + COLORREF PixelAt(int x, int y) const; }; ///////////////////////////////////////////////////////////////////////////// @@ -134,6 +140,7 @@ void FrameSolid(HDC context, RECT const& rect, COLORREF color); bool HidesAccelerators(HWND control); bool HidesFocus(HWND control); RECT Inset(RECT rect, int by); +bool MayRedrawScrollBars(UINT message); void SetImmersiveDarkMode(HWND window, bool dark); void SplitShortcut(std::wstring const& text, std::wstring& caption, std::wstring& shortcut); SIZE TextSize(HDC context, std::wstring const& text, UINT format); @@ -194,6 +201,38 @@ RECT Inset(RECT rect, int by) return rect; } //--------------------------------------------------------------------------- +/* + MayRedrawScrollBars + + Whether an edit box may draw its scroll bars straight away, rather than in WM_NCPAINT, while it handles + 'message': as its text changes (it enables or disables them), as it's resized, and as the mouse enters or + leaves it or its scroll bars (a themed scroll bar's arrows show whether the mouse is over the window). +*/ +bool MayRedrawScrollBars(UINT message) +{ + switch (message) + { + case EM_REPLACESEL: + case WM_CAPTURECHANGED: + case WM_ENABLE: + case WM_KILLFOCUS: + case WM_MOUSELEAVE: + case WM_MOUSEMOVE: + case WM_NCLBUTTONDOWN: + case WM_NCLBUTTONUP: + case WM_NCMOUSELEAVE: + case WM_NCMOUSEMOVE: + case WM_SETFOCUS: + case WM_SETTEXT: + case WM_SIZE: + case WM_WINDOWPOSCHANGED: + return true; + + default: + return false; + } +} +//--------------------------------------------------------------------------- /* SetImmersiveDarkMode @@ -350,6 +389,15 @@ void TGlyphCanvas::Draw(HDC context, int left, int top) const static_cast(m_Height), m_Pixels.data(), &info, DIB_RGB_COLORS); } //--------------------------------------------------------------------------- +void TGlyphCanvas::Fade(COLORREF toward, int numerator, int denominator) +{ + for (std::uint32_t& pixel : m_Pixels) + { + COLORREF const color = RGB((pixel >> 16) & 0xFF, (pixel >> 8) & 0xFF, pixel & 0xFF); + pixel = ToPixel(BlendColors(color, toward, numerator, denominator)); + } +} +//--------------------------------------------------------------------------- void TGlyphCanvas::FillRect(RECT const& rect, COLORREF color) { std::uint32_t const pixel = ToPixel(color); @@ -366,6 +414,16 @@ void TGlyphCanvas::FillRect(RECT const& rect, COLORREF color) } } //--------------------------------------------------------------------------- +COLORREF TGlyphCanvas::PixelAt(int x, int y) const +{ + if (x < 0 || y < 0 || x >= m_Width || y >= m_Height) + return RGB(0, 0, 0); + + std::size_t const row = static_cast(y) * static_cast(m_Width); + std::uint32_t const pixel = m_Pixels[row + static_cast(x)]; + return RGB((pixel >> 16) & 0xFF, (pixel >> 8) & 0xFF, pixel & 0xFF); +} +//--------------------------------------------------------------------------- std::uint32_t TGlyphCanvas::ToPixel(COLORREF color) { return 0xFF000000u | (static_cast(GetRValue(color)) << 16) | @@ -1192,6 +1250,14 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro bool const bordered = kind == EWin32GUIThemedControl::Edit || kind == EWin32GUIThemedControl::ListView || kind == EWin32GUIThemedControl::RichEdit || kind == EWin32GUIThemedControl::TreeView; + bool const edit = kind == EWin32GUIThemedControl::Edit || kind == EWin32GUIThemedControl::RichEdit; + + if (edit && MayRedrawScrollBars(message)) + { + LRESULT const result = DefSubclassProc(control, message, wParam, lParam); + PaintDisabledScrollBars(control); + return result; + } switch (message) { @@ -1280,6 +1346,7 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro if (bordered) { LRESULT const result = DefSubclassProc(control, message, wParam, lParam); + PaintDisabledScrollBars(control); PaintBorder(control); return result; } @@ -1821,6 +1888,88 @@ void TWin32GUITheme::PaintBorder(HWND control) const ReleaseDC(control, context); } //--------------------------------------------------------------------------- +/* + TWin32GUITheme::PaintDisabledScrollBars + + The dark scroll bar theme ("DarkMode_Explorer") draws a disabled scroll bar's arrows nearly white, e.g. the + empty detail pane's, which keeps its scroll bar while there's nothing to scroll. Such a bar is drawn again + in the theme's normal state, which has no thumb when there's nothing to scroll, with its arrows faded + halfway toward the track, so they read as disabled. A bar that's enabled, or hidden, is left as it is. + Called after each message that may draw the bars (see MayRedrawScrollBars()), so it's quick when there's + no disabled bar. +*/ +void TWin32GUITheme::PaintDisabledScrollBars(HWND control) const +{ + RECT window; + GetWindowRect(control, &window); + HDC context = nullptr; + HTHEME theme = nullptr; + + for (LONG const id : { OBJID_VSCROLL, OBJID_HSCROLL }) + { + SCROLLBARINFO info = {}; + info.cbSize = sizeof(info); + + if (GetScrollBarInfo(control, id, &info) == 0 || + (info.rgstate[0] & (STATE_SYSTEM_INVISIBLE | STATE_SYSTEM_OFFSCREEN)) != 0 || + (info.rgstate[0] & STATE_SYSTEM_UNAVAILABLE) == 0) + { + continue; + } + + if (context == nullptr) + { + context = GetWindowDC(control); + theme = OpenThemeData(control, L"ScrollBar"); + } + + if (context == nullptr || theme == nullptr) + break; + + bool const vertical = id == OBJID_VSCROLL; + RECT bar = info.rcScrollBar; + OffsetRect(&bar, -window.left, -window.top); + + // The arrow buttons at its two ends, each as long as a line button. + RECT first = bar; + RECT second = bar; + + if (vertical) + { + first.bottom = first.top + info.dxyLineButton; + second.top = second.bottom - info.dxyLineButton; + } + else + { + first.right = first.left + info.dxyLineButton; + second.left = second.right - info.dxyLineButton; + } + + TPaintBuffer const buffer(context, bar); + HDC const target = buffer.Get(); + DrawThemeBackground(theme, target, vertical ? SBP_UPPERTRACKVERT : SBP_UPPERTRACKHORZ, SCRBS_NORMAL, &bar, + nullptr); + DrawThemeBackground(theme, target, SBP_ARROWBTN, vertical ? ABS_UPNORMAL : ABS_LEFTNORMAL, &first, nullptr); + DrawThemeBackground(theme, target, SBP_ARROWBTN, vertical ? ABS_DOWNNORMAL : ABS_RIGHTNORMAL, &second, + nullptr); + + for (RECT const& button : { first, second }) + { + TGlyphCanvas canvas(button.right - button.left, button.bottom - button.top, m_Palette.Window); + canvas.CopyFrom(target, button.left, button.top); + // Toward its corner's pixel, the button's background. + canvas.Fade(canvas.PixelAt(0, 0), 1, 2); + canvas.Draw(target, button.left, button.top); + } + } + + if (theme != nullptr) + CloseThemeData(theme); + + if (context != nullptr) + ReleaseDC(control, context); +} +//--------------------------------------------------------------------------- /* TWin32GUITheme::PaintMenuBarLine diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h index 3f3efcd..9c38473 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h @@ -186,6 +186,8 @@ class TWin32GUITheme LRESULT OnHeaderCustomDraw(NMCUSTOMDRAW const& draw) const; LRESULT OnTreeCustomDraw(NMTVCUSTOMDRAW& draw) const; void PaintBorder(HWND control) const; + // A disabled scroll bar of 'control' (see the definition). + void PaintDisabledScrollBars(HWND control) const; void PaintMenuBarLine(HWND window) const; void PaintMenuFrame(HWND menu, HDC given) const; void PaintProgressBar(HWND progressBar, HDC context) const; From d172ee8fb3720caacebda84695363ea5553ea3fe Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 06:38:12 -0700 Subject: [PATCH 5/9] Give dark theme's bordered controls their own border, so light one never flashes --- win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp | 80 ++++++++++++++++++- win32/gui/src/ASWUnitTests_Win32GUI_Theme.h | 3 + 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp index ede80e0..74353de 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -58,6 +58,9 @@ DWORD const DarkTitleBarAttributeBefore20H1 = 19; wchar_t const ExplorerTheme[] = L"Explorer"; // The window property that holds which tab is under the mouse, plus one (0 for none), while dark. wchar_t const HotTabProperty[] = L"ASWUnitTests_Win32GUI_HotTab"; +// The window property set on a control that had WS_EX_CLIENTEDGE, which it has in place of it while dark; see +// TWin32GUITheme::ApplyToBorder(). +wchar_t const OwnBorderProperty[] = L"ASWUnitTests_Win32GUI_OwnBorder"; // The menu windows' class, documented as a system class. wchar_t const MenuClassName[] = L"#32768"; // The subclass IDs: an attached control's is ControlSubclassBase plus its kind. @@ -133,6 +136,7 @@ class TPaintBuffer //--------------------------------------------------------------------------- +int BorderWidth(HWND control); bool ClassNameIs(HWND window, wchar_t const* className); void FillGradient(HDC context, RECT const& rect, COLORREF top, COLORREF bottom); void FillSolid(HDC context, RECT const& rect, COLORREF color); @@ -146,6 +150,16 @@ void SplitShortcut(std::wstring const& text, std::wstring& caption, std::wstring SIZE TextSize(HDC context, std::wstring const& text, UINT format); std::wstring WideWindowText(HWND window); +//--------------------------------------------------------------------------- +/* + BorderWidth + + WS_EX_CLIENTEDGE's width at the control's DPI, which the dark look's own border has too. +*/ +int BorderWidth(HWND control) +{ + return (std::max)(1, GetSystemMetricsForDpi(SM_CXEDGE, WindowDPI(control))); +} //--------------------------------------------------------------------------- bool ClassNameIs(HWND window, wchar_t const* className) { @@ -539,6 +553,40 @@ TWin32GUITheme::~TWin32GUITheme() } } //--------------------------------------------------------------------------- +/* + TWin32GUITheme::ApplyToBorder + + A control with WS_EX_CLIENTEDGE paints that border itself, in a light color, whatever its theme, in each + WM_NCPAINT; painting a dark one over it afterwards shows the light one for a moment, e.g. while a splitter + is dragged. So while dark it has no WS_EX_CLIENTEDGE: WM_NCCALCSIZE keeps the same width for a border of + its own, around its scroll bars, which WM_NCPAINT paints (see HandleControlMessage()). Light puts + WS_EX_CLIENTEDGE back. +*/ +void TWin32GUITheme::ApplyToBorder(HWND control) const +{ + LONG_PTR const exStyle = GetWindowLongPtrW(control, GWL_EXSTYLE); + bool const ownBorder = GetPropW(control, OwnBorderProperty) != nullptr; + + if (m_Dark && (exStyle & WS_EX_CLIENTEDGE) != 0) + { + SetPropW(control, OwnBorderProperty, reinterpret_cast(static_cast(1))); + SetWindowLongPtrW(control, GWL_EXSTYLE, exStyle & ~static_cast(WS_EX_CLIENTEDGE)); + } + else if (!m_Dark && ownBorder) + { + RemovePropW(control, OwnBorderProperty); + SetWindowLongPtrW(control, GWL_EXSTYLE, exStyle | WS_EX_CLIENTEDGE); + } + else if (!ownBorder) + { + return; + } + + // Its non-client area is measured again, also for a new DPI while dark. + SetWindowPos(control, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | + SWP_FRAMECHANGED); +} +//--------------------------------------------------------------------------- void TWin32GUITheme::ApplyToControl(TAttachedControl const& attached) { HWND const control = attached.Control; @@ -600,6 +648,12 @@ void TWin32GUITheme::ApplyToControl(TAttachedControl const& attached) break; // Painted by ControlProc() while dark. } + if (attached.Kind == EWin32GUIThemedControl::Edit || attached.Kind == EWin32GUIThemedControl::ListView || + attached.Kind == EWin32GUIThemedControl::RichEdit || attached.Kind == EWin32GUIThemedControl::TreeView) + { + ApplyToBorder(control); + } + RedrawWindow(control, nullptr, nullptr, RDW_ERASE | RDW_FRAME | RDW_INVALIDATE | RDW_ALLCHILDREN); } //--------------------------------------------------------------------------- @@ -833,6 +887,7 @@ LRESULT CALLBACK TWin32GUITheme::ControlProc(HWND control, UINT message, WPARAM { reinterpret_cast(theme)->Unattach(control); RemovePropW(control, HotTabProperty); + RemovePropW(control, OwnBorderProperty); RemoveWindowSubclass(control, &ControlProc, id); return DefSubclassProc(control, message, wParam, lParam); } @@ -1342,6 +1397,20 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro break; + case WM_NCCALCSIZE: + if (bordered && GetPropW(control, OwnBorderProperty) != nullptr) + { + // The border's width taken off the window's rectangle first, so the control puts its scroll bars + // and client area inside it, as with WS_EX_CLIENTEDGE. + RECT* const proposed = wParam != FALSE ? &reinterpret_cast(lParam)->rgrc[0] : + reinterpret_cast(lParam); + int const edge = BorderWidth(control); + InflateRect(proposed, -edge, -edge); + return DefSubclassProc(control, message, wParam, lParam); + } + + break; + case WM_NCPAINT: if (bordered) { @@ -1862,13 +1931,16 @@ LRESULT TWin32GUITheme::OnTreeCustomDraw(NMTVCUSTOMDRAW& draw) const /* TWin32GUITheme::PaintBorder - WS_EX_CLIENTEDGE's border, which stays light under a dark theme, painted over in Carbon's two edit border - colors, the outer one a pixel wide. + The border a bordered control has of its own while dark (see ApplyToBorder()), in Carbon's two edit border + colors, the outer one a pixel wide; or, on one that still has WS_EX_CLIENTEDGE, that border painted over. */ void TWin32GUITheme::PaintBorder(HWND control) const { - if ((GetWindowLongPtrW(control, GWL_EXSTYLE) & WS_EX_CLIENTEDGE) == 0) + if (GetPropW(control, OwnBorderProperty) == nullptr && + (GetWindowLongPtrW(control, GWL_EXSTYLE) & WS_EX_CLIENTEDGE) == 0) + { return; + } RECT rect; GetWindowRect(control, &rect); @@ -1879,7 +1951,7 @@ void TWin32GUITheme::PaintBorder(HWND control) const if (context == nullptr) return; - int const edge = (std::max)(1, GetSystemMetricsForDpi(SM_CXEDGE, WindowDPI(control))); + int const edge = BorderWidth(control); FrameSolid(context, rect, m_Palette.EditBorderOuter); for (int i = 1; i < edge; ++i) diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h index 9c38473..703365c 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h @@ -152,6 +152,9 @@ class TWin32GUITheme DWORD_PTR theme); private: + // Gives a bordered control (an edit box, the tree, the list, the log) its own border while dark, in place + // of WS_EX_CLIENTEDGE's, or WS_EX_CLIENTEDGE back while light; see the definition. + void ApplyToBorder(HWND control) const; // Applies the current look to one attached control. void ApplyToControl(TAttachedControl const& attached); // Makes 'menu''s items, and its submenus', owner-drawn while dark, adding each to 'items', or standard while From e3dd56668cac33b587aaa7ffcfcdc9290e86a7c8 Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 06:45:55 -0700 Subject: [PATCH 6/9] Paint the dark theme's scroll bar corner --- win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp | 202 ++++++++++-------- win32/gui/src/ASWUnitTests_Win32GUI_Theme.h | 4 +- 2 files changed, 114 insertions(+), 92 deletions(-) diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp index 74353de..5785aeb 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -218,9 +218,11 @@ RECT Inset(RECT rect, int by) /* MayRedrawScrollBars - Whether an edit box may draw its scroll bars straight away, rather than in WM_NCPAINT, while it handles - 'message': as its text changes (it enables or disables them), as it's resized, and as the mouse enters or - leaves it or its scroll bars (a themed scroll bar's arrows show whether the mouse is over the window). + Whether a control with scroll bars (an edit box, the tree, the list, the log) may draw them straight away, + rather than in WM_NCPAINT, while it handles 'message': as its text changes (an edit box enables or disables + them), as it's resized (they may appear or go, and with them the corner between them), and as the mouse + enters or leaves it or its scroll bars (a themed scroll bar's arrows show whether the mouse is over the + window). */ bool MayRedrawScrollBars(UINT message) { @@ -605,7 +607,9 @@ void TWin32GUITheme::ApplyToControl(TAttachedControl const& attached) HWND const header = reinterpret_cast(SendMessageW(control, LVM_GETHEADER, 0, 0)); if (header != nullptr) + { SetWindowTheme(header, m_Dark ? L"DarkMode_ItemsView" : nullptr, nullptr); + } COLORREF const background = WindowColor(); SendMessageW(control, LVM_SETBKCOLOR, 0, static_cast(background)); @@ -1305,12 +1309,10 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro bool const bordered = kind == EWin32GUIThemedControl::Edit || kind == EWin32GUIThemedControl::ListView || kind == EWin32GUIThemedControl::RichEdit || kind == EWin32GUIThemedControl::TreeView; - bool const edit = kind == EWin32GUIThemedControl::Edit || kind == EWin32GUIThemedControl::RichEdit; - - if (edit && MayRedrawScrollBars(message)) + if (bordered && MayRedrawScrollBars(message)) { LRESULT const result = DefSubclassProc(control, message, wParam, lParam); - PaintDisabledScrollBars(control); + PaintScrollBars(control); return result; } @@ -1415,7 +1417,7 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro if (bordered) { LRESULT const result = DefSubclassProc(control, message, wParam, lParam); - PaintDisabledScrollBars(control); + PaintScrollBars(control); PaintBorder(control); return result; } @@ -1960,88 +1962,6 @@ void TWin32GUITheme::PaintBorder(HWND control) const ReleaseDC(control, context); } //--------------------------------------------------------------------------- -/* - TWin32GUITheme::PaintDisabledScrollBars - - The dark scroll bar theme ("DarkMode_Explorer") draws a disabled scroll bar's arrows nearly white, e.g. the - empty detail pane's, which keeps its scroll bar while there's nothing to scroll. Such a bar is drawn again - in the theme's normal state, which has no thumb when there's nothing to scroll, with its arrows faded - halfway toward the track, so they read as disabled. A bar that's enabled, or hidden, is left as it is. - Called after each message that may draw the bars (see MayRedrawScrollBars()), so it's quick when there's - no disabled bar. -*/ -void TWin32GUITheme::PaintDisabledScrollBars(HWND control) const -{ - RECT window; - GetWindowRect(control, &window); - HDC context = nullptr; - HTHEME theme = nullptr; - - for (LONG const id : { OBJID_VSCROLL, OBJID_HSCROLL }) - { - SCROLLBARINFO info = {}; - info.cbSize = sizeof(info); - - if (GetScrollBarInfo(control, id, &info) == 0 || - (info.rgstate[0] & (STATE_SYSTEM_INVISIBLE | STATE_SYSTEM_OFFSCREEN)) != 0 || - (info.rgstate[0] & STATE_SYSTEM_UNAVAILABLE) == 0) - { - continue; - } - - if (context == nullptr) - { - context = GetWindowDC(control); - theme = OpenThemeData(control, L"ScrollBar"); - } - - if (context == nullptr || theme == nullptr) - break; - - bool const vertical = id == OBJID_VSCROLL; - RECT bar = info.rcScrollBar; - OffsetRect(&bar, -window.left, -window.top); - - // The arrow buttons at its two ends, each as long as a line button. - RECT first = bar; - RECT second = bar; - - if (vertical) - { - first.bottom = first.top + info.dxyLineButton; - second.top = second.bottom - info.dxyLineButton; - } - else - { - first.right = first.left + info.dxyLineButton; - second.left = second.right - info.dxyLineButton; - } - - TPaintBuffer const buffer(context, bar); - HDC const target = buffer.Get(); - DrawThemeBackground(theme, target, vertical ? SBP_UPPERTRACKVERT : SBP_UPPERTRACKHORZ, SCRBS_NORMAL, &bar, - nullptr); - DrawThemeBackground(theme, target, SBP_ARROWBTN, vertical ? ABS_UPNORMAL : ABS_LEFTNORMAL, &first, nullptr); - DrawThemeBackground(theme, target, SBP_ARROWBTN, vertical ? ABS_DOWNNORMAL : ABS_RIGHTNORMAL, &second, - nullptr); - - for (RECT const& button : { first, second }) - { - TGlyphCanvas canvas(button.right - button.left, button.bottom - button.top, m_Palette.Window); - canvas.CopyFrom(target, button.left, button.top); - // Toward its corner's pixel, the button's background. - canvas.Fade(canvas.PixelAt(0, 0), 1, 2); - canvas.Draw(target, button.left, button.top); - } - } - - if (theme != nullptr) - CloseThemeData(theme); - - if (context != nullptr) - ReleaseDC(control, context); -} -//--------------------------------------------------------------------------- /* TWin32GUITheme::PaintMenuBarLine @@ -2140,6 +2060,108 @@ void TWin32GUITheme::PaintProgressBar(HWND progressBar, HDC context) const failed ? m_Palette.ProgressFailedBottom : m_Palette.ProgressChunkBottom); } //--------------------------------------------------------------------------- +/* + TWin32GUITheme::PaintScrollBars + + What the dark scroll bar theme ("DarkMode_Explorer") leaves light, painted over: + - the corner between a vertical and a horizontal scroll bar, which Windows paints in the system colors + whatever the theme (a light edge around a gray square), painted as the theme's track; + - a disabled scroll bar's arrows, which the theme draws nearly white, e.g. the empty detail pane's, which + keeps its scroll bar while there's nothing to scroll. Such a bar is drawn again in the theme's normal + state, which has no thumb when there's nothing to scroll, with its arrows faded halfway toward the track, + so they read as disabled. + An enabled bar is left as it is. Called after each message that may draw the bars (see + MayRedrawScrollBars()), so it's quick when there's nothing to paint. +*/ +void TWin32GUITheme::PaintScrollBars(HWND control) const +{ + SCROLLBARINFO bars[2] = {}; + LONG const ids[2] = { OBJID_VSCROLL, OBJID_HSCROLL }; + bool visible[2] = { false, false }; + + for (int i = 0; i < 2; ++i) + { + bars[i].cbSize = sizeof(bars[i]); + visible[i] = GetScrollBarInfo(control, ids[i], &bars[i]) != 0 && + (bars[i].rgstate[0] & (STATE_SYSTEM_INVISIBLE | STATE_SYSTEM_OFFSCREEN)) == 0; + } + + bool const corner = visible[0] && visible[1]; + bool const disabled[2] = { + visible[0] && (bars[0].rgstate[0] & STATE_SYSTEM_UNAVAILABLE) != 0, + visible[1] && (bars[1].rgstate[0] & STATE_SYSTEM_UNAVAILABLE) != 0, + }; + + if (!corner && !disabled[0] && !disabled[1]) + return; + + HDC const context = GetWindowDC(control); + HTHEME const theme = OpenThemeData(control, L"ScrollBar"); + RECT window; + GetWindowRect(control, &window); + + if (context != nullptr && theme != nullptr) + { + if (corner) + { + // Below the vertical bar and right of the horizontal one. + RECT box = { bars[0].rcScrollBar.left, bars[1].rcScrollBar.top, bars[0].rcScrollBar.right, + bars[1].rcScrollBar.bottom }; + OffsetRect(&box, -window.left, -window.top); + DrawThemeBackground(theme, context, SBP_LOWERTRACKVERT, SCRBS_NORMAL, &box, nullptr); + } + + for (int i = 0; i < 2; ++i) + { + if (!disabled[i]) + continue; + + bool const vertical = ids[i] == OBJID_VSCROLL; + RECT bar = bars[i].rcScrollBar; + OffsetRect(&bar, -window.left, -window.top); + + // The arrow buttons at its two ends, each as long as a line button. + RECT first = bar; + RECT second = bar; + + if (vertical) + { + first.bottom = first.top + bars[i].dxyLineButton; + second.top = second.bottom - bars[i].dxyLineButton; + } + else + { + first.right = first.left + bars[i].dxyLineButton; + second.left = second.right - bars[i].dxyLineButton; + } + + TPaintBuffer const buffer(context, bar); + HDC const target = buffer.Get(); + DrawThemeBackground(theme, target, vertical ? SBP_UPPERTRACKVERT : SBP_UPPERTRACKHORZ, SCRBS_NORMAL, &bar, + nullptr); + DrawThemeBackground(theme, target, SBP_ARROWBTN, vertical ? ABS_UPNORMAL : ABS_LEFTNORMAL, &first, + nullptr); + DrawThemeBackground(theme, target, SBP_ARROWBTN, vertical ? ABS_DOWNNORMAL : ABS_RIGHTNORMAL, &second, + nullptr); + + for (RECT const& button : { first, second }) + { + TGlyphCanvas canvas(button.right - button.left, button.bottom - button.top, m_Palette.Window); + canvas.CopyFrom(target, button.left, button.top); + // Toward its corner's pixel, the button's background. + canvas.Fade(canvas.PixelAt(0, 0), 1, 2); + canvas.Draw(target, button.left, button.top); + } + } + } + + if (theme != nullptr) + CloseThemeData(theme); + + if (context != nullptr) + ReleaseDC(control, context); +} +//--------------------------------------------------------------------------- /* TWin32GUITheme::PaintStatusBar diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h index 703365c..74114d9 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h @@ -189,11 +189,11 @@ class TWin32GUITheme LRESULT OnHeaderCustomDraw(NMCUSTOMDRAW const& draw) const; LRESULT OnTreeCustomDraw(NMTVCUSTOMDRAW& draw) const; void PaintBorder(HWND control) const; - // A disabled scroll bar of 'control' (see the definition). - void PaintDisabledScrollBars(HWND control) const; void PaintMenuBarLine(HWND window) const; void PaintMenuFrame(HWND menu, HDC given) const; void PaintProgressBar(HWND progressBar, HDC context) const; + // What the dark scroll bar theme leaves light on 'control''s scroll bars (see the definition). + void PaintScrollBars(HWND control) const; void PaintStatusBar(HWND statusBar, HDC context) const; void PaintTabControl(HWND tabControl, HDC context) const; void PaintUpDown(HWND upDown, HDC context) const; From 5043d586f25c0009f6bfb0f8ae57dd511d27e21b Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 12:50:20 -0700 Subject: [PATCH 7/9] Keep a disabled dark scroll bar's arrows dark after the mouse leaves it --- win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp index 5785aeb..dea6f08 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -144,6 +144,7 @@ void FrameSolid(HDC context, RECT const& rect, COLORREF color); bool HidesAccelerators(HWND control); bool HidesFocus(HWND control); RECT Inset(RECT rect, int by); +bool IsOverDisabledScrollBar(HWND control, WPARAM hitTest); bool MayRedrawScrollBars(UINT message); void SetImmersiveDarkMode(HWND window, bool dark); void SplitShortcut(std::wstring const& text, std::wstring& caption, std::wstring& shortcut); @@ -215,6 +216,24 @@ RECT Inset(RECT rect, int by) return rect; } //--------------------------------------------------------------------------- +/* + IsOverDisabledScrollBar + + Whether 'hitTest', a WM_NCHITTEST result, is the control's vertical or horizontal scroll bar, and that bar is + disabled. +*/ +bool IsOverDisabledScrollBar(HWND control, WPARAM hitTest) +{ + if (hitTest != HTVSCROLL && hitTest != HTHSCROLL) + return false; + + SCROLLBARINFO info = {}; + info.cbSize = sizeof(info); + + return GetScrollBarInfo(control, hitTest == HTVSCROLL ? OBJID_VSCROLL : OBJID_HSCROLL, &info) != 0 && + (info.rgstate[0] & STATE_SYSTEM_UNAVAILABLE) != 0; +} +//--------------------------------------------------------------------------- /* MayRedrawScrollBars @@ -1309,6 +1328,15 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro bool const bordered = kind == EWin32GUIThemedControl::Edit || kind == EWin32GUIThemedControl::ListView || kind == EWin32GUIThemedControl::RichEdit || kind == EWin32GUIThemedControl::TreeView; + + if (bordered && message == WM_NCMOUSEMOVE && IsOverDisabledScrollBar(control, wParam)) + { + // Not passed on: over a scroll bar, Windows fades its arrows as the mouse comes and goes, painting them + // from a timer of its own, after PaintScrollBars() has, so a disabled bar's would end up light. A + // disabled bar does nothing with the mouse anyway. + return 0; + } + if (bordered && MayRedrawScrollBars(message)) { LRESULT const result = DefSubclassProc(control, message, wParam, lParam); From 3864a76e0c300d048cd722ef82a5ba39a3dde7aa Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 12:53:20 -0700 Subject: [PATCH 8/9] Stop the bordered controls' frames and a growing tree or list from flickering while resized --- win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp | 158 +++++++++++++++--- win32/gui/src/ASWUnitTests_Win32GUI_Theme.h | 6 +- 2 files changed, 141 insertions(+), 23 deletions(-) diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp index dea6f08..381bb8f 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -61,6 +61,9 @@ wchar_t const HotTabProperty[] = L"ASWUnitTests_Win32GUI_HotTab"; // The window property set on a control that had WS_EX_CLIENTEDGE, which it has in place of it while dark; see // TWin32GUITheme::ApplyToBorder(). wchar_t const OwnBorderProperty[] = L"ASWUnitTests_Win32GUI_OwnBorder"; +// The window property set on the tree or the list while its size changes, between WM_WINDOWPOSCHANGING and +// WM_WINDOWPOSCHANGED; see TWin32GUITheme::HandleControlMessage(). +wchar_t const ResizingProperty[] = L"ASWUnitTests_Win32GUI_Resizing"; // The menu windows' class, documented as a system class. wchar_t const MenuClassName[] = L"#32768"; // The subclass IDs: an attached control's is ControlSubclassBase plus its kind. @@ -136,11 +139,14 @@ class TPaintBuffer //--------------------------------------------------------------------------- +COLORREF BackgroundColor(HWND control, EWin32GUIThemedControl kind); int BorderWidth(HWND control); bool ClassNameIs(HWND window, wchar_t const* className); +void ExcludeRect(HRGN region, RECT const& rect); void FillGradient(HDC context, RECT const& rect, COLORREF top, COLORREF bottom); void FillSolid(HDC context, RECT const& rect, COLORREF color); void FrameSolid(HDC context, RECT const& rect, COLORREF color); +bool HasThemedBorder(HWND control); bool HidesAccelerators(HWND control); bool HidesFocus(HWND control); RECT Inset(RECT rect, int by); @@ -151,6 +157,21 @@ void SplitShortcut(std::wstring const& text, std::wstring& caption, std::wstring SIZE TextSize(HDC context, std::wstring const& text, UINT format); std::wstring WideWindowText(HWND window); +//--------------------------------------------------------------------------- +/* + BackgroundColor + + The background the tree or the list paints, by asking it: the system's window color if it has none of its + own. +*/ +COLORREF BackgroundColor(HWND control, EWin32GUIThemedControl kind) +{ + // The tree's -1 for none is CLR_NONE too, as a COLORREF. + COLORREF const color = static_cast(kind == EWin32GUIThemedControl::TreeView ? + SendMessageW(control, TVM_GETBKCOLOR, 0, 0) : SendMessageW(control, LVM_GETBKCOLOR, 0, 0)); + + return color == CLR_NONE ? GetSysColor(COLOR_WINDOW) : color; +} //--------------------------------------------------------------------------- /* BorderWidth @@ -170,6 +191,17 @@ bool ClassNameIs(HWND window, wchar_t const* className) return lstrcmpiW(name, className) == 0; } //--------------------------------------------------------------------------- +void ExcludeRect(HRGN region, RECT const& rect) +{ + HRGN const excluded = CreateRectRgnIndirect(&rect); + + if (excluded == nullptr) + return; + + CombineRgn(region, region, excluded, RGN_DIFF); + DeleteObject(excluded); +} +//--------------------------------------------------------------------------- /* FillGradient @@ -200,6 +232,18 @@ void FrameSolid(HDC context, RECT const& rect, COLORREF color) SetDCBrushColor(context, old); } //--------------------------------------------------------------------------- +/* + HasThemedBorder + + Whether the dark look paints the control's border (see TWin32GUITheme::PaintBorder()): it has one of its own + in place of WS_EX_CLIENTEDGE, or still has WS_EX_CLIENTEDGE. +*/ +bool HasThemedBorder(HWND control) +{ + return GetPropW(control, OwnBorderProperty) != nullptr || + (GetWindowLongPtrW(control, GWL_EXSTYLE) & WS_EX_CLIENTEDGE) != 0; +} +//--------------------------------------------------------------------------- bool HidesAccelerators(HWND control) { return (SendMessageW(control, WM_QUERYUISTATE, 0, 0) & UISF_HIDEACCEL) != 0; @@ -911,6 +955,7 @@ LRESULT CALLBACK TWin32GUITheme::ControlProc(HWND control, UINT message, WPARAM reinterpret_cast(theme)->Unattach(control); RemovePropW(control, HotTabProperty); RemovePropW(control, OwnBorderProperty); + RemovePropW(control, ResizingProperty); RemoveWindowSubclass(control, &ControlProc, id); return DefSubclassProc(control, message, wParam, lParam); } @@ -1298,6 +1343,29 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro return result; } + bool const doubleBuffered = kind == EWin32GUIThemedControl::ListView || kind == EWin32GUIThemedControl::TreeView; + + if (doubleBuffered && message == WM_WINDOWPOSCHANGING && + (reinterpret_cast(lParam)->flags & SWP_NOSIZE) == 0) + { + SetPropW(control, ResizingProperty, reinterpret_cast(static_cast(1))); + } + else if (doubleBuffered && (message == WM_WINDOWPOSCHANGED || message == WM_PAINT)) + { + RemovePropW(control, ResizingProperty); + } + else if (doubleBuffered && message == WM_ERASEBKGND && GetPropW(control, ResizingProperty) != nullptr) + { + // As the control grows, e.g. by a splitter, Windows erases the part it newly shows straight away, and the + // control paints it later. Being double-buffered, the tree and the list skip the erase, so that part + // showed what the screen had there before, e.g. the scroll bar at its old place, until they painted it. + // Filled with their background here, and only while resizing, so their other repaints don't flicker. + RECT client; + GetClientRect(control, &client); + FillSolid(reinterpret_cast(wParam), client, BackgroundColor(control, kind)); + return 1; + } + if (!m_Dark) { // The control paints itself, through a buffer too, so it doesn't flicker while the window is resized: @@ -1340,7 +1408,7 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro if (bordered && MayRedrawScrollBars(message)) { LRESULT const result = DefSubclassProc(control, message, wParam, lParam); - PaintScrollBars(control); + PaintScrollBars(control, nullptr); return result; } @@ -1444,10 +1512,8 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro case WM_NCPAINT: if (bordered) { - LRESULT const result = DefSubclassProc(control, message, wParam, lParam); - PaintScrollBars(control); - PaintBorder(control); - return result; + PaintNonClient(control, reinterpret_cast(wParam)); + return 0; } break; @@ -1963,31 +2029,22 @@ LRESULT TWin32GUITheme::OnTreeCustomDraw(NMTVCUSTOMDRAW& draw) const The border a bordered control has of its own while dark (see ApplyToBorder()), in Carbon's two edit border colors, the outer one a pixel wide; or, on one that still has WS_EX_CLIENTEDGE, that border painted over. + Into 'context', in the window's coordinates. */ -void TWin32GUITheme::PaintBorder(HWND control) const +void TWin32GUITheme::PaintBorder(HWND control, HDC context) const { - if (GetPropW(control, OwnBorderProperty) == nullptr && - (GetWindowLongPtrW(control, GWL_EXSTYLE) & WS_EX_CLIENTEDGE) == 0) - { + if (!HasThemedBorder(control)) return; - } RECT rect; GetWindowRect(control, &rect); OffsetRect(&rect, -rect.left, -rect.top); - HDC const context = GetWindowDC(control); - - if (context == nullptr) - return; - int const edge = BorderWidth(control); FrameSolid(context, rect, m_Palette.EditBorderOuter); for (int i = 1; i < edge; ++i) FrameSolid(context, Inset(rect, i), m_Palette.EditBorderInner); - - ReleaseDC(control, context); } //--------------------------------------------------------------------------- /* @@ -2052,6 +2109,64 @@ void TWin32GUITheme::PaintMenuFrame(HWND menu, HDC given) const ReleaseDC(menu, context); } //--------------------------------------------------------------------------- +/* + TWin32GUITheme::PaintNonClient + + Each part painted once: the control (and Windows) paint 'update', WM_NCPAINT's region, less what + PaintBorder() and PaintScrollBars() paint, which they then paint. Letting the control paint it all first, + as before, showed its light parts in between, e.g. a disabled scroll bar's arrows and a light border without + the scroll bars, which flickered while the control was resized, e.g. by a splitter. +*/ +void TWin32GUITheme::PaintNonClient(HWND control, HRGN update) const +{ + RECT window; + GetWindowRect(control, &window); + + // In screen coordinates, as WM_NCPAINT's region is: inside the border. + RECT const inside = HasThemedBorder(control) ? Inset(window, BorderWidth(control)) : window; + HRGN const rest = CreateRectRgnIndirect(&inside); + + if (rest != nullptr) + { + // 1 is the whole window. + if (update != reinterpret_cast(1)) + CombineRgn(rest, rest, update, RGN_AND); + + SCROLLBARINFO bars[2] = {}; + LONG const ids[2] = { OBJID_VSCROLL, OBJID_HSCROLL }; + bool visible[2] = { false, false }; + + for (int i = 0; i < 2; ++i) + { + bars[i].cbSize = sizeof(bars[i]); + visible[i] = GetScrollBarInfo(control, ids[i], &bars[i]) != 0 && + (bars[i].rgstate[0] & (STATE_SYSTEM_INVISIBLE | STATE_SYSTEM_OFFSCREEN)) == 0; + + if (visible[i] && (bars[i].rgstate[0] & STATE_SYSTEM_UNAVAILABLE) != 0) + ExcludeRect(rest, bars[i].rcScrollBar); + } + + if (visible[0] && visible[1]) + { + RECT const corner = { bars[0].rcScrollBar.left, bars[1].rcScrollBar.top, bars[0].rcScrollBar.right, + bars[1].rcScrollBar.bottom }; + ExcludeRect(rest, corner); + } + + DefSubclassProc(control, WM_NCPAINT, reinterpret_cast(rest), 0); + DeleteObject(rest); + } + + HDC const context = GetWindowDC(control); + + if (context == nullptr) + return; + + PaintScrollBars(control, context); + PaintBorder(control, context); + ReleaseDC(control, context); +} +//--------------------------------------------------------------------------- /* TWin32GUITheme::PaintProgressBar @@ -2099,9 +2214,10 @@ void TWin32GUITheme::PaintProgressBar(HWND progressBar, HDC context) const state, which has no thumb when there's nothing to scroll, with its arrows faded halfway toward the track, so they read as disabled. An enabled bar is left as it is. Called after each message that may draw the bars (see - MayRedrawScrollBars()), so it's quick when there's nothing to paint. + MayRedrawScrollBars()), so it's quick when there's nothing to paint. Into 'given' if it isn't nullptr + (PaintNonClient()'s), in the window's coordinates, or else the window's own context. */ -void TWin32GUITheme::PaintScrollBars(HWND control) const +void TWin32GUITheme::PaintScrollBars(HWND control, HDC given) const { SCROLLBARINFO bars[2] = {}; LONG const ids[2] = { OBJID_VSCROLL, OBJID_HSCROLL }; @@ -2123,7 +2239,7 @@ void TWin32GUITheme::PaintScrollBars(HWND control) const if (!corner && !disabled[0] && !disabled[1]) return; - HDC const context = GetWindowDC(control); + HDC const context = given != nullptr ? given : GetWindowDC(control); HTHEME const theme = OpenThemeData(control, L"ScrollBar"); RECT window; GetWindowRect(control, &window); @@ -2186,7 +2302,7 @@ void TWin32GUITheme::PaintScrollBars(HWND control) const if (theme != nullptr) CloseThemeData(theme); - if (context != nullptr) + if (context != nullptr && given == nullptr) ReleaseDC(control, context); } //--------------------------------------------------------------------------- diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h index 74114d9..db6d9ac 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h @@ -188,12 +188,14 @@ class TWin32GUITheme bool OnCtlColor(UINT message, HDC context, HWND control, LRESULT& result) const; LRESULT OnHeaderCustomDraw(NMCUSTOMDRAW const& draw) const; LRESULT OnTreeCustomDraw(NMTVCUSTOMDRAW& draw) const; - void PaintBorder(HWND control) const; + void PaintBorder(HWND control, HDC context) const; void PaintMenuBarLine(HWND window) const; void PaintMenuFrame(HWND menu, HDC given) const; + // A bordered control's WM_NCPAINT while dark, of 'update', its region: its scroll bars and border. + void PaintNonClient(HWND control, HRGN update) const; void PaintProgressBar(HWND progressBar, HDC context) const; // What the dark scroll bar theme leaves light on 'control''s scroll bars (see the definition). - void PaintScrollBars(HWND control) const; + void PaintScrollBars(HWND control, HDC given) const; void PaintStatusBar(HWND statusBar, HDC context) const; void PaintTabControl(HWND tabControl, HDC context) const; void PaintUpDown(HWND upDown, HDC context) const; From f8b1fb3805b6c073df5cd74102b3f00f17a78fa7 Mon Sep 17 00:00:00 2001 From: Anthony West Date: Thu, 29 Oct 2026 12:55:12 -0700 Subject: [PATCH 9/9] Don't repaint the dark progress bar on the themed bar's animation timer --- win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp index 381bb8f..58ec3d6 100644 --- a/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -1568,7 +1568,6 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro break; - case WM_TIMER: case PBM_DELTAPOS: case PBM_SETPOS: case PBM_SETRANGE: @@ -1577,7 +1576,8 @@ LRESULT TWin32GUITheme::HandleControlMessage(HWND control, EWin32GUIThemedContro case PBM_STEPIT: if (kind == EWin32GUIThemedControl::ProgressBar) { - // The themed bar animates toward a new position by itself; this one shows it at once. + // The themed bar animates toward a new position by itself; this one shows it at once. Not on the + // themed bar's WM_TIMER, which only steps that animation, drawing nothing while dark. LRESULT const result = DefSubclassProc(control, message, wParam, lParam); InvalidateRect(control, nullptr, FALSE); return result;