diff --git a/CHANGELOG.md b/CHANGELOG.md index 4296f19..d685c12 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 @@ -132,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/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/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/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/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/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..481294a --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.cpp @@ -0,0 +1,172 @@ +/* ************************************************************************** +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 and spin button arrows are 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::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); + + 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..564e5d2 --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Glyphs.h @@ -0,0 +1,77 @@ +/* ************************************************************************** +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' and the spin +// buttons' arrows at 16. +///////////////////////////////////////////////////////////////////////////// +enum class EWin32GUIGlyph +{ + // 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. + 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..4c313ba --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.cpp @@ -0,0 +1,155 @@ +/* ************************************************************************** +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.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); + 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..1016a2c --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Palette.h @@ -0,0 +1,171 @@ +/* ************************************************************************** +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; + // 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 + // 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..bce48b3 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,12 +133,14 @@ 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()); - // 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); @@ -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..58ec3d6 --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.cpp @@ -0,0 +1,2634 @@ +/* ************************************************************************** +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 +//--------------------------------------------------------------------------- +#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 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. +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); + // 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; + // 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; +}; + +///////////////////////////////////////////////////////////////////////////// +// 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; +}; + +//--------------------------------------------------------------------------- + +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); +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); +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 + + 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) +{ + wchar_t name[64] = {}; + GetClassNameW(window, name, 64); + + 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 + + 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); +} +//--------------------------------------------------------------------------- +/* + 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; +} +//--------------------------------------------------------------------------- +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; +} +//--------------------------------------------------------------------------- +/* + 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 + + 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) +{ + 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 + + 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)); + } + } +} +//--------------------------------------------------------------------------- +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(); + 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::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); + + 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; + } + } +} +//--------------------------------------------------------------------------- +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) | + (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); + } +} +//--------------------------------------------------------------------------- +/* + 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; + 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: + case EWin32GUIThemedControl::UpDown: + 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); +} +//--------------------------------------------------------------------------- +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()); + + 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) +{ + 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); + RemovePropW(control, OwnBorderProperty); + RemovePropW(control, ResizingProperty); + 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, 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) +{ + 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 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; + + 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 || 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; + } + + 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: + // 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; + + 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); + PaintScrollBars(control, nullptr); + return result; + } + + 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); + } + } + + 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_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) + { + PaintNonClient(control, reinterpret_cast(wParam)); + return 0; + } + + 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 if (kind == EWin32GUIThemedControl::TabControl) + PaintTabControl(control, buffer.Get()); + else + PaintUpDown(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 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. 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; + } + + 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 + + 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, HDC context) const +{ + if (!HasThemedBorder(control)) + return; + + RECT rect; + GetWindowRect(control, &rect); + OffsetRect(&rect, -rect.left, -rect.top); + + 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); +} +//--------------------------------------------------------------------------- +/* + 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::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 + + 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::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. 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, HDC given) 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 = given != nullptr ? given : 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 && given == nullptr) + ReleaseDC(control, context); +} +//--------------------------------------------------------------------------- +/* + 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); +} +//--------------------------------------------------------------------------- +/* + 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; +} +//--------------------------------------------------------------------------- +void TWin32GUITheme::Refresh() +{ + ApplyToMenus(); + + for (HWND const window : m_Windows) + ApplyToTitleBar(window); + + // 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); +} +//--------------------------------------------------------------------------- +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..db6d9ac --- /dev/null +++ b/win32/gui/src/ASWUnitTests_Win32GUI_Theme.h @@ -0,0 +1,243 @@ +/* ************************************************************************** +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, + // 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, +}; + + +///////////////////////////////////////////////////////////////////////////// +// 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: + // 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 + // 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); + // 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; + 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, 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, HDC given) 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); + +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..361b4d1 --- /dev/null +++ b/win32/tests/Test_ASWUnitTests_Win32GUI_Glyphs.cpp @@ -0,0 +1,261 @@ +/* ************************************************************************** +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); + + 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() +{ + // 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::ArrowLeft, 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: