diff --git a/README.md b/README.md new file mode 100644 index 0000000..75b8731 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# ShellDrive + +ShellDrive mounts remote hosts over SSH (SFTP) on Windows as drive letters, the way SMB shares are mapped. +It is native Win32 code in plain C, from Windows XP SP3 to Windows 11, with no third-party code: the SSH client, +the SFTP client and the cryptography are written for this project. + +> **Status:** pre-1.0, version 0.1.0. There are no public downloads until the binaries are Authenticode-signed +> (see [Distribution](#distribution-and-updates)). Build from source to try it. + +## Features + +- **Two mount backends.** A local WebDAV server mapped through the Windows WebClient redirector (no driver, XP and + later), and [Dokany](https://github.com/dokan-dev/dokany) (the signed upstream driver, Windows 7 SP1 and later) + for a real drive with better semantics. ShellDrive picks Dokany when it is installed; each profile can force + either. +- **Per-user and global drives.** Per-user mounts with remount at logon, and a system service that mounts global + drives for every user before logon (per-machine installs). +- **Connectivity.** ProxyJump with several hops, HTTP CONNECT and SOCKS5 proxies per profile and per hop, and a + pool of 1–4 SSH connections per mount. +- **Authentication.** Password and keyboard-interactive (OTP/2FA), ed25519, ECDSA and RSA keys (OpenSSH, PEM/PKCS#8 + and PuTTY `.ppk` v2/v3), OpenSSH certificates, Pageant and the Windows OpenSSH agent. +- **Host keys.** Trust on first use with a fingerprint prompt, OpenSSH-compatible `known_hosts`, PuTTY's and + OpenSSH's stores read as extra sources, and verified host key rotation (`hostkeys-00@openssh.com`). +- **Cryptography.** Modern algorithms first (curve25519, ed25519, chacha20-poly1305, AES-GCM, ETM MACs), the + post-quantum hybrid `mlkem768x25519-sha256`, Terrapin mitigation, and legacy algorithms ranked last with a + warning in the UI. +- **Resilience.** Reconnection with backoff, keepalive, metadata and read caches, synchronous writes by default + and an optional write-behind window. +- **Interfaces.** A CLI, a tray app with a main window (connection manager), Explorer integration (context menu, + entries in "This PC", network locations) and an installer (EXE and MSI for GPO/Intune). +- **Six languages.** English, Spanish, French, Portuguese (Brazil), Italian and German in every component. + +## Interface + +The main window adapts to the Windows version (design: [`docs/design/06-ui-tiers.md`](docs/design/06-ui-tiers.md), +decision D47). Every tier keeps the same controls, keyboard access and accessibility; High Contrast and the +"Classic" appearance setting always use the classic window. + +| Windows | Style | +|---|---| +| XP, High Contrast, Appearance "Classic" | Classic window with menu bar and toolbar | +| Vista, 7 | Aero: the toolbar on a glass band when desktop composition is on | +| 8, 8.1, 10 | Fluent: command bar with a "···" overflow menu, list on a card, status strip; dark mode from 10 1809 | +| 11 | Fluent with Mica (22H2 and later), rounded corners and a tray flyout to mount and unmount profiles | + +### Windows 11 + +Fluent, dark and light (Windows 11 24H2, Mica backdrop): + +![ShellDrive on Windows 11, dark](docs/images/ui/win11-fluent-dark.png) + +![ShellDrive on Windows 11, light](docs/images/ui/win11-fluent-light.png) + +Appearance set to "Classic" (the same window XP shows): + +![ShellDrive on Windows 11 with the Classic appearance](docs/images/ui/win11-classic.png) + +### Windows 10 + +Fluent, light and dark (Windows 10 IoT Enterprise LTSC 2021, 21H2), and the Classic appearance: + +![ShellDrive on Windows 10, light](docs/images/ui/win10-fluent-light.png) + +![ShellDrive on Windows 10, dark](docs/images/ui/win10-fluent-dark.png) + +![ShellDrive on Windows 10 with the Classic appearance](docs/images/ui/win10-classic.png) + +### Windows 7 + +Windows 7 SP1 with the "Windows 7 Basic" theme (no desktop composition, as in a virtual machine): the Aero tier +keeps the toolbar without the glass band. With composition on, the toolbar sits on a glass band. + +![ShellDrive on Windows 7 Basic](docs/images/ui/win7-basic.png) + +The Classic appearance on Windows 7: + +![ShellDrive on Windows 7 with the Classic appearance](docs/images/ui/win7-classic.png) + +The screenshots come from the `--test-shot=` smoke mode of `shelldrive-ui.exe`, with sample profiles, +on the development host (Windows 11) and the lab virtual machines (Windows 10 and 7). The Windows XP lab machine +is not installed yet; XP uses the classic window shown above. + +## Command line + +```text +shelldrive mount | --host [user@]host[:port] +shelldrive unmount | --all +shelldrive list +shelldrive status [] [--stats] +shelldrive profile import putty [] | ssh-config [] +shelldrive hostkey list | add | import | remove | accept-announced +shelldrive service list | mount | unmount | status +shelldrive diag [--check] | --bundle [FILE] +``` + +`shelldrive help ` lists the options of each command; `--json` prints machine-readable output and +`--lang` picks the language. + +## Supported systems + +| Flavour | Minimum OS | Notes | +|---|---|---| +| `x86-ia32` | XP SP3 | for CPUs without SSE2 | +| `x86-sse2` | XP SP3 | default x86 | +| `x64-xp` | XP x64 SP2, Vista | | +| `x64-modern` | Windows 10 | CFG and CET | +| `arm64` | Windows 10 on ARM | | +| `arm32` | Windows 10 on ARM, 11 up to 23H2 | best effort | + +The setup program picks the flavour for the machine it runs on. + +## Building + +Visual Studio 2022 Build Tools with the v143 and v141_xp toolsets, Windows SDK 10.0.26100 and 7.1A, and CMake 3.25 +or newer. From the repository root: + +```powershell +cmake --preset x64-modern +cmake --build --preset x64-modern +ctest --preset x64-modern +``` + +[`docs/dev/building.md`](docs/dev/building.md) lists every preset, the checks the build enforces (XP import floor, +instruction-set audit) and the installer builds. `tools/ci-local.ps1 -Pipeline` runs the full local gate: all +presets, unit, interop and Dokany tests. + +## Distribution and updates + +A self-installing EXE (which can install the signed Dokany driver as an explicit option) and an MSI. Updates are +checked against a static mirror and verified with ShellDrive's own ed25519 signatures, so they do not depend on +the TLS stack of old Windows versions; the client asks before applying them. Code signing is planned through the +SignPath Foundation before a public 1.0. + +## Documentation + +- [`docs/requirements.md`](docs/requirements.md): product requirements and hard constraints. +- [`docs/design/`](docs/design/README.md): design documents (SSH and crypto, file system, apps and platform, + build and install, threat model, UI tiers) and the accepted decisions. +- [`docs/dev/`](docs/dev/): build guide, milestone notes and lab results. +- [`AGENTS.md`](AGENTS.md): contribution rules (commits, language, localization). + +## License + +[MIT](LICENSE). Dokany is loaded dynamically and redistributed unmodified under its own license. diff --git a/apps/shelldrive-ui/CMakeLists.txt b/apps/shelldrive-ui/CMakeLists.txt index 5e95913..7b47de3 100644 --- a/apps/shelldrive-ui/CMakeLists.txt +++ b/apps/shelldrive-ui/CMakeLists.txt @@ -19,7 +19,7 @@ set(SD_UI_PURE_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/ui_settings_model.c ${CMAKE_CURRENT_SOURCE_DIR}/ui_update_model.c ${CMAKE_CURRENT_SOURCE_DIR}/ui_notify_model.c - ${CMAKE_CURRENT_SOURCE_DIR}/ui_dark_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_theme_model.c ${CMAKE_CURRENT_SOURCE_DIR}/ui_wizard_model.c ${CMAKE_CURRENT_SOURCE_DIR}/ui_admin_model.c ${CMAKE_CURRENT_SOURCE_DIR}/ui_service_model.c @@ -27,7 +27,8 @@ set(SD_UI_PURE_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/ui_events_model.c ${CMAKE_CURRENT_SOURCE_DIR}/ui_mainview_model.c ${CMAKE_CURRENT_SOURCE_DIR}/ui_notice_model.c - ${CMAKE_CURRENT_SOURCE_DIR}/ui_evhist_model.c) + ${CMAKE_CURRENT_SOURCE_DIR}/ui_evhist_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_taskdlg_model.c) set(SD_UI_PURE_SOURCES ${SD_UI_PURE_SOURCES} PARENT_SCOPE) # Icon resources (res/icons): the template gets the absolute icon directory (rc.exe does not search the @@ -48,6 +49,7 @@ sd_add_app(shelldrive-ui ui_editor.c ui_hostkeys.c ui_events.c + ui_flyout.c ui_hkissue.c ui_icon.c ui_import.c @@ -65,14 +67,15 @@ sd_add_app(shelldrive-ui ui_update.c ui_tray.c ui_win.c - ui_dark.c + ui_theme.c ui_wizard.c + ui_flyout.h ui_int.h ui_res.h - ui_dark.h + ui_theme.h ui_wizard.h ui_notify_model.h - ui_dark_model.h + ui_theme_model.h ui_wizard_model.h ui_win.h ui_wire.h @@ -80,6 +83,7 @@ sd_add_app(shelldrive-ui ui_dlg.h ui_measure.h ui_prompt_model.h + ui_taskdlg_model.h ui_profile_model.h ui_sdmount.h ui_hostkeys_model.h diff --git a/apps/shelldrive-ui/main.c b/apps/shelldrive-ui/main.c index 33812e4..7d82738 100644 --- a/apps/shelldrive-ui/main.c +++ b/apps/shelldrive-ui/main.c @@ -12,13 +12,15 @@ * "Repair data..." button, and exits when the page is closed; * --test-window is the smoke test of tests/ui: it * creates the main window hidden, checks the window class and its controls, and exits 0; --test-prompts - * creates every prompt dialog hidden and destroys it (exit 0 when all were created). + * creates every prompt dialog hidden and destroys it (exit 0 when all were created); --test-shot= + * shows the main window with fixture rows and writes a screen capture of it (06 §8.2). * * --prompt-host (03 §1.4, §2.5 step 3): the agent starts this process when a prompt has nobody to answer it. * It shows the tray icon and the prompt dialogs only, never starts an agent, leaves when the agent goes away * and after 60 s with no open prompt. A prompt-host process that finds a running UI forwards and exits. */ #include #include +#include #include #include #include @@ -30,10 +32,11 @@ #include "sd_l10n_ui_ids.h" #include "sd_platform/sd_log.h" #include "sd_platform/sd_platform.h" -#include "ui_dark.h" +#include "ui_theme.h" #include "sd_platform/sd_time.h" #include "ui_dpi.h" #include "ui_evhist_model.h" +#include "ui_flyout.h" #include "ui_wizard.h" #include "sd_config/sd_config.h" #include "ui_admin_model.h" @@ -166,7 +169,7 @@ static void handle_mount_file(const wchar_t *path) if (e == SD_OK && ui_profiles_load() == SD_OK) p = ui_profiles_find_id(id); if (e != SD_OK || p == NULL) { - MessageBoxW(g_app.main, sd_l10n_get(SD_MSG_UI_MOUNT_FILE_INVALID), L"ShellDrive", MB_OK | MB_ICONWARNING); + (void)ui_win_task(g_app.main, sd_l10n_get(SD_MSG_UI_MOUNT_FILE_INVALID), NULL, NULL, MB_OK | MB_ICONWARNING); return; } if (ui_file_has_mark(path)) { @@ -590,11 +593,11 @@ void ui_app_on_reply(ui_ipc_reply *r) SD_LOGI("ui", "prompt answer not accepted by the agent (%s)", sd_err_name(r->err)); } else if (r->kind == UI_IPC_MOUNT && r->err != SD_OK) { /* a refused or failed mount: the mount state events say what happened; this says the request did not start */ - MessageBoxW(g_app.main, sd_l10n_get(SD_MSG_UI_ERROR_MOUNT), L"ShellDrive", MB_OK | MB_ICONERROR); + (void)ui_win_task(g_app.main, sd_l10n_get(SD_MSG_UI_ERROR_MOUNT), NULL, NULL, MB_OK | MB_ICONERROR); } else if (r->kind == UI_IPC_UNMOUNT && r->err != SD_OK) { wchar_t *t = ui_format1(SD_MSG_UI_ERROR_UNMOUNT, "drive", r->drive); if (t != NULL) { - MessageBoxW(g_app.main, t, L"ShellDrive", MB_OK | MB_ICONERROR); + (void)ui_win_task(g_app.main, t, NULL, NULL, MB_OK | MB_ICONERROR); sd_free(t); } } @@ -737,25 +740,81 @@ static int forward_to_running(int argc, wchar_t **argv) return ok; } +/* --theme=[-dark|-light] (06 §8.1): the UI_FORCE_TIER override of the + smoke modes, -1 when the value is not recognized. */ +static int theme_option_forced(const wchar_t *v) +{ + size_t n = wcslen(v); + int dark = 0, light = 0, f; + + if (n > 5 && wcscmp(v + n - 5, L"-dark") == 0) { + dark = 1; + n -= 5; + } else if (n > 6 && wcscmp(v + n - 6, L"-light") == 0) { + light = 1; + n -= 6; + } + if (n == 7 && wcsncmp(v, L"classic", 7) == 0) + f = UI_FORCE_TIER(UI_TIER_CLASSIC, 0, dark); + else if (n == 4 && wcsncmp(v, L"aero", 4) == 0) + f = UI_FORCE_TIER(UI_TIER_AERO, 0, dark); + else if (n == 8 && wcsncmp(v, L"fluent-", 7) == 0 && v[7] >= L'a' && v[7] <= L'c') + f = UI_FORCE_TIER(UI_TIER_FLUENT, (int)v[7], dark); + else + return -1; + return light ? (f | UI_FORCE_LIGHT_BIT) : f; +} + /* Smoke test (--test-window): the main window class is registered, the window and its controls exist, - and the window stays hidden. */ + and the window stays hidden. It prints the theme the model selected (06 §8.1). */ static int run_smoke_test(void) { WNDCLASSEXW wc; HWND w; int ok; + const ui_theme *theme; if (!ui_main_register_class(g_app.inst, g_app.class_main)) return 1; + ui_theme_init(); memset(&wc, 0, sizeof(wc)); wc.cbSize = sizeof(wc); - if (GetClassInfoExW(g_app.inst, g_app.class_main, &wc) == 0) + if (GetClassInfoExW(g_app.inst, g_app.class_main, &wc) == 0) { + ui_theme_shutdown(); return 1; + } w = ui_main_create(g_app.inst, g_app.class_main, 0); - if (w == NULL || !IsWindow(w)) + if (w == NULL || !IsWindow(w)) { + ui_theme_shutdown(); return 1; + } ok = ui_main_test_controls() && !IsWindowVisible(w); + theme = ui_theme_current(); + if (theme != NULL) + printf("theme tier=%d level=%c dark=%d backdrop=%d\n", theme->tier, + theme->level != 0 ? theme->level : '0', theme->dark, theme->backdrop); DestroyWindow(w); + ui_theme_shutdown(); + return ok ? 0 : 1; +} + +/* --test-shot= (06 §8.2): the visible main window with fixture rows, captured from the screen. */ +static int run_shot_test(const wchar_t *path) +{ + HWND w; + int ok; + + if (!ui_main_register_class(g_app.inst, g_app.class_main)) + return 1; + ui_theme_init(); + w = ui_main_create(g_app.inst, g_app.class_main, 1); + if (w == NULL || !IsWindow(w)) { + ui_theme_shutdown(); + return 1; + } + ok = ui_main_test_shot(w, path); + DestroyWindow(w); + ui_theme_shutdown(); return ok ? 0 : 1; } @@ -770,6 +829,7 @@ int WINAPI wWinMain(_In_ HINSTANCE inst, _In_opt_ HINSTANCE prev, _In_ LPWSTR cm INITCOMMONCONTROLSEX icc; HANDLE mutex = NULL; int existed = 0; + int i, test_mode, theme_forced; sd_err e; MSG msg; @@ -802,9 +862,33 @@ int WINAPI wWinMain(_In_ HINSTANCE inst, _In_opt_ HINSTANCE prev, _In_ LPWSTR cm goto done; build_class_names(); - if (argc > 1 && (wcscmp(argv[1], L"--test-window") == 0 || wcscmp(argv[1], L"--test-prompts") == 0 || - wcscmp(argv[1], L"--test-editor") == 0 || wcscmp(argv[1], L"--test-windows") == 0 || - wcscmp(argv[1], L"--test-wizard") == 0)) { + test_mode = argc > 1 && (wcscmp(argv[1], L"--test-window") == 0 || wcscmp(argv[1], L"--test-prompts") == 0 || + wcscmp(argv[1], L"--test-editor") == 0 || wcscmp(argv[1], L"--test-windows") == 0 || + wcscmp(argv[1], L"--test-wizard") == 0 || wcscmp(argv[1], L"--test-flyout") == 0 || + wcsncmp(argv[1], L"--test-shot=", 12) == 0); + + /* --theme=<...> (06 §8.1) forces a tier for the smoke runs; it is refused outside the test modes. */ + theme_forced = -1; + for (i = 2; i < argc; i++) { + if (wcsncmp(argv[i], L"--theme=", 8) == 0) + break; + } + if (i < argc && !test_mode) { + SD_LOGW("ui", "--theme is only valid together with the --test-* smoke modes"); + rc = 2; + goto done; + } + if (i < argc) { + theme_forced = theme_option_forced(argv[i] + 8); + if (theme_forced < 0) { + SD_LOGW("ui", "--theme value not recognized"); + rc = 2; + goto done; + } + } + ui_settings_theme_options(&g_app.settings, theme_forced); /* [ui] style, dark_helpers and their policies */ + + if (test_mode) { /* the smoke tests never read or write the user's profiles: the store is a temporary folder that only this process uses (PID and stamp in the name, removed at exit), so concurrent runs never share it */ wchar_t tmp[MAX_PATH]; @@ -820,20 +904,39 @@ int WINAPI wWinMain(_In_ HINSTANCE inst, _In_opt_ HINSTANCE prev, _In_ LPWSTR cm rc = run_smoke_test(); goto done; } + if (argc > 1 && wcsncmp(argv[1], L"--test-shot=", 12) == 0) { + rc = argv[1][12] != 0 ? run_shot_test(argv[1] + 12) : 2; + goto done; + } if (argc > 1 && wcscmp(argv[1], L"--test-prompts") == 0) { + ui_theme_init(); /* --theme=<...> picks the main instruction style and the command links */ rc = ui_prompt_smoke_test() ? 0 : 1; + ui_theme_shutdown(); goto done; } if (argc > 1 && wcscmp(argv[1], L"--test-editor") == 0) { + ui_theme_init(); rc = ui_editor_smoke_test() ? 0 : 1; + ui_theme_shutdown(); goto done; } if (argc > 1 && wcscmp(argv[1], L"--test-windows") == 0) { + ui_theme_init(); rc = ui_windows_smoke_test() ? 0 : 1; + ui_theme_shutdown(); goto done; } if (argc > 1 && wcscmp(argv[1], L"--test-wizard") == 0) { + ui_theme_init(); /* --theme=<...> picks the Wizard97 or Aero path and the dark controls */ rc = ui_wizard_smoke_test() ? 0 : 1; + ui_theme_shutdown(); + goto done; + } + if (argc > 1 && wcscmp(argv[1], L"--test-flyout") == 0) { + /* the tray flyout of Windows 11 (06 §6.3): created hidden, header and both buttons checked, destroyed */ + ui_theme_init(); + rc = ui_flyout_smoke_test() ? 0 : 1; + ui_theme_shutdown(); goto done; } @@ -860,8 +963,8 @@ int WINAPI wWinMain(_In_ HINSTANCE inst, _In_opt_ HINSTANCE prev, _In_ LPWSTR cm } } - /* dark title bars (7.4): the hook is installed before the first window of this process exists */ - ui_dark_init(); + /* the theme (06 §4): the hook is installed before the first window of this process exists */ + ui_theme_init(); if (!register_ctrl_class(inst) || !ui_main_register_class(inst, g_app.class_main)) goto done; g_app.ctrl = CreateWindowExW(0, g_app.class_ctrl, L"", 0, 0, 0, 0, 0, HWND_MESSAGE, NULL, inst, NULL); @@ -898,6 +1001,8 @@ int WINAPI wWinMain(_In_ HINSTANCE inst, _In_opt_ HINSTANCE prev, _In_ LPWSTR cm while (GetMessageW(&msg, NULL, 0, 0) > 0) { if (ui_prompt_filter(&msg)) continue; /* IsDialogMessage: keyboard handling of the prompt dialogs */ + if (ui_flyout_filter(&msg)) + continue; /* the tray flyout of Windows 11 (06 §6.3): Tab, Escape, Enter */ TranslateMessage(&msg); DispatchMessageW(&msg); } @@ -911,7 +1016,7 @@ int WINAPI wWinMain(_In_ HINSTANCE inst, _In_opt_ HINSTANCE prev, _In_ LPWSTR cm DestroyWindow(g_app.main); if (g_app.ctrl != NULL && IsWindow(g_app.ctrl)) DestroyWindow(g_app.ctrl); - ui_dark_shutdown(); + ui_theme_shutdown(); ui_icon_free_all(); done: diff --git a/apps/shelldrive-ui/ui_about.c b/apps/shelldrive-ui/ui_about.c index ba11228..5137ffe 100644 --- a/apps/shelldrive-ui/ui_about.c +++ b/apps/shelldrive-ui/ui_about.c @@ -15,6 +15,7 @@ #include "ui_dpi.h" #include "ui_int.h" #include "ui_res.h" +#include "ui_theme.h" #include "ui_win.h" #define AB_ID_ICON 4501 @@ -124,15 +125,8 @@ static void on_command(WORD id, WORD code) static void make_fonts(unsigned dpi) { - LOGFONTW lf; - HFONT base = ui_win_font(dpi); - if (base == NULL) - return; - memset(&lf, 0, sizeof(lf)); - GetObjectW(base, sizeof(lf), &lf); - lf.lfWeight = FW_BOLD; - g_ab.font = base; - g_ab.bold = CreateFontIndirectW(&lf); + g_ab.font = ui_win_font(dpi); + g_ab.bold = ui_theme_font(UI_FONT_HEADER, dpi); /* the product name: the header role of the tier */ } static void on_dpi(HWND hwnd, unsigned dpi, const RECT *suggested) @@ -164,6 +158,7 @@ static LRESULT CALLBACK ab_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) g_ab.dpi = ui_dpi_window(hwnd); make_fonts(g_ab.dpi); create_children(hwnd); + ui_theme_apply_dialog(hwnd); layout(hwnd); return 0; case WM_SIZE: diff --git a/apps/shelldrive-ui/ui_dark.c b/apps/shelldrive-ui/ui_dark.c deleted file mode 100644 index 6e32831..0000000 --- a/apps/shelldrive-ui/ui_dark.c +++ /dev/null @@ -1,87 +0,0 @@ -/* shelldrive-ui: dark mode of the title bars (03 §7.4). See ui_dark.h. - * - * Only documented behaviour is used: DWMWA_USE_IMMERSIVE_DARK_MODE on build 22000 and later, read from the - * per-user theme value AppsUseLightTheme. The undocumented uxtheme ordinals are not used (7.4). */ -#include - -#include "sd_platform/sd_platform.h" -#include "ui_dark.h" -#include "ui_dark_model.h" - -typedef HRESULT (WINAPI *ui_pfn_DwmSetWindowAttribute)(HWND hwnd, DWORD attribute, LPCVOID value, DWORD size); - -static ui_pfn_DwmSetWindowAttribute g_dwm_set; -static HHOOK g_hook; -static int g_dark; - -static int read_apps_use_light(void) -{ - HKEY key; - DWORD value = 0, type = 0, cb = sizeof(value); - LONG r; - if (RegOpenKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", 0, - KEY_QUERY_VALUE, &key) != ERROR_SUCCESS) - return -1; - r = RegQueryValueExW(key, L"AppsUseLightTheme", NULL, &type, (LPBYTE)&value, &cb); - RegCloseKey(key); - if (r != ERROR_SUCCESS || type != REG_DWORD || cb != sizeof(value)) - return -1; - return value ? 1 : 0; -} - -static void apply_one(HWND hwnd, int dark) -{ - BOOL on = dark ? TRUE : FALSE; - if (g_dwm_set != NULL && hwnd != NULL) - g_dwm_set(hwnd, UI_DARK_ATTRIBUTE, &on, sizeof(on)); -} - -static int is_captioned(HWND hwnd) -{ - return (GetWindowLongW(hwnd, GWL_STYLE) & WS_CAPTION) == WS_CAPTION; -} - -static BOOL CALLBACK enum_cb(HWND hwnd, LPARAM dark) -{ - if (is_captioned(hwnd)) - apply_one(hwnd, (int)dark); - return TRUE; -} - -/* A window that is activated for the first time gets the attribute before it is seen in its own mode. */ -static LRESULT CALLBACK cbt_proc(int code, WPARAM wp, LPARAM lp) -{ - if (code == HCBT_ACTIVATE && g_dark && wp != 0 && is_captioned((HWND)wp)) - apply_one((HWND)wp, 1); - return CallNextHookEx(g_hook, code, wp, lp); -} - -void ui_dark_refresh(void) -{ - const sd_os_version *v = sd_os_version_get(); - int want = ui_dark_wanted(v ? v->build : 0, g_dwm_set != NULL, read_apps_use_light()); - g_dark = want; - EnumThreadWindows(GetCurrentThreadId(), enum_cb, (LPARAM)want); -} - -void ui_dark_init(void) -{ - g_dwm_set = (ui_pfn_DwmSetWindowAttribute)sd_platform_resolve_by_name(L"dwmapi.dll", "DwmSetWindowAttribute"); - if (g_dwm_set != NULL && sd_os_at_least(10, 0, UI_DARK_MIN_BUILD) && g_hook == NULL) - g_hook = SetWindowsHookExW(WH_CBT, cbt_proc, NULL, GetCurrentThreadId()); - ui_dark_refresh(); -} - -void ui_dark_shutdown(void) -{ - if (g_hook != NULL) - UnhookWindowsHookEx(g_hook); - g_hook = NULL; - g_dwm_set = NULL; - g_dark = 0; -} - -int ui_dark_active(void) -{ - return g_dark; -} diff --git a/apps/shelldrive-ui/ui_dark.h b/apps/shelldrive-ui/ui_dark.h deleted file mode 100644 index 7856403..0000000 --- a/apps/shelldrive-ui/ui_dark.h +++ /dev/null @@ -1,17 +0,0 @@ -/* shelldrive-ui: dark mode of the title bars (03 §7.4). The decision is ui_dark_model.c (pure). - * - * ui_dark_init resolves DwmSetWindowAttribute at run time (sd_platform_resolve_by_name, never imported) and - * installs a thread WH_CBT hook, so every window of this thread gets the title bar attribute when it is activated. - * ui_dark_refresh re-reads the app theme and applies it to every captioned window of this thread; the main window - * calls it on WM_SETTINGCHANGE "ImmersiveColorSet". Only the UI thread calls these functions. */ -#ifndef SD_UI_DARK_H -#define SD_UI_DARK_H - -void ui_dark_init(void); -void ui_dark_shutdown(void); -/* Re-reads AppsUseLightTheme and applies the decision to the windows of this thread. */ -void ui_dark_refresh(void); -/* 1 when the last decision was dark. */ -int ui_dark_active(void); - -#endif /* SD_UI_DARK_H */ diff --git a/apps/shelldrive-ui/ui_dark_model.c b/apps/shelldrive-ui/ui_dark_model.c deleted file mode 100644 index 14718d0..0000000 --- a/apps/shelldrive-ui/ui_dark_model.c +++ /dev/null @@ -1,11 +0,0 @@ -/* shelldrive-ui: the dark mode decision (03 §7.4). See ui_dark_model.h. */ -#include - -#include "ui_dark_model.h" - -int ui_dark_wanted(unsigned os_build, int dwm_present, int apps_use_light) -{ - if (os_build < UI_DARK_MIN_BUILD || !dwm_present) - return 0; /* XP, 7, 8, 8.1, 10: stay light (03 §7.4) */ - return apps_use_light == 0 ? 1 : 0; /* -1 (absent) and 1 (light apps) are light */ -} diff --git a/apps/shelldrive-ui/ui_dark_model.h b/apps/shelldrive-ui/ui_dark_model.h deleted file mode 100644 index 4c77c80..0000000 --- a/apps/shelldrive-ui/ui_dark_model.h +++ /dev/null @@ -1,21 +0,0 @@ -/* shelldrive-ui: the dark mode decision (03 §7.4). - * - * v1 follows the system only where Windows documents it: the title bar of our windows uses the immersive dark - * attribute (DWMWA_USE_IMMERSIVE_DARK_MODE) on Windows 11 build 22000 and later, when the user chose the dark app - * theme. Everything else stays light; client areas use system colors, so High Contrast renders correctly. - * - * Pure: no window or process calls, so the unit tests (tests/ui) build this file on its own. */ -#ifndef SD_UI_DARK_MODEL_H -#define SD_UI_DARK_MODEL_H - -#include - -#define UI_DARK_MIN_BUILD 22000u /* Windows 11: DWMWA_USE_IMMERSIVE_DARK_MODE is documented from here */ -#define UI_DARK_ATTRIBUTE 20 /* DWMWA_USE_IMMERSIVE_DARK_MODE */ - -/* 1 when the title bars must use the dark attribute. os_build: the OS build number (0 when unknown). - dwm_present: DwmSetWindowAttribute was resolved at run time. apps_use_light: AppsUseLightTheme under - HKCU\...\Themes\Personalize, 0 (dark apps), 1 (light apps), or -1 when the value is absent (light). */ -int ui_dark_wanted(unsigned os_build, int dwm_present, int apps_use_light); - -#endif /* SD_UI_DARK_MODEL_H */ diff --git a/apps/shelldrive-ui/ui_dlg.c b/apps/shelldrive-ui/ui_dlg.c index ad48f72..4cc0524 100644 --- a/apps/shelldrive-ui/ui_dlg.c +++ b/apps/shelldrive-ui/ui_dlg.c @@ -121,42 +121,99 @@ static void set_text(ui_dlg_item *it, const wchar_t *text) wcsncpy_s(it->text, SD_COUNTOF(it->text), text ? text : L"", _TRUNCATE); } +/* The horizontal span of the next content: indented to the right of the icon while the cursor is above its bottom. */ +static void span(const ui_dlg *d, int *x, int *w) +{ + int left = (d->icon_bottom > 0 && d->cursor < d->icon_bottom) ? d->icon_left : d->margin; + *x = left; + *w = d->width - left - d->margin; +} + static int text_height(const ui_dlg *d, const wchar_t *text) { - int h = d->measure(d->measure_ctx, text ? text : L"", d->width - 2 * d->margin, NULL); + int x, w, h; + span(d, &x, &w); + h = d->measure(d->measure_ctx, text ? text : L"", w, NULL); return h < DLG_MIN_TEXT_DLU ? DLG_MIN_TEXT_DLU : h; } sd_err ui_dlg_text(ui_dlg *d, WORD id, const wchar_t *text, DWORD extra) { ui_dlg_item *it; - int h; + int h, sx, sw; if (d == NULL || (it = next_item(d)) == NULL) return SD_E_INVALID_PARAM; h = text_height(d, text) + DLG_TEXT_PAD; it->cls = UI_DLG_STATIC; it->id = id; it->style = WS_CHILD | WS_VISIBLE | SS_LEFT | extra; - it->x = (short)d->margin; + span(d, &sx, &sw); + it->x = (short)sx; it->y = (short)d->cursor; - it->cx = (short)(d->width - 2 * d->margin); + it->cx = (short)sw; it->cy = (short)h; set_text(it, text); d->cursor += h + UI_DLG_GAP; return SD_OK; } +sd_err ui_dlg_heading(ui_dlg *d, WORD id, const wchar_t *text, DWORD extra) +{ + ui_dlg_item *it; + int sx, sw, pct, h, min_h; + if (d == NULL || (it = next_item(d)) == NULL) + return SD_E_INVALID_PARAM; + pct = d->heading_pct > 100 ? d->heading_pct : 100; + span(d, &sx, &sw); + /* a font pct percent larger wraps as the dialog font does in a column pct percent narrower */ + h = d->measure(d->measure_ctx, text ? text : L"", sw * 100 / pct, NULL); + h = (h * pct + 99) / 100; + min_h = DLG_MIN_TEXT_DLU * pct / 100; + if (h < min_h) + h = min_h; + h += DLG_TEXT_PAD; + it->cls = UI_DLG_STATIC; + it->id = id; + it->style = WS_CHILD | WS_VISIBLE | SS_LEFT | extra; + it->x = (short)sx; + it->y = (short)d->cursor; + it->cx = (short)sw; + it->cy = (short)h; + set_text(it, text); + d->cursor += h + UI_DLG_GAP; + return SD_OK; +} + +sd_err ui_dlg_icon(ui_dlg *d, WORD id) +{ + ui_dlg_item *it; + if (d == NULL || (it = next_item(d)) == NULL) + return SD_E_INVALID_PARAM; + it->cls = UI_DLG_STATIC; + it->id = id; + it->style = WS_CHILD | WS_VISIBLE | SS_ICON | SS_CENTERIMAGE; + it->x = (short)d->margin; + it->y = (short)d->cursor; + it->cx = UI_DLG_ICON_CX; + it->cy = UI_DLG_ICON_CY; + d->icon_left = d->margin + UI_DLG_ICON_CX + UI_DLG_ICON_GAP; + d->icon_bottom = d->cursor + UI_DLG_ICON_CY; + return SD_OK; +} + sd_err ui_dlg_edit(ui_dlg *d, WORD id, DWORD extra) { ui_dlg_item *it; + int sx, sw; if (d == NULL || (it = next_item(d)) == NULL) return SD_E_INVALID_PARAM; it->cls = UI_DLG_EDIT; it->id = id; it->style = WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL | ES_LEFT | extra; - it->x = (short)d->margin; + span(d, &sx, &sw); + it->x = (short)sx; it->y = (short)d->cursor; - it->cx = (short)(d->width - 2 * d->margin); + it->cx = (short)sw; it->cy = UI_DLG_EDIT_HEIGHT; d->cursor += UI_DLG_EDIT_HEIGHT + UI_DLG_GAP; return SD_OK; @@ -165,7 +222,7 @@ sd_err ui_dlg_edit(ui_dlg *d, WORD id, DWORD extra) sd_err ui_dlg_multiline(ui_dlg *d, WORD id, const wchar_t *text, int max_lines) { ui_dlg_item *it; - int h; + int h, sx, sw; if (d == NULL || max_lines < 1 || (it = next_item(d)) == NULL) return SD_E_INVALID_PARAM; h = text_height(d, text) + DLG_TEXT_PAD; @@ -177,9 +234,10 @@ sd_err ui_dlg_multiline(ui_dlg *d, WORD id, const wchar_t *text, int max_lines) it->id = id; it->style = WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY | ES_LEFT; - it->x = (short)d->margin; + span(d, &sx, &sw); + it->x = (short)sx; it->y = (short)d->cursor; - it->cx = (short)(d->width - 2 * d->margin); + it->cx = (short)sw; it->cy = (short)h; set_text(it, text); d->cursor += h + UI_DLG_GAP; @@ -189,14 +247,16 @@ sd_err ui_dlg_multiline(ui_dlg *d, WORD id, const wchar_t *text, int max_lines) sd_err ui_dlg_combo(ui_dlg *d, WORD id) { ui_dlg_item *it; + int sx, sw; if (d == NULL || (it = next_item(d)) == NULL) return SD_E_INVALID_PARAM; it->cls = UI_DLG_COMBO; it->id = id; it->style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_VSCROLL | CBS_DROPDOWNLIST | CBS_HASSTRINGS | CBS_AUTOHSCROLL; - it->x = (short)d->margin; + span(d, &sx, &sw); + it->x = (short)sx; it->y = (short)d->cursor; - it->cx = (short)(d->width - 2 * d->margin); + it->cx = (short)sw; it->cy = UI_DLG_EDIT_HEIGHT; d->cursor += UI_DLG_EDIT_HEIGHT + UI_DLG_GAP; return SD_OK; @@ -205,7 +265,7 @@ sd_err ui_dlg_combo(ui_dlg *d, WORD id) sd_err ui_dlg_check(ui_dlg *d, WORD id, const wchar_t *text) { ui_dlg_item *it; - int h; + int h, sx, sw; if (d == NULL || (it = next_item(d)) == NULL) return SD_E_INVALID_PARAM; h = text_height(d, text); @@ -214,9 +274,10 @@ sd_err ui_dlg_check(ui_dlg *d, WORD id, const wchar_t *text) it->cls = UI_DLG_CHECK; it->id = id; it->style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX | BS_MULTILINE | BS_LEFT; - it->x = (short)d->margin; + span(d, &sx, &sw); + it->x = (short)sx; it->y = (short)d->cursor; - it->cx = (short)(d->width - 2 * d->margin); + it->cx = (short)sw; it->cy = (short)h; set_text(it, text); d->cursor += h + UI_DLG_GAP; @@ -278,6 +339,47 @@ sd_err ui_dlg_buttons(ui_dlg *d, const WORD *ids, const wchar_t *const *texts, s return SD_OK; } +sd_err ui_dlg_commands(ui_dlg *d, const WORD *ids, const wchar_t *const *texts, const wchar_t *const *notes, + size_t n, WORD def_id) +{ + size_t i; + int sx, sw; + if (d == NULL || ids == NULL || texts == NULL || n == 0 || n > UI_DLG_MAX_ITEMS) + return SD_E_INVALID_PARAM; + if (!d->command_links) + return ui_dlg_buttons(d, ids, texts, n, def_id); + if (d->count + n > UI_DLG_MAX_ITEMS) + return SD_E_INVALID_PARAM; + for (i = 0; i < n; i++) { + ui_dlg_item *it = next_item(d); + int th, nh = 0, h; + if (it == NULL) + return SD_E_INVALID_PARAM; + span(d, &sx, &sw); + th = d->measure(d->measure_ctx, texts[i], sw - UI_DLG_LINK_INDENT, NULL); + if (th < DLG_MIN_TEXT_DLU) + th = DLG_MIN_TEXT_DLU; + th = th * 12 / 10; /* the main text of a command link is one size larger */ + if (notes != NULL && notes[i] != NULL && notes[i][0] != L'\0') { + nh = d->measure(d->measure_ctx, notes[i], sw - UI_DLG_LINK_INDENT, NULL); + if (nh < DLG_MIN_TEXT_DLU) + nh = DLG_MIN_TEXT_DLU; + } + h = th + nh + UI_DLG_LINK_PAD; + it->cls = UI_DLG_BUTTON; + it->id = ids[i]; + it->style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | + ((def_id != 0 && ids[i] == def_id) ? BS_DEFCOMMANDLINK : BS_COMMANDLINK); + it->x = (short)sx; + it->y = (short)d->cursor; + it->cx = (short)sw; + it->cy = (short)h; + set_text(it, texts[i]); + d->cursor += h + UI_DLG_GAP; + } + return SD_OK; +} + sd_err ui_dlg_build(ui_dlg *d, unsigned char **out, size_t *out_bytes) { dbuf b; diff --git a/apps/shelldrive-ui/ui_dlg.h b/apps/shelldrive-ui/ui_dlg.h index 8f8ed08..2b6f633 100644 --- a/apps/shelldrive-ui/ui_dlg.h +++ b/apps/shelldrive-ui/ui_dlg.h @@ -15,6 +15,12 @@ #include "sd/base.h" #include "sd_base/sd_err.h" +/* Vista and later (commctrl.h declares them only for those targets). */ +#ifndef BS_COMMANDLINK +#define BS_COMMANDLINK 0x0000000EL +#define BS_DEFCOMMANDLINK 0x0000000FL +#endif + #define UI_DLG_MAX_ITEMS 40 #define UI_DLG_TEXT_MAX 512 /* UTF-16 units of one control text, no terminator */ #define UI_DLG_TITLE_MAX 256 @@ -48,6 +54,9 @@ typedef struct ui_dlg { int cursor; /* next free y, dialog units */ int margin; /* left, right and bottom margin */ int min_height; /* dialog units: the height is at least this (pages of one sheet) */ + int heading_pct; /* size of the main instruction font relative to the dialog font, percent (0 = 100) */ + int command_links; /* 1: ui_dlg_commands builds BS_COMMANDLINK buttons, 0: plain push buttons */ + int icon_left, icon_bottom; /* the icon column of ui_dlg_icon: content above icon_bottom is indented to icon_left */ DWORD style; /* dialog style, DS_SETFONT is added by ui_dlg_build */ ui_dlg_measure_fn measure; void *measure_ctx; @@ -62,6 +71,22 @@ sd_err ui_dlg_init(ui_dlg *d, const wchar_t *title, int width_dlu, const wchar_t /* Static text, full width, wrapped, measured. extra: SS_* style bits. */ sd_err ui_dlg_text(ui_dlg *d, WORD id, const wchar_t *text, DWORD extra); +/* The main instruction: static text like ui_dlg_text, laid out for a font heading_pct percent of the dialog font + (the page sets the font and color at run time: ui_prompt.c). The measuring is scaled, so a larger font still + fits its lines. */ +sd_err ui_dlg_heading(ui_dlg *d, WORD id, const wchar_t *text, DWORD extra); + +/* A 32 x 32 pixel icon static (SS_ICON) at the left of the next content; the page sets the icon with STM_SETICON. + The text controls added while the cursor is above the icon's bottom are indented to its right. */ +sd_err ui_dlg_icon(ui_dlg *d, WORD id); + +/* Command links (Vista and later, command_links = 1): one BS_COMMANDLINK push button per choice, full width, + stacked, each tall enough for its text and its note (notes[i] may be NULL; the page sets it with BCM_SETNOTE and + re-measures with BCM_GETIDEALSIZE). With command_links = 0 these are the plain buttons of ui_dlg_buttons with + the same texts and the notes are not shown. def_id: the default button (0 = none: trust decisions, TM-R03). */ +sd_err ui_dlg_commands(ui_dlg *d, const WORD *ids, const wchar_t *const *texts, const wchar_t *const *notes, + size_t n, WORD def_id); + /* Single-line edit, full width. extra: ES_* bits (ES_PASSWORD, ES_READONLY). */ sd_err ui_dlg_edit(ui_dlg *d, WORD id, DWORD extra); @@ -95,5 +120,10 @@ sd_err ui_dlg_parse_check(const unsigned char *blob, size_t bytes, size_t *count #define UI_DLG_CHECK_HEIGHT 10 #define UI_DLG_BUTTON_HEIGHT 14 #define UI_DLG_GAP 4 +#define UI_DLG_ICON_CX 22 /* 32 pixels at 96 DPI with a 9 pt font, in dialog units (x) */ +#define UI_DLG_ICON_CY 18 +#define UI_DLG_ICON_GAP 6 +#define UI_DLG_LINK_PAD 8 /* top and bottom padding of a command link, dialog units */ +#define UI_DLG_LINK_INDENT 14 /* left of a command link text (the arrow), dialog units */ #endif /* SD_UI_DLG_H */ diff --git a/apps/shelldrive-ui/ui_editor.c b/apps/shelldrive-ui/ui_editor.c index db2d793..c503f67 100644 --- a/apps/shelldrive-ui/ui_editor.c +++ b/apps/shelldrive-ui/ui_editor.c @@ -35,6 +35,7 @@ #include "ui_int.h" #include "ui_measure.h" #include "ui_profile_model.h" +#include "ui_theme.h" #include "ui_win.h" #define ED_WIDTH 300 /* dialog units; every page of the sheet has this width */ @@ -648,6 +649,7 @@ static INT_PTR CALLBACK page_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) pg->hwnd = hwnd; write_page(pg->ed, pg->index, hwnd); page_scroll_setup(pg, hwnd); + ui_theme_apply_dialog(hwnd); return TRUE; } case WM_NOTIFY: { @@ -980,7 +982,7 @@ int ui_editor_run(HWND owner, ui_edit_mode mode, sd_profile *p) memset(&hdr, 0, sizeof(hdr)); hdr.dwSize = sizeof(hdr); hdr.dwFlags = PSH_PROPSHEETPAGE | PSH_NOAPPLYNOW | PSH_NOCONTEXTHELP | PSH_USECALLBACK; - hdr.pfnCallback = ui_win_sheet_callback; + hdr.pfnCallback = ui_theme_sheet_callback; hdr.hwndParent = owner; hdr.hInstance = g_app.inst; hdr.pszCaption = sd_l10n_get(mode == UI_EDIT_NEW ? SD_MSG_UI_EDITOR_CAPTION_NEW @@ -1007,6 +1009,7 @@ static INT_PTR CALLBACK confirm_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) (void)lp; switch (msg) { case WM_INITDIALOG: + ui_theme_apply_dialog(hwnd); return TRUE; case WM_COMMAND: if (LOWORD(wp) == IDOK || LOWORD(wp) == IDCANCEL) { diff --git a/apps/shelldrive-ui/ui_events.c b/apps/shelldrive-ui/ui_events.c index bbc193d..476d48e 100644 --- a/apps/shelldrive-ui/ui_events.c +++ b/apps/shelldrive-ui/ui_events.c @@ -17,6 +17,7 @@ #include "ui_dpi.h" #include "ui_events_model.h" #include "ui_int.h" +#include "ui_theme.h" #include "ui_win.h" #define EV_ID_LIST 4401 @@ -294,6 +295,7 @@ static LRESULT CALLBACK ev_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) g_ev.dpi = ui_dpi_window(hwnd); g_ev.font = ui_win_font(g_ev.dpi); create_children(hwnd); + ui_theme_apply_dialog(hwnd); layout(hwnd); refresh_view(); return 0; diff --git a/apps/shelldrive-ui/ui_flyout.c b/apps/shelldrive-ui/ui_flyout.c new file mode 100644 index 0000000..8504f9d --- /dev/null +++ b/apps/shelldrive-ui/ui_flyout.c @@ -0,0 +1,636 @@ +/* shelldrive-ui: the Windows 11 tray flyout (06 §6.3, D47). See ui_flyout.h. + * + * A WS_POPUP | WS_BORDER tool window above the tray icon: a header, the connected-drives line the tray tooltip uses, + * one check box per profile (checked = mounted; a click mounts or unmounts through the calls of the main window and + * the tray menu), and the buttons "Open ShellDrive" (accent) and "Settings". It closes when it loses activation or on + * Escape. The strings are the tray's and the menu's; the colors come from the palette (ui_theme_color). + * + * Shell_NotifyIconGetRect (Windows 7) is resolved at run time, so the XP import floor stays empty. */ +#include +#include +#include +#include + +#include "sd_base/sd_mem.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_l10n_ui_ids.h" +#include "sd_platform/sd_platform.h" +#include "sd_platform/sd_time.h" +#include "ui_dpi.h" +#include "ui_flyout.h" +#include "ui_int.h" +#include "ui_mainview_model.h" +#include "ui_theme.h" +#include "ui_win.h" + + +/* The layout in 96-DPI units. */ +#define FLY_WIDTH 320 +#define FLY_PAD 16 +#define FLY_TOP 14 +#define FLY_HEADER_H 24 +#define FLY_SUB_H 18 +#define FLY_GAP 10 +#define FLY_ROW_H 26 +#define FLY_STATUS_H 16 +#define FLY_STATUS_INDENT 22 /* the status line sits under the text of the check box */ +#define FLY_BUTTON_H 32 +#define FLY_BUTTON_GAP 8 +#define FLY_BOTTOM 14 +#define FLY_SCREEN_GAP 12 +#define FLY_MAX_ROWS 12 +#define FLY_REOPEN_MS 400 /* a click on the icon that deactivated the flyout must not reopen it */ + +#define FLY_ID_HEADER 7003 +#define FLY_ID_SUB 7004 +#define FLY_ID_EMPTY 7005 +#define FLY_ID_OPEN 7001 +#define FLY_ID_SETTINGS 7002 +#define FLY_ID_ROW 7100 /* + row */ +#define FLY_ID_STATUS 7200 /* + row */ + +#define UI_FLYOUT_TRAY_ID 1u /* the uID of the icon (ui_tray.c UI_TRAY_ID) */ + +/* NOTIFYICONIDENTIFIER of Windows 7, spelled out for the XP SDK. */ +typedef struct fly_nii { + DWORD cbSize; + HWND hWnd; + UINT uID; + GUID guidItem; +} fly_nii; +typedef HRESULT (WINAPI *fly_pfn_GetRect)(const fly_nii *id, RECT *rect); + +typedef struct fly_row { + HWND check; + HWND status; + char profile_id[33]; + wchar_t drive[3]; /* the drive of the mount, "" when not mounted */ + int mounted; +} fly_row; + +typedef struct fly_state { + HWND hwnd; + HWND header, sub, empty, open, settings; + HFONT font, bold; + unsigned dpi; + int rows; + fly_row row[FLY_MAX_ROWS]; +} fly_state; + +static fly_state g_fl; +static int g_closing; +static uint64_t g_closed_tick; +static int g_class_ok; + +#define S(v) ui_dpi_scale((v), g_fl.dpi) + +int ui_flyout_wanted(void) +{ + const ui_theme *t = ui_theme_current(); + return t != NULL && t->tier == UI_TIER_FLUENT && (t->level == UI_LEVEL_B || t->level == UI_LEVEL_C); +} + +/* ---- the rows ---- */ + +/* The mount of a profile: the first item of the model that came from it (NULL when not mounted). */ +static const ui_mount *mount_of(const char *profile_id) +{ + size_t i; + if (profile_id == NULL || profile_id[0] == 0) + return NULL; + for (i = 0; i < g_app.model.count; i++) + if (strcmp(g_app.model.items[i].profile_id, profile_id) == 0) + return &g_app.model.items[i]; + return NULL; +} + +/* The text of one row: the name, or ui.flyout.row_label (" ()") with the drive of the mount or the one the profile asks for. */ +static void row_text(const sd_profile *p, const ui_mount *m, wchar_t *out, size_t cch) +{ + wchar_t name[160], drive[4]; + + ui_text_utf8(p->name, name, SD_COUNTOF(name)); + drive[0] = 0; + if (m != NULL && m->drive[0] != 0) { + wcscpy_s(drive, SD_COUNTOF(drive), m->drive); + } else if (p->target[0] != 0 && p->target[1] == ':' && p->target[2] == 0) { + drive[0] = (wchar_t)(unsigned char)p->target[0]; + drive[1] = L':'; + drive[2] = 0; + } + if (drive[0] != 0) { + sd_arg a[2]; + a[0].name = "name"; + a[0].type = SD_ARG_STR; + a[0].v.s = name; + a[1].name = "drive"; + a[1].type = SD_ARG_STR; + a[1].v.s = drive; + (void)sd_l10n_format(out, cch, SD_MSG_UI_FLYOUT_ROW_LABEL, a, 2); + } else { + wcsncpy_s(out, cch, name, _TRUNCATE); + } +} + +static void set_text_if_changed(HWND w, const wchar_t *text) +{ + wchar_t cur[300]; + cur[0] = 0; + GetWindowTextW(w, cur, SD_COUNTOF(cur)); + if (wcscmp(cur, text) != 0) + SetWindowTextW(w, text); +} + +/* The connected-drives line of the tray tooltip. */ +static void update_sub(void) +{ + size_t n = ui_model_mounted_count(&g_app.model); + if (n == 0) { + set_text_if_changed(g_fl.sub, sd_l10n_get(SD_MSG_UI_TRAY_TIP_NONE)); + } else { + wchar_t *t = ui_format_count(SD_MSG_UI_TRAY_TIP_COUNT, (long long)n); + if (t != NULL) { + set_text_if_changed(g_fl.sub, t); + sd_free(t); + } + } +} + +/* Check state, text and status line of the existing rows from the profiles and the model. */ +static void update_rows(void) +{ + int i; + + for (i = 0; i < g_fl.rows; i++) { + const sd_profile *p = ui_profiles_at((size_t)i); + const ui_mount *m; + fly_row *r = &g_fl.row[i]; + wchar_t text[300]; + const wchar_t *status = L""; + + if (p == NULL) + continue; + m = mount_of(p->id); + r->mounted = m != NULL && ui_mv_is_mounted(m->state, m->session, m->attempt); + r->drive[0] = 0; + if (m != NULL && m->drive[0] != 0) + wcscpy_s(r->drive, SD_COUNTOF(r->drive), m->drive); + row_text(p, m, text, SD_COUNTOF(text)); + set_text_if_changed(r->check, text); + SendMessageW(r->check, BM_SETCHECK, r->mounted ? BST_CHECKED : BST_UNCHECKED, 0); + if (m != NULL && strcmp(m->state, "mounted") != 0) + status = ui_model_state_text(m); + set_text_if_changed(r->status, status); + ShowWindow(r->status, status[0] != 0 ? SW_SHOWNA : SW_HIDE); + } + update_sub(); +} + +/* ---- layout and creation ---- */ + +static HWND make_child(const wchar_t *cls, const wchar_t *text, DWORD style, int id, HFONT font) +{ + HWND w = CreateWindowExW(0, cls, text, WS_CHILD | WS_VISIBLE | style, 0, 0, 10, 10, g_fl.hwnd, + (HMENU)(INT_PTR)id, g_app.inst, NULL); + if (w != NULL) { + SendMessageW(w, WM_SETFONT, (WPARAM)font, 0); + ui_theme_apply_control(w); + } + return w; +} + +/* Positions the children and returns the client height. */ +static int layout(void) +{ + int w = S(FLY_WIDTH), pad = S(FLY_PAD), inner = w - 2 * pad, y = S(FLY_TOP), i, bw; + + MoveWindow(g_fl.header, pad, y, inner, S(FLY_HEADER_H), TRUE); + y += S(FLY_HEADER_H); + MoveWindow(g_fl.sub, pad, y, inner, S(FLY_SUB_H), TRUE); + y += S(FLY_SUB_H) + S(FLY_GAP); + if (g_fl.rows == 0) { + MoveWindow(g_fl.empty, pad, y, inner, S(FLY_SUB_H), TRUE); + y += S(FLY_SUB_H); + } + for (i = 0; i < g_fl.rows; i++) { + fly_row *r = &g_fl.row[i]; + int has_status = (GetWindowLongW(r->status, GWL_STYLE) & WS_VISIBLE) != 0; + MoveWindow(r->check, pad, y, inner, S(FLY_ROW_H), TRUE); + y += S(FLY_ROW_H); + if (has_status) { + MoveWindow(r->status, pad + S(FLY_STATUS_INDENT), y, inner - S(FLY_STATUS_INDENT), S(FLY_STATUS_H), TRUE); + y += S(FLY_STATUS_H); + } + } + y += S(FLY_GAP); + bw = (inner - S(FLY_BUTTON_GAP)) / 2; + MoveWindow(g_fl.open, pad, y, bw, S(FLY_BUTTON_H), TRUE); + MoveWindow(g_fl.settings, pad + bw + S(FLY_BUTTON_GAP), y, inner - bw - S(FLY_BUTTON_GAP), S(FLY_BUTTON_H), TRUE); + y += S(FLY_BUTTON_H) + S(FLY_BOTTOM); + return y; +} + +/* Sets the window size for a client of the given height, keeping the bottom edge where it is. */ +static void fit_window(int client_h) +{ + RECT rc, wr; + int w, h; + + rc.left = 0; + rc.top = 0; + rc.right = S(FLY_WIDTH); + rc.bottom = client_h; + AdjustWindowRectEx(&rc, (DWORD)GetWindowLongW(g_fl.hwnd, GWL_STYLE), FALSE, + (DWORD)GetWindowLongW(g_fl.hwnd, GWL_EXSTYLE)); + w = rc.right - rc.left; + h = rc.bottom - rc.top; + GetWindowRect(g_fl.hwnd, &wr); + SetWindowPos(g_fl.hwnd, NULL, wr.left, wr.bottom - h, w, h, SWP_NOZORDER | SWP_NOACTIVATE); +} + +static HFONT make_bold(HFONT base) +{ + LOGFONTW lf; + if (base == NULL || GetObjectW(base, sizeof(lf), &lf) == 0) + return NULL; + lf.lfWeight = FW_BOLD; + lf.lfHeight = lf.lfHeight - lf.lfHeight / 5; /* a fifth larger (lfHeight is negative) */ + return CreateFontIndirectW(&lf); +} + +static void free_fonts(void) +{ + if (g_fl.font != NULL) + DeleteObject(g_fl.font); + if (g_fl.bold != NULL) + DeleteObject(g_fl.bold); + g_fl.font = NULL; + g_fl.bold = NULL; +} + +/* Creates the children for the current profiles; the window exists and g_fl.dpi is set. */ +static int build_children(void) +{ + size_t n = ui_profiles_count(); + int i; + + g_fl.font = ui_win_font(g_fl.dpi); + g_fl.bold = make_bold(g_fl.font); + g_fl.header = make_child(L"STATIC", sd_l10n_get(SD_MSG_UI_NOTIFY_TITLE), SS_LEFT | SS_NOPREFIX | SS_ENDELLIPSIS, + FLY_ID_HEADER, g_fl.bold != NULL ? g_fl.bold : g_fl.font); + g_fl.sub = make_child(L"STATIC", L"", SS_LEFT | SS_NOPREFIX | SS_ENDELLIPSIS, FLY_ID_SUB, g_fl.font); + g_fl.rows = n > FLY_MAX_ROWS ? FLY_MAX_ROWS : (int)n; + g_fl.empty = NULL; + if (g_fl.rows == 0) + g_fl.empty = make_child(L"STATIC", sd_l10n_get(SD_MSG_UI_TRAY_NO_DRIVES), SS_LEFT | SS_NOPREFIX, FLY_ID_EMPTY, + g_fl.font); + for (i = 0; i < g_fl.rows; i++) { + const sd_profile *p = ui_profiles_at((size_t)i); + fly_row *r = &g_fl.row[i]; + memset(r, 0, sizeof(*r)); + if (p != NULL) + strcpy_s(r->profile_id, sizeof(r->profile_id), p->id); + r->check = make_child(L"BUTTON", L"", WS_TABSTOP | BS_CHECKBOX | BS_LEFT | BS_VCENTER, FLY_ID_ROW + i, g_fl.font); + r->status = make_child(L"STATIC", L"", SS_LEFT | SS_NOPREFIX | SS_ENDELLIPSIS, FLY_ID_STATUS + i, g_fl.font); + if (r->check == NULL || r->status == NULL) + return 0; + } + g_fl.open = make_child(L"BUTTON", sd_l10n_get(SD_MSG_UI_TRAY_OPEN), WS_TABSTOP | BS_OWNERDRAW, FLY_ID_OPEN, + g_fl.font); + g_fl.settings = make_child(L"BUTTON", sd_l10n_get(SD_MSG_UI_MENU_SETTINGS), WS_TABSTOP | BS_PUSHBUTTON, + FLY_ID_SETTINGS, g_fl.font); + if (g_fl.header == NULL || g_fl.sub == NULL || g_fl.open == NULL || g_fl.settings == NULL || + (g_fl.rows == 0 && g_fl.empty == NULL)) + return 0; + update_rows(); + return 1; +} + +/* ---- the window ---- */ + +static void close_flyout(int by_deactivation) +{ + HWND w = g_fl.hwnd; + if (w == NULL || g_closing) + return; + g_closing = 1; + if (by_deactivation) + g_closed_tick = sd_tick_ms(); + DestroyWindow(w); + g_closing = 0; +} + +void ui_flyout_close(void) +{ + close_flyout(0); +} + +static COLORREF blend(COLORREF a, COLORREF b, int pct_b) +{ + return RGB((GetRValue(a) * (100 - pct_b) + GetRValue(b) * pct_b) / 100, + (GetGValue(a) * (100 - pct_b) + GetGValue(b) * pct_b) / 100, + (GetBValue(a) * (100 - pct_b) + GetBValue(b) * pct_b) / 100); +} + +/* The accent push button "Open ShellDrive": owner drawn, since the theme has no accent button on every tier. */ +static void draw_accent_button(const DRAWITEMSTRUCT *di) +{ + COLORREF fill = ui_theme_color(UI_TOK_ACCENT); + RECT rc = di->rcItem; + POINT pt; + HBRUSH br; + HPEN pen, old_pen; + HGDIOBJ old_br, old_font; + wchar_t text[96]; + int r = S(4); + + GetCursorPos(&pt); + ScreenToClient(di->hwndItem, &pt); + if (di->itemState & ODS_SELECTED) + fill = blend(fill, RGB(0, 0, 0), 25); + else if (PtInRect(&rc, pt)) + fill = blend(fill, RGB(0, 0, 0), 10); + + FillRect(di->hDC, &rc, ui_theme_brush(UI_TOK_LAYER)); + br = CreateSolidBrush(fill); + pen = CreatePen(PS_SOLID, 1, fill); + old_br = SelectObject(di->hDC, br); + old_pen = (HPEN)SelectObject(di->hDC, pen); + RoundRect(di->hDC, rc.left, rc.top, rc.right, rc.bottom, 2 * r, 2 * r); + SelectObject(di->hDC, old_pen); + SelectObject(di->hDC, old_br); + DeleteObject(pen); + DeleteObject(br); + + GetWindowTextW(di->hwndItem, text, SD_COUNTOF(text)); + old_font = SelectObject(di->hDC, (HFONT)SendMessageW(di->hwndItem, WM_GETFONT, 0, 0)); + SetBkMode(di->hDC, TRANSPARENT); + SetTextColor(di->hDC, ui_theme_color(UI_TOK_ACCENT_TEXT)); + DrawTextW(di->hDC, text, -1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); + SelectObject(di->hDC, old_font); + if (di->itemState & ODS_FOCUS) { + RECT f = di->rcItem; + InflateRect(&f, -S(3), -S(3)); + SetTextColor(di->hDC, ui_theme_color(UI_TOK_ACCENT_TEXT)); + DrawFocusRect(di->hDC, &f); + } +} + +/* A click on a row: mounts the profile, or unmounts its drive (the calls of the main window and the tray menu). */ +static void row_clicked(int i) +{ + fly_row *r; + sd_err e; + + if (i < 0 || i >= g_fl.rows) + return; + r = &g_fl.row[i]; + if (r->mounted && r->drive[0] != 0) + e = ui_ipc_unmount(r->drive); + else + e = ui_ipc_mount(r->profile_id); + if (e == SD_OK) { + r->mounted = !r->mounted; + SendMessageW(r->check, BM_SETCHECK, r->mounted ? BST_CHECKED : BST_UNCHECKED, 0); + } else if (!r->mounted) { + close_flyout(0); + ui_win_message(NULL, sd_l10n_get(SD_MSG_UI_ERROR_MOUNT), MB_OK | MB_ICONERROR); + } +} + +static LRESULT CALLBACK fly_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + switch (msg) { + case WM_ERASEBKGND: { + RECT rc; + GetClientRect(hwnd, &rc); + FillRect((HDC)wp, &rc, ui_theme_brush(UI_TOK_LAYER)); + return 1; + } + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: { + HDC dc = (HDC)wp; + int id = GetDlgCtrlID((HWND)lp); + int secondary = id == FLY_ID_SUB || (id >= FLY_ID_STATUS && id < FLY_ID_STATUS + FLY_MAX_ROWS); + SetTextColor(dc, ui_theme_color(secondary ? UI_TOK_TEXT_SECONDARY : UI_TOK_TEXT)); + SetBkColor(dc, ui_theme_color(UI_TOK_LAYER)); + return (LRESULT)ui_theme_brush(UI_TOK_LAYER); + } + case WM_NOTIFY: { + const NMCUSTOMDRAW *cd = (const NMCUSTOMDRAW *)lp; + if (cd->hdr.code == NM_CUSTOMDRAW && cd->hdr.idFrom >= FLY_ID_ROW && cd->hdr.idFrom < FLY_ID_ROW + FLY_MAX_ROWS) { + /* the check box text in the palette's color; the theme draws the glyph */ + if (cd->dwDrawStage == CDDS_PREPAINT) + SetTextColor(cd->hdc, ui_theme_color(UI_TOK_TEXT)); + return CDRF_DODEFAULT; + } + break; + } + case WM_DRAWITEM: + if (wp == FLY_ID_OPEN) { + draw_accent_button((const DRAWITEMSTRUCT *)lp); + return TRUE; + } + break; + case WM_COMMAND: { + int id = LOWORD(wp); + if (id == IDCANCEL) { + close_flyout(0); + return 0; + } + if (id == IDOK) { + /* Enter on the focused row or button acts as a click on it */ + HWND f = GetFocus(); + if (f != NULL && GetParent(f) == hwnd) + SendMessageW(f, BM_CLICK, 0, 0); + return 0; + } + if (HIWORD(wp) != BN_CLICKED) + break; + if (id == FLY_ID_OPEN) { + close_flyout(0); + ui_main_show(); + return 0; + } + if (id == FLY_ID_SETTINGS) { + close_flyout(0); + ui_windows_command(UI_CMD_SETTINGS); + return 0; + } + if (id >= FLY_ID_ROW && id < FLY_ID_ROW + FLY_MAX_ROWS) { + row_clicked(id - FLY_ID_ROW); + return 0; + } + break; + } + case WM_LBUTTONDBLCLK: + close_flyout(0); + ui_main_show(); + return 0; + case WM_ACTIVATE: + if (LOWORD(wp) == WA_INACTIVE && IsWindowVisible(hwnd)) + close_flyout(1); + return 0; + case WM_NCDESTROY: + free_fonts(); + memset(&g_fl, 0, sizeof(g_fl)); + return DefWindowProcW(hwnd, msg, wp, lp); + default: + break; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +static int register_class(wchar_t name[320]) +{ + WNDCLASSEXW wc; + + ui_win_class_name(L"flyout", name); + if (g_class_ok) + return 1; + memset(&wc, 0, sizeof(wc)); + wc.cbSize = sizeof(wc); + wc.style = CS_DBLCLKS; + wc.lpfnWndProc = fly_proc; + wc.hInstance = g_app.inst; + wc.hCursor = LoadCursorW(NULL, (LPCWSTR)IDC_ARROW); + wc.lpszClassName = name; + if (RegisterClassExW(&wc) == 0 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) + return 0; + g_class_ok = 1; + return 1; +} + +/* Creates the window at (x, y) with the children; not shown. */ +static HWND create_flyout(int x, int y) +{ + wchar_t cls[320]; + HWND w; + + if (!register_class(cls)) + return NULL; + memset(&g_fl, 0, sizeof(g_fl)); + w = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_TOPMOST, cls, sd_l10n_get(SD_MSG_UI_NOTIFY_TITLE), + WS_POPUP | WS_BORDER | WS_CLIPCHILDREN, x, y, 100, 100, NULL, NULL, g_app.inst, NULL); + if (w == NULL) + return NULL; + g_fl.hwnd = w; + g_fl.dpi = ui_dpi_window(w); + if (g_fl.dpi == 0) + g_fl.dpi = UI_DPI_BASE; + ui_theme_apply_window(w, UI_THEME_WND_FLYOUT); + if (!build_children()) { + DestroyWindow(w); + return NULL; + } + return w; +} + +/* Where the icon is: Shell_NotifyIconGetRect when the shell has it, else the cursor. */ +static void icon_anchor(POINT *center, int *have_rect) +{ + fly_pfn_GetRect get_rect; + fly_nii id; + RECT rc; + + *have_rect = 0; + GetCursorPos(center); + get_rect = (fly_pfn_GetRect)sd_platform_resolve_by_name(L"shell32.dll", "Shell_NotifyIconGetRect"); + if (get_rect == NULL || g_app.ctrl == NULL) + return; + memset(&id, 0, sizeof(id)); + id.cbSize = sizeof(id); + id.hWnd = g_app.ctrl; + id.uID = UI_FLYOUT_TRAY_ID; + if (SUCCEEDED(get_rect(&id, &rc)) && rc.right > rc.left) { + center->x = (rc.left + rc.right) / 2; + center->y = (rc.top + rc.bottom) / 2; + *have_rect = 1; + } +} + +void ui_flyout_open(void) +{ + POINT cur, anchor; + MONITORINFO mi; + HMONITOR mon; + int have_rect, client_h, x, y, w, gap; + RECT wr; + HWND hwnd; + + if (g_fl.hwnd != NULL) { + close_flyout(0); /* a second click on the icon closes it */ + return; + } + if (g_closed_tick != 0 && sd_tick_ms() - g_closed_tick < FLY_REOPEN_MS) + return; /* this click is what deactivated and closed it */ + icon_anchor(&anchor, &have_rect); + GetCursorPos(&cur); + mon = MonitorFromPoint(cur, MONITOR_DEFAULTTONEAREST); + memset(&mi, 0, sizeof(mi)); + mi.cbSize = sizeof(mi); + if (mon == NULL || !GetMonitorInfoW(mon, &mi)) { + SystemParametersInfoW(SPI_GETWORKAREA, 0, &mi.rcWork, 0); + } + hwnd = create_flyout(mi.rcWork.right - 10, mi.rcWork.bottom - 10); + if (hwnd == NULL) + return; + client_h = layout(); + gap = S(FLY_SCREEN_GAP); + /* bottom right of the work area; centered on the icon when the shell tells where it is */ + fit_window(client_h); + GetWindowRect(hwnd, &wr); + w = wr.right - wr.left; + x = have_rect ? anchor.x - w / 2 : mi.rcWork.right - w - gap; + if (x + w > mi.rcWork.right - gap) + x = mi.rcWork.right - w - gap; + if (x < mi.rcWork.left + gap) + x = mi.rcWork.left + gap; + y = mi.rcWork.bottom - (wr.bottom - wr.top) - gap; + if (y < mi.rcWork.top) + y = mi.rcWork.top; + SetWindowPos(hwnd, HWND_TOPMOST, x, y, 0, 0, SWP_NOSIZE | SWP_NOACTIVATE); + ShowWindow(hwnd, SW_SHOW); + SetForegroundWindow(hwnd); + SetFocus(g_fl.rows > 0 ? g_fl.row[0].check : g_fl.open); +} + +void ui_flyout_refresh(void) +{ + if (g_fl.hwnd == NULL || g_closing) + return; + if (g_fl.rows != (ui_profiles_count() > FLY_MAX_ROWS ? FLY_MAX_ROWS : (int)ui_profiles_count())) { + close_flyout(0); /* the profile list changed: the next open rebuilds the rows */ + return; + } + update_rows(); + fit_window(layout()); + InvalidateRect(g_fl.hwnd, NULL, TRUE); +} + +int ui_flyout_filter(MSG *msg) +{ + if (g_fl.hwnd == NULL || !IsWindow(g_fl.hwnd)) + return 0; + return IsDialogMessageW(g_fl.hwnd, msg) ? 1 : 0; +} + +/* ---- smoke test (--test-flyout) ---- */ + +int ui_flyout_smoke_test(void) +{ + HWND w; + int ok; + + if (g_fl.hwnd != NULL) + return 0; + w = create_flyout(0, 0); + if (w == NULL) + return 0; + fit_window(layout()); + ok = IsWindow(w) && !IsWindowVisible(w) && GetDlgItem(w, FLY_ID_HEADER) != NULL && + GetDlgItem(w, FLY_ID_SUB) != NULL && GetDlgItem(w, FLY_ID_OPEN) != NULL && + GetDlgItem(w, FLY_ID_SETTINGS) != NULL; + DestroyWindow(w); + return ok ? 1 : 0; +} diff --git a/apps/shelldrive-ui/ui_flyout.h b/apps/shelldrive-ui/ui_flyout.h new file mode 100644 index 0000000..56d00a3 --- /dev/null +++ b/apps/shelldrive-ui/ui_flyout.h @@ -0,0 +1,23 @@ +/* shelldrive-ui: the Windows 11 tray flyout (06 §6.3, D47). See ui_flyout.c. + * + * Only on the Fluent tier at level 'b' or 'c' (build 22000+): a left click or Enter on the tray icon opens this small + * window instead of the main window. The right click menu, and every other tier, keep the popup menu of ui_tray.c. + * Only the UI thread calls these functions. */ +#ifndef SD_UI_FLYOUT_H +#define SD_UI_FLYOUT_H + +#include + +/* 1 when the current theme shows the flyout (Fluent, level 'b' or 'c'). */ +int ui_flyout_wanted(void); +/* Opens the flyout above the tray icon; closes it when it is already open. Rows are rebuilt on each open. */ +void ui_flyout_open(void); +void ui_flyout_close(void); +/* The mount list or the profiles changed: updates the open flyout in place (does nothing when closed). */ +void ui_flyout_refresh(void); +/* Keyboard handling for the message loop (IsDialogMessage): 1 when the message was consumed. */ +int ui_flyout_filter(MSG *msg); +/* --test-flyout: creates the flyout hidden, checks the header and both buttons, destroys it. 1 on success. */ +int ui_flyout_smoke_test(void); + +#endif /* SD_UI_FLYOUT_H */ diff --git a/apps/shelldrive-ui/ui_hkissue.c b/apps/shelldrive-ui/ui_hkissue.c index aadc628..de0bb99 100644 --- a/apps/shelldrive-ui/ui_hkissue.c +++ b/apps/shelldrive-ui/ui_hkissue.c @@ -322,17 +322,23 @@ static sd_err build_type_dialog(const ui_hki *h, unsigned char **blob, size_t *l static void stop_source(HWND owner, ui_hki_source src) { sd_settings s; - wchar_t *text; + wchar_t *text, *title; const wchar_t *name = src == UI_HKI_SRC_PUTTY ? L"PuTTY" : L"OpenSSH"; sd_err e; + int answer; text = ui_format1(SD_MSG_UI_HKI_STOP_CONFIRM, "source", name); - if (text == NULL) - return; - if (MessageBoxW(owner, text, L"ShellDrive", MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2) != IDYES) { + title = ui_format1(SD_MSG_UI_HKI_STOP_TITLE, "source", name); + if (text == NULL || title == NULL) { sd_free(text); + sd_free(title); return; } + /* No is the default button: Enter never turns a source off */ + answer = ui_win_task(owner, title, text, NULL, MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2); sd_free(text); + sd_free(title); + if (answer != IDYES) + return; sd_settings_defaults(&s); (void)sd_settings_load(&s); if (src == UI_HKI_SRC_PUTTY) diff --git a/apps/shelldrive-ui/ui_hostkeys.c b/apps/shelldrive-ui/ui_hostkeys.c index 05ad3e4..be75e23 100644 --- a/apps/shelldrive-ui/ui_hostkeys.c +++ b/apps/shelldrive-ui/ui_hostkeys.c @@ -31,6 +31,7 @@ #include "ui_hostkeys_model.h" #include "ui_int.h" #include "ui_measure.h" +#include "ui_theme.h" #include "ui_win.h" #define HK_ID_TAB 4201 @@ -591,7 +592,7 @@ static void remove_selected(void) text = sd_l10n_format_alloc(SD_MSG_UI_HOSTKEYS_CONFIRM_REMOVE, args, 2); if (text == NULL) return; - if (MessageBoxW(g_hk.wnd, text, L"ShellDrive", MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2) != IDYES) { + if (ui_win_task(g_hk.wnd, text, NULL, NULL, MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2) != IDYES) { sd_free(text); return; } @@ -788,7 +789,7 @@ static void accept_selected(int add_only) return; a = &g_hk.anns[p]; if (!add_only && ann_has_removals(a->id)) { - if (MessageBoxW(g_hk.wnd, sd_l10n_get(SD_MSG_UI_HOSTKEYS_CONFIRM_ACCEPT_REMOVALS), L"ShellDrive", + if (ui_win_task(g_hk.wnd, sd_l10n_get(SD_MSG_UI_HOSTKEYS_CONFIRM_ACCEPT_REMOVALS), NULL, NULL, MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2) != IDYES) return; } @@ -844,6 +845,7 @@ static INT_PTR CALLBACK ask_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) SetDlgItemTextW(hwnd, HK_DLG_HOST, g_ask_prefill); g_ask_prefill[0] = 0; } + ui_theme_apply_dialog(hwnd); return TRUE; case WM_COMMAND: if (c == NULL) @@ -923,6 +925,7 @@ static INT_PTR CALLBACK review_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) (void)lp; switch (msg) { case WM_INITDIALOG: + ui_theme_apply_dialog(hwnd); return TRUE; case WM_COMMAND: if (LOWORD(wp) == IDOK || LOWORD(wp) == IDCANCEL) { @@ -1355,6 +1358,7 @@ static LRESULT CALLBACK hk_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) g_hk.dpi = ui_dpi_window(hwnd); g_hk.font = ui_win_font(g_hk.dpi); create_children(hwnd); + ui_theme_apply_dialog(hwnd); layout(hwnd); update_buttons(); return 0; diff --git a/apps/shelldrive-ui/ui_import.c b/apps/shelldrive-ui/ui_import.c index 390eeac..93b6978 100644 --- a/apps/shelldrive-ui/ui_import.c +++ b/apps/shelldrive-ui/ui_import.c @@ -14,6 +14,8 @@ #include "ui_dlg.h" #include "ui_int.h" #include "ui_measure.h" +#include "ui_theme.h" +#include "ui_win.h" #define IMP_WIDTH 340 #define IMP_LIST 1600 /* the list view (created over the placeholder edit) */ @@ -138,8 +140,8 @@ static void load_source(imp_ctx *c, int putty) else e = sd_import_ssh_config_file(NULL, &env, &c->res); if (e != SD_OK && c->res.count == 0 && c->res.nskipped == 0) { - MessageBoxW(c->dlg, sd_l10n_get(putty ? SD_MSG_UI_IMPORT_ERROR_PUTTY : SD_MSG_UI_IMPORT_ERROR_OPENSSH), - L"ShellDrive", MB_OK | MB_ICONWARNING); + ui_win_task(c->dlg, sd_l10n_get(putty ? SD_MSG_UI_IMPORT_ERROR_PUTTY : SD_MSG_UI_IMPORT_ERROR_OPENSSH), NULL, + NULL, MB_OK | MB_ICONWARNING); c->loaded = 0; SendMessageW(c->list, LVM_DELETEALLITEMS, 0, 0); return; @@ -168,7 +170,7 @@ static int import_checked(imp_ctx *c) } e = sd_import_write(&c->res, &written); if (e != SD_OK && written == 0) { - MessageBoxW(c->dlg, sd_l10n_get(SD_MSG_UI_ERROR_SAVE), L"ShellDrive", MB_OK | MB_ICONERROR); + ui_win_task(c->dlg, sd_l10n_get(SD_MSG_UI_ERROR_SAVE), NULL, NULL, MB_OK | MB_ICONERROR); return 0; } if (written > 0) { @@ -202,6 +204,7 @@ static INT_PTR CALLBACK import_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) SendMessageW(c->list, LVM_SETEXTENDEDLISTVIEWSTYLE, 0, LVS_EX_CHECKBOXES | LVS_EX_FULLROWSELECT | LVS_EX_LABELTIP); set_list_columns(c->list); SendMessageW(c->list, WM_SETFONT, SendMessageW(hwnd, WM_GETFONT, 0, 0), MAKELPARAM(TRUE, 0)); + ui_theme_apply_dialog(hwnd); return TRUE; } case WM_COMMAND: diff --git a/apps/shelldrive-ui/ui_int.h b/apps/shelldrive-ui/ui_int.h index 24786c3..90475a7 100644 --- a/apps/shelldrive-ui/ui_int.h +++ b/apps/shelldrive-ui/ui_int.h @@ -5,7 +5,7 @@ * The profile editor and the mount dialog (ui_editor.c), the profile list (ui_profiles.c), the import wizard * (ui_import.c), the .sdmount verb, the secondary windows of M3 (ui_win.h: host keys, Logs, Settings, About) and * the prompt host (prompts.ui, ui_prompt.c), the first-run wizard (ui_wizard.c), the balloon rate limit and queue - * (ui_tray.c, ui_notify_model.c) and dark title bars (ui_dark.c) are in. Not yet: reconnect. */ + * (ui_tray.c, ui_notify_model.c) and the OS-tiered theme (ui_theme.c) are in. Not yet: reconnect. */ #ifndef SD_UI_INT_H #define SD_UI_INT_H @@ -55,6 +55,7 @@ #define UI_CTL_TOOLBAR 4003 #define UI_CTL_LIST 4001 #define UI_CTL_STATUS 4002 +#define UI_CTL_OVERFLOW 4004 /* the "..." overflow button of the Fluent command bar (06 §6.1) */ #define UI_MAX_MOUNTS 64 @@ -261,6 +262,10 @@ HWND ui_main_create(HINSTANCE inst, const wchar_t *class_name, int visible); void ui_main_show(void); void ui_main_refresh(void); int ui_main_test_controls(void); +/* --test-shot= (06 §8.2): hwnd is the visible main window; it is sized to 880x560 (96-DPI + units), filled with four fixture rows (no agent), the first row selected, and after ~1.5 s the window + rectangle is captured from the screen DC into the .bmp. */ +int ui_main_test_shot(HWND hwnd, const wchar_t *path); /* The selected profile of the list (NULL when none), and its mount (NULL when not mounted). */ const sd_profile *ui_main_selected_profile(void); ui_mount *ui_main_selected_mount(void); diff --git a/apps/shelldrive-ui/ui_logs.c b/apps/shelldrive-ui/ui_logs.c index 37064ec..90c6c13 100644 --- a/apps/shelldrive-ui/ui_logs.c +++ b/apps/shelldrive-ui/ui_logs.c @@ -21,6 +21,7 @@ #include "ui_dpi.h" #include "ui_int.h" #include "ui_logs_model.h" +#include "ui_theme.h" #include "ui_win.h" #define LG_ID_LEVEL 4301 @@ -352,6 +353,7 @@ static LRESULT CALLBACK lg_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) g_lg.font = ui_win_font(g_lg.dpi); g_lg.mono = make_mono(g_lg.dpi); create_children(hwnd); + ui_theme_apply_dialog(hwnd); layout(hwnd); SetTimer(hwnd, LG_TIMER, LG_POLL_MS, NULL); status_msg(g_app.agent_up ? SD_MSG_UI_LOGS_LOADING : SD_MSG_UI_LOGS_AGENT_DOWN); diff --git a/apps/shelldrive-ui/ui_main.c b/apps/shelldrive-ui/ui_main.c index 36892b1..4a9990e 100644 --- a/apps/shelldrive-ui/ui_main.c +++ b/apps/shelldrive-ui/ui_main.c @@ -23,7 +23,9 @@ #include "sd_l10n/sd_l10n.h" #include "sd_l10n_ui_ids.h" #include "sd_platform/sd_log.h" -#include "ui_dark.h" +#include "sd_platform/sd_platform.h" +#include "sd_platform/sd_time.h" +#include "ui_theme.h" #include "ui_dpi.h" #include "ui_int.h" #include "ui_mainview_model.h" @@ -33,6 +35,9 @@ #ifndef WM_DPICHANGED #define WM_DPICHANGED 0x02E0 #endif +#ifndef WM_DWMCOMPOSITIONCHANGED +#define WM_DWMCOMPOSITIONCHANGED 0x031E +#endif /* ListView groups are comctl32 v6 (XP and later). The group structure is spelled out in its XP layout: the Vista fields are not needed and the XP SDK of the v141_xp toolset does not declare them. */ @@ -48,6 +53,18 @@ #ifndef LVIF_GROUPID #define LVIF_GROUPID 0x0100 #endif +#ifndef LVCDI_GROUP +#define LVCDI_GROUP 1 +#endif +#ifndef LVM_GETGROUPINFO +#define LVM_GETGROUPINFO (LVM_FIRST + 149) +#endif +#ifndef LVM_GETGROUPRECT +#define LVM_GETGROUPRECT (LVM_FIRST + 98) +#endif +#ifndef LVGGR_HEADER +#define LVGGR_HEADER 1 +#endif #define UI_LVGF_HEADER 0x00000001u #define UI_LVGF_GROUPID 0x00000010u #ifndef LVS_EX_DOUBLEBUFFER @@ -95,6 +112,773 @@ static int dpx(int v) return ui_dpi_scale(v, g_app.dpi); } +/* ---- Fluent window: command bar, Mica band, card (06 §3.3, §3.5, §6.1) ---- + * + * Only on the Fluent tier (chosen when the window is created; a tier change between Fluent and the others at + * run time takes effect at the next start, colors and dark/light follow live). The command bar is owner-drawn + * push buttons whose ids are the UI_CMD_* ids, so WM_COMMAND is unchanged. On level 'c' the top band is Mica: + * the frame is extended into the client area by the band height and the buttons are drawn with buffered + * paint so their alpha is right (GDI writes alpha 0). Every post-XP function is resolved at run time. */ + +#ifndef ODS_NOFOCUSRECT +#define ODS_NOFOCUSRECT 0x0200 +#endif +#define UI_BPBF_TOPDOWNDIB 2 +#define UI_BPPF_ERASE 1 +#define UI_DTT_TEXTCOLOR 0x00000001u +#define UI_DTT_COMPOSITED 0x00002000u +#define UI_DTT_GLOWSIZE 0x00000800u +#define UI_TMT_TEXTGLOWSIZE 2425 +#define UI_AERO_GLOW_DEFAULT 10 +#define UI_DWMWA_EXTENDED_FRAME_BOUNDS 9 +#define UI_CB_COUNT (UI_TB_BUTTONS + 1) + +typedef struct ui_margins { int left, right, top, bottom; } ui_margins; +typedef struct ui_bp_params { DWORD cbSize, dwFlags; const RECT *exclude; const void *blend; } ui_bp_params; +typedef struct ui_dttopts { + DWORD dwSize, dwFlags; + COLORREF crText, crBorder, crShadow; + int shadow_type; + POINT shadow_offset; + int border_size, font_prop, color_prop, state_id; + BOOL apply_overlay; + int glow_size; + void *callback; + LPARAM lparam; +} ui_dttopts; + +typedef HRESULT (WINAPI *ui_pfn_extend)(HWND hwnd, const ui_margins *m); +typedef HRESULT (WINAPI *ui_pfn_getattr)(HWND hwnd, DWORD attr, void *value, DWORD size); +typedef HRESULT (WINAPI *ui_pfn_bpinit)(void); +typedef HANDLE (WINAPI *ui_pfn_bpbegin)(HDC target, const RECT *rc, int format, ui_bp_params *params, HDC *out); +typedef HRESULT (WINAPI *ui_pfn_bpend)(HANDLE buf, BOOL update); +typedef HRESULT (WINAPI *ui_pfn_bpalpha)(HANDLE buf, const RECT *rc, BYTE alpha); +typedef HANDLE (WINAPI *ui_pfn_opentheme)(HWND hwnd, LPCWSTR classes); +typedef HRESULT (WINAPI *ui_pfn_closetheme)(HANDLE theme); +typedef HRESULT (WINAPI *ui_pfn_themetext)(HANDLE theme, HDC dc, int part, int state, LPCWSTR text, int cch, + DWORD flags, LPRECT rc, const ui_dttopts *opts); +typedef HRESULT (WINAPI *ui_pfn_settheme)(HWND hwnd, LPCWSTR subapp, LPCWSTR sublist); +typedef HRESULT (WINAPI *ui_pfn_themebg)(HANDLE theme, HDC dc, int part, int state, const RECT *rc, const RECT *clip); +typedef HRESULT (WINAPI *ui_pfn_themeint)(HANDLE theme, int part, int state, int prop, int *value); + +static ui_pfn_extend g_fx_extend; +static ui_pfn_getattr g_fx_getattr; +static ui_pfn_bpinit g_fx_bpinit, g_fx_bpuninit; +static ui_pfn_bpbegin g_fx_bpbegin; +static ui_pfn_bpend g_fx_bpend; +static ui_pfn_bpalpha g_fx_bpalpha; +static ui_pfn_opentheme g_fx_open; +static ui_pfn_closetheme g_fx_close; +static ui_pfn_themetext g_fx_text; +static ui_pfn_settheme g_fx_settheme; +static ui_pfn_themebg g_fx_bg; +static ui_pfn_themeint g_fx_int; +static int g_fx_resolved; +static int g_bp_inited; /* BufferedPaintInit succeeded on this thread */ + +static int g_fluent; /* the window was created with the Fluent controls */ +static int g_aero; /* the window was created on the Aero tier (glass band while composition is on) */ +static HWND g_cb[UI_TB_BUTTONS]; /* the command bar buttons, in k_cb order */ +static HWND g_overflow; /* the "..." button */ +static HWND g_hot; /* the command bar button under the mouse */ +static WNDPROC g_cb_proc; /* the original button window procedure (the buttons are subclassed) */ +static HMENU g_menu; /* the menu bar, or on Fluent the overflow popup with the same items */ +static int g_band_h; /* height of the top band (command bar), px */ +static int g_frame_cy; /* the top margin last given to DwmExtendFrameIntoClientArea */ +static RECT g_card, g_strip; /* the layer card around the list and the status strip, client px */ + +static const struct { int cmd; sd_msg_id text; int gap; } k_cb[UI_TB_BUTTONS] = { + { UI_CMD_NEW, SD_MSG_UI_MAIN_NEW, 0 }, { UI_CMD_EDIT, SD_MSG_UI_MAIN_EDIT, 0 }, + { UI_CMD_DELETE, SD_MSG_UI_MAIN_DELETE, 0 }, { UI_CMD_MOUNT, SD_MSG_UI_MAIN_MOUNT, 1 }, + { UI_CMD_UNMOUNT, SD_MSG_UI_MAIN_UNMOUNT, 0 }, { UI_CMD_OPEN_DRIVE, SD_MSG_UI_MAIN_OPEN, 0 }, + { UI_CMD_HOSTKEYS, SD_MSG_UI_MENU_HOSTKEYS, 1 }, { UI_CMD_LOGS, SD_MSG_UI_MENU_LOGS, 0 } +}; + +static void fx_resolve(void) +{ + if (g_fx_resolved) + return; + g_fx_resolved = 1; + g_fx_extend = (ui_pfn_extend)sd_platform_resolve_by_name(L"dwmapi.dll", "DwmExtendFrameIntoClientArea"); + g_fx_getattr = (ui_pfn_getattr)sd_platform_resolve_by_name(L"dwmapi.dll", "DwmGetWindowAttribute"); + g_fx_bpinit = (ui_pfn_bpinit)sd_platform_resolve_by_name(L"uxtheme.dll", "BufferedPaintInit"); + g_fx_bpuninit = (ui_pfn_bpinit)sd_platform_resolve_by_name(L"uxtheme.dll", "BufferedPaintUnInit"); + g_fx_bpbegin = (ui_pfn_bpbegin)sd_platform_resolve_by_name(L"uxtheme.dll", "BeginBufferedPaint"); + g_fx_bpend = (ui_pfn_bpend)sd_platform_resolve_by_name(L"uxtheme.dll", "EndBufferedPaint"); + g_fx_bpalpha = (ui_pfn_bpalpha)sd_platform_resolve_by_name(L"uxtheme.dll", "BufferedPaintSetAlpha"); + g_fx_open = (ui_pfn_opentheme)sd_platform_resolve_by_name(L"uxtheme.dll", "OpenThemeData"); + g_fx_close = (ui_pfn_closetheme)sd_platform_resolve_by_name(L"uxtheme.dll", "CloseThemeData"); + g_fx_text = (ui_pfn_themetext)sd_platform_resolve_by_name(L"uxtheme.dll", "DrawThemeTextEx"); + g_fx_settheme = (ui_pfn_settheme)sd_platform_resolve_by_name(L"uxtheme.dll", "SetWindowTheme"); + g_fx_bg = (ui_pfn_themebg)sd_platform_resolve_by_name(L"uxtheme.dll", "DrawThemeBackground"); + g_fx_int = (ui_pfn_themeint)sd_platform_resolve_by_name(L"uxtheme.dll", "GetThemeInt"); +} + +/* 1 when the band can be Mica: level 'c' and every function of the extended-frame and buffered-paint path + resolved (BufferedPaintInit succeeded). Otherwise the band is a plain UI_TOK_SURFACE strip (06 §3.3 S1). */ +static int mica_active(void) +{ + const ui_theme *t = ui_theme_current(); + return g_fluent && t != NULL && t->backdrop && g_fx_extend != NULL && g_bp_inited && g_fx_bpbegin != NULL && + g_fx_bpend != NULL && g_fx_bpalpha != NULL && g_fx_open != NULL && g_fx_close != NULL && g_fx_text != NULL; +} + +/* 1 while the Aero glass band is shown: the window was created on Aero, the theme still reports glass (composition + on) and every function of the extended-frame and buffered-paint path resolved. Turns 0 when composition is + switched off (WM_DWMCOMPOSITIONCHANGED), and the toolbar then paints normally. */ +static int aero_glass(void) +{ + const ui_theme *t = ui_theme_current(); + return g_aero && !g_fluent && t != NULL && t->tier == UI_TIER_AERO && t->glass && g_app.toolbar != NULL && + g_fx_extend != NULL && g_bp_inited && g_fx_bpbegin != NULL && g_fx_bpend != NULL && g_fx_open != NULL && + g_fx_close != NULL && g_fx_text != NULL && g_fx_bg != NULL; +} + +/* a moved toward b by pct percent, per channel */ +static COLORREF blend(COLORREF a, COLORREF b, int pct) +{ + int r = GetRValue(a) + (GetRValue(b) - GetRValue(a)) * pct / 100; + int g = GetGValue(a) + (GetGValue(b) - GetGValue(a)) * pct / 100; + int bl = GetBValue(a) + (GetBValue(b) - GetBValue(a)) * pct / 100; + return RGB(r, g, bl); +} + +/* BufferedPaintSetAlpha(buf, NULL, 255) for a button whose rounded corners (clip, NULL when square) stay + transparent: the rows of the corners are set span by span, the rest in one call. */ +static void set_alpha_clipped(HANDLE buf, const RECT *rc, HRGN clip) +{ + RECT r; + int rad = dpx(4), y, i, x0, x1; + if (clip == NULL || rad < 1 || rc->bottom - rc->top < 2 * rad) { + g_fx_bpalpha(buf, rc, 255); + return; + } + r = *rc; + r.top += rad; + r.bottom -= rad; + g_fx_bpalpha(buf, &r, 255); + for (i = 0; i < rad; i++) { + for (y = 0; y < 2; y++) { + int row = y == 0 ? rc->top + i : rc->bottom - 1 - i; + for (x0 = rc->left; x0 < rc->right && !PtInRegion(clip, x0, row); x0++) + ; + for (x1 = rc->right - 1; x1 > x0 && !PtInRegion(clip, x1, row); x1--) + ; + if (x0 <= x1) { + r.left = x0; + r.right = x1 + 1; + r.top = row; + r.bottom = row + 1; + g_fx_bpalpha(buf, &r, 255); + } + } + } +} + +/* The extended frame covers the command bar band on Mica and the toolbar band on Aero glass, nothing otherwise. */ +static void update_frame(HWND hwnd) +{ + ui_margins m; + memset(&m, 0, sizeof(m)); + if (g_fx_extend == NULL) + return; + if (mica_active() || aero_glass()) + m.top = g_band_h; + if (m.top == g_frame_cy) + return; + g_frame_cy = m.top; + g_fx_extend(hwnd, &m); +} + +/* One command bar button (WM_DRAWITEM): idle, hover, pressed, disabled, focus; the accent fill for New. */ +static void draw_cmd_button(const DRAWITEMSTRUCT *d) +{ + const ui_theme *t = ui_theme_current(); + RECT rc = d->rcItem; + wchar_t text[96]; + int accent = (int)d->CtlID == UI_CMD_NEW; + int disabled = (d->itemState & ODS_DISABLED) != 0; + int pressed = (d->itemState & ODS_SELECTED) != 0 && !disabled; + int hot = d->hwndItem == g_hot && !disabled; + int focus = (d->itemState & ODS_FOCUS) != 0 && (d->itemState & ODS_NOFOCUSRECT) == 0; + int rounded = t != NULL && t->level != UI_LEVEL_A; + int opaque = accent || hot || pressed || focus; + int use_bp = mica_active(); + COLORREF fill, fg, surface = ui_theme_color(UI_TOK_SURFACE); + HANDLE theme = NULL, buf = NULL; + HDC dc = d->hDC, mdc = NULL, target; + HFONT old; + HBRUSH br; + HRGN clip = NULL; + + /* the overflow button's window text is its accessible name ("More options"); it shows three dots */ + if (d->CtlID == UI_CTL_OVERFLOW) + wcscpy_s(text, SD_COUNTOF(text), L"\x00B7\x00B7\x00B7"); + else + GetWindowTextW(d->hwndItem, text, SD_COUNTOF(text)); + if (accent) { + fg = ui_theme_color(UI_TOK_ACCENT_TEXT); + fill = ui_theme_color(UI_TOK_ACCENT); + /* the palette accent as is; hover lightens it a little, pressed darkens it (whichever the text color) */ + if (pressed) + fill = blend(fill, RGB(0, 0, 0), 14); + else if (hot) + fill = blend(fill, RGB(255, 255, 255), 12); + } else { + fg = disabled ? blend(ui_theme_color(UI_TOK_TEXT_SECONDARY), surface, 45) : ui_theme_color(UI_TOK_TEXT); + fill = pressed ? blend(surface, ui_theme_color(UI_TOK_TEXT), 16) + : (hot || focus) ? blend(surface, ui_theme_color(UI_TOK_TEXT), 9) : surface; + } + if (use_bp && !opaque) { + theme = g_fx_open(d->hwndItem, L"CompositedWindow::Window"); + if (theme == NULL) + theme = g_fx_open(d->hwndItem, L"WINDOW"); + if (theme == NULL) + opaque = 1; /* no way to get the text alpha right: draw it as an opaque button */ + } + if (use_bp) { + ui_bp_params bpp; + memset(&bpp, 0, sizeof(bpp)); + bpp.cbSize = sizeof(bpp); + bpp.dwFlags = UI_BPPF_ERASE; + buf = g_fx_bpbegin(dc, &rc, UI_BPBF_TOPDOWNDIB, &bpp, &mdc); + } + target = buf != NULL ? mdc : dc; + old = (HFONT)SelectObject(target, g_app.font); + if (rounded) { /* also clip here: the window region alone left square corners painted */ + clip = CreateRoundRectRgn(rc.left, rc.top, rc.right + 1, rc.bottom + 1, dpx(8), dpx(8)); + SelectClipRgn(target, clip); + } + if (opaque || buf == NULL) { + br = CreateSolidBrush(fill); + FillRect(target, &rc, br); /* corners: clip above and the window region of layout_fluent */ + DeleteObject(br); + } + if (buf != NULL && !opaque && theme != NULL) { + ui_dttopts o; + memset(&o, 0, sizeof(o)); + o.dwSize = sizeof(o); + o.dwFlags = UI_DTT_COMPOSITED | UI_DTT_TEXTCOLOR; + o.crText = fg; + g_fx_text(theme, target, 0, 0, text, -1, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX, &rc, &o); + } else { + SetBkMode(target, TRANSPARENT); + SetTextColor(target, fg); + DrawTextW(target, text, -1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + } + if (focus) { + HPEN pen = CreatePen(PS_INSIDEFRAME, dpx(2) > 1 ? dpx(2) : 2, ui_theme_color(UI_TOK_FOCUS)); + HGDIOBJ op = SelectObject(target, pen); + HGDIOBJ ob = SelectObject(target, GetStockObject(NULL_BRUSH)); + if (rounded) + RoundRect(target, rc.left, rc.top, rc.right, rc.bottom, dpx(8), dpx(8)); + else + Rectangle(target, rc.left, rc.top, rc.right, rc.bottom); + SelectObject(target, ob); + SelectObject(target, op); + DeleteObject(pen); + } + SelectObject(target, old); + if (clip != NULL) + SelectClipRgn(target, NULL); + if (buf != NULL) { + if (opaque) + set_alpha_clipped(buf, &rc, clip); /* a filled button is opaque inside its corners, an idle one keeps the text alpha only */ + g_fx_bpend(buf, TRUE); + } + if (clip != NULL) + DeleteObject(clip); + if (theme != NULL) + g_fx_close(theme); +} + +/* NM_CUSTOMDRAW of the toolbar on the glass band (06 §3.2): a transparent toolbar over black (glass) whose buttons + are painted into a buffered-paint DIB: the hot/pressed background of the toolbar theme and the text through + DrawThemeTextEx with the composited glow of the Windows 7 caption. GDI text alone would write alpha 0. */ +static LRESULT aero_toolbar_draw(NMTBCUSTOMDRAW *cd) +{ + HANDLE buf, bt, ct; + HDC mdc = NULL; + RECT rc = cd->nmcd.rc, tr; + ui_bp_params bpp; + ui_dttopts o; + wchar_t text[96]; + int state = cd->nmcd.uItemState, glow = UI_AERO_GLOW_DEFAULT, id = (int)cd->nmcd.dwItemSpec; + int disabled = (state & CDIS_DISABLED) != 0; + HFONT old; + + if (cd->nmcd.dwDrawStage == CDDS_PREPAINT) + return CDRF_NOTIFYITEMDRAW; + if (cd->nmcd.dwDrawStage != CDDS_ITEMPREPAINT) + return CDRF_DODEFAULT; + text[0] = 0; + if (id != 0) + SendMessageW(g_app.toolbar, TB_GETBUTTONTEXTW, (WPARAM)id, (LPARAM)text); + if (text[0] == 0) + return CDRF_SKIPDEFAULT; /* separators: the black band shows through */ + ct = g_fx_open(g_app.toolbar, L"CompositedWindow::Window"); + if (ct == NULL) + return CDRF_DODEFAULT; + memset(&bpp, 0, sizeof(bpp)); + bpp.cbSize = sizeof(bpp); + bpp.dwFlags = UI_BPPF_ERASE; + buf = g_fx_bpbegin(cd->nmcd.hdc, &rc, UI_BPBF_TOPDOWNDIB, &bpp, &mdc); + if (buf == NULL) { + g_fx_close(ct); + return CDRF_DODEFAULT; + } + if ((state & (CDIS_HOT | CDIS_SELECTED)) != 0 && !disabled) { + bt = g_fx_open(g_app.toolbar, L"Toolbar"); + if (bt != NULL) { + g_fx_bg(bt, mdc, 1 /* TP_BUTTON */, (state & CDIS_SELECTED) != 0 ? 3 /* TS_PRESSED */ : 2 /* TS_HOT */, &rc, + NULL); + g_fx_close(bt); + } + } + if (g_fx_int != NULL) + g_fx_int(ct, 1 /* WP_CAPTION */, 1 /* CS_ACTIVE */, UI_TMT_TEXTGLOWSIZE, &glow); + if (glow <= 0) + glow = UI_AERO_GLOW_DEFAULT; + memset(&o, 0, sizeof(o)); + o.dwSize = sizeof(o); + o.dwFlags = UI_DTT_COMPOSITED | UI_DTT_GLOWSIZE; + if (disabled) { + o.dwFlags |= UI_DTT_TEXTCOLOR; + o.crText = GetSysColor(COLOR_GRAYTEXT); + } else { + o.dwFlags |= UI_DTT_TEXTCOLOR; + o.crText = RGB(0, 0, 0); + } + o.glow_size = glow; + old = (HFONT)SelectObject(mdc, g_app.font); + tr = rc; + g_fx_text(ct, mdc, 1, 1, text, -1, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX, &tr, &o); + SelectObject(mdc, old); + g_fx_bpend(buf, TRUE); + g_fx_close(ct); + return CDRF_SKIPDEFAULT; +} + +/* The glass band: black in the client area (alpha 0 shows the glass). */ +static void aero_fill_band(HDC dc, HWND hwnd) +{ + RECT rc; + GetClientRect(hwnd, &rc); + if (g_band_h < rc.bottom) + rc.bottom = g_band_h; + FillRect(dc, &rc, (HBRUSH)GetStockObject(BLACK_BRUSH)); +} + +/* Focus order of the command bar: the buttons, the overflow button, then the list. */ +static HWND cb_at(int i) +{ + return i < UI_TB_BUTTONS ? g_cb[i] : g_overflow; +} + +static void cb_focus_from(int from, int dir, int wrap) +{ + int i = from; + for (;;) { + HWND h; + i += dir; + if (i < 0 || i >= UI_CB_COUNT) { + if (!wrap) { + SetFocus(g_app.list); + return; + } + i = i < 0 ? UI_CB_COUNT - 1 : 0; + } + h = cb_at(i); + if (i == from) + return; + if (h != NULL && IsWindowEnabled(h) && IsWindowVisible(h)) { + SetFocus(h); + return; + } + } +} + +/* Tab and Shift+Tab from the list enter the command bar (the main window has no dialog manager). */ +static void cb_focus_edge(int last) +{ + cb_focus_from(last ? UI_CB_COUNT : -1, last ? -1 : 1, 0); +} + +static void show_overflow(void) +{ + RECT r; + if (g_menu == NULL || g_overflow == NULL || g_app.main == NULL) + return; + GetWindowRect(g_overflow, &r); + SetForegroundWindow(g_app.main); + TrackPopupMenu(g_menu, TPM_RIGHTALIGN | TPM_TOPALIGN | TPM_RIGHTBUTTON, r.right, r.bottom, 0, g_app.main, NULL); + PostMessageW(g_app.main, WM_NULL, 0, 0); +} + +static LRESULT CALLBACK cb_proc(HWND h, UINT msg, WPARAM wp, LPARAM lp) +{ + TRACKMOUSEEVENT tme; + int i, at = -1; + switch (msg) { + case WM_ERASEBKGND: + return 1; /* WM_DRAWITEM paints the whole button */ + case WM_MOUSEMOVE: + if (g_hot != h) { + HWND prev = g_hot; + g_hot = h; + memset(&tme, 0, sizeof(tme)); + tme.cbSize = sizeof(tme); + tme.dwFlags = TME_LEAVE; + tme.hwndTrack = h; + TrackMouseEvent(&tme); + if (prev != NULL) + InvalidateRect(prev, NULL, FALSE); + InvalidateRect(h, NULL, FALSE); + } + break; + case WM_MOUSELEAVE: + if (g_hot == h) { + g_hot = NULL; + InvalidateRect(h, NULL, FALSE); + } + break; + case WM_KEYDOWN: + for (i = 0; i < UI_CB_COUNT; i++) + if (cb_at(i) == h) + at = i; + if (wp == VK_RETURN) { + SendMessageW(h, BM_CLICK, 0, 0); + return 0; + } + if (wp == VK_ESCAPE) { + SetFocus(g_app.list); + return 0; + } + if (wp == VK_RIGHT || wp == VK_LEFT) { + cb_focus_from(at, wp == VK_RIGHT ? 1 : -1, 1); + return 0; + } + if (wp == VK_TAB) { + cb_focus_from(at, GetKeyState(VK_SHIFT) < 0 ? -1 : 1, 0); + return 0; + } + break; + case WM_GETDLGCODE: + return CallWindowProcW(g_cb_proc, h, msg, wp, lp) | DLGC_WANTTAB | DLGC_WANTARROWS; + default: + break; + } + return CallWindowProcW(g_cb_proc, h, msg, wp, lp); +} + +static HWND make_cb_button(HWND parent, const wchar_t *text, int id) +{ + HWND b = CreateWindowExW(0, L"BUTTON", text, WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_OWNERDRAW, 0, 0, 10, 10, + parent, (HMENU)(UINT_PTR)id, g_app.inst, NULL); + WNDPROC old; + if (b == NULL) + return NULL; + old = (WNDPROC)SetWindowLongPtrW(b, GWLP_WNDPROC, (LONG_PTR)cb_proc); + if (g_cb_proc == NULL) + g_cb_proc = old; + return b; +} + +static int make_command_bar(HWND parent) +{ + size_t i; + for (i = 0; i < UI_TB_BUTTONS; i++) { + g_cb[i] = make_cb_button(parent, sd_l10n_get(k_cb[i].text), k_cb[i].cmd); + if (g_cb[i] == NULL) + return 0; + } + g_overflow = make_cb_button(parent, sd_l10n_get(SD_MSG_UI_MAIN_MORE_OPTIONS), UI_CTL_OVERFLOW); + return g_overflow != NULL; +} + +/* The list on the card: layer colors for the rows, the DarkMode_ItemsView theme of the helpers on dark (06 §3.5, + §3.6). The header is dark even without the helpers through fluent_header_draw. */ +static void apply_list_theme(void) +{ + const ui_theme *t = ui_theme_current(); + HWND hdr; + if (!g_fluent || g_app.list == NULL || t == NULL) + return; + SendMessageW(g_app.list, LVM_SETBKCOLOR, 0, (LPARAM)ui_theme_color(UI_TOK_LAYER)); + SendMessageW(g_app.list, LVM_SETTEXTBKCOLOR, 0, (LPARAM)ui_theme_color(UI_TOK_LAYER)); + SendMessageW(g_app.list, LVM_SETTEXTCOLOR, 0, (LPARAM)ui_theme_color(UI_TOK_TEXT)); + ui_theme_apply_control(g_app.list); + hdr = (HWND)SendMessageW(g_app.list, LVM_GETHEADER, 0, 0); + if (g_fx_settheme != NULL) { + /* the list keeps DarkMode_Explorer from ui_theme_apply_control (DarkMode_ItemsView drew column lines through + the empty area). The header is painted by fluent_header_draw, so it takes the classic (empty) theme: any + themed header continues its column dividers as lines through the list area on Windows 11. */ + if (hdr != NULL) + g_fx_settheme(hdr, L"", L""); + } + InvalidateRect(g_app.list, NULL, TRUE); + if (hdr != NULL) + InvalidateRect(hdr, NULL, TRUE); +} + +/* NM_CUSTOMDRAW of the list header: layer background, secondary text, a stroke line under the items. */ +static LRESULT fluent_header_draw(NMCUSTOMDRAW *cd) +{ + HDITEMW hi; + wchar_t text[96]; + RECT rc, line; + HFONT old; + if (cd->dwDrawStage == CDDS_PREPAINT) { + FillRect(cd->hdc, &cd->rc, ui_theme_brush(UI_TOK_LAYER)); + return CDRF_NOTIFYITEMDRAW | CDRF_NOTIFYPOSTPAINT; + } + if (cd->dwDrawStage == CDDS_POSTPAINT) { + /* the filler to the right of the last item is painted by the theme after the items: cover it */ + int n = (int)SendMessageW(cd->hdr.hwndFrom, HDM_GETITEMCOUNT, 0, 0); + GetClientRect(cd->hdr.hwndFrom, &rc); + if (n > 0 && SendMessageW(cd->hdr.hwndFrom, HDM_GETITEMRECT, (WPARAM)(n - 1), (LPARAM)&line)) + rc.left = line.right; + if (rc.left < rc.right) { + FillRect(cd->hdc, &rc, ui_theme_brush(UI_TOK_LAYER)); + line = rc; + line.top = line.bottom - 1; + FillRect(cd->hdc, &line, ui_theme_brush(UI_TOK_STROKE)); + } + return CDRF_DODEFAULT; + } + if (cd->dwDrawStage != CDDS_ITEMPREPAINT) + return CDRF_DODEFAULT; + rc = cd->rc; + text[0] = 0; + memset(&hi, 0, sizeof(hi)); + hi.mask = HDI_TEXT; + hi.pszText = text; + hi.cchTextMax = SD_COUNTOF(text); + SendMessageW(cd->hdr.hwndFrom, HDM_GETITEMW, (WPARAM)cd->dwItemSpec, (LPARAM)&hi); + FillRect(cd->hdc, &rc, ui_theme_brush(UI_TOK_LAYER)); + line = rc; + line.top = line.bottom - 1; + FillRect(cd->hdc, &line, ui_theme_brush(UI_TOK_STROKE)); + old = (HFONT)SelectObject(cd->hdc, g_app.font); + SetBkMode(cd->hdc, TRANSPARENT); + SetTextColor(cd->hdc, ui_theme_color(UI_TOK_TEXT_SECONDARY)); + rc.left += dpx(8); + rc.right -= dpx(4); + DrawTextW(cd->hdc, text, -1, &rc, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); + SelectObject(cd->hdc, old); + return CDRF_SKIPDEFAULT; +} + +/* NM_CUSTOMDRAW of the list on Fluent: the group headers (Vista+) are drawn here in UI_TOK_TEXT with a semibold + font, because the dark ListView theme leaves them dark blue on the dark layer. Rows keep the default drawing + (selection and focus stay the control's own). */ +static LRESULT fluent_list_draw(NMLVCUSTOMDRAW *cd) +{ + ui_lvgroup grp; + wchar_t text[128]; + LOGFONTW lf; + HFONT font, old; + RECT rc = cd->nmcd.rc, tr; + SIZE sz; + int id; + const ui_theme *t = ui_theme_current(); + int dark = t != NULL && t->dark; + + /* a group header is one CDDS_PREPAINT with dwItemType LVCDI_GROUP (the list itself and the rows have other types) */ + if (cd->nmcd.dwDrawStage == CDDS_PREPAINT && cd->dwItemType != LVCDI_GROUP) + return dark ? CDRF_NOTIFYITEMDRAW : CDRF_DODEFAULT; + if (cd->nmcd.dwDrawStage == CDDS_ITEMPREPAINT) { + /* the dark selection: Windows 10's DarkMode_Explorer list draws the selected row with the classic light + highlight behind the text, so the row is filled with UI_TOK_SELECTION here and drawn as not selected */ + int item = (int)cd->nmcd.dwItemSpec; + if (!dark || !(SendMessageW(g_app.list, LVM_GETITEMSTATE, (WPARAM)item, LVIS_SELECTED) & LVIS_SELECTED)) + return CDRF_DODEFAULT; + rc.left = LVIR_BOUNDS; + if (SendMessageW(g_app.list, LVM_GETITEMRECT, (WPARAM)item, (LPARAM)&rc)) + FillRect(cd->nmcd.hdc, &rc, ui_theme_brush(UI_TOK_SELECTION)); + cd->nmcd.uItemState &= ~(UINT)(CDIS_SELECTED | CDIS_FOCUS); + cd->clrTextBk = ui_theme_color(UI_TOK_SELECTION); + cd->clrText = ui_theme_color(UI_TOK_TEXT); + return CDRF_NEWFONT; + } + if (cd->nmcd.dwDrawStage != CDDS_PREPAINT) + return CDRF_DODEFAULT; + id = (int)cd->nmcd.dwItemSpec; + text[0] = 0; + memset(&grp, 0, sizeof(grp)); + grp.cbSize = sizeof(grp); + grp.mask = UI_LVGF_HEADER; + grp.pszHeader = text; + grp.cchHeader = SD_COUNTOF(text); + if (SendMessageW(g_app.list, LVM_GETGROUPINFO, (WPARAM)id, (LPARAM)&grp) < 0 || text[0] == 0) + return CDRF_DODEFAULT; + /* nmcd.rc of a group spans its header and its rows: draw in the header band only (LVM_GETGROUPRECT, Vista+) */ + rc.top = LVGGR_HEADER; + if (!SendMessageW(g_app.list, LVM_GETGROUPRECT, (WPARAM)id, (LPARAM)&rc)) + return CDRF_DODEFAULT; + memset(&lf, 0, sizeof(lf)); + GetObjectW(g_app.font, sizeof(lf), &lf); + lf.lfWeight = FW_SEMIBOLD; + font = CreateFontIndirectW(&lf); + old = (HFONT)SelectObject(cd->nmcd.hdc, font != NULL ? font : g_app.font); + SetBkMode(cd->nmcd.hdc, TRANSPARENT); + SetTextColor(cd->nmcd.hdc, ui_theme_color(UI_TOK_TEXT)); + tr = rc; + tr.left += dpx(6); + DrawTextW(cd->nmcd.hdc, text, -1, &tr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); + if (GetTextExtentPoint32W(cd->nmcd.hdc, text, (int)wcslen(text), &sz)) { + RECT line = rc; + line.left = tr.left + sz.cx + dpx(8); + line.right -= dpx(4); + line.top = (rc.top + rc.bottom) / 2; + line.bottom = line.top + 1; + if (line.left < line.right) + FillRect(cd->nmcd.hdc, &line, ui_theme_brush(UI_TOK_STROKE)); + } + SelectObject(cd->nmcd.hdc, old); + if (font != NULL) + DeleteObject(font); + return CDRF_SKIPDEFAULT; +} + +/* The ListView is subclassed to see the custom draw of its header: the control sends the header's WM_NOTIFY to + itself and does not forward NM_CUSTOMDRAW to its parent. */ +static WNDPROC g_list_proc; + +static LRESULT CALLBACK list_proc(HWND h, UINT msg, WPARAM wp, LPARAM lp) +{ + if (msg == WM_NOTIFY && lp != 0) { + NMHDR *nm = (NMHDR *)lp; + if (nm->code == NM_CUSTOMDRAW && nm->hwndFrom == (HWND)SendMessageW(h, LVM_GETHEADER, 0, 0) && + ui_theme_brush(UI_TOK_LAYER) != NULL) + return fluent_header_draw((NMCUSTOMDRAW *)nm); + } + return CallWindowProcW(g_list_proc, h, msg, wp, lp); +} + +/* Places the command bar, the card with the list and the status strip; sets the Mica frame (06 §6.1). */ +static void layout_fluent(HWND hwnd) +{ + RECT rc; + HDC dc; + HFONT old; + TEXTMETRICW tm; + const ui_theme *t = ui_theme_current(); + int m, w, h, bh, x, y, i, ox, ow, sh, cw; + int right[UI_TB_BUTTONS]; + + m = dpx(8); + GetClientRect(hwnd, &rc); + w = rc.right - rc.left; + h = rc.bottom - rc.top; + dc = GetDC(hwnd); + old = (HFONT)SelectObject(dc, g_app.font); + GetTextMetricsW(dc, &tm); + bh = tm.tmHeight + dpx(14); + if (bh < dpx(32)) + bh = dpx(32); + g_band_h = bh + dpx(12); + sh = tm.tmHeight + dpx(14); + y = dpx(6); + x = m; + ow = bh + dpx(8); + ox = w - m - ow; + for (i = 0; i < UI_TB_BUTTONS; i++) { + wchar_t text[96]; + SIZE sz; + int n = GetWindowTextW(g_cb[i], text, SD_COUNTOF(text)); + sz.cx = 0; + sz.cy = 0; + GetTextExtentPoint32W(dc, text, n, &sz); + cw = (int)sz.cx + dpx(24); + if (k_cb[i].gap) + x += dpx(10); + MoveWindow(g_cb[i], x, y, cw, bh, TRUE); + if (t != NULL && t->level != UI_LEVEL_A) + SetWindowRgn(g_cb[i], CreateRoundRectRgn(0, 0, cw + 1, bh + 1, dpx(8), dpx(8)), TRUE); + x += cw; + right[i] = x; + x += dpx(2); + } + SelectObject(dc, old); + ReleaseDC(hwnd, dc); + MoveWindow(g_overflow, ox, y, ow, bh, TRUE); + if (t != NULL && t->level != UI_LEVEL_A) + SetWindowRgn(g_overflow, CreateRoundRectRgn(0, 0, ow + 1, bh + 1, dpx(8), dpx(8)), TRUE); + for (i = 0; i < UI_TB_BUTTONS; i++) /* a narrow window hides what does not fit; the overflow menu stays */ + ShowWindow(g_cb[i], right[i] <= ox - dpx(4) ? SW_SHOWNA : SW_HIDE); + + g_card.left = m; + g_card.top = g_band_h; + g_card.right = w - m > m ? w - m : m; + g_card.bottom = h - m - sh - dpx(6); + if (g_card.bottom < g_card.top + 2) + g_card.bottom = g_card.top + 2; + g_strip.left = g_card.left; + g_strip.right = g_card.right; + g_strip.top = g_card.bottom + dpx(6); + g_strip.bottom = g_strip.top + sh; + MoveWindow(g_app.list, g_card.left + 1, g_card.top + 1, g_card.right - g_card.left - 2, + g_card.bottom - g_card.top - 2, TRUE); + MoveWindow(g_app.status, g_strip.left + dpx(10), g_strip.top + 1, g_strip.right - g_strip.left - dpx(20), + g_strip.bottom - g_strip.top - 2, TRUE); + update_frame(hwnd); + RedrawWindow(hwnd, NULL, NULL, RDW_INVALIDATE | RDW_ERASE | RDW_ALLCHILDREN); +} + +/* WM_ERASEBKGND: the band is black on Mica (alpha 0 lets the backdrop show), else the surface color. */ +static void fluent_erase(HWND hwnd, HDC dc) +{ + RECT rc, band; + GetClientRect(hwnd, &rc); + band = rc; + band.bottom = g_band_h < rc.bottom ? g_band_h : rc.bottom; + FillRect(dc, &band, mica_active() ? (HBRUSH)GetStockObject(BLACK_BRUSH) : ui_theme_brush(UI_TOK_SURFACE)); + rc.top = band.bottom; + FillRect(dc, &rc, ui_theme_brush(UI_TOK_SURFACE)); +} + +/* WM_PAINT: the card border and the status strip (the list and the status text are child windows). */ +static void fluent_paint(HWND hwnd) +{ + PAINTSTRUCT ps; + HDC dc = BeginPaint(hwnd, &ps); + FillRect(dc, &g_strip, ui_theme_brush(UI_TOK_LAYER)); + FrameRect(dc, &g_strip, ui_theme_brush(UI_TOK_STROKE)); + FrameRect(dc, &g_card, ui_theme_brush(UI_TOK_STROKE)); + EndPaint(hwnd, &ps); +} + +static int CALLBACK face_cb(const LOGFONTW *lf, const TEXTMETRICW *tm, DWORD type, LPARAM lp) +{ + (void)lf; + (void)tm; + (void)type; + *(int *)lp = 1; + return 0; +} + +/* 1 when "Segoe UI Variable Text" is installed (Windows 11); the body font uses it on Fluent (06 §3.3). */ +static int has_variable_face(void) +{ + static int known = -1; + LOGFONTW lf; + HDC dc; + int found = 0; + if (known >= 0) + return known; + memset(&lf, 0, sizeof(lf)); + lf.lfCharSet = DEFAULT_CHARSET; + wcscpy_s(lf.lfFaceName, SD_COUNTOF(lf.lfFaceName), L"Segoe UI Variable Text"); + dc = GetDC(NULL); + if (dc != NULL) { + EnumFontFamiliesExW(dc, &lf, (FONTENUMPROCW)face_cb, (LPARAM)&found, 0); + ReleaseDC(NULL, dc); + } + known = found; + return known; +} + static HFONT make_font(unsigned dpi) { NONCLIENTMETRICSW ncm; @@ -118,13 +902,21 @@ static HFONT make_font(unsigned dpi) sys = UI_DPI_BASE; h = (h * (long long)dpi + (long long)sys / 2) / (long long)sys; lf.lfHeight = (LONG)-h; + if (g_fluent && has_variable_face()) + wcscpy_s(lf.lfFaceName, SD_COUNTOF(lf.lfFaceName), L"Segoe UI Variable Text"); return CreateFontIndirectW(&lf); } static void set_children_font(void) { + size_t i; if (g_app.toolbar) SendMessageW(g_app.toolbar, WM_SETFONT, (WPARAM)g_app.font, MAKELPARAM(TRUE, 0)); + for (i = 0; i < UI_TB_BUTTONS; i++) + if (g_cb[i]) + SendMessageW(g_cb[i], WM_SETFONT, (WPARAM)g_app.font, MAKELPARAM(TRUE, 0)); + if (g_overflow) + SendMessageW(g_overflow, WM_SETFONT, (WPARAM)g_app.font, MAKELPARAM(TRUE, 0)); if (g_app.list) SendMessageW(g_app.list, WM_SETFONT, (WPARAM)g_app.font, MAKELPARAM(TRUE, 0)); if (g_app.status) @@ -187,6 +979,10 @@ static void layout(HWND hwnd) { RECT rc, sb; int m, w, h, sbh, lh, y, th; + if (g_fluent && g_app.list && g_app.status && g_overflow) { + layout_fluent(hwnd); + return; + } if (!g_app.list || !g_app.status || !g_app.toolbar) return; m = dpx(8); @@ -199,6 +995,8 @@ static void layout(HWND hwnd) th = toolbar_height(w - 2 * m > 0 ? w - 2 * m : 0); MoveWindow(g_app.toolbar, m, m / 2, w - 2 * m > 0 ? w - 2 * m : 0, th, TRUE); y = m / 2 + th + m / 2; + g_band_h = g_aero ? y : 0; /* the toolbar band: glass on Aero (06 §3.2) */ + update_frame(hwnd); lh = h - y - sbh - m; if (lh < 0) lh = 0; @@ -323,15 +1121,18 @@ static const wchar_t *security_text(unsigned sec) static void enable_tb(int id, int on) { + size_t i; if (g_app.toolbar) SendMessageW(g_app.toolbar, TB_ENABLEBUTTON, (WPARAM)id, MAKELPARAM(on ? TRUE : FALSE, 0)); + for (i = 0; i < UI_TB_BUTTONS; i++) + if (g_cb[i] != NULL && (int)GetDlgCtrlID(g_cb[i]) == id && IsWindowEnabled(g_cb[i]) != (on ? TRUE : FALSE)) + EnableWindow(g_cb[i], on ? TRUE : FALSE); } static void enable_menu(UINT id, int on) { - HMENU bar = g_app.main ? GetMenu(g_app.main) : NULL; - if (bar) - EnableMenuItem(bar, id, MF_BYCOMMAND | (on ? MF_ENABLED : MF_GRAYED)); + if (g_menu) + EnableMenuItem(g_menu, id, MF_BYCOMMAND | (on ? MF_ENABLED : MF_GRAYED)); } static void update_buttons(void) @@ -359,6 +1160,15 @@ static void update_buttons(void) enable_menu(UI_CMD_SECURITY, secure); } +/* The status bar (Classic, Aero) or the STATIC strip of the Fluent window shows the text. */ +static void put_status(const wchar_t *text) +{ + if (g_fluent) + SetWindowTextW(g_app.status, text); + else + SendMessageW(g_app.status, SB_SETTEXTW, 0, (LPARAM)text); +} + static void set_status_text(void) { wchar_t *t; @@ -370,13 +1180,13 @@ static void set_status_text(void) if (!g_app.agent_up) { /* "ShellDrive is not running. Click here to start it." while a click starts it: "Starting ShellDrive..." */ text = sd_l10n_get(g_agent_starting ? SD_MSG_UI_STATUS_AGENT_STARTING : SD_MSG_UI_STATUS_AGENT_DOWN); - SendMessageW(g_app.status, SB_SETTEXTW, 0, (LPARAM)text); + put_status(text); return; } /* The status bar counts the drives that are mounted (reconnecting included), like the tray (03 §7.2). */ t = ui_format_count(SD_MSG_UI_STATUS_COUNT, (long long)ui_model_mounted_count(&g_app.model)); if (t != NULL) { - SendMessageW(g_app.status, SB_SETTEXTW, 0, (LPARAM)t); + put_status(t); sd_free(t); } } @@ -406,6 +1216,35 @@ static void insert_groups(void) } } +/* One list row: the cells, its group and the icons of the Status and Security cells (-1: none). */ +static void insert_row(int index, const wchar_t *const cells[UI_COLUMNS], int group, int status_image, int sec_image) +{ + LVITEMW it; + int j; + memset(&it, 0, sizeof(it)); + it.mask = LVIF_TEXT | (g_groups_on ? LVIF_GROUPID : 0); + it.iItem = index; + it.pszText = (LPWSTR)cells[0]; + it.iGroupId = group; + SendMessageW(g_app.list, LVM_INSERTITEMW, 0, (LPARAM)&it); + for (j = 1; j < UI_COLUMNS; j++) { + LVITEMW sub; + memset(&sub, 0, sizeof(sub)); + sub.mask = LVIF_TEXT; + sub.iItem = index; + sub.iSubItem = j; + sub.pszText = (LPWSTR)cells[j]; + if (j == UI_COL_STATUS && status_image >= 0) { + sub.iImage = status_image; + sub.mask |= LVIF_IMAGE; + } else if (j == UI_COL_SECURITY && sec_image >= 0) { + sub.iImage = sec_image; + sub.mask |= LVIF_IMAGE; + } + SendMessageW(g_app.list, LVM_SETITEMW, 0, (LPARAM)&sub); + } +} + void ui_main_refresh(void) { size_t i; @@ -422,8 +1261,6 @@ void ui_main_refresh(void) const sd_profile *p = row->profile >= 0 ? ui_profiles_at((size_t)row->profile) : NULL; wchar_t name[160], server[300], status[96]; const wchar_t *cells[UI_COLUMNS]; - LVITEMW it; - int j; if (p) { ui_text_utf8(p->name, name, SD_COUNTOF(name)); @@ -442,30 +1279,9 @@ void ui_main_refresh(void) cells[3] = m ? m->backend : L""; cells[UI_COL_STATUS] = status; cells[UI_COL_SECURITY] = m ? security_text(m->sec) : L""; - memset(&it, 0, sizeof(it)); - it.mask = LVIF_TEXT | (g_groups_on ? LVIF_GROUPID : 0); - it.iItem = (int)i; - it.pszText = (LPWSTR)cells[0]; - it.iGroupId = row->group; - SendMessageW(g_app.list, LVM_INSERTITEMW, 0, (LPARAM)&it); - for (j = 1; j < UI_COLUMNS; j++) { - LVITEMW sub; - memset(&sub, 0, sizeof(sub)); - sub.mask = LVIF_TEXT; - sub.iItem = (int)i; - sub.iSubItem = j; - sub.pszText = (LPWSTR)cells[j]; - if (j == UI_COL_STATUS && m != NULL) { - sub.iImage = image_of(ui_mv_status_icon(ui_mv_status_of(m->state, m->session, m->attempt))); - if (sub.iImage >= 0) - sub.mask |= LVIF_IMAGE; - } else if (j == UI_COL_SECURITY && m != NULL) { - sub.iImage = image_of(ui_mv_sec_icon(m->sec)); - if (sub.iImage >= 0) - sub.mask |= LVIF_IMAGE; - } - SendMessageW(g_app.list, LVM_SETITEMW, 0, (LPARAM)&sub); - } + insert_row((int)i, cells, row->group, + m ? image_of(ui_mv_status_icon(ui_mv_status_of(m->state, m->session, m->attempt))) : -1, + m ? image_of(ui_mv_sec_icon(m->sec)) : -1); } SendMessageW(g_app.list, WM_SETREDRAW, TRUE, 0); InvalidateRect(g_app.list, NULL, TRUE); @@ -486,7 +1302,7 @@ void ui_main_reload_profiles(void) static void message(sd_msg_id id, UINT flags) { - MessageBoxW(g_app.main, sd_l10n_get(id), k_title, flags); + (void)ui_win_task(g_app.main, sd_l10n_get(id), NULL, NULL, flags); } /* Saves a profile (sd_profile_save), then has the agent read the store again (03 §2.4). */ @@ -665,6 +1481,7 @@ static void on_command(WORD id) case UI_CMD_OPEN_DRIVE: do_open_selected(); break; case UI_CMD_REFRESH: ui_ipc_list(); break; case UI_CMD_SECURITY: do_security_details(); break; + case UI_CTL_OVERFLOW: show_overflow(); break; case UI_CMD_HOSTKEYS: case UI_CMD_LOGS: case UI_CMD_SETTINGS: @@ -678,27 +1495,55 @@ static void on_command(WORD id) } } -static void on_dpi(HWND hwnd, unsigned dpi, const RECT *suggested) +static void rebuild_font(void) { HFONT old = g_app.font; + g_app.font = make_font(g_app.dpi); + set_children_font(); + if (old) + DeleteObject(old); +} + +static void on_dpi(HWND hwnd, unsigned dpi, const RECT *suggested) +{ if (dpi == 0) dpi = UI_DPI_BASE; g_app.dpi = dpi; if (suggested) SetWindowPos(hwnd, NULL, suggested->left, suggested->top, suggested->right - suggested->left, suggested->bottom - suggested->top, SWP_NOZORDER | SWP_NOACTIVATE); - g_app.font = make_font(dpi); - set_children_font(); - if (old) - DeleteObject(old); + rebuild_font(); build_images(); set_columns(); layout(hwnd); } -static HMENU build_menu(void) +/* The system theme, the colors, the accent, DWM composition or the dark setting changed (06 §4): the theme has been + re-selected by ui_theme_refresh. A change of tier between Fluent and the others takes effect at the next start + (the controls of the window are chosen at creation); the palette, dark/light and the Mica band follow live. */ +static void on_theme_changed(HWND hwnd) +{ + ui_theme_refresh(); + if (g_aero) { + /* composition on or off: the glass band follows live (margins 0 and normal toolbar painting without it) */ + update_frame(hwnd); + layout(hwnd); + RedrawWindow(hwnd, NULL, NULL, RDW_INVALIDATE | RDW_ERASE | RDW_ALLCHILDREN | RDW_FRAME); + return; + } + if (!g_fluent) + return; + ui_theme_apply_window(hwnd, UI_THEME_WND_MAIN); + apply_list_theme(); + rebuild_font(); + layout(hwnd); + RedrawWindow(hwnd, NULL, NULL, RDW_INVALIDATE | RDW_ERASE | RDW_ALLCHILDREN | RDW_FRAME); +} + +/* The menu bar (popup == 0) or, on Fluent, a popup menu with the same three submenus for the "..." button. */ +static HMENU build_menu(int popup) { - HMENU bar = CreateMenu(); + HMENU bar = popup ? CreatePopupMenu() : CreateMenu(); HMENU conn = CreatePopupMenu(); HMENU tools = CreatePopupMenu(); HMENU help = CreatePopupMenu(); @@ -744,7 +1589,7 @@ static HWND make_toolbar(HWND parent) size_t i; tb = CreateWindowExW(0, TOOLBARCLASSNAMEW, L"", - WS_CHILD | WS_VISIBLE | WS_TABSTOP | TBSTYLE_FLAT | TBSTYLE_LIST | TBSTYLE_WRAPABLE | + WS_CHILD | WS_VISIBLE | WS_TABSTOP | TBSTYLE_FLAT | TBSTYLE_LIST | TBSTYLE_WRAPABLE | (g_aero ? TBSTYLE_TRANSPARENT : 0) | CCS_NODIVIDER | CCS_NOPARENTALIGN | CCS_NORESIZE, 0, 0, 10, 10, parent, (HMENU)(UINT_PTR)UI_CTL_TOOLBAR, g_app.inst, NULL); if (tb == NULL) @@ -771,19 +1616,47 @@ static int on_create(HWND hwnd) LVCOLUMNW col; int i; + const ui_theme *theme = ui_theme_current(); + + /* Fluent: command bar, layer card and status strip instead of the menu bar, toolbar and status bar (06 §6.1) */ + g_fluent = theme != NULL && theme->tier == UI_TIER_FLUENT; + g_aero = theme != NULL && theme->tier == UI_TIER_AERO; + if (g_fluent || g_aero) { + fx_resolve(); + if (!g_bp_inited && g_fx_bpinit != NULL && g_fx_bpinit() >= 0) + g_bp_inited = 1; + } g_app.dpi = ui_dpi_window(hwnd); g_app.font = make_font(g_app.dpi); - SetMenu(hwnd, build_menu()); - - g_app.toolbar = make_toolbar(hwnd); - g_app.list = CreateWindowExW(WS_EX_CLIENTEDGE, L"SysListView32", L"", + g_menu = build_menu(g_fluent); + if (!g_fluent) + SetMenu(hwnd, g_menu); + + if (g_fluent) { + if (!make_command_bar(hwnd)) + return -1; + g_app.toolbar = NULL; + } else { + g_app.toolbar = make_toolbar(hwnd); + } + g_app.list = CreateWindowExW(g_fluent ? 0 : WS_EX_CLIENTEDGE, L"SysListView32", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP | LVS_REPORT | LVS_SINGLESEL | LVS_SHOWSELALWAYS, 0, 0, 10, 10, hwnd, (HMENU)(UINT_PTR)UI_CTL_LIST, g_app.inst, NULL); - g_app.status = CreateWindowExW(0, L"msctls_statusbar32", L"", WS_CHILD | WS_VISIBLE, 0, 0, 0, 0, hwnd, - (HMENU)(UINT_PTR)UI_CTL_STATUS, g_app.inst, NULL); - if (!g_app.toolbar || !g_app.list || !g_app.status) + if (g_fluent) + g_app.status = CreateWindowExW(0, L"STATIC", L"", WS_CHILD | WS_VISIBLE | SS_NOTIFY | SS_LEFT | SS_CENTERIMAGE, + 0, 0, 0, 0, hwnd, (HMENU)(UINT_PTR)UI_CTL_STATUS, g_app.inst, NULL); + else + g_app.status = CreateWindowExW(0, L"msctls_statusbar32", L"", WS_CHILD | WS_VISIBLE, 0, 0, 0, 0, hwnd, + (HMENU)(UINT_PTR)UI_CTL_STATUS, g_app.inst, NULL); + if ((!g_fluent && !g_app.toolbar) || !g_app.list || !g_app.status) return -1; + if (g_fluent) { + g_list_proc = (WNDPROC)SetWindowLongPtrW(g_app.list, GWLP_WNDPROC, (LONG_PTR)list_proc); + apply_list_theme(); + } else if (g_aero) { + ui_theme_apply_control(g_app.list); /* the Explorer theme of the white list under the glass band */ + } SendMessageW(g_app.list, LVM_SETEXTENDEDLISTVIEWSTYLE, 0, LVS_EX_FULLROWSELECT | LVS_EX_LABELTIP | LVS_EX_SUBITEMIMAGES | LVS_EX_DOUBLEBUFFER); @@ -837,23 +1710,27 @@ void ui_main_agent_start_done(sd_err err) ui_text_utf8(sd_err_name(err), code, SD_COUNTOF(code)); t = ui_format1(SD_MSG_UI_STATUS_AGENT_START_FAILED, "code", code); if (t != NULL) { - MessageBoxW(g_app.main, t, L"ShellDrive", MB_OK | MB_ICONERROR); + (void)ui_win_task(g_app.main, t, NULL, NULL, MB_OK | MB_ICONERROR); sd_free(t); } } set_status_text(); } -static void on_notify(const NMHDR *nm) +static LRESULT on_notify(NMHDR *nm) { if (nm->idFrom == UI_CTL_STATUS) { /* the status bar is the Start button while the agent is not running (7.5) */ if ((nm->code == NM_CLICK || nm->code == NM_DBLCLK) && !g_app.agent_up) ui_main_start_agent(); - return; + return 0; } + if (nm->idFrom == UI_CTL_TOOLBAR && nm->code == NM_CUSTOMDRAW) + return aero_glass() ? aero_toolbar_draw((NMTBCUSTOMDRAW *)nm) : CDRF_DODEFAULT; if (nm->idFrom != UI_CTL_LIST) - return; + return 0; + if (nm->code == NM_CUSTOMDRAW && g_fluent) + return fluent_list_draw((NMLVCUSTOMDRAW *)nm); if (nm->code == LVN_ITEMCHANGED) { update_buttons(); } else if (nm->code == NM_DBLCLK) { @@ -864,7 +1741,10 @@ static void on_notify(const NMHDR *nm) do_delete(); else if (kd->wVKey == VK_RETURN) activate_selected(); + else if (kd->wVKey == VK_TAB && g_fluent) + cb_focus_edge(GetKeyState(VK_SHIFT) < 0); } + return 0; } static LRESULT CALLBACK main_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) @@ -880,11 +1760,62 @@ static LRESULT CALLBACK main_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) layout(hwnd); return 0; case WM_COMMAND: + if (g_fluent && LOWORD(wp) == UI_CTL_STATUS) { + /* the status strip is the Start button while the agent is not running (7.5) */ + if (HIWORD(wp) == STN_CLICKED && !g_app.agent_up) + ui_main_start_agent(); + return 0; + } on_command(LOWORD(wp)); return 0; case WM_NOTIFY: - on_notify((const NMHDR *)lp); - return 0; + return on_notify((NMHDR *)lp); + case WM_DRAWITEM: + if (g_fluent && wp != 0 && ((const DRAWITEMSTRUCT *)lp)->CtlType == ODT_BUTTON) { + draw_cmd_button((const DRAWITEMSTRUCT *)lp); + return TRUE; + } + break; + case WM_CTLCOLORSTATIC: + if (g_fluent && (HWND)lp == g_app.status) { + SetTextColor((HDC)wp, ui_theme_color(UI_TOK_TEXT_SECONDARY)); + SetBkColor((HDC)wp, ui_theme_color(UI_TOK_LAYER)); + return (LRESULT)ui_theme_brush(UI_TOK_LAYER); + } + break; + case WM_ERASEBKGND: + if (g_fluent) { + fluent_erase(hwnd, (HDC)wp); + return 1; + } + if (aero_glass()) { + RECT rc; + GetClientRect(hwnd, &rc); + FillRect((HDC)wp, &rc, (HBRUSH)(COLOR_BTNFACE + 1)); + aero_fill_band((HDC)wp, hwnd); + return 1; + } + break; + case WM_PAINT: + if (g_fluent) { + fluent_paint(hwnd); + return 0; + } + if (aero_glass()) { + PAINTSTRUCT ps; + HDC dc = BeginPaint(hwnd, &ps); + aero_fill_band(dc, hwnd); + EndPaint(hwnd, &ps); + return 0; + } + break; + case WM_SYSCOMMAND: + /* Alt pressed alone (and F10) opens the overflow menu where the menu bar was (06 §6.1) */ + if (g_fluent && (wp & 0xFFF0) == SC_KEYMENU && lp == 0) { + show_overflow(); + return 0; + } + break; case WM_CLOSE: ShowWindow(hwnd, SW_HIDE); return 0; @@ -892,11 +1823,30 @@ static LRESULT CALLBACK main_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) on_dpi(hwnd, (unsigned)LOWORD(wp), (const RECT *)lp); return 0; case WM_SETTINGCHANGE: - /* the app theme changed (7.4): dark title bars follow it; the broadcast reaches every top-level window */ + /* the app theme changed (06 §4): the dark decision follows it; the broadcast reaches every window */ if (lp != 0 && wcscmp((const wchar_t *)lp, L"ImmersiveColorSet") == 0) - ui_dark_refresh(); + on_theme_changed(hwnd); + break; + case WM_THEMECHANGED: + case WM_SYSCOLORCHANGE: + case WM_DWMCOMPOSITIONCHANGED: + /* the system theme, the system colors or DWM composition changed: re-select the tier (06 §4) */ + on_theme_changed(hwnd); break; case WM_DESTROY: + if (g_bp_inited && g_fx_bpuninit != NULL) + g_fx_bpuninit(); + g_bp_inited = 0; + g_fluent = 0; + g_aero = 0; + g_band_h = 0; + memset(g_cb, 0, sizeof(g_cb)); + g_overflow = NULL; + g_hot = NULL; + if (g_menu != NULL && GetMenu(hwnd) == NULL) + DestroyMenu(g_menu); /* the Fluent popup; a menu bar goes with its window */ + g_menu = NULL; + g_frame_cy = 0; if (g_app.font) DeleteObject(g_app.font); g_app.font = NULL; @@ -942,6 +1892,10 @@ HWND ui_main_create(HINSTANCE inst, const wchar_t *class_name, int visible) g_app.main = hwnd; /* The creation size is in pixels of 96 DPI; WM_CREATE has set the window DPI by now. */ SetWindowPos(hwnd, NULL, 0, 0, dpx(UI_CLIENT_W), dpx(UI_CLIENT_H), SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + if (g_fluent) { + ui_theme_apply_window(hwnd, UI_THEME_WND_MAIN); /* Mica on level 'c' (06 §3.3) */ + layout(hwnd); /* sets the extended frame for the band */ + } if (visible) ShowWindow(hwnd, SW_SHOW); return hwnd; @@ -964,23 +1918,156 @@ int ui_main_test_controls(void) HWND header; HMENU bar; int i; - if (!g_app.list || !g_app.status || !g_app.toolbar || !IsWindow(g_app.list) || !IsWindow(g_app.status) || - !IsWindow(g_app.toolbar)) + if (!g_app.list || !g_app.status || !IsWindow(g_app.list) || !IsWindow(g_app.status)) return 0; - for (i = 0; i < UI_TB_BUTTONS; i++) - if (SendMessageW(g_app.toolbar, TB_COMMANDTOINDEX, (WPARAM)k_cmds[i], 0) < 0) + if (g_fluent) { + /* the command bar: eight owner-drawn command buttons with the UI_CMD_* ids and the overflow button; no toolbar, + no menu bar, a STATIC strip instead of the status bar (06 §6.1) */ + if (g_app.toolbar != NULL || GetMenu(g_app.main) != NULL || g_overflow == NULL || !IsWindow(g_overflow) || + GetDlgCtrlID(g_overflow) != UI_CTL_OVERFLOW) return 0; - /* eight buttons and two separators */ - if ((int)SendMessageW(g_app.toolbar, TB_BUTTONCOUNT, 0, 0) != UI_TB_BUTTONS + 2) - return 0; + for (i = 0; i < UI_TB_BUTTONS; i++) { + if (g_cb[i] == NULL || !IsWindow(g_cb[i]) || GetDlgCtrlID(g_cb[i]) != k_cmds[i] || + (GetWindowLongW(g_cb[i], GWL_STYLE) & BS_TYPEMASK) != BS_OWNERDRAW) + return 0; + } + } else { + if (!g_app.toolbar || !IsWindow(g_app.toolbar)) + return 0; + for (i = 0; i < UI_TB_BUTTONS; i++) + if (SendMessageW(g_app.toolbar, TB_COMMANDTOINDEX, (WPARAM)k_cmds[i], 0) < 0) + return 0; + /* eight buttons and two separators */ + if ((int)SendMessageW(g_app.toolbar, TB_BUTTONCOUNT, 0, 0) != UI_TB_BUTTONS + 2) + return 0; + } header = (HWND)SendMessageW(g_app.list, LVM_GETHEADER, 0, 0); if (header == NULL || (int)SendMessageW(header, HDM_GETITEMCOUNT, 0, 0) != UI_COLUMNS) return 0; if (g_images == NULL || (HIMAGELIST)SendMessageW(g_app.list, LVM_GETIMAGELIST, LVSIL_SMALL, 0) != g_images) return 0; - bar = g_app.main ? GetMenu(g_app.main) : NULL; + bar = g_menu; /* the menu bar, or the overflow popup on Fluent */ if (bar == NULL || GetMenuState(bar, UI_CMD_SECURITY, MF_BYCOMMAND) == (UINT)-1 || GetMenuState(bar, UI_CMD_DUPLICATE, MF_BYCOMMAND) == (UINT)-1) return 0; return 1; } + +/* ---- --test-shot (06 §8.2) ---- */ + +typedef struct ui_shot_row { + const wchar_t *cells[UI_COLUMNS]; + int group, status_image, sec_image; +} ui_shot_row; + +/* Fixture strings are test data and are not localized. */ +static const ui_shot_row k_shot_rows[4] = { + { { L"Work files", L"files.example.org", L"W:", L"Dokany", L"Connected", L"OK" }, UI_MV_GROUP_MINE, 0, 0 }, + { { L"Home server", L"nas.example.net", L"H:", L"WebDAV", L"Not mounted", L"Legacy encryption" }, + UI_MV_GROUP_MINE, -1, 2 }, + { { L"Projects", L"sftp.corp.example", L"P:", L"Dokany", L"Reconnecting (3)", L"OK" }, UI_MV_GROUP_ORG, 1, 0 }, + { { L"Shared archive", L"archive.corp.example", L"S:", L"Dokany", L"Connected", L"OK" }, UI_MV_GROUP_GLOBAL, 0, 0 } +}; + +static void pump_for(DWORD ms) +{ + uint64_t t0 = sd_tick_ms(); + MSG msg; + while (sd_tick_ms() - t0 < ms) { + while (PeekMessageW(&msg, NULL, 0, 0, PM_REMOVE)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + Sleep(10); + } +} + +/* The window rectangle from the screen DC into a 24-bit .bmp (the screen DC shows the Mica backdrop). */ +static int capture_to_bmp(HWND hwnd, const wchar_t *path) +{ + RECT r; + BITMAPINFO bi; + BITMAPFILEHEADER fh; + void *bits = NULL; + HDC screen, mem; + HBITMAP bmp, oldbmp; + HANDLE file; + DWORD done, size; + int w, h, ok = 0; + + fx_resolve(); + if (g_fx_getattr == NULL || FAILED(g_fx_getattr(hwnd, UI_DWMWA_EXTENDED_FRAME_BOUNDS, &r, sizeof(r)))) + GetWindowRect(hwnd, &r); + w = r.right - r.left; + h = r.bottom - r.top; + if (w <= 0 || h <= 0) + return 0; + memset(&bi, 0, sizeof(bi)); + bi.bmiHeader.biSize = sizeof(bi.bmiHeader); + bi.bmiHeader.biWidth = w; + bi.bmiHeader.biHeight = h; /* bottom-up */ + bi.bmiHeader.biPlanes = 1; + bi.bmiHeader.biBitCount = 24; + bi.bmiHeader.biCompression = BI_RGB; + screen = GetDC(NULL); + mem = CreateCompatibleDC(screen); + bmp = CreateDIBSection(screen, &bi, DIB_RGB_COLORS, &bits, NULL, 0); + if (screen == NULL || mem == NULL || bmp == NULL || bits == NULL) { + if (bmp) + DeleteObject(bmp); + if (mem) + DeleteDC(mem); + if (screen) + ReleaseDC(NULL, screen); + return 0; + } + oldbmp = (HBITMAP)SelectObject(mem, bmp); + size = (DWORD)(((w * 3 + 3) & ~3) * h); + if (BitBlt(mem, 0, 0, w, h, screen, r.left, r.top, SRCCOPY | 0x40000000 /* CAPTUREBLT */)) { + memset(&fh, 0, sizeof(fh)); + fh.bfType = 0x4D42; + fh.bfOffBits = (DWORD)(sizeof(fh) + sizeof(bi.bmiHeader)); + fh.bfSize = fh.bfOffBits + size; + bi.bmiHeader.biSizeImage = size; + file = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (file != INVALID_HANDLE_VALUE) { + ok = WriteFile(file, &fh, sizeof(fh), &done, NULL) && done == sizeof(fh) && + WriteFile(file, &bi.bmiHeader, sizeof(bi.bmiHeader), &done, NULL) && done == sizeof(bi.bmiHeader) && + WriteFile(file, bits, size, &done, NULL) && done == size; + CloseHandle(file); + } + } + SelectObject(mem, oldbmp); + DeleteObject(bmp); + DeleteDC(mem); + ReleaseDC(NULL, screen); + return ok; +} + +int ui_main_test_shot(HWND hwnd, const wchar_t *path) +{ + size_t i; + if (hwnd == NULL || path == NULL || !g_app.list) + return 0; + /* the fixture rows replace the list without an agent; g_rows keeps the groups for insert_groups */ + g_nrows = SD_COUNTOF(k_shot_rows); + for (i = 0; i < g_nrows; i++) { + g_rows[i].profile = -1; + g_rows[i].mount = -1; + g_rows[i].group = k_shot_rows[i].group; + } + SendMessageW(g_app.list, WM_SETREDRAW, FALSE, 0); + SendMessageW(g_app.list, LVM_DELETEALLITEMS, 0, 0); + if (g_groups_on) + insert_groups(); + for (i = 0; i < g_nrows; i++) + insert_row((int)i, k_shot_rows[i].cells, k_shot_rows[i].group, k_shot_rows[i].status_image, + k_shot_rows[i].sec_image); + SendMessageW(g_app.list, WM_SETREDRAW, TRUE, 0); + ListView_SetItemState(g_app.list, 0, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED); + set_status_text(); + SetWindowPos(hwnd, HWND_TOPMOST, 60, 60, dpx(880), dpx(560), SWP_SHOWWINDOW); + SetForegroundWindow(hwnd); + pump_for(1500); + return capture_to_bmp(hwnd, path); +} diff --git a/apps/shelldrive-ui/ui_prompt.c b/apps/shelldrive-ui/ui_prompt.c index 30a31e1..a268e52 100644 --- a/apps/shelldrive-ui/ui_prompt.c +++ b/apps/shelldrive-ui/ui_prompt.c @@ -18,11 +18,28 @@ #include "sd_l10n/sd_l10n.h" #include "sd_l10n_ui_ids.h" #include "sd_platform/sd_log.h" +#include "sd_platform/sd_platform.h" #include "sd_platform/sd_time.h" #include "ui_dlg.h" #include "ui_int.h" #include "ui_measure.h" #include "ui_prompt_model.h" +#include "ui_theme.h" + +/* Vista and later; not in the SDK 7.1A headers of the XP flavours (resolved or sent at run time only). */ +#ifndef BCM_FIRST +#define BCM_FIRST 0x1600 +#endif +#ifndef BCM_GETIDEALSIZE +#define BCM_GETIDEALSIZE (BCM_FIRST + 0x0001) +#endif +#ifndef BCM_SETNOTE +#define BCM_SETNOTE (BCM_FIRST + 0x0009) +#endif +#define UI_LIM_LARGE 1 /* LoadIconMetric: LIM_LARGE */ +#define UI_TEXT_MAININSTRUCTION 1 /* TEXTSTYLE part */ +#define UI_TMT_FONT 210 +#define UI_TMT_TEXTCOLOR 3803 #define UI_PROMPT_MAX_OPEN 16 #define UI_PROMPT_WIDTH 300 /* dialog units */ @@ -35,6 +52,12 @@ #define ID_P_DEADLINE 222 #define ID_P_FP 223 /* read-only fingerprint of a host key */ #define ID_P_BTN_ONCE 225 +#define ID_P_HEAD 230 /* the main instruction (first line) */ +#define ID_P_ICON 231 /* the icon of a trust prompt */ +#define UI_HEAD_PCT_BOLD 108 /* the main instruction: bold message font (XP, Classic without a theme) */ +#define UI_HEAD_PCT_AERO 133 /* 12 pt over the 9 pt message font (TEXT_MAININSTRUCTION) */ +#define UI_HEAD_PCT_FLUENT 160 /* about 15 pt semibold */ +#define UI_P_MAX_LINKS 3 #define ID_P_TEXT_BASE 300 /* 300 + index: static text lines */ #define TIMER_REFRESH 1 @@ -48,8 +71,24 @@ typedef struct ui_pdlg { HWND deadline; uint64_t deadline_at; /* sd_tick_ms value when the prompt times out */ int closing; + HWND head; /* the main instruction control, NULL when the prompt has none */ + HFONT head_font; + COLORREF head_color; + int head_color_set; + HICON icon; + int icon_owned; /* LoadIconMetric icons are destroyed, the shared system icon is not */ + WORD link_ids[UI_P_MAX_LINKS]; /* the command links (Vista and later) and their notes */ + const wchar_t *link_notes[UI_P_MAX_LINKS]; + size_t nlinks; } ui_pdlg; +/* Filled by the add_* builders of the dialog being built (single UI thread) and copied to the ui_pdlg. */ +static struct { + WORD ids[UI_P_MAX_LINKS]; + const wchar_t *notes[UI_P_MAX_LINKS]; + size_t n; +} g_links; + static ui_pdlg *g_open[UI_PROMPT_MAX_OPEN]; static size_t g_nopen; @@ -66,6 +105,71 @@ static void wipe_text(wchar_t *s, size_t units) SecureZeroMemory(s, units * sizeof(wchar_t)); } +/* ---- main instruction, icon and choices (06 §6.2) ---- */ + +/* 0: the message font in bold (XP, or Classic without a theme font), 1: the TEXT_MAININSTRUCTION style of the + visual styles (Vista and 7, also Classic forced on those), 2: semibold, larger, in the palette text color (8+). */ +static int head_kind(void) +{ + const ui_theme *t = ui_theme_current(); + if (t == NULL) + return 0; + if (t->tier == UI_TIER_FLUENT) + return 2; + if (t->tier == UI_TIER_AERO || sd_os_at_least(6, 0, 0)) + return 1; + return 0; +} + +static int head_pct(void) +{ + int k = head_kind(); + return k == 2 ? UI_HEAD_PCT_FLUENT : k == 1 ? UI_HEAD_PCT_AERO : UI_HEAD_PCT_BOLD; +} + +/* The system icon a trust prompt shows, or NULL for the credential prompts. */ +static const wchar_t *prompt_icon_name(ui_prompt_kind kind) +{ + switch (kind) { + case UI_PK_HOSTKEY: + return MAKEINTRESOURCEW(32514); /* IDI_QUESTION */ + case UI_PK_HOSTKEY_CHANGED: + case UI_PK_LEGACY_ACK: + case UI_PK_WEBDAV_TOKEN_ONLY: + return MAKEINTRESOURCEW(32515); /* IDI_WARNING */ + default: + return NULL; + } +} + +/* The first line of a prompt: its icon (trust prompts) and the main instruction. */ +static sd_err add_head(ui_dlg *d, const ui_prompt *p, const wchar_t *text) +{ + sd_err e = SD_OK; + if (prompt_icon_name(p->kind) != NULL) + e = ui_dlg_icon(d, ID_P_ICON); + if (e == SD_OK) + e = ui_dlg_heading(d, ID_P_HEAD, text, SS_NOPREFIX); + return e; +} + +/* The choices of a trust prompt: command links with notes on Vista and later, plain buttons otherwise. No + default button (TM-R03). */ +static sd_err add_choices(ui_dlg *d, const WORD *ids, const wchar_t *const *texts, const wchar_t *const *notes, + size_t n) +{ + size_t i; + g_links.n = 0; + if (d->command_links && n <= UI_P_MAX_LINKS) { + for (i = 0; i < n; i++) { + g_links.ids[i] = ids[i]; + g_links.notes[i] = notes[i]; + } + g_links.n = n; + } + return ui_dlg_commands(d, ids, texts, notes, n, 0); +} + /* ---- dialog layout ---- */ static sd_err add_hop_lines(ui_dlg *d, const ui_prompt *p) @@ -117,7 +221,7 @@ static sd_err add_intro_host(ui_dlg *d, const ui_prompt *p, sd_msg_id id) t = fmt(id, a, 2); if (t == NULL) return SD_E_NO_MEMORY; - e = ui_dlg_text(d, ID_P_TEXT_BASE, t, SS_NOPREFIX); + e = add_head(d, p, t); sd_free(t); return e; } @@ -125,7 +229,7 @@ static sd_err add_intro_host(ui_dlg *d, const ui_prompt *p, sd_msg_id id) static sd_err add_hostkey_dialog(ui_dlg *d, const ui_prompt *p) { static const WORD ids[3] = { IDOK, ID_P_BTN_ONCE, IDCANCEL }; - const wchar_t *texts[3]; + const wchar_t *texts[3], *notes[3]; sd_arg a[1]; wchar_t *t; sd_err e; @@ -142,12 +246,16 @@ static sd_err add_hostkey_dialog(ui_dlg *d, const ui_prompt *p) if (e == SD_OK) e = ui_dlg_text(d, ID_P_TEXT_BASE + 94, sd_l10n_get(SD_MSG_UI_PROMPT_FIELD_FINGERPRINT), 0); if (e == SD_OK) e = ui_dlg_multiline(d, ID_P_FP, p->fingerprint, 1); if (e == SD_OK) e = ui_dlg_text(d, ID_P_TEXT_BASE + 95, sd_l10n_get(SD_MSG_UI_PROMPT_HOSTKEY_CHECK), 0); - if (e == SD_OK) e = ui_dlg_text(d, ID_P_TEXT_BASE + 96, sd_l10n_get(SD_MSG_UI_PROMPT_HOSTKEY_CHOICE), 0); + if (e == SD_OK && !d->command_links) /* the notes of the command links say what each choice does */ + e = ui_dlg_text(d, ID_P_TEXT_BASE + 96, sd_l10n_get(SD_MSG_UI_PROMPT_HOSTKEY_CHOICE), 0); if (e == SD_OK) e = ui_dlg_text(d, ID_P_TEXT_BASE + 97, sd_l10n_get(SD_MSG_UI_PROMPT_CHOOSE_BUTTON), 0); texts[0] = sd_l10n_get(SD_MSG_UI_PROMPT_BTN_TRUST); texts[1] = sd_l10n_get(SD_MSG_UI_PROMPT_BTN_ONCE); texts[2] = sd_l10n_get(SD_MSG_UI_PROMPT_BTN_CANCEL); - if (e == SD_OK) e = ui_dlg_buttons(d, ids, texts, 3, 0); + notes[0] = sd_l10n_get(SD_MSG_UI_PROMPT_NOTE_TRUST); + notes[1] = sd_l10n_get(SD_MSG_UI_PROMPT_NOTE_ONCE); + notes[2] = sd_l10n_get(SD_MSG_UI_PROMPT_NOTE_CANCEL); + if (e == SD_OK) e = add_choices(d, ids, texts, notes, 3); return e; } @@ -227,7 +335,7 @@ static sd_err add_password_dialog(ui_dlg *d, const ui_prompt *p, int passphrase) t = fmt(SD_MSG_UI_PROMPT_TARGET_LINE, a, 2); if (t == NULL) return SD_E_NO_MEMORY; - e = ui_dlg_text(d, ID_P_TEXT_BASE + 98, t, SS_NOPREFIX); + e = add_head(d, p, t); sd_free(t); } if (e == SD_OK) e = add_profile_line(d, p); @@ -300,7 +408,7 @@ static sd_err add_password_change_dialog(ui_dlg *d, const ui_prompt *p) t = fmt(SD_MSG_UI_PROMPT_CHANGE_INTRO, a, 1); if (t == NULL) return SD_E_NO_MEMORY; - e = ui_dlg_text(d, ID_P_TEXT_BASE, t, SS_NOPREFIX); + e = add_head(d, p, t); sd_free(t); if (e == SD_OK) e = add_profile_line(d, p); if (e == SD_OK) e = add_field(d, sd_l10n_get(SD_MSG_UI_PROMPT_FIELD_CURRENT), 0, 1); @@ -317,7 +425,7 @@ static sd_err add_password_change_dialog(ui_dlg *d, const ui_prompt *p) static sd_err add_legacy_dialog(ui_dlg *d, const ui_prompt *p) { static const WORD ids[2] = { IDOK, IDCANCEL }; - const wchar_t *texts[2]; + const wchar_t *texts[2], *notes[2]; sd_arg a[2]; wchar_t *t; sd_err e; @@ -327,14 +435,16 @@ static sd_err add_legacy_dialog(ui_dlg *d, const ui_prompt *p) t = fmt(SD_MSG_UI_PROMPT_LEGACY_INTRO, a, 2); if (t == NULL) return SD_E_NO_MEMORY; - e = ui_dlg_text(d, ID_P_TEXT_BASE, t, SS_NOPREFIX); + e = add_head(d, p, t); sd_free(t); if (e == SD_OK) e = add_profile_line(d, p); if (e == SD_OK) e = add_hop_lines(d, p); if (e == SD_OK) e = ui_dlg_text(d, ID_P_TEXT_BASE + 93, sd_l10n_get(SD_MSG_UI_PROMPT_LEGACY_TEXT), 0); texts[0] = sd_l10n_get(SD_MSG_UI_PROMPT_BTN_ANYWAY); texts[1] = sd_l10n_get(SD_MSG_UI_PROMPT_BTN_CANCEL); - if (e == SD_OK) e = ui_dlg_buttons(d, ids, texts, 2, 0); + notes[0] = sd_l10n_get(SD_MSG_UI_PROMPT_NOTE_ANYWAY); + notes[1] = sd_l10n_get(SD_MSG_UI_PROMPT_NOTE_CANCEL); + if (e == SD_OK) e = add_choices(d, ids, texts, notes, 2); return e; } @@ -343,14 +453,16 @@ static sd_err add_legacy_dialog(ui_dlg *d, const ui_prompt *p) static sd_err add_token_only_dialog(ui_dlg *d, const ui_prompt *p) { static const WORD ids[2] = { IDOK, IDCANCEL }; - const wchar_t *texts[2]; + const wchar_t *texts[2], *notes[2]; sd_err e; - SD_UNUSED(p); - e = ui_dlg_text(d, ID_P_TEXT_BASE, sd_l10n_get(SD_MSG_UI_WEBDAV_TOKEN_ONLY_ASK), SS_NOPREFIX); + e = add_head(d, p, sd_l10n_get(SD_MSG_UI_PROMPT_TOKEN_ONLY_HEAD)); + if (e == SD_OK) e = ui_dlg_text(d, ID_P_TEXT_BASE, sd_l10n_get(SD_MSG_UI_WEBDAV_TOKEN_ONLY_ASK), SS_NOPREFIX); if (e == SD_OK) e = ui_dlg_text(d, ID_P_TEXT_BASE + 93, sd_l10n_get(SD_MSG_UI_PROMPT_CHOOSE_BUTTON), 0); texts[0] = sd_l10n_get(SD_MSG_UI_PROMPT_BTN_TOKEN_CONTINUE); texts[1] = sd_l10n_get(SD_MSG_UI_PROMPT_BTN_CANCEL); - if (e == SD_OK) e = ui_dlg_buttons(d, ids, texts, 2, 0); + notes[0] = sd_l10n_get(SD_MSG_UI_PROMPT_NOTE_TOKEN_CONTINUE); + notes[1] = sd_l10n_get(SD_MSG_UI_PROMPT_NOTE_CANCEL); + if (e == SD_OK) e = add_choices(d, ids, texts, notes, 2); return e; } @@ -368,7 +480,7 @@ static sd_err add_proxy_dialog(ui_dlg *d, const ui_prompt *p) t = fmt(SD_MSG_UI_PROMPT_PROXY_INTRO, a, 3); if (t == NULL) return SD_E_NO_MEMORY; - e = ui_dlg_text(d, ID_P_TEXT_BASE, t, SS_NOPREFIX); + e = add_head(d, p, t); sd_free(t); if (e == SD_OK) { a[0].name = "user"; a[0].type = SD_ARG_STR; a[0].v.s = p->user; @@ -432,7 +544,11 @@ static sd_err build_dialog(const ui_prompt *p, ui_dlg *d, unsigned char **blob, ui_measure_init(&m, face, points, dpi); title = dialog_title(p); e = ui_dlg_init(d, title ? title : L"ShellDrive", UI_PROMPT_WIDTH, face, points, ui_measure_cb, &m); + g_links.n = 0; if (e == SD_OK) { + const ui_theme *th = ui_theme_current(); + d->heading_pct = head_pct(); + d->command_links = th != NULL && th->command_links; switch (p->kind) { case UI_PK_HOSTKEY: e = add_hostkey_dialog(d, p); @@ -679,6 +795,173 @@ static void place_dialog(ui_pdlg *pd) } } +typedef HANDLE (WINAPI *pfn_OpenThemeData)(HWND hwnd, LPCWSTR classes); +typedef HRESULT (WINAPI *pfn_CloseThemeData)(HANDLE theme); +typedef HRESULT (WINAPI *pfn_GetThemeFont)(HANDLE theme, HDC dc, int part, int state, int prop, LOGFONTW *lf); +typedef HRESULT (WINAPI *pfn_GetThemeColor)(HANDLE theme, int part, int state, int prop, COLORREF *color); +typedef HRESULT (WINAPI *pfn_LoadIconMetric)(HINSTANCE inst, PCWSTR name, int lims, HICON *icon); + +static int face_installed(const wchar_t *face) +{ + LOGFONTW lf; + HFONT f; + HDC dc; + wchar_t got[LF_FACESIZE]; + int ok = 0; + memset(&lf, 0, sizeof(lf)); + lf.lfHeight = -12; + lf.lfCharSet = DEFAULT_CHARSET; + wcsncpy_s(lf.lfFaceName, SD_COUNTOF(lf.lfFaceName), face, _TRUNCATE); + f = CreateFontIndirectW(&lf); + dc = GetDC(NULL); + if (f != NULL && dc != NULL) { + HGDIOBJ old = SelectObject(dc, f); + got[0] = L'\0'; + GetTextFaceW(dc, SD_COUNTOF(got), got); + ok = _wcsicmp(got, face) == 0; + SelectObject(dc, old); + } + if (dc != NULL) + ReleaseDC(NULL, dc); + if (f != NULL) + DeleteObject(f); + return ok; +} + +/* The font and color of the main instruction (06 §6.2). The message font of the dialog is the base. */ +static void style_heading(ui_pdlg *pd) +{ + HWND hwnd = pd->hwnd; + HFONT base = (HFONT)SendMessageW(hwnd, WM_GETFONT, 0, 0); + LOGFONTW lf; + int kind = head_kind(); + int have = 0; + + pd->head = GetDlgItem(hwnd, ID_P_HEAD); + if (pd->head == NULL) + return; + memset(&lf, 0, sizeof(lf)); + if (base == NULL || GetObjectW(base, sizeof(lf), &lf) == 0) + GetObjectW(GetStockObject(DEFAULT_GUI_FONT), sizeof(lf), &lf); + if (kind == 1) { + /* TEXTSTYLE / TEXT_MAININSTRUCTION: Segoe UI 12 pt, dark blue on Aero; resolved by name from uxtheme.dll */ + pfn_OpenThemeData open_t = (pfn_OpenThemeData)sd_platform_resolve_by_name(L"uxtheme.dll", "OpenThemeData"); + pfn_CloseThemeData close_t = (pfn_CloseThemeData)sd_platform_resolve_by_name(L"uxtheme.dll", "CloseThemeData"); + pfn_GetThemeFont get_f = (pfn_GetThemeFont)sd_platform_resolve_by_name(L"uxtheme.dll", "GetThemeFont"); + pfn_GetThemeColor get_c = (pfn_GetThemeColor)sd_platform_resolve_by_name(L"uxtheme.dll", "GetThemeColor"); + HANDLE th = open_t != NULL ? open_t(hwnd, L"TEXTSTYLE") : NULL; + if (th != NULL) { + LOGFONTW tf; + HDC dc = GetDC(NULL); + memset(&tf, 0, sizeof(tf)); + if (get_f != NULL && dc != NULL && + SUCCEEDED(get_f(th, dc, UI_TEXT_MAININSTRUCTION, 0, UI_TMT_FONT, &tf)) && tf.lfHeight != 0) { + /* the style gives the height for the DPI of dc; keep its face and size, and the dialog's charset */ + tf.lfCharSet = lf.lfCharSet; + lf = tf; + have = 1; + } + if (dc != NULL) + ReleaseDC(NULL, dc); + if (have && ui_theme_current() != NULL && ui_theme_current()->tier == UI_TIER_AERO && get_c != NULL && + SUCCEEDED(get_c(th, UI_TEXT_MAININSTRUCTION, 0, UI_TMT_TEXTCOLOR, &pd->head_color))) + pd->head_color_set = 1; + if (close_t != NULL) + close_t(th); + } + if (!have) { + lf.lfHeight = lf.lfHeight * UI_HEAD_PCT_AERO / 100; + lf.lfWeight = FW_NORMAL; + } + } else if (kind == 2) { + lf.lfHeight = lf.lfHeight * UI_HEAD_PCT_FLUENT / 100; + lf.lfWeight = FW_SEMIBOLD; + wcsncpy_s(lf.lfFaceName, SD_COUNTOF(lf.lfFaceName), + face_installed(L"Segoe UI Variable Display") ? L"Segoe UI Variable Display" : L"Segoe UI", _TRUNCATE); + pd->head_color = ui_theme_color(UI_TOK_TEXT); + pd->head_color_set = 1; + } else { + lf.lfWeight = FW_BOLD; + } + pd->head_font = CreateFontIndirectW(&lf); + if (pd->head_font != NULL) + SendMessageW(pd->head, WM_SETFONT, (WPARAM)pd->head_font, FALSE); +} + +static void load_icon(ui_pdlg *pd) +{ + const wchar_t *name = prompt_icon_name(pd->p->kind); + HWND ctl = GetDlgItem(pd->hwnd, ID_P_ICON); + HMODULE cc = GetModuleHandleW(L"comctl32.dll"); + pfn_LoadIconMetric lim = cc != NULL ? (pfn_LoadIconMetric)GetProcAddress(cc, "LoadIconMetric") : NULL; + HICON icon = NULL; + if (name == NULL || ctl == NULL) + return; + if (lim != NULL && SUCCEEDED(lim(NULL, name, UI_LIM_LARGE, &icon)) && icon != NULL) { + pd->icon_owned = 1; + } else { + icon = LoadIconW(NULL, name); /* XP: the shared system icon */ + pd->icon_owned = 0; + } + pd->icon = icon; + if (icon != NULL) + SendMessageW(ctl, STM_SETICON, (WPARAM)icon, 0); +} + +/* Command links: the notes, then the ideal height of each for its width (BCM_GETIDEALSIZE). The links are the + last controls: they are stacked again at their ideal heights (never shorter than the template estimate) and + the dialog grows by the difference. */ +static void fit_links(ui_pdlg *pd) +{ + HWND hwnd = pd->hwnd; + size_t i; + int y = 0, gap = 0, old_bottom = 0, delta; + int tops[UI_P_MAX_LINKS], lefts[UI_P_MAX_LINKS], widths[UI_P_MAX_LINKS], heights[UI_P_MAX_LINKS]; + RECT wr; + + if (pd->nlinks == 0 || pd->nlinks > UI_P_MAX_LINKS) + return; + for (i = 0; i < pd->nlinks; i++) { + HWND b = GetDlgItem(hwnd, pd->link_ids[i]); + RECT r; + POINT pt; + SIZE sz; + if (b == NULL) + return; + if (pd->link_notes[i] != NULL) + SendMessageW(b, BCM_SETNOTE, 0, (LPARAM)pd->link_notes[i]); + GetWindowRect(b, &r); + pt.x = r.left; + pt.y = r.top; + ScreenToClient(hwnd, &pt); + lefts[i] = pt.x; + tops[i] = pt.y; + widths[i] = r.right - r.left; + heights[i] = r.bottom - r.top; + old_bottom = pt.y + heights[i]; + sz.cx = widths[i]; + sz.cy = 0; + if (SendMessageW(b, BCM_GETIDEALSIZE, 0, (LPARAM)&sz) && sz.cy > heights[i]) + heights[i] = sz.cy; + } + y = tops[0]; + { + /* the template gap between two links: the distance from the old bottom of the first to the top of the next */ + RECT r0; + GetWindowRect(GetDlgItem(hwnd, pd->link_ids[0]), &r0); + gap = pd->nlinks > 1 ? tops[1] - tops[0] - (r0.bottom - r0.top) : 0; + } + for (i = 0; i < pd->nlinks; i++) { + SetWindowPos(GetDlgItem(hwnd, pd->link_ids[i]), NULL, lefts[i], y, widths[i], heights[i], + SWP_NOZORDER | SWP_NOACTIVATE); + y += heights[i] + gap; + } + delta = (y - gap) - old_bottom; + if (delta != 0 && GetWindowRect(hwnd, &wr)) + SetWindowPos(hwnd, NULL, 0, 0, wr.right - wr.left, (wr.bottom - wr.top) + delta, + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); +} + static INT_PTR CALLBACK prompt_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { ui_pdlg *pd = (ui_pdlg *)GetWindowLongPtrW(hwnd, DWLP_USER); @@ -703,6 +986,10 @@ static INT_PTR CALLBACK prompt_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) pd->deadline_at = sd_tick_ms() + (uint64_t)pd->p->deadline_ms; if (pd->deadline != NULL) SetTimer(hwnd, TIMER_REFRESH, UI_PROMPT_REFRESH_MS, NULL); + style_heading(pd); + load_icon(pd); + fit_links(pd); + ui_theme_apply_dialog(hwnd); /* dark Fluent: title bar, DarkMode_* controls; nothing otherwise */ place_dialog(pd); if (pd->p->kind == UI_PK_HOSTKEY) { HWND fp = GetDlgItem(hwnd, ID_P_FP); @@ -715,6 +1002,23 @@ static INT_PTR CALLBACK prompt_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) } return FALSE; } + case WM_CTLCOLORDLG: + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: { + LRESULT res = 0; + if (pd != NULL && msg == WM_CTLCOLORSTATIC && pd->head != NULL && (HWND)lp == pd->head && pd->head_color_set && + !ui_theme_ctlcolor(msg, wp, lp, &res)) { + /* the main instruction in the color of its style on the light themes (the dark palette sets its own) */ + SetTextColor((HDC)wp, pd->head_color); + SetBkMode((HDC)wp, TRANSPARENT); + return (INT_PTR)GetSysColorBrush(COLOR_3DFACE); + } + if (ui_theme_ctlcolor(msg, wp, lp, &res)) + return (INT_PTR)res; + break; + } case WM_COMMAND: if (pd == NULL) break; @@ -750,10 +1054,15 @@ static INT_PTR CALLBACK prompt_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) } list_remove(pd); pd->hwnd = NULL; + pd->head = NULL; } return TRUE; case WM_NCDESTROY: if (pd != NULL) { + if (pd->head_font != NULL) + DeleteObject(pd->head_font); + if (pd->icon != NULL && pd->icon_owned) + DestroyIcon(pd->icon); SetWindowLongPtrW(hwnd, DWLP_USER, 0); sd_secure_free(pd->p, sizeof(*pd->p)); sd_free(pd); @@ -792,6 +1101,14 @@ static sd_err create_dialog(ui_prompt *p, int show, HWND *out) sd_free(pd); return e; } + { + size_t i; + pd->nlinks = g_links.n; + for (i = 0; i < g_links.n; i++) { + pd->link_ids[i] = g_links.ids[i]; + pd->link_notes[i] = g_links.notes[i]; + } + } hwnd = CreateDialogIndirectParamW(g_app.inst, (LPCDLGTEMPLATEW)blob, NULL, prompt_proc, (LPARAM)pd); sd_free(blob); if (hwnd == NULL) { @@ -905,7 +1222,29 @@ int ui_prompt_filter(MSG *msg) return 0; } -/* Smoke test (--test-prompts): every kind is built and its dialog created hidden, then destroyed. */ +/* The trust prompts have no default button (TM-R03) and, with command links, every choice is one. */ +static int trust_buttons_ok(HWND hwnd, const ui_pdlg *pd) +{ + static const WORD ids[3] = { IDOK, ID_P_BTN_ONCE, IDCANCEL }; + const ui_theme *t = ui_theme_current(); + size_t i; + for (i = 0; i < 3; i++) { + HWND b = GetDlgItem(hwnd, ids[i]); + LONG style; + if (b == NULL) + continue; /* the legacy and token prompts have two choices */ + style = GetWindowLongW(b, GWL_STYLE) & 0xF; + if (style == BS_DEFPUSHBUTTON || style == 0xF) + return 0; + if (t != NULL && t->command_links && style != 0xE) + return 0; + } + return pd == NULL || pd->head != NULL; +} + +/* Smoke test (--test-prompts): every kind is built and its dialog created hidden, then destroyed. The theme is + the one of --theme=<...> (main.c initializes it): the main instruction exists, the trust prompts keep no + default button, and on a tier with command links the choices are command links. */ int ui_prompt_smoke_test(void) { static const ui_prompt_kind kinds[] = { @@ -925,6 +1264,16 @@ int ui_prompt_smoke_test(void) sd_secure_free(p, sizeof(*p)); continue; } + { + ui_pdlg *pd = (ui_pdlg *)GetWindowLongPtrW(hwnd, DWLP_USER); + int trust = kinds[k] == UI_PK_HOSTKEY || kinds[k] == UI_PK_LEGACY_ACK || kinds[k] == UI_PK_WEBDAV_TOKEN_ONLY; + int has_head = kinds[k] != UI_PK_PASSPHRASE; /* the passphrase prompt opens with the key fingerprint */ + if (pd == NULL || (has_head && pd->head == NULL) || (trust && !trust_buttons_ok(hwnd, pd))) { + ok = 0; + } else if (prompt_icon_name(kinds[k]) != NULL && (pd->icon == NULL || GetDlgItem(hwnd, ID_P_ICON) == NULL)) { + ok = 0; + } + } DestroyWindow(hwnd); } return ok; diff --git a/apps/shelldrive-ui/ui_secdlg.c b/apps/shelldrive-ui/ui_secdlg.c index f087aaf..7cfc051 100644 --- a/apps/shelldrive-ui/ui_secdlg.c +++ b/apps/shelldrive-ui/ui_secdlg.c @@ -199,10 +199,11 @@ static void do_refuse(HWND owner, const sec_dlg *c) if (!save_profile(owner, &p)) return; /* the setting applies to the next connection: offer to reconnect now */ - text = ui_format1(SD_MSG_UI_SECDLG_REFUSE_ASK, "drive", c->drive); + text = ui_format1(SD_MSG_UI_SECDLG_REFUSE_TITLE, "drive", c->drive); if (text == NULL) return; - if (MessageBoxW(owner, text, L"ShellDrive", MB_YESNO | MB_ICONQUESTION) == IDYES && g_app.agent_up) { + if (ui_win_task(owner, text, sd_l10n_get(SD_MSG_UI_SECDLG_REFUSE_ASK), NULL, MB_YESNO | MB_ICONQUESTION) == IDYES && + g_app.agent_up) { strcpy_s(g_reconnect_profile, sizeof(g_reconnect_profile), c->profile_id); ui_ipc_unmount(c->drive); } diff --git a/apps/shelldrive-ui/ui_service.c b/apps/shelldrive-ui/ui_service.c index 6e8cbac..b06c0bb 100644 --- a/apps/shelldrive-ui/ui_service.c +++ b/apps/shelldrive-ui/ui_service.c @@ -28,6 +28,7 @@ #include "ui_dpi.h" #include "ui_int.h" #include "ui_service_model.h" +#include "ui_theme.h" #include "ui_win.h" #ifndef BCM_SETSHIELD @@ -537,6 +538,7 @@ static LRESULT CALLBACK sv_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) g_sv.font = ui_win_font(g_sv.dpi); ui_svc_view_init(&g_sv.view); create_children(hwnd); + ui_theme_apply_dialog(hwnd); layout(hwnd); update_controls(); return 0; diff --git a/apps/shelldrive-ui/ui_settings.c b/apps/shelldrive-ui/ui_settings.c index fb41569..2f45ca8 100644 --- a/apps/shelldrive-ui/ui_settings.c +++ b/apps/shelldrive-ui/ui_settings.c @@ -16,12 +16,14 @@ #include "sd_l10n/sd_l10n.h" #include "sd_l10n_ui_ids.h" #include "sd_platform/sd_log.h" +#include "sd_platform/sd_platform.h" #include "sd_policy/sd_policy.h" #include "sd_ssh/sd_known_hosts.h" #include "sd_webdav/sd_webdav.h" #include "ui_dpi.h" #include "ui_int.h" #include "ui_settings_model.h" +#include "ui_theme.h" #include "ui_update.h" #include "ui_win.h" @@ -67,6 +69,11 @@ #define ST_ID_CHK_PRIVACY 4440 #define ST_ID_NOTE_PRIVACY 4441 #define ST_ID_HINT_KINDS 4442 +#define ST_ID_LBL_STYLE 4443 +#define ST_ID_STYLE 4444 +#define ST_ID_NOTE_STYLE 4445 +#define ST_ID_CHK_DARK 4446 +#define ST_ID_NOTE_DARK 4447 #define ST_ID_KIND_BASE 4450 /* + sd_notify_kind */ #define ST_CLIENT_H 800 @@ -95,6 +102,7 @@ static struct { HWND tab, chk_openssh, chk_putty, note_openssh, note_putty, chk_roam, hint_roam, lbl_wc, val_wc; HWND note_lang; HWND agent_logon, lbl_write, write, note_write, chk_privacy, note_privacy, hint_kinds; + HWND lbl_style, style, note_style, chk_dark, note_dark; /* Appearance (06 5.3, 3.6) */ HWND kind[SD_NOTIFY_COUNT]; /* [ui] notify_ switches (Notifications page) */ ui_set_policy policy; /* the Group Policy values read when the window opened (03 §3.5) */ unsigned locked; /* UI_SET_F_* fields the policies lock */ @@ -216,6 +224,32 @@ static void policy_read(ui_set_policy *p) p->privacy_set = 1; p->privacy_strict = dw == 1 ? 1 : 0; } + if (sd_policy_get_dword(SD_POLICY_UISTYLE, &dw) && (dw == 0 || dw == 1)) { + p->style_set = 1; + p->style_classic = dw == 1 ? 1 : 0; + } + if (sd_policy_get_dword(SD_POLICY_UIDARKHELPERS, &dw) && dw == 0) + p->dark_off = 1; +} + +/* The windows the dark-mode helpers can theme need Windows 10 1809 (build 17763) or later (06 3.6). */ +static int dark_supported(void) +{ + const sd_os_version *v = sd_os_version_get(); + return v != NULL && v->build >= UI_THEME_BUILD_HELPERS_MIN; +} + +static int g_forced_tier = -1; /* the --theme override of the test modes; it wins over settings and policy */ + +void ui_settings_theme_options(const sd_settings *s, int forced_tier) +{ + ui_set_policy pol; + int style, dark; + g_forced_tier = forced_tier; + policy_read(&pol); + ui_settings_theme_effective(s, &pol, &style, &dark); + ui_theme_set_options(style == SD_SETTINGS_STYLE_CLASSIC ? UI_THEME_STYLE_CLASSIC : UI_THEME_STYLE_AUTO, dark, + forced_tier); } /* State of a live host key source (TM-R06), read-only: a file that other users can write reads as INSECURE. */ @@ -285,6 +319,12 @@ static void show_model(const ui_settings_model *m, unsigned locked, int openssh_ SendMessageW(g_st.chk_privacy, BM_SETCHECK, m->log_strict ? BST_CHECKED : BST_UNCHECKED, 0); EnableWindow(g_st.chk_privacy, (locked & UI_SET_F_PRIVACY) ? FALSE : TRUE); SetWindowTextW(g_st.note_privacy, (locked & UI_SET_F_PRIVACY) ? note_text(UI_SET_NOTE_MANAGED) : L""); + SendMessageW(g_st.style, CB_SETCURSEL, m->ui_style == SD_SETTINGS_STYLE_CLASSIC ? 1 : 0, 0); + EnableWindow(g_st.style, (locked & UI_SET_F_STYLE) ? FALSE : TRUE); + SetWindowTextW(g_st.note_style, (locked & UI_SET_F_STYLE) ? note_text(UI_SET_NOTE_MANAGED) : L""); + SendMessageW(g_st.chk_dark, BM_SETCHECK, m->dark_helpers ? BST_CHECKED : BST_UNCHECKED, 0); + EnableWindow(g_st.chk_dark, (!(locked & UI_SET_F_DARK) && dark_supported()) ? TRUE : FALSE); + SetWindowTextW(g_st.note_dark, (locked & UI_SET_F_DARK) ? note_text(UI_SET_NOTE_MANAGED) : L""); SendMessageW(g_st.notify, BM_SETCHECK, m->notify ? BST_CHECKED : BST_UNCHECKED, 0); for (k = 0; k < SD_NOTIFY_COUNT; k++) { SendMessageW(g_st.kind[k], BM_SETCHECK, (m->notify_kinds & (1u << k)) ? BST_CHECKED : BST_UNCHECKED, 0); @@ -318,10 +358,18 @@ static void populate_write(void) SendMessageW(g_st.write, CB_ADDSTRING, 0, (LPARAM)sd_l10n_get(SD_MSG_UI_SETTINGS_WRITE_BEHIND)); } +static void populate_style(void) +{ + SendMessageW(g_st.style, CB_RESETCONTENT, 0, 0); + SendMessageW(g_st.style, CB_ADDSTRING, 0, (LPARAM)sd_l10n_get(SD_MSG_UI_SETTINGS_APPEARANCE_AUTO)); + SendMessageW(g_st.style, CB_ADDSTRING, 0, (LPARAM)sd_l10n_get(SD_MSG_UI_SETTINGS_APPEARANCE_CLASSIC)); +} + static void populate(void) { fill_lang(); populate_write(); + populate_style(); SendMessageW(g_st.notify, BM_SETCHECK, g_st.model.notify ? BST_CHECKED : BST_UNCHECKED, 0); SendMessageW(g_st.logon, BM_SETCHECK, g_st.model.start_at_logon ? BST_CHECKED : BST_UNCHECKED, 0); @@ -402,6 +450,8 @@ static void on_save(void) m.agent_logon = SendMessageW(g_st.agent_logon, BM_GETCHECK, 0, 0) == BST_CHECKED ? 1 : 0; m.write_mode = SendMessageW(g_st.write, CB_GETCURSEL, 0, 0) == 1 ? SD_PROFILE_WRITE_BEHIND : SD_PROFILE_WRITE_THROUGH; m.log_strict = SendMessageW(g_st.chk_privacy, BM_GETCHECK, 0, 0) == BST_CHECKED ? 1 : 0; + m.ui_style = SendMessageW(g_st.style, CB_GETCURSEL, 0, 0) == 1 ? SD_SETTINGS_STYLE_CLASSIC : SD_SETTINGS_STYLE_AUTO; + m.dark_helpers = SendMessageW(g_st.chk_dark, BM_GETCHECK, 0, 0) == BST_CHECKED ? 1 : 0; for (k = 0; k < SD_NOTIFY_COUNT; k++) if (SendMessageW(g_st.kind[k], BM_GETCHECK, 0, 0) == BST_CHECKED) m.notify_kinds |= 1u << k; @@ -445,10 +495,18 @@ static void on_save(void) if ((changed != 0 || chan_changed) && g_app.agent_up && (g_app.agent_features & UI_FEAT_RELOAD)) (void)ui_ipc_call(UI_IPC_RELOAD, "config.reload", NULL); + /* the theme follows [ui] style and dark_helpers live: Settings, the flyout and the dialogs re-theme now; the main + window builds its controls only when it is created, so it changes with the next start (06 5.3) */ + if (changed & (UI_SET_F_STYLE | UI_SET_F_DARK)) { + ui_settings_theme_options(&s, g_forced_tier); + ui_theme_refresh(); + } g_st.model = m; g_st.loaded = s; if (changed & UI_SET_F_LANGUAGE) ui_win_message(g_st.wnd, sd_l10n_get(SD_MSG_UI_SETTINGS_RESTART_NOTE), MB_OK | MB_ICONINFORMATION); + if (changed & UI_SET_F_STYLE) + ui_win_message(g_st.wnd, sd_l10n_get(SD_MSG_UI_SETTINGS_APPEARANCE_RESTART), MB_OK | MB_ICONINFORMATION); DestroyWindow(g_st.wnd); } @@ -601,7 +659,7 @@ enum { ST_PAGE_GENERAL = 0, ST_PAGE_SECURITY, ST_PAGE_NOTIFY, ST_PAGE_UPDATES, S static void show_page(int page) { - HWND general[16], security[8], notify[1 + SD_NOTIFY_COUNT], updates[10]; + HWND general[21], security[8], notify[1 + SD_NOTIFY_COUNT], updates[10]; const size_t ng = SD_COUNTOF(general), ns = SD_COUNTOF(security), nn = SD_COUNTOF(notify), nu = SD_COUNTOF(updates); size_t i; @@ -614,6 +672,8 @@ static void show_page(int page) security[6] = g_st.chk_privacy; security[7] = g_st.note_privacy; general[12] = g_st.agent_logon; general[13] = g_st.lbl_write; general[14] = g_st.write; general[15] = g_st.note_write; + general[16] = g_st.lbl_style; general[17] = g_st.style; general[18] = g_st.note_style; + general[19] = g_st.chk_dark; general[20] = g_st.note_dark; notify[0] = g_st.hint_kinds; for (i = 0; i < SD_NOTIFY_COUNT; i++) notify[1 + i] = g_st.kind[i]; @@ -657,6 +717,8 @@ static void layout(HWND hwnd) if (lw_d > lw) lw = lw_d; lw_e = ui_win_text_width(g_st.font, sd_l10n_get(SD_MSG_UI_SETTINGS_WRITE_DEFAULT)); if (lw_e > lw) lw = lw_e; + lw_e = ui_win_text_width(g_st.font, sd_l10n_get(SD_MSG_UI_SETTINGS_APPEARANCE)); + if (lw_e > lw) lw = lw_e; lw += dpx(12); cw = dpx(260); @@ -692,6 +754,16 @@ static void layout(HWND hwnd) xp = x0 + lw + cw + gap; place(g_st.note_write, xp, y + dpx(4), x0 + pw - xp, rh); y += rh + gap; + place(g_st.lbl_style, x0, y + dpx(4), lw, rh); + place(g_st.style, x0 + lw, y, cw, dpx(200)); + SendMessageW(g_st.style, CB_SETDROPPEDWIDTH, (WPARAM)(pw - lw > cw ? pw - lw : cw), 0); + xp = x0 + lw + cw + gap; + place(g_st.note_style, xp, y + dpx(4), x0 + pw - xp, rh); + y += rh + gap / 2; + place(g_st.chk_dark, x0, y, pw, rh); + y += rh; + place(g_st.note_dark, x0 + dpx(20), y, pw - dpx(20), sh); + y += sh + gap; place(g_st.lbl_cache, x0, y + dpx(4), lw, rh); place(g_st.cache, x0 + lw, y, dpx(100), rh); x = x0 + lw + dpx(100) + gap; @@ -805,6 +877,13 @@ static void create_children(HWND hwnd) ST_ID_CHK_ROAM, hwnd); g_st.hint_roam = make_child(L"STATIC", sd_l10n_get(SD_MSG_UI_SETTINGS_ROAM_HINT), SS_LEFT | SS_NOPREFIX, ST_ID_HINT_ROAM, hwnd); + g_st.lbl_style = make_child(L"STATIC", sd_l10n_get(SD_MSG_UI_SETTINGS_APPEARANCE), SS_LEFT | SS_NOPREFIX, + ST_ID_LBL_STYLE, hwnd); + g_st.style = make_child(L"COMBOBOX", L"", WS_TABSTOP | WS_VSCROLL | CBS_DROPDOWNLIST, ST_ID_STYLE, hwnd); + g_st.note_style = make_child(L"STATIC", L"", SS_LEFT | SS_NOPREFIX, ST_ID_NOTE_STYLE, hwnd); + g_st.chk_dark = make_child(L"BUTTON", sd_l10n_get(SD_MSG_UI_SETTINGS_DARK_HELPERS), WS_TABSTOP | BS_AUTOCHECKBOX, + ST_ID_CHK_DARK, hwnd); + g_st.note_dark = make_child(L"STATIC", L"", SS_LEFT | SS_NOPREFIX, ST_ID_NOTE_DARK, hwnd); g_st.agent_logon = make_child(L"BUTTON", sd_l10n_get(SD_MSG_UI_SETTINGS_AGENT_LOGON), WS_TABSTOP | BS_AUTOCHECKBOX, ST_ID_AGENT_LOGON, hwnd); g_st.lbl_write = make_child(L"STATIC", sd_l10n_get(SD_MSG_UI_SETTINGS_WRITE_DEFAULT), SS_LEFT | SS_NOPREFIX, @@ -890,6 +969,7 @@ static LRESULT CALLBACK st_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) g_st.dpi = ui_dpi_window(hwnd); g_st.font = ui_win_font(g_st.dpi); create_children(hwnd); + ui_theme_apply_dialog(hwnd); layout(hwnd); return 0; case WM_SIZE: diff --git a/apps/shelldrive-ui/ui_settings_model.c b/apps/shelldrive-ui/ui_settings_model.c index c4a6fca..bc06ae3 100644 --- a/apps/shelldrive-ui/ui_settings_model.c +++ b/apps/shelldrive-ui/ui_settings_model.c @@ -36,6 +36,8 @@ void ui_settings_from_sd(const sd_settings *s, ui_settings_model *m) m->write_mode = s->default_write_mode == SD_PROFILE_WRITE_BEHIND ? SD_PROFILE_WRITE_BEHIND : SD_PROFILE_WRITE_THROUGH; m->log_strict = s->log_privacy == SD_SETTINGS_PRIVACY_STRICT ? 1 : 0; m->agent_logon = s->agent_start_at_logon ? 1 : 0; + m->ui_style = s->ui_style == SD_SETTINGS_STYLE_CLASSIC ? SD_SETTINGS_STYLE_CLASSIC : SD_SETTINGS_STYLE_AUTO; + m->dark_helpers = s->ui_dark_helpers ? 1 : 0; for (k = 0; k < SD_NOTIFY_COUNT; k++) if (s->notify_kind[k]) m->notify_kinds |= 1u << k; @@ -54,6 +56,8 @@ void ui_settings_to_sd(const ui_settings_model *m, sd_settings *s) s->default_write_mode = m->write_mode == SD_PROFILE_WRITE_BEHIND ? SD_PROFILE_WRITE_BEHIND : SD_PROFILE_WRITE_THROUGH; s->log_privacy = m->log_strict ? SD_SETTINGS_PRIVACY_STRICT : SD_SETTINGS_PRIVACY_STANDARD; s->agent_start_at_logon = m->agent_logon ? 1 : 0; + s->ui_style = m->ui_style == SD_SETTINGS_STYLE_CLASSIC ? SD_SETTINGS_STYLE_CLASSIC : SD_SETTINGS_STYLE_AUTO; + s->ui_dark_helpers = m->dark_helpers ? 1 : 0; for (k = 0; k < SD_NOTIFY_COUNT; k++) s->notify_kind[k] = (m->notify_kinds & (1u << k)) ? 1 : 0; } @@ -71,6 +75,10 @@ unsigned ui_settings_locked(const ui_set_policy *p) mask |= UI_SET_F_WRITE; if (p->privacy_set) mask |= UI_SET_F_PRIVACY; + if (p->style_set) + mask |= UI_SET_F_STYLE; + if (p->dark_off) + mask |= UI_SET_F_DARK; return mask; } @@ -87,6 +95,10 @@ unsigned ui_settings_apply_policy(ui_settings_model *m, const ui_set_policy *p) m->write_mode = SD_PROFILE_WRITE_THROUGH; if (mask & UI_SET_F_PRIVACY) m->log_strict = p->privacy_strict ? 1 : 0; + if (mask & UI_SET_F_STYLE) + m->ui_style = p->style_classic ? SD_SETTINGS_STYLE_CLASSIC : SD_SETTINGS_STYLE_AUTO; + if (mask & UI_SET_F_DARK) + m->dark_helpers = 0; return mask; } @@ -104,6 +116,8 @@ unsigned ui_settings_changed(const ui_settings_model *a, const ui_settings_model if (a->write_mode != b->write_mode) d |= UI_SET_F_WRITE; if ((a->log_strict != 0) != (b->log_strict != 0)) d |= UI_SET_F_PRIVACY; if ((a->agent_logon != 0) != (b->agent_logon != 0)) d |= UI_SET_F_AGENT; + if (a->ui_style != b->ui_style) d |= UI_SET_F_STYLE; + if ((a->dark_helpers != 0) != (b->dark_helpers != 0)) d |= UI_SET_F_DARK; if ((a->notify_kinds & UI_SET_KINDS_ALL) != (b->notify_kinds & UI_SET_KINDS_ALL)) d |= UI_SET_F_KINDS; return d; } @@ -122,6 +136,10 @@ void ui_settings_to_sd_masked(const ui_settings_model *m, unsigned locked, sd_se s->default_write_mode = keep.default_write_mode; if (locked & UI_SET_F_PRIVACY) s->log_privacy = keep.log_privacy; + if (locked & UI_SET_F_STYLE) + s->ui_style = keep.ui_style; + if (locked & UI_SET_F_DARK) + s->ui_dark_helpers = keep.ui_dark_helpers; } ui_set_note ui_settings_source_note(int enabled, int locked, int src_state) @@ -191,3 +209,17 @@ sd_err ui_settings_agent_run_command(const wchar_t *host_exe, wchar_t *out, size return SD_E_NAME_TOO_LONG; return SD_OK; } + +void ui_settings_theme_effective(const sd_settings *s, const ui_set_policy *p, int *style, int *dark_helpers) +{ + int st = s != NULL && s->ui_style == SD_SETTINGS_STYLE_CLASSIC ? SD_SETTINGS_STYLE_CLASSIC : SD_SETTINGS_STYLE_AUTO; + int dk = s == NULL || s->ui_dark_helpers ? 1 : 0; + if (p != NULL && p->style_set) + st = p->style_classic ? SD_SETTINGS_STYLE_CLASSIC : SD_SETTINGS_STYLE_AUTO; + if (p != NULL && p->dark_off) + dk = 0; + if (style != NULL) + *style = st; + if (dark_helpers != NULL) + *dark_helpers = dk; +} diff --git a/apps/shelldrive-ui/ui_settings_model.h b/apps/shelldrive-ui/ui_settings_model.h index 682000f..f16636a 100644 --- a/apps/shelldrive-ui/ui_settings_model.h +++ b/apps/shelldrive-ui/ui_settings_model.h @@ -35,6 +35,8 @@ typedef struct ui_settings_model { int log_strict; /* [log] privacy = strict (keep host names and paths out of the log, 10.1) */ int agent_logon; /* [agent] start_at_logon (the agent's Run value, 3.2 Start) */ unsigned notify_kinds; /* [ui] notify_: bit k is sd_notify_kind k (7.7); under the notify master */ + int ui_style; /* [ui] style: SD_SETTINGS_STYLE_AUTO or _CLASSIC (06 5.3) */ + int dark_helpers; /* [ui] dark_helpers: the dark-mode helpers are allowed (06 3.6) */ } ui_settings_model; /* Fields of the model, as bits of the masks below. */ @@ -50,6 +52,8 @@ typedef struct ui_settings_model { #define UI_SET_F_PRIVACY 0x4000u /* locked by policy LogPrivacy */ #define UI_SET_F_KINDS 0x8000u /* any [ui] notify_ switch */ #define UI_SET_F_AGENT 0x10000u /* [agent] start_at_logon */ +#define UI_SET_F_STYLE 0x20000u /* [ui] style; locked by policy UiStyle */ +#define UI_SET_F_DARK 0x40000u /* [ui] dark_helpers; locked by policy UiDarkHelpers = 0 */ /* The policy values the window reads (03 §3.5). The view fills it from sd_policy; the tests fill it by hand. A locked field is shown read-only with the value the policy enforces and is never written back to settings.ini. */ @@ -60,6 +64,9 @@ typedef struct ui_set_policy { int force_through; /* ForceWriteThrough = 1: the default write mode is through */ int privacy_set; /* LogPrivacy has a value (0 or 1) */ int privacy_strict; /* LogPrivacy = 1 */ + int style_set; /* UiStyle has a value (0 auto or 1 classic) */ + int style_classic; /* UiStyle = 1 */ + int dark_off; /* UiDarkHelpers = 0: the dark-mode helpers are off */ } ui_set_policy; /* Number of notification kinds with a switch, and the mask with all of them on. */ @@ -73,6 +80,10 @@ unsigned ui_settings_locked(const ui_set_policy *p); host key sources: off, because nothing is read from them). Returns the locked mask. */ unsigned ui_settings_apply_policy(ui_settings_model *m, const ui_set_policy *p); +/* The [ui] style and dark_helpers values the theme runs with: the settings, overridden by the policies UiStyle and + UiDarkHelpers (06 5.3, 3.6). style is SD_SETTINGS_STYLE_*; dark_helpers is 1 when the helpers are allowed. */ +void ui_settings_theme_effective(const sd_settings *s, const ui_set_policy *p, int *style, int *dark_helpers); + /* Mask of the fields that differ between two models (the write diff: nothing changed, nothing is written). */ unsigned ui_settings_changed(const ui_settings_model *before, const ui_settings_model *after); diff --git a/apps/shelldrive-ui/ui_svc.c b/apps/shelldrive-ui/ui_svc.c index e357128..0530279 100644 --- a/apps/shelldrive-ui/ui_svc.c +++ b/apps/shelldrive-ui/ui_svc.c @@ -415,7 +415,7 @@ static int launch_elevated(HWND owner, HANDLE *child, const wchar_t *args) if (!ShellExecuteExW(&sei)) { /* the user declining the UAC prompt is not an error to report */ if (GetLastError() != ERROR_CANCELLED) - MessageBoxW(owner, sd_l10n_get(SD_MSG_UI_ADMIN_LAUNCH_FAILED), L"ShellDrive", MB_OK | MB_ICONERROR); + ui_win_task(owner, sd_l10n_get(SD_MSG_UI_ADMIN_LAUNCH_FAILED), NULL, NULL, MB_OK | MB_ICONERROR); return 0; } *child = sei.hProcess; diff --git a/apps/shelldrive-ui/ui_taskdlg_model.c b/apps/shelldrive-ui/ui_taskdlg_model.c new file mode 100644 index 0000000..8802236 --- /dev/null +++ b/apps/shelldrive-ui/ui_taskdlg_model.c @@ -0,0 +1,96 @@ +/* shelldrive-ui: the mapping of MessageBox flags to a task dialog (06 §6.2). See ui_taskdlg_model.h. */ +#include +#include +#include + +#include "ui_taskdlg_model.h" + +#ifndef MB_TYPEMASK +#define MB_TYPEMASK 0x0000000Fu +#endif + +int ui_task_map_flags(UINT mb_flags, ui_task_map *out) +{ + static const int ids_ok[] = { IDOK }; + static const int ids_okcancel[] = { IDOK, IDCANCEL }; + static const int ids_yesno[] = { IDYES, IDNO }; + static const int ids_yesnocancel[] = { IDYES, IDNO, IDCANCEL }; + static const int ids_retrycancel[] = { IDRETRY, IDCANCEL }; + const int *ids; + size_t n, pick; + unsigned buttons; + int cancel; + + if (out == NULL) + return 0; + memset(out, 0, sizeof(*out)); + switch (mb_flags & MB_TYPEMASK) { + case MB_OK: + ids = ids_ok; n = 1; buttons = UI_TASK_BTN_OK; cancel = 1; + break; + case MB_OKCANCEL: + ids = ids_okcancel; n = 2; buttons = UI_TASK_BTN_OK | UI_TASK_BTN_CANCEL; cancel = 1; + break; + case MB_YESNO: + ids = ids_yesno; n = 2; buttons = UI_TASK_BTN_YES | UI_TASK_BTN_NO; cancel = 0; + break; + case MB_YESNOCANCEL: + ids = ids_yesnocancel; n = 3; buttons = UI_TASK_BTN_YES | UI_TASK_BTN_NO | UI_TASK_BTN_CANCEL; cancel = 1; + break; + case MB_RETRYCANCEL: + ids = ids_retrycancel; n = 2; buttons = UI_TASK_BTN_RETRY | UI_TASK_BTN_CANCEL; cancel = 1; + break; + default: + return 0; + } + switch (mb_flags & MB_DEFMASK) { + case MB_DEFBUTTON2: pick = 1; break; + case MB_DEFBUTTON3: pick = 2; break; + default: pick = 0; break; + } + if (pick >= n) + pick = 0; + out->buttons = buttons; + out->default_id = ids[pick]; + out->allow_cancel = cancel; + switch (mb_flags & MB_ICONMASK) { + case MB_ICONERROR: out->icon = UI_TASK_ICON_ERROR; break; /* also MB_ICONSTOP and MB_ICONHAND */ + case MB_ICONWARNING: out->icon = UI_TASK_ICON_WARNING; break; /* also MB_ICONEXCLAMATION */ + case MB_ICONINFORMATION: out->icon = UI_TASK_ICON_INFO; break; /* also MB_ICONASTERISK */ + default: out->icon = UI_TASK_ICON_NONE; break; + } + return 1; +} + +static size_t append(wchar_t *out, size_t cap, size_t len, const wchar_t *sep, const wchar_t *text) +{ + size_t sl = len > 0 ? wcslen(sep) : 0, tl = wcslen(text); + if (len + sl + tl + 1 > cap) + return (size_t)-1; + if (sl > 0) + memcpy(out + len, sep, sl * sizeof(wchar_t)); + memcpy(out + len + sl, text, tl * sizeof(wchar_t)); + out[len + sl + tl] = L'\0'; + return len + sl + tl; +} + +size_t ui_task_plain_text(const wchar_t *main_text, const wchar_t *content, const wchar_t *details, + wchar_t *out, size_t cap) +{ + size_t len = 0; + if (out == NULL || cap == 0) + return 0; + out[0] = L'\0'; + if (main_text == NULL || main_text[0] == L'\0') + return 0; + len = append(out, cap, 0, L"\n\n", main_text); + if (len != (size_t)-1 && content != NULL && content[0] != L'\0') + len = append(out, cap, len, L"\n\n", content); + if (len != (size_t)-1 && details != NULL && details[0] != L'\0') + len = append(out, cap, len, L"\n\n", details); + if (len == (size_t)-1) { + out[0] = L'\0'; + return 0; + } + return len; +} diff --git a/apps/shelldrive-ui/ui_taskdlg_model.h b/apps/shelldrive-ui/ui_taskdlg_model.h new file mode 100644 index 0000000..1913a62 --- /dev/null +++ b/apps/shelldrive-ui/ui_taskdlg_model.h @@ -0,0 +1,42 @@ +/* shelldrive-ui: the mapping of MessageBox flags to a task dialog (06 §6.2). Pure: no window or process calls, + * so the unit tests (tests/ui) build this file on its own through SD_UI_PURE_SOURCES. ui_win_task (ui_win.c) + * uses it to call TaskDialogIndirect with the buttons, icon and default button a MessageBoxW call would have. */ +#ifndef SD_UI_TASKDLG_MODEL_H +#define SD_UI_TASKDLG_MODEL_H + +#include + +/* TDCBF_* values of the common buttons (commctrl.h has them only from the Vista headers). */ +#define UI_TASK_BTN_OK 0x0001u +#define UI_TASK_BTN_YES 0x0002u +#define UI_TASK_BTN_NO 0x0004u +#define UI_TASK_BTN_CANCEL 0x0008u +#define UI_TASK_BTN_RETRY 0x0010u + +/* The main icon is MAKEINTRESOURCE(icon): TD_WARNING_ICON is -1, TD_ERROR_ICON -2, TD_INFORMATION_ICON -3. */ +#define UI_TASK_ICON_NONE 0 +#define UI_TASK_ICON_WARNING (-1) +#define UI_TASK_ICON_ERROR (-2) +#define UI_TASK_ICON_INFO (-3) + +typedef struct ui_task_map { + unsigned buttons; /* UI_TASK_BTN_* mask */ + int icon; /* UI_TASK_ICON_* */ + int default_id; /* IDOK, IDYES, IDNO, IDCANCEL or IDRETRY: the button with the focus */ + int allow_cancel; /* 1 when Escape / the close box can end the dialog (a Cancel button, or only OK) */ +} ui_task_map; + +/* Maps MB_* flags. Returns 1 on success; 0 for a button set a task dialog does not offer as common buttons + (MB_ABORTRETRYIGNORE, MB_CANCELTRYCONTINUE, MB_HELP), where the caller uses MessageBoxW. The buttons appear + in the order of MessageBox: OK / Cancel, Yes / No / Cancel, Retry / Cancel; MB_DEFBUTTONn picks the nth one + (a number past the last falls back to the first). MB_ICONQUESTION has no task dialog icon (the Windows + guidelines drop it), so it maps to none. */ +int ui_task_map_flags(UINT mb_flags, ui_task_map *out); + +/* The plain text of a message box when no task dialog is available: "main", then a blank line and "content", + then a blank line and "details" for each one that is non-empty. Returns the length (without the NUL), or 0 + when the buffer is too small or main is empty. */ +size_t ui_task_plain_text(const wchar_t *main_text, const wchar_t *content, const wchar_t *details, + wchar_t *out, size_t cap); + +#endif /* SD_UI_TASKDLG_MODEL_H */ diff --git a/apps/shelldrive-ui/ui_theme.c b/apps/shelldrive-ui/ui_theme.c new file mode 100644 index 0000000..19f5e78 --- /dev/null +++ b/apps/shelldrive-ui/ui_theme.c @@ -0,0 +1,716 @@ +/* shelldrive-ui: the OS-tiered theme (06 §4, §5). See ui_theme.h. + * + * Gathers the inputs (OS version, DWM, High Contrast, AppsUseLightTheme, visual styles, remote session), + * selects the tier with ui_theme_select and owns the palette: colors and cached brushes per token. Every + * post-XP function is resolved at run time, by name from System32 or, for the undocumented dark helpers of + * 06 §3.6, by ordinal; there is no static import of dwmapi.dll or uxtheme.dll in any flavour, so the XP + * import floor (tests/os-imports) stays empty. */ +#include +#include + +#include + +#include "sd/base.h" +#include "sd_platform/sd_platform.h" +#include "ui_dpi.h" +#include "ui_theme.h" +#include "ui_win.h" + +/* Window properties of the dialog helpers: the original window procedure of a themed window, the marker of a + window ui_theme_apply_dialog handled (a refresh re-applies it), and the controls that were switched to a dark + class or a dark list palette (so a switch back to a light theme can undo it). */ +#define UI_PROP_OLDPROC L"sd.theme.oldproc" +#define UI_PROP_DIALOG L"sd.theme.dialog" +#define UI_PROP_CTLDARK L"sd.theme.ctldark" +#define UI_PROP_LISTDARK L"sd.theme.listdark" +#define UI_PROP_LISTPROC L"sd.theme.listproc" + +#ifndef TMT_FONT +#define TMT_FONT 210 +#endif + +/* DWM attributes and values the XP SDK 7.1A headers lack (06 §12); DwmSetWindowAttribute itself is resolved + at run time, and an attribute the OS does not know fails harmlessly. */ +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif +#ifndef DWMWA_WINDOW_CORNER_PREFERENCE +#define DWMWA_WINDOW_CORNER_PREFERENCE 33 +#endif +#ifndef DWMWA_SYSTEMBACKDROP_TYPE +#define DWMWA_SYSTEMBACKDROP_TYPE 38 +#endif +#ifndef DWMWCP_ROUNDSMALL +#define DWMWCP_ROUNDSMALL 3 +#endif +#ifndef DWMSBT_MAINWINDOW +#define DWMSBT_MAINWINDOW 2 +#endif +#ifndef DWMSBT_TRANSIENTWINDOW +#define DWMSBT_TRANSIENTWINDOW 3 +#endif + +/* The undocumented dark-mode helpers of 06 §3.6, resolved by ordinal only. */ +#define UI_UX_ORD_REFRESH_IMMERSIVE_COLOR_POLICY_STATE 104 +#define UI_UX_ORD_ALLOW_DARK_MODE_FOR_WINDOW 133 +#define UI_UX_ORD_ALLOW_DARK_MODE_FOR_APP 135 /* SetPreferredAppMode(int) from build 18362 */ +#define UI_UX_ORD_FLUSH_MENU_THEMES 136 + +typedef HRESULT (WINAPI *ui_pfn_DwmSetWindowAttribute)(HWND hwnd, DWORD attribute, LPCVOID value, DWORD size); +typedef HRESULT (WINAPI *ui_pfn_DwmIsCompositionEnabled)(BOOL *enabled); +typedef HRESULT (WINAPI *ui_pfn_DwmGetColorizationColor)(DWORD *color, BOOL *opaque); +typedef HRESULT (WINAPI *ui_pfn_SetWindowTheme)(HWND hwnd, const wchar_t *subapp, const wchar_t *sublist); +typedef BOOL (WINAPI *ui_pfn_IsAppThemed)(void); +typedef BOOL (WINAPI *ui_pfn_IsThemeActive)(void); +typedef void (WINAPI *ui_pfn_RefreshImmersiveColorPolicyState)(void); +typedef void (WINAPI *ui_pfn_AllowDarkModeForWindow)(HWND hwnd, BOOL allow); +typedef void (WINAPI *ui_pfn_PreferredAppMode)(int mode); /* AllowDarkModeForApp(BOOL) before 18362 */ +typedef void (WINAPI *ui_pfn_FlushMenuThemes)(void); + +/* Classic and Aero map every token to a system color (06 §5.1); warning and error stay plain text there. */ +static const int k_sys_tokens[UI_TOK_COUNT] = { + COLOR_BTNFACE, /* UI_TOK_SURFACE */ + COLOR_WINDOW, /* UI_TOK_LAYER */ + COLOR_WINDOWTEXT, /* UI_TOK_TEXT */ + COLOR_GRAYTEXT, /* UI_TOK_TEXT_SECONDARY */ + COLOR_HIGHLIGHT, /* UI_TOK_ACCENT */ + COLOR_HIGHLIGHTTEXT,/* UI_TOK_ACCENT_TEXT */ + COLOR_BTNSHADOW, /* UI_TOK_STROKE */ + COLOR_HIGHLIGHT, /* UI_TOK_SELECTION */ + COLOR_WINDOWTEXT, /* UI_TOK_FOCUS */ + COLOR_WINDOWTEXT, /* UI_TOK_WARNING */ + COLOR_WINDOWTEXT /* UI_TOK_ERROR */ +}; + +static ui_pfn_DwmSetWindowAttribute g_dwm_set; +static ui_pfn_DwmIsCompositionEnabled g_dwm_composition; +static ui_pfn_DwmGetColorizationColor g_dwm_colorization; +static ui_pfn_SetWindowTheme g_ux_set_window_theme; +static ui_pfn_IsAppThemed g_ux_is_app_themed; +static ui_pfn_IsThemeActive g_ux_is_theme_active; +static ui_pfn_RefreshImmersiveColorPolicyState g_refresh_policy; +static ui_pfn_AllowDarkModeForWindow g_allow_window; +static ui_pfn_PreferredAppMode g_preferred_app_mode; +static ui_pfn_FlushMenuThemes g_flush_menus; +static HHOOK g_hook; +static ui_theme g_theme; +static int g_ready; +static int g_style_setting; +static int g_dark_helpers_setting = 1; +static int g_forced_tier = -1; +static int g_helpers_on; /* the last refresh switched the helper policy on */ +static COLORREF g_accent; +static COLORREF g_sys[UI_TOK_COUNT]; +static COLORREF g_colors[UI_TOK_COUNT]; +static HBRUSH g_brushes[UI_TOK_COUNT]; + +static int read_apps_use_light(void) +{ + HKEY key; + DWORD value = 0, type = 0, cb = sizeof(value); + LONG r; + if (RegOpenKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", 0, + KEY_QUERY_VALUE, &key) != ERROR_SUCCESS) + return -1; + r = RegQueryValueExW(key, L"AppsUseLightTheme", NULL, &type, (LPBYTE)&value, &cb); + RegCloseKey(key); + if (r != ERROR_SUCCESS || type != REG_DWORD || cb != sizeof(value)) + return -1; + return value ? 1 : 0; +} + +static void resolve_apis(void) +{ + const sd_os_version *v = sd_os_version_get(); + HMODULE ux; + + g_dwm_set = (ui_pfn_DwmSetWindowAttribute)sd_platform_resolve_by_name(L"dwmapi.dll", + "DwmSetWindowAttribute"); + g_dwm_composition = (ui_pfn_DwmIsCompositionEnabled)sd_platform_resolve_by_name(L"dwmapi.dll", + "DwmIsCompositionEnabled"); + g_dwm_colorization = (ui_pfn_DwmGetColorizationColor)sd_platform_resolve_by_name(L"dwmapi.dll", + "DwmGetColorizationColor"); + g_ux_set_window_theme = (ui_pfn_SetWindowTheme)sd_platform_resolve_by_name(L"uxtheme.dll", "SetWindowTheme"); + g_ux_is_app_themed = (ui_pfn_IsAppThemed)sd_platform_resolve_by_name(L"uxtheme.dll", "IsAppThemed"); + g_ux_is_theme_active = (ui_pfn_IsThemeActive)sd_platform_resolve_by_name(L"uxtheme.dll", "IsThemeActive"); + + /* The ordinals name other functions (or nothing) before 1809, so they are looked up from build 17763 only + (06 §3.6). */ + if (v == NULL || v->build < UI_THEME_BUILD_HELPERS_MIN) + return; + ux = sd_platform_load_system_dll(L"uxtheme.dll"); + if (ux == NULL) + return; + g_refresh_policy = (ui_pfn_RefreshImmersiveColorPolicyState)GetProcAddress( + ux, MAKEINTRESOURCEA(UI_UX_ORD_REFRESH_IMMERSIVE_COLOR_POLICY_STATE)); + g_allow_window = (ui_pfn_AllowDarkModeForWindow)GetProcAddress( + ux, MAKEINTRESOURCEA(UI_UX_ORD_ALLOW_DARK_MODE_FOR_WINDOW)); + g_preferred_app_mode = (ui_pfn_PreferredAppMode)GetProcAddress( + ux, MAKEINTRESOURCEA(UI_UX_ORD_ALLOW_DARK_MODE_FOR_APP)); + g_flush_menus = (ui_pfn_FlushMenuThemes)GetProcAddress(ux, + MAKEINTRESOURCEA(UI_UX_ORD_FLUSH_MENU_THEMES)); +} + +static void gather_inputs(ui_theme_inputs *in) +{ + const sd_os_version *v = sd_os_version_get(); + BOOL composed = FALSE; + HIGHCONTRASTW hc; + DWORD argb = 0; + BOOL opaque = FALSE; + + memset(in, 0, sizeof(*in)); + in->os_major = v != NULL ? v->major : 0; + in->os_minor = v != NULL ? v->minor : 0; + in->os_build = v != NULL ? v->build : 0; + in->dwm_present = g_dwm_set != NULL; + if (g_dwm_composition != NULL && SUCCEEDED(g_dwm_composition(&composed))) + in->composition = composed ? 1 : 0; + memset(&hc, 0, sizeof(hc)); + hc.cbSize = sizeof(hc); + if (SystemParametersInfoW(SPI_GETHIGHCONTRAST, sizeof(hc), &hc, 0)) + in->high_contrast = (hc.dwFlags & HCF_HIGHCONTRASTON) != 0; + in->apps_use_light = read_apps_use_light(); + in->themed = g_ux_is_app_themed != NULL && g_ux_is_theme_active != NULL && g_ux_is_app_themed() && + g_ux_is_theme_active(); + in->remote_session = GetSystemMetrics(SM_REMOTESESSION) != 0; + in->style_setting = g_style_setting; + in->dark_helpers_setting = g_dark_helpers_setting; + in->forced_tier = g_forced_tier; + in->helper_ordinals = g_allow_window != NULL && g_preferred_app_mode != NULL; + + g_accent = RGB(0x00, 0x78, 0xD4); /* the fallback of 06 §5.1 when DWM has no color */ + if (g_dwm_colorization != NULL && SUCCEEDED(g_dwm_colorization(&argb, &opaque))) + g_accent = RGB((int)((argb >> 16) & 0xFF), (int)((argb >> 8) & 0xFF), (int)(argb & 0xFF)); +} + +static void read_sys_colors(void) +{ + int i; + for (i = 0; i < UI_TOK_COUNT; i++) + g_sys[i] = GetSysColor(k_sys_tokens[i]); +} + +/* The brushes follow the colors; a brush whose color did not change is kept (06 §8.3 S3). */ +static void rebuild_brushes(void) +{ + int i; + for (i = 0; i < UI_TOK_COUNT; i++) { + LOGBRUSH lb; + if (g_brushes[i] != NULL && + (GetObjectW(g_brushes[i], sizeof(lb), &lb) == 0 || lb.lbColor != g_colors[i])) { + DeleteObject(g_brushes[i]); + g_brushes[i] = NULL; + } + if (g_brushes[i] == NULL) + g_brushes[i] = CreateSolidBrush(g_colors[i]); + } +} + +static void set_window_theme(HWND hwnd, const wchar_t *subapp) +{ + if (g_ux_set_window_theme != NULL) + g_ux_set_window_theme(hwnd, subapp, NULL); +} + +static int class_is(HWND hwnd, const wchar_t *name) +{ + wchar_t cls[32]; + if (GetClassNameW(hwnd, cls, SD_COUNTOF(cls)) == 0) + return 0; + return wcscmp(cls, name) == 0; +} + +/* The title bar attribute (when the tier wants it, the documented value or 19) and the per-window dark-mode + flag of the helpers. A light theme clears both, so a refresh can undo a dark one. */ +static void apply_dark(HWND hwnd, const ui_theme *t) +{ + int attr; + BOOL on; + + if (hwnd == NULL || g_dwm_set == NULL) + return; + attr = t->titlebar_attr != 0 ? t->titlebar_attr : DWMWA_USE_IMMERSIVE_DARK_MODE; + on = t->dark ? TRUE : FALSE; + g_dwm_set(hwnd, (DWORD)attr, &on, sizeof(on)); + if (g_allow_window != NULL && (t->dark_helpers || g_helpers_on)) + g_allow_window(hwnd, on); +} + +static void apply_helpers_policy(void) +{ + int want = g_theme.dark_helpers; + + if (g_preferred_app_mode != NULL) { + if (want) + g_preferred_app_mode(1); /* AllowDarkModeForApp(TRUE) / SetPreferredAppMode(AllowDark) */ + else if (g_helpers_on) + g_preferred_app_mode(0); /* back to the default when the helpers turn off */ + g_helpers_on = want; + } + if (want) { + if (g_refresh_policy != NULL) + g_refresh_policy(); + if (g_flush_menus != NULL) + g_flush_menus(); + } +} + +static int is_captioned(HWND hwnd) +{ + return (GetWindowLongW(hwnd, GWL_STYLE) & WS_CAPTION) == WS_CAPTION; +} + +static BOOL CALLBACK enum_cb(HWND hwnd, LPARAM lp) +{ + (void)lp; + if (is_captioned(hwnd)) + apply_dark(hwnd, &g_theme); + /* a window ui_theme_apply_dialog handled gets its controls re-themed, then every window repaints */ + if (GetPropW(hwnd, UI_PROP_DIALOG) != NULL) + ui_theme_apply_dialog(hwnd); + RedrawWindow(hwnd, NULL, NULL, RDW_INVALIDATE | RDW_ERASE | RDW_ALLCHILDREN | RDW_FRAME); + return TRUE; +} + +/* A window that is activated for the first time gets the attribute before it is seen in its own mode. */ +static LRESULT CALLBACK cbt_proc(int code, WPARAM wp, LPARAM lp) +{ + if (code == HCBT_ACTIVATE && wp != 0 && is_captioned((HWND)wp)) + apply_dark((HWND)wp, &g_theme); + return CallNextHookEx(g_hook, code, wp, lp); +} + +void ui_theme_set_options(int style_setting, int dark_helpers_setting, int forced_tier) +{ + g_style_setting = style_setting; + g_dark_helpers_setting = dark_helpers_setting ? 1 : 0; + g_forced_tier = forced_tier; +} + +void ui_theme_init(void) +{ + const sd_os_version *v = sd_os_version_get(); + + resolve_apis(); + if (g_dwm_set != NULL && v != NULL && v->build >= UI_THEME_BUILD_HELPERS_MIN && g_hook == NULL) + g_hook = SetWindowsHookExW(WH_CBT, cbt_proc, NULL, GetCurrentThreadId()); + ui_theme_refresh(); +} + +void ui_theme_shutdown(void) +{ + int i; + if (g_hook != NULL) + UnhookWindowsHookEx(g_hook); + g_hook = NULL; + for (i = 0; i < UI_TOK_COUNT; i++) { + if (g_brushes[i] != NULL) + DeleteObject(g_brushes[i]); + g_brushes[i] = NULL; + } + g_ready = 0; +} + +void ui_theme_refresh(void) +{ + ui_theme_inputs in; + + gather_inputs(&in); + ui_theme_select(&in, &g_theme); + g_ready = 1; + read_sys_colors(); + ui_theme_palette(&g_theme, g_accent, g_sys, g_colors); + rebuild_brushes(); + apply_helpers_policy(); + EnumThreadWindows(GetCurrentThreadId(), enum_cb, 0); +} + +const ui_theme *ui_theme_current(void) +{ + return g_ready ? &g_theme : NULL; +} + +COLORREF ui_theme_color(int token) +{ + if (token < 0 || token >= UI_TOK_COUNT) + return 0; + return g_colors[token]; +} + +HBRUSH ui_theme_brush(int token) +{ + if (token < 0 || token >= UI_TOK_COUNT) + return NULL; + return g_brushes[token]; +} + +void ui_theme_apply_window(HWND hwnd, int kind) +{ + const ui_theme *t = ui_theme_current(); + int backdrop = 0, corner = 0; + + if (hwnd == NULL || t == NULL) + return; + apply_dark(hwnd, t); + if (g_dwm_set == NULL) + return; + if (kind == UI_THEME_WND_MAIN && t->backdrop) + backdrop = DWMSBT_MAINWINDOW; /* Mica on the main window (06 §3.3, 2c) */ + else if (kind == UI_THEME_WND_FLYOUT && t->backdrop) + backdrop = DWMSBT_TRANSIENTWINDOW; /* Acrylic on the tray flyout (06 §6.3) */ + if (kind == UI_THEME_WND_FLYOUT && t->corners) + corner = DWMWCP_ROUNDSMALL; + if (backdrop != 0) + g_dwm_set(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, &backdrop, sizeof(backdrop)); + if (corner != 0) + g_dwm_set(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner)); +} + +void ui_theme_apply_control(HWND ctl) +{ + const ui_theme *t = ui_theme_current(); + + if (ctl == NULL || t == NULL) + return; + if (t->dark && t->dark_helpers) { + /* DarkMode_Explorer covers the ListView, the TreeView, buttons and the scroll bars; combo and edit use + the CFD classes (06 §3.6). */ + set_window_theme(ctl, class_is(ctl, L"Edit") || class_is(ctl, L"ComboBox") || + class_is(ctl, L"ComboBoxEx32") ? L"DarkMode_CFD" : L"DarkMode_Explorer"); + if (g_allow_window != NULL) + g_allow_window(ctl, TRUE); + SetPropW(ctl, UI_PROP_CTLDARK, (HANDLE)1); + return; + } + if (GetPropW(ctl, UI_PROP_CTLDARK) != NULL) { + /* the theme switched from dark to light while the control lives: back to the default class */ + set_window_theme(ctl, NULL); + if (g_allow_window != NULL) + g_allow_window(ctl, FALSE); + RemovePropW(ctl, UI_PROP_CTLDARK); + } + if ((t->tier == UI_TIER_FLUENT || t->tier == UI_TIER_AERO) && class_is(ctl, L"SysListView32")) { + /* the Explorer theme of the list on light Fluent and Aero (06 §6.1) */ + set_window_theme(ctl, L"Explorer"); + } + /* Classic: nothing (06 §3.1) */ +} + +int ui_theme_header_customdraw(NMCUSTOMDRAW *cd, LRESULT *result) +{ + HDITEMW hi; + wchar_t text[96]; + RECT rc, line; + HFONT font, old; + HWND hdr; + unsigned dpi; + + if (cd == NULL || result == NULL || ui_theme_brush(UI_TOK_LAYER) == NULL) + return 0; + hdr = cd->hdr.hwndFrom; + if (cd->dwDrawStage == CDDS_PREPAINT) { + FillRect(cd->hdc, &cd->rc, ui_theme_brush(UI_TOK_LAYER)); + *result = CDRF_NOTIFYITEMDRAW | CDRF_NOTIFYPOSTPAINT; + return 1; + } + if (cd->dwDrawStage == CDDS_POSTPAINT) { + /* the filler to the right of the last item is painted by the theme after the items: cover it */ + int n = (int)SendMessageW(hdr, HDM_GETITEMCOUNT, 0, 0); + GetClientRect(hdr, &rc); + if (n > 0 && SendMessageW(hdr, HDM_GETITEMRECT, (WPARAM)(n - 1), (LPARAM)&line)) + rc.left = line.right; + if (rc.left < rc.right) { + FillRect(cd->hdc, &rc, ui_theme_brush(UI_TOK_LAYER)); + line = rc; + line.top = line.bottom - 1; + FillRect(cd->hdc, &line, ui_theme_brush(UI_TOK_STROKE)); + } + *result = CDRF_DODEFAULT; + return 1; + } + if (cd->dwDrawStage != CDDS_ITEMPREPAINT) { + *result = CDRF_DODEFAULT; + return 1; + } + rc = cd->rc; + text[0] = 0; + memset(&hi, 0, sizeof(hi)); + hi.mask = HDI_TEXT; + hi.pszText = text; + hi.cchTextMax = SD_COUNTOF(text); + SendMessageW(hdr, HDM_GETITEMW, (WPARAM)cd->dwItemSpec, (LPARAM)&hi); + FillRect(cd->hdc, &rc, ui_theme_brush(UI_TOK_LAYER)); + line = rc; + line.top = line.bottom - 1; + FillRect(cd->hdc, &line, ui_theme_brush(UI_TOK_STROKE)); + font = (HFONT)SendMessageW(hdr, WM_GETFONT, 0, 0); + if (font == NULL) + font = (HFONT)GetStockObject(DEFAULT_GUI_FONT); + old = (HFONT)SelectObject(cd->hdc, font); + SetBkMode(cd->hdc, TRANSPARENT); + SetTextColor(cd->hdc, ui_theme_color(UI_TOK_TEXT_SECONDARY)); + dpi = ui_dpi_window(hdr); + rc.left += ui_dpi_scale(8, dpi); + rc.right -= ui_dpi_scale(4, dpi); + DrawTextW(cd->hdc, text, -1, &rc, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); + SelectObject(cd->hdc, old); + *result = CDRF_SKIPDEFAULT; + return 1; +} + +/* The list subclass: the header NM_CUSTOMDRAW reaches the ListView, not its parent. Only the dark palette + draws the header; otherwise the default handling runs. */ +static LRESULT CALLBACK list_proc(HWND h, UINT msg, WPARAM wp, LPARAM lp) +{ + WNDPROC old = (WNDPROC)GetPropW(h, UI_PROP_LISTPROC); + const ui_theme *t = ui_theme_current(); + + if (old == NULL) + return DefWindowProcW(h, msg, wp, lp); + if (msg == WM_NOTIFY && t != NULL && t->dark) { + NMHDR *nm = (NMHDR *)lp; + LRESULT r = 0; + if (nm->code == NM_CUSTOMDRAW && nm->hwndFrom == (HWND)SendMessageW(h, LVM_GETHEADER, 0, 0) && + ui_theme_header_customdraw((NMCUSTOMDRAW *)nm, &r)) + return r; + } + if (msg == WM_NCDESTROY) { + SetWindowLongPtrW(h, GWLP_WNDPROC, (LONG_PTR)old); + RemovePropW(h, UI_PROP_LISTPROC); + RemovePropW(h, UI_PROP_LISTDARK); + RemovePropW(h, UI_PROP_CTLDARK); + } + return CallWindowProcW(old, h, msg, wp, lp); +} + +void ui_theme_style_list(HWND list) +{ + const ui_theme *t = ui_theme_current(); + HWND hdr; + + if (list == NULL || t == NULL) + return; + hdr = (HWND)SendMessageW(list, LVM_GETHEADER, 0, 0); + if (GetPropW(list, UI_PROP_LISTPROC) == NULL) { + WNDPROC old = (WNDPROC)SetWindowLongPtrW(list, GWLP_WNDPROC, (LONG_PTR)list_proc); + if (old != NULL) + SetPropW(list, UI_PROP_LISTPROC, (HANDLE)old); + } + ui_theme_apply_control(list); + if (t->dark) { + SendMessageW(list, LVM_SETBKCOLOR, 0, (LPARAM)ui_theme_color(UI_TOK_LAYER)); + SendMessageW(list, LVM_SETTEXTBKCOLOR, 0, (LPARAM)ui_theme_color(UI_TOK_LAYER)); + SendMessageW(list, LVM_SETTEXTCOLOR, 0, (LPARAM)ui_theme_color(UI_TOK_TEXT)); + /* a themed header continues its column dividers through the list on Windows 11: the classic (empty) + theme and the custom draw above paint it */ + if (hdr != NULL) + set_window_theme(hdr, L""); + SetPropW(list, UI_PROP_LISTDARK, (HANDLE)1); + } else if (GetPropW(list, UI_PROP_LISTDARK) != NULL) { + SendMessageW(list, LVM_SETBKCOLOR, 0, (LPARAM)GetSysColor(COLOR_WINDOW)); + SendMessageW(list, LVM_SETTEXTBKCOLOR, 0, (LPARAM)GetSysColor(COLOR_WINDOW)); + SendMessageW(list, LVM_SETTEXTCOLOR, 0, (LPARAM)GetSysColor(COLOR_WINDOWTEXT)); + if (hdr != NULL) + set_window_theme(hdr, NULL); + RemovePropW(list, UI_PROP_LISTDARK); + } + InvalidateRect(list, NULL, TRUE); + if (hdr != NULL) + InvalidateRect(hdr, NULL, TRUE); +} + +/* The subclass of a themed dialog or plain window: on the dark palette it answers WM_CTLCOLOR* and erases the + background with the surface brush, so the owner's own window procedure needs no theme code. */ +static LRESULT CALLBACK themed_proc(HWND h, UINT msg, WPARAM wp, LPARAM lp) +{ + WNDPROC old = (WNDPROC)GetPropW(h, UI_PROP_OLDPROC); + const ui_theme *t = ui_theme_current(); + LRESULT r = 0; + + if (old == NULL) + return DefWindowProcW(h, msg, wp, lp); + if (t != NULL && t->dark) { + if (msg == WM_ERASEBKGND) { + RECT rc; + GetClientRect(h, &rc); + FillRect((HDC)wp, &rc, ui_theme_brush(UI_TOK_SURFACE)); + return 1; + } + if (ui_theme_ctlcolor(msg, wp, lp, &r)) + return r; + } + if (msg == WM_NCDESTROY) { + SetWindowLongPtrW(h, GWLP_WNDPROC, (LONG_PTR)old); + RemovePropW(h, UI_PROP_OLDPROC); + RemovePropW(h, UI_PROP_DIALOG); + } + return CallWindowProcW(old, h, msg, wp, lp); +} + +static BOOL CALLBACK apply_child_cb(HWND child, LPARAM lp) +{ + (void)lp; + if (class_is(child, L"SysHeader32")) + return TRUE; /* the list owns its header (ui_theme_style_list) */ + if (class_is(child, L"SysListView32")) + ui_theme_style_list(child); + else + ui_theme_apply_control(child); + return TRUE; +} + +void ui_theme_apply_dialog(HWND dlg) +{ + const ui_theme *t = ui_theme_current(); + + if (dlg == NULL || t == NULL) + return; + ui_theme_apply_window(dlg, UI_THEME_WND_DIALOG); + if (GetPropW(dlg, UI_PROP_OLDPROC) == NULL) { + WNDPROC old = (WNDPROC)SetWindowLongPtrW(dlg, GWLP_WNDPROC, (LONG_PTR)themed_proc); + if (old != NULL) + SetPropW(dlg, UI_PROP_OLDPROC, (HANDLE)old); + } + SetPropW(dlg, UI_PROP_DIALOG, (HANDLE)1); + EnumChildWindows(dlg, apply_child_cb, 0); +} + +int CALLBACK ui_theme_sheet_callback(HWND sheet, UINT msg, LPARAM lp) +{ + ui_win_sheet_callback(sheet, msg, lp); + if (msg == PSCB_INITIALIZED) + ui_theme_apply_dialog(sheet); /* the frame, the tab control and OK / Cancel / Apply */ + return 0; +} + +int ui_theme_ctlcolor(UINT msg, WPARAM wp, LPARAM lp, LRESULT *result) +{ + const ui_theme *t = ui_theme_current(); + HDC dc = (HDC)wp; + int bg; + + (void)lp; + if (t == NULL || !t->dark || result == NULL) + return 0; + /* edits and lists sit on the layer color, dialog backgrounds, labels and buttons on the surface (06 §5.1) */ + bg = msg == WM_CTLCOLOREDIT || msg == WM_CTLCOLORLISTBOX ? UI_TOK_LAYER : UI_TOK_SURFACE; + switch (msg) { + case WM_CTLCOLORDLG: + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + SetTextColor(dc, ui_theme_color(UI_TOK_TEXT)); + SetBkColor(dc, ui_theme_color(bg)); + *result = (LRESULT)ui_theme_brush(bg); + return 1; + default: + return 0; + } +} + +/* ---- fonts (06 §5.2) ---- */ + +static int CALLBACK font_exists_cb(const LOGFONTW *lf, const TEXTMETRICW *tm, DWORD type, LPARAM lp) +{ + (void)lf; + (void)tm; + (void)type; + *(int *)lp = 1; + return 0; +} + +static int font_exists(const wchar_t *face) +{ + LOGFONTW lf; + HDC dc = GetDC(NULL); + int found = 0; + + memset(&lf, 0, sizeof(lf)); + lf.lfCharSet = DEFAULT_CHARSET; + wcsncpy_s(lf.lfFaceName, LF_FACESIZE, face, _TRUNCATE); + EnumFontFamiliesExW(dc, &lf, (FONTENUMPROCW)font_exists_cb, (LPARAM)&found, 0); + ReleaseDC(NULL, dc); + return found; +} + +typedef void *(WINAPI *ui_pfn_OpenThemeData)(HWND hwnd, const wchar_t *classes); +typedef HRESULT (WINAPI *ui_pfn_GetThemeFont)(void *theme, HDC dc, int part, int state, int prop, LOGFONTW *lf); +typedef HRESULT (WINAPI *ui_pfn_CloseThemeData)(void *theme); + +/* TEXT_MAININSTRUCTION of the TEXTSTYLE class, the font of the Aero wizard and task dialog headings; 0 when the + theme does not have it (the caller falls back to the bold message font). */ +static int aero_instruction_font(LOGFONTW *lf, unsigned dpi) +{ + ui_pfn_OpenThemeData open_theme = (ui_pfn_OpenThemeData)sd_platform_resolve_by_name(L"uxtheme.dll", + "OpenThemeData"); + ui_pfn_GetThemeFont get_font = (ui_pfn_GetThemeFont)sd_platform_resolve_by_name(L"uxtheme.dll", + "GetThemeFont"); + ui_pfn_CloseThemeData close_theme = (ui_pfn_CloseThemeData)sd_platform_resolve_by_name(L"uxtheme.dll", + "CloseThemeData"); + void *theme; + LOGFONTW tf; + int ok = 0; + long long h; + unsigned sys = ui_dpi_system(); + + if (open_theme == NULL || get_font == NULL || close_theme == NULL) + return 0; + theme = open_theme(NULL, L"TEXTSTYLE"); + if (theme == NULL) + return 0; + memset(&tf, 0, sizeof(tf)); + if (SUCCEEDED(get_font(theme, NULL, 1 /* TEXT_MAININSTRUCTION */, 0, TMT_FONT, &tf)) && tf.lfFaceName[0] != 0) { + h = tf.lfHeight < 0 ? -(long long)tf.lfHeight : (long long)tf.lfHeight; + if (h != 0) { + if (sys == 0) + sys = UI_DPI_BASE; + if (dpi == 0) + dpi = UI_DPI_BASE; + h = (h * (long long)dpi + (long long)sys / 2) / (long long)sys; + *lf = tf; + lf->lfHeight = (LONG)-h; + ok = 1; + } + } + close_theme(theme); + return ok; +} + +HFONT ui_theme_font(int role, unsigned dpi) +{ + const ui_theme *t = ui_theme_current(); + HFONT base = ui_win_font(dpi); + LOGFONTW lf; + + if (base == NULL || role == UI_FONT_BODY || t == NULL) + return base; + memset(&lf, 0, sizeof(lf)); + GetObjectW(base, sizeof(lf), &lf); + DeleteObject(base); + lf.lfWeight = FW_BOLD; /* Classic and XP: the message font in bold */ + if (t->tier == UI_TIER_FLUENT) { + if (role == UI_FONT_HEADER) { + lf.lfHeight = lf.lfHeight * 3 / 2; /* a page title: one and a half times the body */ + if (font_exists(L"Segoe UI Variable Display")) { + wcscpy_s(lf.lfFaceName, LF_FACESIZE, L"Segoe UI Variable Display"); + } else if (font_exists(L"Segoe UI Semibold")) { + wcscpy_s(lf.lfFaceName, LF_FACESIZE, L"Segoe UI Semibold"); + lf.lfWeight = FW_NORMAL; + } + } else if (font_exists(L"Segoe UI Variable Text")) { + wcscpy_s(lf.lfFaceName, LF_FACESIZE, L"Segoe UI Variable Text"); + } else if (font_exists(L"Segoe UI Semibold")) { + wcscpy_s(lf.lfFaceName, LF_FACESIZE, L"Segoe UI Semibold"); + lf.lfWeight = FW_NORMAL; + } + if (lf.lfWeight == FW_BOLD) + lf.lfWeight = FW_SEMIBOLD; + } else if (t->tier == UI_TIER_AERO && role == UI_FONT_HEADER) { + LOGFONTW tf; + if (aero_instruction_font(&tf, dpi)) + lf = tf; + } + return CreateFontIndirectW(&lf); +} diff --git a/apps/shelldrive-ui/ui_theme.h b/apps/shelldrive-ui/ui_theme.h new file mode 100644 index 0000000..077d4af --- /dev/null +++ b/apps/shelldrive-ui/ui_theme.h @@ -0,0 +1,72 @@ +/* shelldrive-ui: the OS-tiered theme (06 §4, §5). The decision is ui_theme_model.c (pure). + * + * ui_theme_init resolves every post-XP function at run time (sd_platform_resolve_by_name for the dwmapi and + * uxtheme entry points, GetProcAddress by ordinal for the dark helpers of 06 §3.6; never a static import, so + * the XP import floor stays empty) and installs a thread WH_CBT hook, so every window of this thread gets the + * title bar attribute and the dark-mode flag when it is first activated. ui_theme_refresh re-gathers the + * inputs, re-selects and re-applies to every captioned window of this thread; the main window calls it on + * WM_SETTINGCHANGE "ImmersiveColorSet", WM_THEMECHANGED, WM_SYSCOLORCHANGE and WM_DWMCOMPOSITIONCHANGED. + * Only the UI thread calls these functions. */ +#ifndef SD_UI_THEME_H +#define SD_UI_THEME_H + +#include +#include + +#include "ui_theme_model.h" + +/* The kinds of top-level window ui_theme_apply_window knows (06 §3.3, §6.3). */ +#define UI_THEME_WND_MAIN 0 /* the main window: Mica backdrop on Fluent 2c */ +#define UI_THEME_WND_FLYOUT 1 /* the tray flyout: Acrylic backdrop and small corners on 11 */ +#define UI_THEME_WND_DIALOG 2 /* dialogs and property sheets: no backdrop */ + +/* ui_theme_set_options must be called before ui_theme_init. style: UI_THEME_STYLE_AUTO or _CLASSIC (06 §5.3); + dark_helpers_setting: 1 = the helpers of 3.6 are allowed, 0 = the kill switch; forced_tier: -1 none, else + UI_FORCE_TIER(tier, level, dark), the --theme override of the test modes (06 §8.1). */ +void ui_theme_set_options(int style_setting, int dark_helpers_setting, int forced_tier); + +void ui_theme_init(void); +void ui_theme_shutdown(void); +/* Re-gathers the inputs, re-selects and applies the theme to the windows of this thread. */ +void ui_theme_refresh(void); +/* The last decision; NULL before ui_theme_init. */ +const ui_theme *ui_theme_current(void); +/* The color of a UI_TOK_* token of the current theme. */ +COLORREF ui_theme_color(int token); +/* A cached solid brush of a UI_TOK_* token; recreated by ui_theme_refresh when the color changes. */ +HBRUSH ui_theme_brush(int token); +/* Applies the title bar attribute, the dark-mode flag and, per kind, the DWM backdrop and corners. */ +void ui_theme_apply_window(HWND hwnd, int kind); +/* Themes one control for the tier: the DarkMode_* classes with the helpers on dark, "Explorer" on the light + Fluent and Aero ListViews, nothing on Classic (06 §3.6, §6.1). */ +void ui_theme_apply_control(HWND ctl); +/* Dialogs and secondary windows on a dark Fluent theme (06 §3.5, §6.2, §6.4): ui_theme_apply_dialog themes + dlg (UI_THEME_WND_DIALOG) and every child control, and subclasses dlg so that on the dark palette it answers + WM_CTLCOLOR* and erases its background itself (works for dialogs and plain windows); call it from + WM_INITDIALOG or after creating the children. ui_theme_refresh re-applies it to the windows that called it. ui_theme_ctlcolor answers WM_CTLCOLORDLG/STATIC/BTN/EDIT/LISTBOX: it returns 1 and sets *result + to the brush to return when the dark palette applies, 0 otherwise (the default handling then runs, so + Classic, Aero, light Fluent and High Contrast keep the system colors). */ +void ui_theme_apply_dialog(HWND dlg); +int ui_theme_ctlcolor(UINT msg, WPARAM wp, LPARAM lp, LRESULT *result); + +/* Fonts by role (06 §5.2); the caller deletes the font. UI_FONT_HEADER: a window or page title (Aero: + TEXT_MAININSTRUCTION of the TEXTSTYLE theme; Fluent: Segoe UI Variable Display or Segoe UI Semibold at one and + a half times the body; Classic and XP: the message font in bold). UI_FONT_SECTION: a group title (Fluent: + Segoe UI Variable Text or Semibold at the body size; otherwise bold). UI_FONT_BODY: the message font. */ +#define UI_FONT_BODY 0 +#define UI_FONT_HEADER 1 +#define UI_FONT_SECTION 2 +HFONT ui_theme_font(int role, unsigned dpi); + +/* NM_CUSTOMDRAW of a ListView header on the dark palette: layer background, secondary text, a stroke line under + the items (the model of the main window's header). Returns 1 and sets *result when it handled the notification. */ +int ui_theme_header_customdraw(NMCUSTOMDRAW *cd, LRESULT *result); +/* Themes one ListView for the palette: the layer colors on dark (and back to the system colors after a switch to + light), the control theme, and a subclass that draws the header. ui_theme_apply_dialog calls it for every + list among the children. */ +void ui_theme_style_list(HWND list); +/* PROPSHEETHEADER pfnCallback of the editor and the wizard: ui_win_sheet_callback plus the theme of the sheet + frame, its tab control and its buttons on PSCB_INITIALIZED. */ +int CALLBACK ui_theme_sheet_callback(HWND sheet, UINT msg, LPARAM lp); + +#endif /* SD_UI_THEME_H */ diff --git a/apps/shelldrive-ui/ui_theme_model.c b/apps/shelldrive-ui/ui_theme_model.c new file mode 100644 index 0000000..42434e3 --- /dev/null +++ b/apps/shelldrive-ui/ui_theme_model.c @@ -0,0 +1,239 @@ +/* shelldrive-ui: the theme decision and the palette (06 §4, §5.1). See ui_theme_model.h. */ +#include + +#include + +#include "ui_theme_model.h" + +/* The contrast floors of 7.1, times 100: the accent is a UI boundary (3:1) and is adjusted in the palette + until it reads as text against the layer (4.5:1). */ +#define UI_CONTRAST_TEXT_X100 450 +#define UI_CONTRAST_ACCENT_X100 450 +/* The step of one accent adjustment, in RGB units toward black (light) or white (dark). */ +#define UI_ACCENT_STEP 8 + +static int is_aero_os(const ui_theme_inputs *in) +{ + return in->os_major == 6 && (in->os_minor == 0 || in->os_minor == 1); +} + +static int is_fluent_os(const ui_theme_inputs *in) +{ + return in->os_major > 6 || (in->os_major == 6 && in->os_minor >= 2); +} + +void ui_theme_select(const ui_theme_inputs *in, ui_theme *out) +{ + int tier, level = 0, helpers, apps_light; + + memset(out, 0, sizeof(*out)); + + /* The tier: High Contrast, the classic setting or no visual styles force Classic (3.1, 7.1); the OS + decides otherwise. task_dialogs, command_links and the Aero wizard stay on Vista+ in every tier, they + follow High Contrast by themselves (06 §4). */ + if (in->high_contrast || in->style_setting == UI_THEME_STYLE_CLASSIC || !in->themed) + tier = UI_TIER_CLASSIC; + else if (is_aero_os(in)) + tier = UI_TIER_AERO; + else if (is_fluent_os(in)) + tier = UI_TIER_FLUENT; + else + tier = UI_TIER_CLASSIC; /* XP and anything older (06 §3.1) */ + + if (tier == UI_TIER_FLUENT) { + if (in->os_build >= UI_THEME_BUILD_LEVEL_C) + level = UI_LEVEL_C; + else if (in->os_build >= UI_THEME_BUILD_WIN11) + level = UI_LEVEL_B; + else + level = UI_LEVEL_A; /* 8, 8.1 and 10 (06 §3.3) */ + } + + if (in->forced_tier != -1) { /* the --theme override of the test modes (06 §8.1) */ + tier = UI_FORCE_TIER_OF(in->forced_tier); + if (tier != UI_TIER_FLUENT) + level = 0; /* only Fluent has a sub-level (06 §3.3) */ + else if (UI_FORCE_LEVEL_OF(in->forced_tier) != 0) + level = UI_FORCE_LEVEL_OF(in->forced_tier); + } + + out->tier = tier; + out->level = level; + out->command_bar = tier == UI_TIER_FLUENT; + out->task_dialogs = in->os_major >= 6; + out->command_links = out->task_dialogs; + out->aero_wizard = out->task_dialogs; + out->glass = tier == UI_TIER_AERO && in->composition && !in->remote_session && in->dwm_present; + out->backdrop = tier == UI_TIER_FLUENT && level == UI_LEVEL_C && in->dwm_present; + out->corners = tier == UI_TIER_FLUENT && in->os_build >= UI_THEME_BUILD_WIN11 && in->dwm_present; + + helpers = ui_theme_helpers_allowed(in->os_build, in->helper_ordinals, in->dark_helpers_setting); + + /* Dark: Fluent only, when apps use the dark theme, through the documented title bar on 22000+ or the + helpers from 1809 (06 §3.5, §3.6). Windows 10 without the helpers stays light. */ + apps_light = in->apps_use_light; + if (in->forced_tier != -1 && UI_FORCE_DARK_OF(in->forced_tier)) + apps_light = 0; /* the -dark suffix of --theme (06 §8.1) */ + if (in->forced_tier != -1 && UI_FORCE_LIGHT_OF(in->forced_tier)) + apps_light = 1; /* the -light suffix of --theme */ + if (tier == UI_TIER_FLUENT && apps_light == 0 && in->dwm_present && + (in->os_build >= UI_THEME_BUILD_WIN11 || helpers)) + out->dark = 1; + out->dark_helpers = out->dark && helpers; + + /* The title bar attribute: 20 from build 18985, 19 on 17763..18984 with the helpers, nothing otherwise. */ + if (out->dark) { + if (in->os_build >= UI_THEME_BUILD_ATTR20_MIN) + out->titlebar_attr = UI_THEME_TITLEBAR_ATTR_20; + else if (out->dark_helpers) + out->titlebar_attr = UI_THEME_TITLEBAR_ATTR_19; + } +} + +int ui_theme_helpers_allowed(unsigned build, int ordinals_present, int setting) +{ + if (!setting || build < UI_THEME_BUILD_HELPERS_MIN) + return 0; /* the kill switch, or a build without the ordinals (06 §3.6) */ + if (build <= UI_THEME_HELPERS_MAX_TESTED) + return 1; /* on the allow-list kept from the lab runs */ + return ordinals_present ? 1 : 0; /* newer builds keep them only while the exports resolve */ +} + +/* WCAG 2.x linear value of each 8-bit sRGB channel: c/255/12.92 up to 0.03928, else ((c/255+0.055)/1.055)^2.4. + A table instead of pow(): the x86 CRT math dispatcher is not on the x86-ia32 ISA allow list (tools/sd-isacheck). */ +static const double k_linear[256] = { + 0.0000000000, 0.0003035270, 0.0006070540, 0.0009105810, 0.0012141079, 0.0015176349, + 0.0018211619, 0.0021246889, 0.0024282159, 0.0027317429, 0.0030352698, 0.0033465358, + 0.0036765073, 0.0040247170, 0.0043914420, 0.0047769535, 0.0051815167, 0.0056053916, + 0.0060488330, 0.0065120908, 0.0069954102, 0.0074990320, 0.0080231930, 0.0085681256, + 0.0091340587, 0.0097212173, 0.0103298230, 0.0109600940, 0.0116122452, 0.0122864884, + 0.0129830323, 0.0137020830, 0.0144438436, 0.0152085144, 0.0159962934, 0.0168073758, + 0.0176419545, 0.0185002201, 0.0193823610, 0.0202885631, 0.0212190104, 0.0221738848, + 0.0231533662, 0.0241576324, 0.0251868596, 0.0262412219, 0.0273208916, 0.0284260395, + 0.0295568344, 0.0307134437, 0.0318960331, 0.0331047666, 0.0343398068, 0.0356013149, + 0.0368894504, 0.0382043716, 0.0395462353, 0.0409151969, 0.0423114106, 0.0437350293, + 0.0451862044, 0.0466650863, 0.0481718242, 0.0497065660, 0.0512694584, 0.0528606470, + 0.0544802764, 0.0561284900, 0.0578054302, 0.0595112382, 0.0612460542, 0.0630100177, + 0.0648032667, 0.0666259386, 0.0684781698, 0.0703600957, 0.0722718507, 0.0742135684, + 0.0761853815, 0.0781874218, 0.0802198203, 0.0822827071, 0.0843762115, 0.0865004620, + 0.0886555863, 0.0908417112, 0.0930589628, 0.0953074666, 0.0975873471, 0.0998987282, + 0.1022417331, 0.1046164841, 0.1070231030, 0.1094617108, 0.1119324278, 0.1144353738, + 0.1169706678, 0.1195384280, 0.1221387722, 0.1247718176, 0.1274376804, 0.1301364767, + 0.1328683216, 0.1356333297, 0.1384316150, 0.1412632911, 0.1441284709, 0.1470272665, + 0.1499597898, 0.1529261520, 0.1559264637, 0.1589608351, 0.1620293756, 0.1651321945, + 0.1682694002, 0.1714411007, 0.1746474037, 0.1778884160, 0.1811642442, 0.1844749945, + 0.1878207723, 0.1912016827, 0.1946178304, 0.1980693196, 0.2015562538, 0.2050787364, + 0.2086368701, 0.2122307574, 0.2158605001, 0.2195261997, 0.2232279573, 0.2269658735, + 0.2307400485, 0.2345505822, 0.2383975738, 0.2422811225, 0.2462013267, 0.2501582847, + 0.2541520943, 0.2581828529, 0.2622506575, 0.2663556048, 0.2704977910, 0.2746773121, + 0.2788942635, 0.2831487404, 0.2874408377, 0.2917706498, 0.2961382708, 0.3005437944, + 0.3049873141, 0.3094689228, 0.3139887134, 0.3185467781, 0.3231432091, 0.3277780981, + 0.3324515363, 0.3371636150, 0.3419144249, 0.3467040564, 0.3515325995, 0.3564001441, + 0.3613067798, 0.3662525956, 0.3712376805, 0.3762621230, 0.3813260114, 0.3864294338, + 0.3915724777, 0.3967552307, 0.4019777798, 0.4072402119, 0.4125426135, 0.4178850708, + 0.4232676700, 0.4286904966, 0.4341536362, 0.4396571738, 0.4452011945, 0.4507857828, + 0.4564110232, 0.4620769997, 0.4677837961, 0.4735314961, 0.4793201831, 0.4851499401, + 0.4910208498, 0.4969329951, 0.5028864580, 0.5088813209, 0.5149176654, 0.5209955732, + 0.5271151257, 0.5332764040, 0.5394794890, 0.5457244614, 0.5520114015, 0.5583403896, + 0.5647115057, 0.5711248295, 0.5775804404, 0.5840784179, 0.5906188409, 0.5972017884, + 0.6038273389, 0.6104955708, 0.6172065624, 0.6239603917, 0.6307571363, 0.6375968740, + 0.6444796820, 0.6514056374, 0.6583748173, 0.6653872983, 0.6724431570, 0.6795424696, + 0.6866853124, 0.6938717613, 0.7011018919, 0.7083757799, 0.7156935005, 0.7230551289, + 0.7304607401, 0.7379104088, 0.7454042095, 0.7529422168, 0.7605245047, 0.7681511472, + 0.7758222183, 0.7835377915, 0.7912979403, 0.7991027380, 0.8069522577, 0.8148465722, + 0.8227857544, 0.8307698768, 0.8387990117, 0.8468732315, 0.8549926081, 0.8631572135, + 0.8713671192, 0.8796223969, 0.8879231179, 0.8962693534, 0.9046611744, 0.9130986518, + 0.9215818563, 0.9301108584, 0.9386857285, 0.9473065367, 0.9559733532, 0.9646862479, + 0.9734452904, 0.9822505503, 0.9911020971, 1.0000000000 +}; + +static double channel_luminance(unsigned c) +{ + return k_linear[c & 0xFF]; +} + +unsigned ui_theme_contrast_x100(COLORREF a, COLORREF b) +{ + double la = 0.2126 * channel_luminance(a & 0xFF) + 0.7152 * channel_luminance((a >> 8) & 0xFF) + + 0.0722 * channel_luminance((a >> 16) & 0xFF); + double lb = 0.2126 * channel_luminance(b & 0xFF) + 0.7152 * channel_luminance((b >> 8) & 0xFF) + + 0.0722 * channel_luminance((b >> 16) & 0xFF); + double hi = la > lb ? la : lb; + double lo = la > lb ? lb : la; + + return (unsigned)(((hi + 0.05) / (lo + 0.05)) * 100.0 + 0.5); +} + +/* One step of the accent adjustment: toward white on dark, toward black on light. */ +static COLORREF accent_step(COLORREF c, int dark) +{ + int r = (int)(c & 0xFF), g = (int)((c >> 8) & 0xFF), b = (int)((c >> 16) & 0xFF); + + if (dark) { + r += UI_ACCENT_STEP < 255 - r ? UI_ACCENT_STEP : 255 - r; + g += UI_ACCENT_STEP < 255 - g ? UI_ACCENT_STEP : 255 - g; + b += UI_ACCENT_STEP < 255 - b ? UI_ACCENT_STEP : 255 - b; + } else { + r -= UI_ACCENT_STEP < r ? UI_ACCENT_STEP : r; + g -= UI_ACCENT_STEP < g ? UI_ACCENT_STEP : g; + b -= UI_ACCENT_STEP < b ? UI_ACCENT_STEP : b; + } + return RGB(r, g, b); +} + +/* The accent is adjusted until it meets the text floor against the layer (5.1); the direction is bounded, + so the loop ends at the ramp end at the latest. */ +static COLORREF adjust_accent(COLORREF accent, COLORREF layer, int dark) +{ + int i; + for (i = 0; i < 40 && ui_theme_contrast_x100(accent, layer) < UI_CONTRAST_ACCENT_X100; i++) { + COLORREF next = accent_step(accent, dark); + if (next == accent) + break; + accent = next; + } + return accent; +} + +/* Black or white, whichever contrasts more with the accent. */ +static COLORREF accent_text_for(COLORREF accent) +{ + return ui_theme_contrast_x100(RGB(255, 255, 255), accent) >= + ui_theme_contrast_x100(RGB(0, 0, 0), accent) ? RGB(255, 255, 255) : RGB(0, 0, 0); +} + +/* White blended 8% over the layer: the dark selection of 5.1. */ +static COLORREF blend_selection(COLORREF layer) +{ + int r = (int)(layer & 0xFF), g = (int)((layer >> 8) & 0xFF), b = (int)((layer >> 16) & 0xFF); + + return RGB((unsigned)((r * 92 + 255 * 8 + 50) / 100), (unsigned)((g * 92 + 255 * 8 + 50) / 100), + (unsigned)((b * 92 + 255 * 8 + 50) / 100)); +} + +void ui_theme_palette(const ui_theme *t, COLORREF accent, const COLORREF sys[UI_TOK_COUNT], + COLORREF out[UI_TOK_COUNT]) +{ + COLORREF layer; + int dark; + + if (t->tier != UI_TIER_FLUENT) { + memcpy(out, sys, sizeof(COLORREF) * UI_TOK_COUNT); /* Classic and Aero: the system colors (06 §5.1) */ + return; + } + + dark = t->dark; + out[UI_TOK_SURFACE] = dark ? RGB(0x1F, 0x21, 0x28) : RGB(0xF3, 0xF3, 0xF3); + layer = dark ? RGB(0x2A, 0x2C, 0x33) : RGB(0xFF, 0xFF, 0xFF); + out[UI_TOK_LAYER] = layer; + out[UI_TOK_TEXT] = dark ? RGB(0xFF, 0xFF, 0xFF) : RGB(0x1A, 0x1A, 0x1A); + out[UI_TOK_TEXT_SECONDARY] = dark ? RGB(0xC5, 0xC5, 0xC5) : RGB(0x5C, 0x5C, 0x5C); + out[UI_TOK_STROKE] = dark ? RGB(0x38, 0x3B, 0x43) : RGB(0xE0, 0xE3, 0xE8); + out[UI_TOK_SELECTION] = dark ? blend_selection(layer) : RGB(0xEA, 0xF1, 0xFB); + out[UI_TOK_WARNING] = dark ? RGB(0xFC, 0xE1, 0x00) : RGB(0x8A, 0x53, 0x00); + out[UI_TOK_ERROR] = dark ? RGB(0xFF, 0x99, 0xA4) : RGB(0xC4, 0x2B, 0x1C); + accent = adjust_accent(accent, layer, dark); + out[UI_TOK_ACCENT] = accent; + out[UI_TOK_ACCENT_TEXT] = accent_text_for(accent); + out[UI_TOK_FOCUS] = out[UI_TOK_TEXT]; +} diff --git a/apps/shelldrive-ui/ui_theme_model.h b/apps/shelldrive-ui/ui_theme_model.h new file mode 100644 index 0000000..80d1f7b --- /dev/null +++ b/apps/shelldrive-ui/ui_theme_model.h @@ -0,0 +1,98 @@ +/* shelldrive-ui: the theme decision and palette (06 §4, §5.1). The decision is ui_theme_model.c (pure). + * + * ui_theme_select picks one of the OS tiers of 06 §3 for the running system: Classic (XP, High Contrast, no + * visual styles, or the classic setting), Aero (Vista/7) and Fluent (8 and later) with the sub-levels + * 'a' (8-10), 'b' (11 22000+) and 'c' (11 22621+). Dark follows AppsUseLightTheme: the documented title bar + * on 22000+, and on Windows 10 from 1809 the undocumented uxtheme helpers of 06 §3.6 (option B, D47) behind + * a build allow-list and a kill switch. Pure: no window or process calls, so the unit tests (tests/ui) build + * this file on its own; ui_theme.c (not pure) gathers the inputs and applies the decision. */ +#ifndef SD_UI_THEME_MODEL_H +#define SD_UI_THEME_MODEL_H + +#include + +/* Tiers (06 §4). */ +#define UI_TIER_CLASSIC 0 +#define UI_TIER_AERO 1 +#define UI_TIER_FLUENT 2 + +/* Fluent sub-levels (06 §3.3): level is 0 outside Fluent, else 'a', 'b' or 'c'. */ +#define UI_LEVEL_A 'a' +#define UI_LEVEL_B 'b' +#define UI_LEVEL_C 'c' + +/* The [ui] style setting (06 §5.3). */ +#define UI_THEME_STYLE_AUTO 0 +#define UI_THEME_STYLE_CLASSIC 1 + +/* Build floors of the model (06 §3.3, §3.6, §12). */ +#define UI_THEME_BUILD_HELPERS_MIN 17763u /* 10 1809: the uxtheme dark-mode helpers exist */ +#define UI_THEME_BUILD_ATTR20_MIN 18985u /* the title bar attribute becomes 20 (20 from here) */ +#define UI_THEME_BUILD_WIN11 22000u /* Fluent 2b: documented dark title bar, corner preference */ +#define UI_THEME_BUILD_LEVEL_C 22621u /* Fluent 2c: DWMWA_SYSTEMBACKDROP_TYPE */ +#define UI_THEME_HELPERS_MAX_TESTED 26100u /* newest build the lab tested the helpers on (06 §3.6) */ + +/* The title bar attribute ids the model reports (DWMWA_USE_IMMERSIVE_DARK_MODE and its undocumented + predecessor; the caller resolves DwmSetWindowAttribute at run time). */ +#define UI_THEME_TITLEBAR_ATTR_NONE 0 +#define UI_THEME_TITLEBAR_ATTR_19 19 +#define UI_THEME_TITLEBAR_ATTR_20 20 + +/* forced_tier packs the test override of 06 §8.1 (--theme=...): bits 0-7 the tier (UI_TIER_*), bits 8-15 + the forced Fluent sub-level ('a'..'c', 0 = the level of this OS), bit 16 forces the dark palette (the + -dark suffix), bit 17 forces the light palette (the -light suffix; wins over -dark). -1 means no override. */ +#define UI_FORCE_TIER(tier, level, dark) \ + ((tier) | ((int)(unsigned char)(level) << 8) | ((dark) ? 0x10000 : 0)) +#define UI_FORCE_TIER_OF(f) ((f) & 0xFF) +#define UI_FORCE_LEVEL_OF(f) (((f) >> 8) & 0xFF) +#define UI_FORCE_DARK_OF(f) (((f) >> 16) & 0x01) +#define UI_FORCE_LIGHT_BIT 0x20000 +#define UI_FORCE_LIGHT_OF(f) (((f) >> 17) & 0x01) + +typedef struct ui_theme_inputs { + unsigned os_major, os_minor, os_build; + int dwm_present; /* DwmSetWindowAttribute resolved */ + int composition; /* DwmIsCompositionEnabled, 1 on 8+ */ + int high_contrast; /* SPI_GETHIGHCONTRAST, HCF_HIGHCONTRASTON */ + int apps_use_light; /* AppsUseLightTheme: 0, 1, or -1 when absent */ + int themed; /* IsAppThemed() && IsThemeActive(), 0 on the Windows Classic theme */ + int remote_session; /* GetSystemMetrics(SM_REMOTESESSION) */ + int style_setting; /* [ui] style: auto, classic (5.3) */ + int dark_helpers_setting; /* 1 = the helpers of 3.6 are allowed, 0 = the kill switch */ + int helper_ordinals; /* the uxtheme ordinals of 3.6 resolved (only looked up from build 17763) */ + int forced_tier; /* -1 none; else UI_FORCE_TIER(tier, level, dark), for the test modes */ +} ui_theme_inputs; + +typedef struct ui_theme { + int tier; /* UI_TIER_CLASSIC, UI_TIER_AERO, UI_TIER_FLUENT */ + int level; /* 0, or 'a' / 'b' / 'c' for Fluent */ + int glass, backdrop, dark, corners, command_bar, task_dialogs, command_links, aero_wizard; + int dark_helpers; /* the undocumented helpers of 3.6 are in use */ + int titlebar_attr; /* 0 none, 19 or 20; the DWM attribute that turns the title bar dark */ +} ui_theme; + +/* Picks the tier and its mechanisms for the inputs (06 §4). Each mechanism is also gated by its own + resolved function pointer in ui_theme.c, so a missing export degrades that mechanism alone. */ +void ui_theme_select(const ui_theme_inputs *in, ui_theme *out); + +/* 1 when the dark-mode helpers of 3.6 may be used: the kill switch off, build 17763 or later, and either a + build on the tested allow-list (up to UI_THEME_HELPERS_MAX_TESTED) or, on a newer build, the proof that + the ordinal exports resolve. */ +int ui_theme_helpers_allowed(unsigned build, int ordinals_present, int setting); + +/* Palette tokens (06 §5.1). */ +typedef enum ui_tok { + UI_TOK_SURFACE, UI_TOK_LAYER, UI_TOK_TEXT, UI_TOK_TEXT_SECONDARY, UI_TOK_ACCENT, UI_TOK_ACCENT_TEXT, + UI_TOK_STROKE, UI_TOK_SELECTION, UI_TOK_FOCUS, UI_TOK_WARNING, UI_TOK_ERROR, UI_TOK_COUNT +} ui_tok; + +/* Fills out with the colors of the theme: Classic and Aero get the sys values (the caller fills them from + GetSysColor), Fluent light/dark the palettes of 5.1. The accent is adjusted, lighter on dark and darker + on light, until it meets the contrast floor against the layer. */ +void ui_theme_palette(const ui_theme *t, COLORREF accent, const COLORREF sys[UI_TOK_COUNT], + COLORREF out[UI_TOK_COUNT]); + +/* WCAG 2.x contrast ratio of two colors, times 100 (sRGB linearization). */ +unsigned ui_theme_contrast_x100(COLORREF a, COLORREF b); + +#endif /* SD_UI_THEME_MODEL_H */ diff --git a/apps/shelldrive-ui/ui_tray.c b/apps/shelldrive-ui/ui_tray.c index f54c9c6..b46e911 100644 --- a/apps/shelldrive-ui/ui_tray.c +++ b/apps/shelldrive-ui/ui_tray.c @@ -21,6 +21,7 @@ #include "sd_platform/sd_log.h" #include "sd_platform/sd_platform.h" #include "sd_platform/sd_time.h" +#include "ui_flyout.h" #include "ui_int.h" #include "ui_mainview_model.h" #include "ui_notice_model.h" @@ -169,6 +170,7 @@ void ui_tray_update(void) return; fill_nid(&nid); Shell_NotifyIconW(NIM_MODIFY, (NOTIFYICONDATAW *)&nid); + ui_flyout_refresh(); /* an open flyout (06 §6.3) follows the mounts */ } void ui_tray_remove(void) @@ -645,6 +647,20 @@ void ui_tray_callback(LPARAM mouse_message) /* NOTIFYICON_VERSION_4 packs the icon id into HIWORD and the mouse message into LOWORD; version 0 sends the message itself. Mouse messages are below 0x10000, so LOWORD serves both. */ UINT m = LOWORD(mouse_message); + if (ui_flyout_wanted()) { + /* Windows 11 (06 §6.3): a click or Enter opens the flyout, a double click the main window; the menu stays */ + if (m == NIN_SELECT || m == NIN_KEYSELECT) { + ui_flyout_open(); + return; + } + if (m == WM_LBUTTONDBLCLK) { + ui_flyout_close(); + ui_main_show(); + return; + } + if (m == WM_LBUTTONUP) + return; + } if (m == WM_LBUTTONUP || m == NIN_SELECT || m == NIN_KEYSELECT) ui_main_show(); else if (m == WM_RBUTTONUP || m == WM_CONTEXTMENU) diff --git a/apps/shelldrive-ui/ui_win.c b/apps/shelldrive-ui/ui_win.c index 668d0e5..14a687a 100644 --- a/apps/shelldrive-ui/ui_win.c +++ b/apps/shelldrive-ui/ui_win.c @@ -14,6 +14,8 @@ #include "ui_dpi.h" #include "ui_int.h" #include "ui_res.h" +#include "ui_taskdlg_model.h" +#include "ui_theme.h" #include "ui_update.h" #include "ui_win.h" @@ -93,19 +95,131 @@ int ui_win_register(HINSTANCE inst, const wchar_t *name, WNDPROC proc) return 1; } +/* ---- task dialogs (06 §6.2) ---- + TaskDialogIndirect lives in comctl32 v6 (Vista and later) and is resolved from the already loaded module, never + imported: the XP import floor stays empty. The struct is a private copy of TASKDIALOGCONFIG with the documented + layout (packed like commctrl.h), because the XP flavours compile against headers that do not declare it. */ +#include +typedef struct sd_task_button { + int id; + PCWSTR text; +} sd_task_button; + +typedef struct sd_task_config { + UINT cbSize; + HWND hwndParent; + HINSTANCE hInstance; + DWORD dwFlags; + DWORD dwCommonButtons; + PCWSTR pszWindowTitle; + PCWSTR pszMainIcon; /* union with HICON: MAKEINTRESOURCE(-1..-4) here */ + PCWSTR pszMainInstruction; + PCWSTR pszContent; + UINT cButtons; + const sd_task_button *pButtons; + int nDefaultButton; + UINT cRadioButtons; + const sd_task_button *pRadioButtons; + int nDefaultRadioButton; + PCWSTR pszVerificationText; + PCWSTR pszExpandedInformation; + PCWSTR pszExpandedControlText; + PCWSTR pszCollapsedControlText; + PCWSTR pszFooterIcon; /* union with HICON */ + PCWSTR pszFooter; + void *pfCallback; + LONG_PTR lpCallbackData; + UINT cxWidth; +} sd_task_config; +#include + +#define SD_TDF_ALLOW_DIALOG_CANCELLATION 0x0008u +#define SD_TDF_POSITION_RELATIVE_TO_WINDOW 0x1000u + +typedef HRESULT (WINAPI *ui_pfn_TaskDialogIndirect)(const sd_task_config *cfg, int *button, int *radio, BOOL *verify); + +static ui_pfn_TaskDialogIndirect task_dialog_fn(void) +{ + static ui_pfn_TaskDialogIndirect fn; + static int resolved; + HMODULE cc; + if (!resolved) { + resolved = 1; + cc = GetModuleHandleW(L"comctl32.dll"); + if (cc != NULL) + fn = (ui_pfn_TaskDialogIndirect)GetProcAddress(cc, "TaskDialogIndirect"); + } + return fn; +} + +int ui_win_task(HWND owner, const wchar_t *main_text, const wchar_t *content, const wchar_t *details, + UINT mb_flags) +{ + const ui_theme *t = ui_theme_current(); + ui_pfn_TaskDialogIndirect fn; + ui_task_map map; + wchar_t plain[2048 + 1536]; + const wchar_t *text; + + if (main_text == NULL) + main_text = L""; + if (t != NULL && t->task_dialogs && (fn = task_dialog_fn()) != NULL && main_text[0] != L'\0' && + ui_task_map_flags(mb_flags, &map)) { + sd_task_config cfg; + int button = 0; + memset(&cfg, 0, sizeof(cfg)); + cfg.cbSize = sizeof(cfg); + cfg.hwndParent = owner; + cfg.dwFlags = SD_TDF_POSITION_RELATIVE_TO_WINDOW | (map.allow_cancel ? SD_TDF_ALLOW_DIALOG_CANCELLATION : 0u); + cfg.dwCommonButtons = map.buttons; + cfg.pszWindowTitle = L"ShellDrive"; + cfg.pszMainIcon = map.icon != UI_TASK_ICON_NONE ? MAKEINTRESOURCEW(map.icon) : NULL; + cfg.pszMainInstruction = main_text; + cfg.pszContent = (content != NULL && content[0] != L'\0') ? content : NULL; + if (details != NULL && details[0] != L'\0') { + /* expandable, collapsed by default; the control texts are the system's own (Windows localizes them) */ + cfg.pszExpandedInformation = details; + } + cfg.nDefaultButton = map.default_id; + if (SUCCEEDED(fn(&cfg, &button, NULL, NULL)) && button != 0) + return button; + } + text = main_text; + if (ui_task_plain_text(main_text, content, details, plain, SD_COUNTOF(plain)) > 0) + text = plain; + return MessageBoxW(owner, text, L"ShellDrive", mb_flags | MB_SETFOREGROUND); +} + void ui_win_message(HWND owner, const wchar_t *text, UINT flags) { - MessageBoxW(owner, text, L"ShellDrive", flags | MB_SETFOREGROUND); + (void)ui_win_task(owner, text, NULL, NULL, flags); +} + +/* 1 when needle occurs in s. Not the CRT wcsstr: its x86 version dispatches to SSE4.2 code that the x86-ia32 ISA + audit has not reviewed (tools/sd-isacheck). */ +static int text_contains(const wchar_t *s, const wchar_t *needle) +{ + size_t n = wcslen(needle); + if (n == 0) + return 1; + for (; *s != L'\0'; s++) { + if (wcsncmp(s, needle, n) == 0) + return 1; + } + return 0; } void ui_win_error(HWND owner, sd_msg_id msg, sd_err err) { wchar_t code[64]; - wchar_t *t; + wchar_t *t, *details; ui_text_utf8(sd_err_name(err), code, SD_COUNTOF(code)); t = ui_format1(msg, "code", code); if (t != NULL) { - ui_win_message(owner, t, MB_OK | MB_ICONERROR); + /* the code goes in the expandable details, unless the catalog message already names it inline */ + details = text_contains(t, code) ? NULL : ui_format1(SD_MSG_UI_WIN_ERROR_DETAILS, "code", code); + (void)ui_win_task(owner, t, NULL, details, MB_OK | MB_ICONERROR); + sd_free(details); sd_free(t); } } diff --git a/apps/shelldrive-ui/ui_win.h b/apps/shelldrive-ui/ui_win.h index 2270834..7084eff 100644 --- a/apps/shelldrive-ui/ui_win.h +++ b/apps/shelldrive-ui/ui_win.h @@ -13,6 +13,7 @@ #include "sd/base.h" #include "sd_base/sd_err.h" #include "sd_base/sd_json.h" +#include "sd_config/sd_config.h" #include "sd_l10n/sd_l10n.h" #ifndef WM_DPICHANGED @@ -42,9 +43,19 @@ int ui_win_text_width(HFONT font, const wchar_t *text); void ui_win_class_name(const wchar_t *suffix, wchar_t out[320]); /* Registers a window class once; true when it exists afterwards. */ int ui_win_register(HINSTANCE inst, const wchar_t *name, WNDPROC proc); -/* Message box titled ShellDrive. */ +/* A message titled ShellDrive in the style of the OS tier (06 6.2). On Vista and later (the theme's task_dialogs) + it is a task dialog (TaskDialogIndirect, resolved from comctl32 at run time): main is the main instruction, + content the text under it (NULL for none), details the expandable area (NULL for none, collapsed by default), + and mb_flags map as for MessageBoxW: MB_OK, MB_OKCANCEL, MB_YESNO, MB_YESNOCANCEL, MB_RETRYCANCEL, the MB_ICON* + icons (MB_ICONQUESTION shows none) and MB_DEFBUTTON2 / 3 for the focused button. Returns IDOK, IDCANCEL, IDYES, + IDNO or IDRETRY like MessageBoxW. On XP, with Classic forced on a system without task dialogs, for a button set + a task dialog lacks, or when TaskDialogIndirect is missing or fails, it is MessageBoxW with "main", "content" and + "details" as paragraphs of one text. */ +int ui_win_task(HWND owner, const wchar_t *main_text, const wchar_t *content, const wchar_t *details, + UINT mb_flags); +/* Message box titled ShellDrive: ui_win_task with the text as the main instruction. */ void ui_win_message(HWND owner, const wchar_t *text, UINT flags); -/* Error message box: the catalog message msg with {code} = the sd_err name. */ +/* Error message box: the catalog message msg with {code} = the sd_err name; the code is repeated in the details. */ void ui_win_error(HWND owner, sd_msg_id msg, sd_err err); /* Copies text to the clipboard as CF_UNICODETEXT. */ int ui_win_copy_text(HWND owner, const wchar_t *text, size_t len); @@ -86,6 +97,10 @@ HWND ui_logs_create_hidden(void); void ui_settings_open(void); void ui_settings_open_security(void); /* opens Settings on the Security page (balloon of hostkey_source_insecure) */ void ui_settings_close(void); +/* Reads the effective [ui] style and dark_helpers (settings overridden by the UiStyle and UiDarkHelpers policies) and + passes them to ui_theme_set_options with the --theme override (-1 none). Called before ui_theme_init, and again by + Settings when it saves a change (06 5.3, 3.6). */ +void ui_settings_theme_options(const sd_settings *s, int forced_tier); void ui_settings_on_reply(int kind, sd_err err, const sd_json *result); void ui_settings_update_refresh(void); /* the update group: status, enabled controls (ui_update.c) */ int ui_settings_update_controls_ok(void); /* --test-windows: the update group controls exist */ diff --git a/apps/shelldrive-ui/ui_wizard.c b/apps/shelldrive-ui/ui_wizard.c index decc61a..9012f5d 100644 --- a/apps/shelldrive-ui/ui_wizard.c +++ b/apps/shelldrive-ui/ui_wizard.c @@ -20,10 +20,19 @@ #include "ui_dlg.h" #include "ui_int.h" #include "ui_measure.h" +#include "ui_theme.h" #include "ui_win.h" #include "ui_wizard.h" #include "ui_wizard_model.h" +/* The Vista SDK constants the XP SDK 7.1A headers lack (06 §6.5). */ +#ifndef PSH_AEROWIZARD +#define PSH_AEROWIZARD 0x00004000 +#endif +#ifndef PSP_USEHEADERTITLE +#define PSP_USEHEADERTITLE 0x00002000 +#endif + #define WIZ_WIDTH 280 #define WIZ_TEXT 0 /* static texts have no id */ #define WIZ_ID_IMPORT 5101 @@ -330,7 +339,7 @@ static void do_create(wiz_ctx *ctx, HWND page) if (!ui_editor_run(ctx->sheet, UI_EDIT_NEW, &p)) return; if (sd_profile_save(&p) != SD_OK) { - MessageBoxW(ctx->sheet, sd_l10n_get(SD_MSG_UI_ERROR_SAVE), L"ShellDrive", MB_OK | MB_ICONERROR); + ui_win_task(ctx->sheet, sd_l10n_get(SD_MSG_UI_ERROR_SAVE), NULL, NULL, MB_OK | MB_ICONERROR); return; } /* the agent reads the store again, as after any profile change (03 §2.4) */ @@ -361,6 +370,8 @@ static INT_PTR CALLBACK wiz_page_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) pg->ctx->sheet = GetParent(hwnd); if (pg->index == PG_FINISH) EnableWindow(GetDlgItem(hwnd, WIZ_ID_MOUNT), FALSE); /* on once a profile exists */ + /* the page background, its controls and the dark palette (06 §3.6) */ + ui_theme_apply_dialog(hwnd); return TRUE; } case WM_NOTIFY: { @@ -413,6 +424,8 @@ void ui_wizard_run(HWND owner) PROPSHEETPAGEW psp[WIZ_PAGES]; PROPSHEETHEADERW hdr; int pg; + /* Vista and later: the Aero wizard (06 §6.5); XP keeps Wizard97 */ + int aero = ui_theme_current() != NULL && ui_theme_current()->aero_wizard; memset(&ctx, 0, sizeof(ctx)); for (pg = 0; pg < WIZ_PAGES; pg++) { @@ -429,12 +442,18 @@ void ui_wizard_run(HWND owner) psp[pg].pResource = (LPCDLGTEMPLATEW)blob[pg]; psp[pg].pfnDlgProc = wiz_page_proc; psp[pg].pszTitle = sd_l10n_get(page_title(pg)); + if (aero) { + /* the page title goes in the header of the Aero wizard */ + psp[pg].dwFlags |= PSP_USEHEADERTITLE; + psp[pg].pszHeaderTitle = sd_l10n_get(page_title(pg)); + } psp[pg].lParam = (LPARAM)&ctx.pages[pg]; } memset(&hdr, 0, sizeof(hdr)); hdr.dwSize = sizeof(hdr); - hdr.dwFlags = PSH_WIZARD97 | PSH_PROPSHEETPAGE | PSH_NOAPPLYNOW | PSH_NOCONTEXTHELP | PSH_USECALLBACK; - hdr.pfnCallback = ui_win_sheet_callback; + /* PSH_HEADER, PSH_WATERMARK and their bitmaps are Wizard97 only: never set with PSH_AEROWIZARD */ + hdr.dwFlags = (aero ? PSH_AEROWIZARD : PSH_WIZARD97) | PSH_PROPSHEETPAGE | PSH_NOAPPLYNOW | PSH_NOCONTEXTHELP | PSH_USECALLBACK; + hdr.pfnCallback = ui_theme_sheet_callback; hdr.hwndParent = owner; hdr.hInstance = g_app.inst; hdr.pszCaption = sd_l10n_get(SD_MSG_UI_WIZARD_CAPTION); diff --git a/docs/design/03-apps-platform.md b/docs/design/03-apps-platform.md index 504b614..4bf6025 100644 --- a/docs/design/03-apps-platform.md +++ b/docs/design/03-apps-platform.md @@ -1675,6 +1675,10 @@ usage synopsis (option names and syntax are not translated). Help wraps at the c ## 7. Tray app and main window +The UI renders in OS tiers (Classic on XP, Aero on Vista/7, Fluent on 8–11 with Mica and full dark mode): +[`06-ui-tiers.md`](06-ui-tiers.md), accepted as D47. It amends 7.1 (standard controls may be custom or owner drawn, +and the main window uses a command bar on Fluent), supersedes 7.4 and amends 11 as noted there. + ### 7.1 Toolkit rules - Pure Win32 + common controls v6 (ListView, TreeView, Toolbar, Tab, PropertySheet, Link control `SysLink` (v6, @@ -1732,6 +1736,10 @@ usage synopsis (option names and syntax are not translated). Help wraps at the c ### 7.4 Dark mode +**Superseded by D47 and 06 §3.5–§3.6:** full dark mode on Windows 11 and on 10 from 1809, including the undocumented +uxtheme helpers behind an allow-list and a kill switch (`[ui] dark_helpers`, policy `UiDarkHelpers`). The text below +is the original v1 decision, kept for the record. + - v1 follows the system for what Windows supports documentedly and stays light elsewhere: - Title bar: `DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE = 20, TRUE)` when apps use dark theme (`AppsUseLightTheme = 0`) — documented as supported from Windows 11 build 22000 @@ -2552,8 +2560,9 @@ localized summary, or JSON with English keys under `--json`. `status --stats` pr ## 11. Accessibility -- Standard controls only (no custom-drawn controls in v1), so MSAA (XP) and UI Automation proxies (Vista+) expose - them without extra code. If a custom control is ever added it must answer `WM_GETOBJECT`. +- Standard controls only, so MSAA (XP) and UI Automation proxies (Vista+) expose them without extra code. They + may be custom drawn or owner drawn (06 §7.1, D47): the control, its window text and its proxy stay the system's, + and every custom-drawn control draws its own focus ring. If a custom control is ever added it must answer `WM_GETOBJECT`. - Every input has a preceding static label with a mnemonic (`&`), so screen readers pick up the name and keyboard users can jump; tab order follows visual order (generated from the layout table); dialogs work with `IsDialogMessage`; default and cancel buttons are set; Escape closes. Exception: trust decisions (host key diff --git a/docs/design/06-ui-tiers.md b/docs/design/06-ui-tiers.md new file mode 100644 index 0000000..c1a2639 --- /dev/null +++ b/docs/design/06-ui-tiers.md @@ -0,0 +1,444 @@ +# 06 — OS-tiered UI rendering (proposal) + +Status: **accepted 2026-10-31 as option B with the choices of §10.1 (D47).** It extends 03 §7 (tray and main +window) and §11 (accessibility) and supersedes 03 §7.4 (dark mode) where the two differ. The user chose from the +visual mock-ups of each tier (main window, tray, wizard); §10.1 records the answers. Conventions are 03's: "XP" means Windows XP SP3, every API newer than XP is +resolved at run time (`sd_platform_resolve`, `sd_platform_resolve_by_name`), and facts not confirmed against a primary +source are marked **[verify]** and listed in the lab plan (§8). + +Scope: `apps/shelldrive-ui` (tray, main window, first-run wizard, profile editor, prompts, the M3 windows). The shell +extension (`apps/shelldrive-shellext`) draws nothing of its own beyond menu bitmaps and keeps 03 §8; the setup EXE +(04) is out of scope. + +--- + +## 1. Where the UI stands (survey, 2026-10-31) + +- Toolkit (03 §7.1): pure Win32 and comctl32 v6 from the manifest, dialogs generated in memory from C tables with a + measuring layout pass (`ui_dlg.c`), property sheets for the profile editor (`ui_editor.c`, capped to the work area + with scrolling pages) and a `PSH_WIZARD97` wizard (`ui_wizard.c`); both sheets get localized buttons through + `ui_win_sheet_callback`. Main window (`ui_main.c`): menu bar, flat text-only toolbar, report ListView with groups, + status bar with the mounted-drive count; `COLOR_BTNFACE` class brush. +- Font: the `SPI_GETNONCLIENTMETRICS` message font, rescaled to the window DPI (`ui_win_font`): Tahoma on XP, Segoe + UI on Vista and later. No theme fonts (`TEXT_MAININSTRUCTION`), no Segoe UI Variable. +- DPI (03 §7.3, `ui_dpi.c`): PerMonitorV2 on 10 1703+, system DPI elsewhere, `WM_DPICHANGED` re-runs the layout. + This already adapts per OS and needs no change here. +- Dark mode (03 §7.4, `ui_dark.c`, `ui_dark_model.c`): only the title bar, with `DWMWA_USE_IMMERSIVE_DARK_MODE` on + build 22000+ when `AppsUseLightTheme = 0`, applied to every captioned window of the UI thread by a `WH_CBT` hook. + Client areas stay light (`GetSysColor`), so on Windows 11 dark a window has a dark title bar over a light body. +- Tray (03 §7.2): `NOTIFYICONDATA` size and version per OS, light/dark icon variants from `SystemUsesLightTheme`, + standard popup menu. +- Not used anywhere: DWM frame extension or backdrops, `uxtheme` drawing (`DrawThemeTextEx`, buffered paint), + `TaskDialogIndirect`, command links, `PSH_AEROWIZARD`, custom draw. Message boxes are `MessageBoxW`. +- Imports: the XP floor lists (`tests/os-imports/xp-*.txt`) have neither `dwmapi.dll` nor `uxtheme.dll`, so on the + XP flavours both must stay dynamic; the x64-modern floor (Windows 10 1507) has `uxtheme.dll!*` but not `dwmapi.dll`. + +What adapts per OS today: DPI, the notification structure, the tray icon variant and the Windows 11 dark title bar. +Everything else renders the same from XP to 11, which is why the UI reads as an XP-era application on 10 and 11. + +## 2. Goals and non-goals + +- One code base, **one binary per flavour**, choosing the look at run time (§4). The XP floor and the import checks + stay as they are. +- Each tier looks native to its OS generation: XP classic with visual styles; Vista/7 Aero; 10/11 Fluent-like. +- Documented APIs first. Anything undocumented is an explicit, separately decided option (§10, option B). +- No new third-party code (D1), no new runtime dependency, no change to the IPC, the models or the strings of + existing screens beyond what §7 lists. +- Accessibility and High Contrast never regress: every tier keeps standard controls and their MSAA/UIA proxies. +- Non-goal: WinUI 3, XAML Islands, Direct2D/DirectWrite rendering (§3.4). Non-goal: animations beyond what DWM and + the common controls do themselves. + +## 3. Mechanisms per tier + +### 3.1 Tier 0, Classic (XP; also the fallback everywhere) + +What exists today: comctl32 v6 visual styles (Luna, Royale, Classic), Tahoma message font, Wizard97, standard +dialogs. Additions that cost nothing on XP: + +- Consistent spacing tokens (margins, row gaps) in the layout tables, at 96-DPI units scaled per DPI. These help every + tier and are the only XP-visible change. +- Nothing from `dwmapi` or post-XP `uxtheme`/comctl32; `SetWindowTheme` and `OpenThemeData` exist on XP but are not + needed by this tier. + +Classic is also chosen on any OS when High Contrast is on, when the user or policy forces it (§5.3), and as the +fallback of any tier whose capability probe fails. + +### 3.2 Tier 1, Aero (Vista, 7; composition on) + +| Mechanism | API | Floor | Load | +|---|---|---|---| +| Glass header band in the main window | `DwmExtendFrameIntoClientArea`, `DwmIsCompositionEnabled` | Vista | `dwmapi.dll` by name | +| Text and icons on glass | `DrawThemeTextEx` (`DTT_COMPOSITED`, `DTT_GLOWSIZE`), `BeginBufferedPaint` / `BufferedPaintSetAlpha` | Vista | `uxtheme.dll` by name | +| Main instruction font and color | `OpenThemeData(L"TEXTSTYLE")`, `GetThemeFont` / `GetThemeColor` (`TEXT_MAININSTRUCTION`) | Vista | `uxtheme.dll` | +| Task dialogs for errors and confirmations (not trust decisions, §6.2) | `TaskDialogIndirect` | Vista, comctl32 v6 | `GetProcAddress` on the loaded comctl32 | +| Command links in prompts | `BS_COMMANDLINK`, `BCM_SETNOTE`, `BCM_GETIDEALSIZE` | Vista | style chosen at run time | +| Aero wizard | `PSH_AEROWIZARD` | Vista | flag chosen at run time | +| Larger, crisp icons | `LoadIconMetric` / `LoadIconWithScaleDown` | Vista | comctl32 by name | + +Notes: + +- Composition can be off on Vista and 7 (Basic or Classic theme, some RDP sessions, VMs without a WDDM driver). + The tier checks `DwmIsCompositionEnabled` at start and on `WM_DWMCOMPOSITIONCHANGED` and drops the glass band + (keeping task dialogs, command links, the Aero wizard and theme fonts, which do not need composition). +- On 8, 8.1 and 10 the extended frame is drawn opaque, without blur **[verify]**, so the glass band is used on Vista + and 7 only. 8/8.1 use tier 2 in its light form. +- GDI text drawn on an extended frame comes out with alpha 0 (invisible). Every pixel we draw on glass goes through + buffered paint or `DrawThemeTextEx`; child controls are never placed on the glass region. + +### 3.3 Tier 2, Fluent (8, 8.1, 10, 11) + +Sub-levels by documented capability, not by marketing version: + +| Level | Build | Adds | +|---|---|---| +| 2a | 8 – 10 (9200 – 19045) | Flat look: white content surface, accent color from `DwmGetColorizationColor`, Segoe UI with the theme text sizes, command bar instead of the menu bar (§6.1), no backdrop, square corners. Dark on 10 1809+ (17763) through the helpers of §3.6 when apps use the dark theme. | +| 2b | 11 21H2 (22000+) | Dark title bar (already shipped), client-area dark palette (§3.5), `DWMWA_WINDOW_CORNER_PREFERENCE` (33) for our popups, `DWMWA_BORDER_COLOR` (34) to match the accent. | +| 2c | 11 22H2 (22621+) | Mica backdrop: `DWMWA_SYSTEMBACKDROP_TYPE` (38) = `DWMSBT_MAINWINDOW` (2) on the main window, `DWMSBT_TRANSIENTWINDOW` (3, Acrylic) on the tray flyout if built (§6.3). | + +- `DWMWA_SYSTEMBACKDROP_TYPE` is documented from 22621; the earlier undocumented `DWMWA_MICA_EFFECT` (1029) of + 22000 is not used. +- DWM disables the backdrop itself (solid fallback color) when transparency effects are off, in battery saver, in + most RDP sessions and when the window is inactive. Our drawing must look right on the solid fallback, which it + does if the backdrop region is drawn as "transparent over the theme background" and nothing relies on blur. +- Mica is only visible where the client pixels are transparent: the frame is extended into the top band of the + window (title + command bar, as in File Explorer and Notepad) and the band is painted with buffered paint. The + content area (the ListView) stays an opaque "layer" surface. Whether the toolbar control can be custom drawn into + the extended region with correct alpha is spike **S1** (§8.3); the fallback is a Mica title bar over an opaque + command bar, which is still visually consistent with Windows 11. +- Fonts: the message font stays the base (Segoe UI). On 11, "Segoe UI Variable Text/Display" is used for the window + title and section headers when `EnumFontFamiliesExW` finds it; otherwise Segoe UI at the theme sizes. +- Rounded corners on top-level windows come from Windows 11 itself; we only set the preference on our own popups. + Inside the client area we keep square GDI shapes (GDI `RoundRect` is not antialiased); selection and focus use + filled rectangles with a 1-px accent line, which reads as Fluent without antialiasing. + +### 3.4 Rejected mechanisms + +- **WinUI 3 (Windows App SDK).** Needs the Windows App SDK runtime (framework package or a self-contained copy of + tens of MB), C++/WinRT, Windows 10 1809+. Not callable from C in any maintainable way, adds a runtime dependency + and Microsoft binaries to redistribute, cannot exist in the XP flavours, and would split every screen into two + implementations. Rejected. +- **XAML Islands (system XAML).** Windows 10 1903+, C++/WinRT, effectively frozen by Microsoft in favor of WinUI 3. + Same split and same C problem. Rejected (03 §7.1 already excludes it). +- **Direct2D / DirectWrite for owner-drawn surfaces.** Available on 7 SP1 (and Vista SP2 with the platform update), + loadable dynamically, gives antialiased rounded shapes. It adds a second rendering path for little visible gain + over GDI + buffered paint for a list-and-buttons UI. Not now; revisit only if a future screen needs it. +- **Separate "modern" binaries.** The x86 and x64-xp flavours run on XP through 11, so they need run-time tiers + regardless; x64-modern still spans 10 1507 to 11 and needs levels 2a–2c at run time. A separate build would add + flavours and lab runs without removing any run-time check. + +### 3.5 Dark client areas + +There is no documented Win32 dark mode for the common controls (03 §7.4 stands). With documented messages only, the +main window's controls can be painted dark: + +| Control | Documented technique | Residual light parts | +|---|---|---| +| ListView | `LVM_SETBKCOLOR`, `LVM_SETTEXTCOLOR`, `LVM_SETTEXTBKCOLOR`; `NM_CUSTOMDRAW` for group headers and selection | scroll bars | +| Header | `NM_CUSTOMDRAW` from the header (`CDDS_ITEMPREPAINT`), background in `WM_ERASEBKGND` of a subclass | none | +| Toolbar / command bar | `NM_CUSTOMDRAW` (`TBCDRF_*`, `CDRF_SKIPDEFAULT` with our own item drawing) | none | +| Status band | replaced by a `STATIC` (`SS_OWNERDRAW` keeps its window text as the accessible name) | none | +| Edit, list box | `WM_CTLCOLOREDIT` / `WM_CTLCOLORLISTBOX` | scroll bars, edit border on some themes | +| Push buttons, check boxes, radios | `NM_CUSTOMDRAW` from v6 buttons (text color and background), focus ring drawn by us | check glyph keeps the light theme part **[verify]** | +| Menu bar, popup menus | none documented short of fully owner-drawn menus | menu bar (removed in tier 2, §6.1), popup menus stay light | +| Property sheet tabs, combo drop-downs, spin boxes, date pickers | none practical | stay light | + +Conclusion: with documented APIs, the **main window** (and the tray flyout) can be fully dark except scroll bars; +dialogs with combo boxes, tabs and spin boxes (profile editor, Settings) cannot. Those keep the light body with a +dark title bar, as today, unless option B (§10) is chosen. The pure decision function `ui_dark_wanted` grows into the +theme model of §5. + +### 3.6 Undocumented dark-mode helpers (chosen, D47) + +The user chose full dark mode (option B), including Windows 10 from 1809. On top of §3.5: + +| Helper | Where | Builds | +|---|---|---| +| `uxtheme` ordinal 135: `AllowDarkModeForApp(BOOL)` on 17763, `SetPreferredAppMode(AllowDark)` on 18362+ | process start | 17763+ | +| ordinal 133 `AllowDarkModeForWindow(HWND, BOOL)` | each top-level window and the controls that need it | 17763+ | +| ordinal 104 `RefreshImmersiveColorPolicyState`, 136 `FlushMenuThemes` | after a theme change | 17763+ | +| `SetWindowTheme(hwnd, L"DarkMode_Explorer", NULL)` (ListView, TreeView, scroll bars, buttons), `L"DarkMode_CFD"` (combo, edit) | per control | 17763+ | +| title bar: `DwmSetWindowAttribute` 19 before 18985, 20 from 18985 (20 is documented from 22000) | top-level windows | 17763+ | + +Rules: the helpers are resolved by ordinal from System32 `uxtheme.dll` only for builds on an allow-list kept in +`ui_theme_model.c` (17763 up to the newest build tested in the lab); an unknown newer build uses them only when the +ordinal's export exists and the build is newer than the newest allow-listed one (Windows has kept them stable since +1809), and a kill switch turns them off: `[ui] dark_helpers = off` in the user settings and the policy value +`UiDarkHelpers = 0`. With the helpers off, the documented subset of §3.5 still applies. Popup menus, scroll bars, +tabs and combo drop-downs become dark only through the helpers; every custom-drawn part of §3.5 is still ours. +Each lab run per Windows release checks them (§8.2). + +## 4. Tier selection + +A single pure function, `ui_theme_select`, in a new `ui_theme_model.c` (unit tested like `ui_dark_model.c`): + +```c +typedef struct ui_theme_inputs { + unsigned os_major, os_minor, os_build; + int dwm_present; /* DwmSetWindowAttribute resolved */ + int composition; /* DwmIsCompositionEnabled, 1 on 8+ */ + int high_contrast; /* SPI_GETHIGHCONTRAST, HCF_HIGHCONTRASTON */ + int apps_use_light; /* AppsUseLightTheme: 0, 1, or -1 when absent */ + int themed; /* IsAppThemed() && IsThemeActive(), 0 on the Windows Classic theme */ + int remote_session; /* GetSystemMetrics(SM_REMOTESESSION) */ + int style_setting; /* [ui] style: auto, classic (5.3) */ +} ui_theme_inputs; + +typedef struct ui_theme { + int tier; /* UI_TIER_CLASSIC, UI_TIER_AERO, UI_TIER_FLUENT */ + int level; /* 0, or 'a' / 'b' / 'c' for Fluent */ + int glass, backdrop, dark, corners, command_bar, task_dialogs, command_links, aero_wizard; +} ui_theme; + +void ui_theme_select(const ui_theme_inputs *in, ui_theme *out); +``` + +Rules, in order: High Contrast or `style = classic` or not themed → Classic (task dialogs and the Aero wizard stay +on, they follow High Contrast by themselves). XP → Classic. Vista/7 → Aero, `glass` only with composition and not +in a remote session. 8–10 → Fluent 2a. 22000+ → 2b, `dark` from `AppsUseLightTheme`. 22621+ → 2c with `backdrop`. +Each mechanism is also gated by its own resolved function pointer, so a missing export degrades that mechanism +alone. + +`ui_theme.c` (not pure) gathers the inputs, resolves the functions, owns the palette (brushes, colors, fonts per +DPI) and re-selects on `WM_SETTINGCHANGE` (`ImmersiveColorSet`, `WindowsThemeElement`), `WM_THEMECHANGED`, +`WM_SYSCOLORCHANGE`, `WM_DWMCOMPOSITIONCHANGED` and `WM_DPICHANGED`. `ui_dark.c` becomes the title-bar part of it. +Windows ask the theme for colors and brushes instead of calling `GetSysColor` directly; in Classic and High Contrast +the theme returns exactly the `GetSysColor` values, so those paths are unchanged. + +New run-time resolutions, all by name from System32 (`sd_platform_resolve_by_name`), cached in `ui_theme.c`: +`dwmapi!DwmExtendFrameIntoClientArea`, `DwmIsCompositionEnabled`, `DwmGetColorizationColor`, +`DwmSetWindowAttribute` (already used), `uxtheme!BufferedPaintInit`, `BeginBufferedPaint`, `EndBufferedPaint`, +`BufferedPaintSetAlpha`, `DrawThemeTextEx`, `OpenThemeData`, `CloseThemeData`, `GetThemeFont`, `GetThemeColor`, +`IsAppThemed`, `IsThemeActive`, `comctl32!TaskDialogIndirect`, `LoadIconMetric`. No new static import in any flavour; +`importcheck` proves it on every build (§8.1). + +## 5. Palette, typography, settings + +### 5.1 Palette + +Semantic tokens, not raw colors: `surface`, `layer`, `text`, `text_secondary`, `accent`, `accent_text`, `stroke`, +`selection`, `focus`, `warning`, `error`. Classic maps each token to a `COLOR_*` system color. Fluent light/dark use +the Windows 11 neutral ramps (light surface ~#F3F3F3 with white layer; dark surface ~#202020 with #2B2B2B layer) and +the user's accent from `DwmGetColorizationColor`, adjusted in the model until it meets the contrast floor of §6.4. +The model computes WCAG contrast ratios, so the unit tests can assert them for every accent the tests feed it. + +### 5.2 Typography + +| Role | Classic | Aero | Fluent | +|---|---|---|---| +| Body | message font | message font | message font (Segoe UI) | +| Window header / page title | message font bold | `TEXT_MAININSTRUCTION` (theme font and color) | Segoe UI Variable Display semibold, 20 px at 96 DPI, else Segoe UI Semibold | +| Section header | message font bold | theme `TEXT_INSTRUCTION` **[verify part id]** | Segoe UI Variable Text semibold, 14 px | +| Secondary text | message font, `COLOR_GRAYTEXT` | same | body size, `text_secondary` | + +Every font is created per DPI by the theme and freed on DPI or setting change; the layout pass measures with the +same fonts, so longer German and French strings keep fitting. + +### 5.3 User setting and policy + +`[ui] style = auto | classic` in the user settings (03 §3), shown in Settings as "Appearance": "Match Windows +(recommended)" / "Classic". A Group Policy value `UiStyle` (03 §3.5) can force `classic` for locked-down or +remote-desktop fleets. Changing it re-selects the tier live (§4) without a restart. + +## 6. Screens per tier + +### 6.1 Main window + +| | Classic (XP) | Aero (Vista/7) | Fluent (10/11) | +|---|---|---|---| +| Frame | standard | glass band across the top (title + command bar) when composed | 2a: light; 2b: dark title bar, rounded by Windows; 2c: Mica band | +| Commands | menu bar + text toolbar (today) | menu bar + toolbar on the glass band with glow text | command bar (toolbar with text, icons from the existing image list) + "More" overflow button holding the menu bar's items; Alt still opens the overflow menu | +| List | ListView with groups | same, Explorer theme (`SetWindowTheme(L"Explorer")`, documented) for the hover/selection look | opaque layer card, our row selection and group header drawing; dark on 2b/2c | +| Status | status bar | status bar | status text strip (owner-drawn STATIC) with the Start link as a real button | +| Empty state | text in the list | same | centered "No connections yet" with a "New connection" button **(new strings)** | + +The "More" overflow keeps every menu command reachable with the keyboard (03 §11) and keeps the accelerators. + +### 6.2 Prompts (host key, legacy algorithm, token-only WebDAV, credentials) + +- Trust decisions keep **no default button** (TM-R03). `TaskDialogIndirect` always has a default button (the first + one when `nDefaultButton` matches none), so trust prompts are **not** task dialogs. They stay our generated dialogs + and gain, per tier: the main-instruction font (Aero, Fluent), a shield or warning icon from `LoadIconMetric` + (Vista+), command links for the choices (`BS_COMMANDLINK` with `BCM_SETNOTE` notes, Vista+; plain buttons on XP), + and the dark palette on 2b/2c (all their controls are in the dark-capable set of §3.5). +- Non-trust messages (errors with a code, "delete this connection?", agent start failed) use `TaskDialogIndirect` + on Vista+ with the error code in the expandable details, and `MessageBoxW` on XP. One helper, + `ui_win_task(owner, main, content, details, buttons)`, hides the split. + +### 6.3 Tray + +- Icon: unchanged (light/dark variants already follow `SystemUsesLightTheme`). +- Menu: standard popup menu on every tier (it stays light on Windows 11 dark; see option B). +- Windows 11 (in the first slice, D47): a flyout (a small popup window with Acrylic, rounded small corners, one row per + profile with a mount toggle, "Open ShellDrive", "Settings") opened by left click on 2c; the popup menu stays on + right click / Apps key. Keyboard: the flyout opens with Enter on the tray icon too, closes with Escape and on + deactivation, and its rows are standard buttons and check boxes (accessible names from their text). + +### 6.4 Profile editor, Settings, host key manager, Logs, Events, About + +- Classic: as today. Aero: theme header fonts; the profile editor stays a property sheet. Fluent: header fonts, + spacing tokens, light body with dark title bar on 2b/2c (tabs, combos and spin boxes cannot be dark, §3.5). +- Later and separately decided: a Fluent profile editor with a left navigation list (ListBox styled as a nav pane) + instead of tabs. Not proposed for 1.0. + +### 6.5 First-run wizard + +`PSH_WIZARD97` on XP, `PSH_AEROWIZARD` on Vista+ (documented flag, comctl32 v6), which gives the Aero wizard frame on +7 and the flat wizard look on 10/11 with the same pages. Page headers move to the page title fields the Aero wizard +uses; the strings already exist. + +## 7. Accessibility and localization + +### 7.1 Accessibility (amends 03 §11) + +- 03 §11 "no custom-drawn controls in v1" becomes "no custom controls": standard controls may be **custom drawn or + owner drawn**, because the control, its window text and its MSAA/UIA proxy stay the system's. No new window class + paints text that a screen reader cannot get; the status strip and empty state are STATIC/BUTTON windows. +- Focus: every custom-drawn control draws a visible focus ring (2 px `focus` token) and honors + `WM_UPDATEUISTATE` / `UISF_HIDEFOCUS`; tab order still comes from the layout tables. +- Contrast: body text ≥ 4.5:1 and UI boundaries (focus ring, selection, borders) ≥ 3:1 against their surface, checked + by the unit tests for light, dark and a set of accent colors (§5.1). Status is never color only (03 §11 stands). +- High Contrast: forces Classic, so every color comes from `GetSysColor` and no backdrop or glass is drawn. +- `SPI_GETCLIENTAREAANIMATION` off: no hover fades (we draw none in the first slice anyway). +- The UIA tree of each screen is checked with Inspect / Accessibility Insights on 11 and Narrator on 10 and 11, and + with MSAA (Inspect32) on XP, for names, roles and focus order. + +### 7.2 Localization (03 §9) + +- No layout-sensitive change: all text still goes through the measuring layout pass; command links are measured with + `BCM_GETIDEALSIZE`. The six locales are all Latin script, and Segoe UI (Variable) covers them. +- New strings, each in en, es (voseo), fr ("vous"), pt-BR, it, de before it is done: Appearance setting (label and two + choices), "More" overflow button and its tooltip, empty-state text and button, command-link notes for the trust + prompts (one per choice), task-dialog main instructions where today's message is a single sentence (split into + instruction and content). The first slice (§9) needs only the Appearance strings and "More". +- Plural forms are unaffected; no string is concatenated with tier-specific text. + +## 8. Tests and lab plan + +### 8.1 Automated (local gate and CI) + +- `tests/ui`: `test_theme.c` drives `ui_theme_select` across a table of (OS, build, HC, composition, theme, setting) + rows, and the palette contrast checks of §7.1. `test_dark.c` folds into it. +- `--test-window` reports the selected tier and level and checks that the main window created the controls of that + tier; a new `--theme=classic|aero|fluent-a|fluent-b|fluent-c` override (test modes only) forces a tier, so the + smoke runs on the host exercise every tier's code path without the matching OS. +- `importcheck` on every flavour stays the guard: no `dwmapi.dll` import anywhere, no post-XP `uxtheme` or comctl32 + export on the XP flavours. +- `x64-analysis` (PREfast) covers the new paint code. + +### 8.2 Lab (Hyper-V VMs under `D:\sdlab`, 04 §11.4) + +| Target | Tier shown | Available now | Notes | +|---|---|---|---| +| `sd-xp-sp3-x86` | Classic | no — needs the maintainer's product-key install | Luna and Windows Classic theme; 120 DPI run | +| `sd-win7-sp1-x86`, `sd-win7-sp1-x64` | Aero, composition **off** | yes | Hyper-V's synthetic video has no WDDM driver, so Aero glass does not compose in the VM **[verify]**: this checks the fallback; glass itself needs an RDP 7 session with desktop composition or real hardware | +| `sd-win10-ltsc21-x64` (19044) | Fluent 2a | yes | light; dark only to confirm 2a ignores it | +| `sd-win11-23h2-x64` | Fluent 2b/2c | no — the VM has no OS (host DVD defect) | | +| Development host (Windows 11 26100) | Fluent 2c | yes | Mica, dark, light, accent colors, 100/150/200 % | + +Screenshots: the lab guest runs the harness as SYSTEM, which has no interactive desktop, so screenshots need an +autologon user and a scheduled task in that session that starts `shelldrive-ui.exe --theme-shot ` (a test mode +that opens the main window with fixture profiles and captures it with `PrintWindow`; `PrintWindow` does not capture +the Mica backdrop, so the 2c shots come from a host-side capture of the desktop). The WMI thumbnail the lab already +uses (`GetVirtualSystemThumbnailImage`) is a low-resolution backup. + +### 8.3 Spikes before the first slice is merged + +- **S1** Custom-drawn toolbar inside the extended frame with correct alpha (buffered paint), on 7 (glass) and + 11 (Mica). Fallback: band without child controls; the command bar sits on the opaque layer. +- **S2** Dark check boxes and radio buttons with v6 `NM_CUSTOMDRAW`: whether the glyph can stay theme-drawn while the + text and background are ours **[verify]**. +- **S3** `WM_DWMCOMPOSITIONCHANGED` and theme switches at run time on 7 and 11 without leaking fonts or brushes + (GDI object count before and after 50 switches). + +## 9. First vertical slice (proposed) + +Main window and theme plumbing only: + +1. `ui_theme_model.c` (pure, tested) and `ui_theme.c`; `ui_dark.c` folded in; the settings key and policy of §5.3 + with the Appearance control in Settings and its strings in all six languages. +2. Main window on Fluent 2b/2c: Mica band (or the S1 fallback), dark and light palettes, command bar with the "More" + overflow replacing the menu bar, custom-drawn ListView rows and headers, status strip, Segoe UI Variable headers. +3. Fluent 2a (10) light variant of the same window; Aero (7) theme fonts and glass band with the composition-off + fallback; Classic (XP) unchanged except spacing tokens. +4. Tests of §8.1, gate `tools/ci-local.ps1` green, screenshots: host (11: light, dark, Mica on/off), Win10 VM, + Win7 VM (composition off), XP when the VM exists. + +5. The tray flyout of §6.3 on Windows 11 (2b/2c) and the Aero wizard of §6.5 on Vista+ (D47). + +The prompts, task dialogs and the other windows follow in later slices in the order §6 lists them. + +## 10. Options considered + +| Option | What | Cost | Risk | +|---|---|---|---| +| **A (recommended)** | Run-time tiers in one binary per flavour, documented APIs only: Classic / Aero / Fluent 2a–2c as above. Dark client areas only on 11 and only where documented techniques reach (main window, prompts). | medium; one theme module, custom draw in the main window, then screen by screen | popup menus, scroll bars and tabbed dialogs stay light on 11 dark | +| B | A plus the undocumented dark-mode helpers on 10 1809+ (`uxtheme` ordinals 135 `SetPreferredAppMode`, 133 `AllowDarkModeForWindow`, theme classes `DarkMode_Explorer` / `DarkMode_CFD`) behind a build allow-list, for dark menus, scroll bars, combos and tabs; dark title bar on 10 20H1+ via attribute 20 | A + small; most of the dark gaps close | reverses 03 §7.4; ordinals may change in any Windows update; needs a kill switch and a lab run per Windows release | +| C | Minimal: only what needs no custom drawing — task dialogs, command links, Aero wizard, theme fonts, Mica title bar (`DWMSBT_MAINWINDOW` without frame extension), corner preference | small | the client area still looks like today on 10/11; no dark body | +| D | Separate modern UI (WinUI 3 or Direct2D) for x64-modern/arm64 only | large; two UIs | rejected in §3.4 | + +### 10.1 Decisions (2026-10-31, D47) + +Answers from the user after reviewing the mock-ups: + +- Windows 10/11 main window: the Fluent direction with Mica on 11 (command bar with an accent "New connection" + button, the list on a layer card, Windows 11 corners); Windows 10 keeps the flat variant without Mica. +- Dark mode: **option B**, full dark through the helpers of §3.6, on Windows 11 and on Windows 10 from 1809. +- Menu bar: replaced on 10/11 by the "···" overflow button at the end of the command bar (Alt opens it). +- Windows 7: the glass band of mock-up D, falling back to the normal frame without composition. +- Windows XP: classic with tidied spacing (mock-up E), nothing that needs post-XP APIs. +- Tray: the Windows 11 flyout on left click is **in the first slice**; the popup menu stays on right click and on + every other OS. +- First slice: §9 plus the tray flyout and the Aero wizard (§6.5). + +### 10.2 Implementation status (2026-10-31, branch `feat/ui-modern-tiers`) + +All four slices of §6 are implemented: the theme model and platform layer (`ui_theme_model.c`, `ui_theme.c`), the +Fluent main window with Mica, command bar and "···" overflow, the Aero glass band, the Windows 11 tray flyout, the +Aero wizard, the Appearance setting with the `UiStyle` and `UiDarkHelpers` policies, the trust prompts with command +links and main instructions, task dialogs for the other messages (`ui_win_task`), and the themed secondary windows. +Test modes: `--theme=[-dark|-light]` with `--test-window`, `--test-prompts`, `--test-editor`, +`--test-windows`, `--test-wizard`, `--test-flyout`, and `--test-shot=` for screenshots. + +Host screenshots (Windows 11 24H2, `--test-shot`): Fluent 2c dark and light, Fluent 2a light, Classic and forced Aero +checked. They found the list group titles centered over the rows of their group (`nmcd.rc` of an `LVCDI_GROUP` item +spans header and rows); the custom draw now takes the header band from `LVM_GETGROUPRECT`. + +Lab screenshots (2026-10-31; an autologon lab user and an interactive logon task run `--test-shot`, the PNGs return +over COM1): Windows 10 LTSC 2021 (`x64-modern`: automatic light, forced dark, Classic) and Windows 7 SP1 x64 +(`x64-xp`: automatic, forced Aero, Classic). On 10 the dark list drew the selected row with the classic light +highlight behind the text; the list custom draw now fills it with `UI_TOK_SELECTION`. The 7 guest has no desktop +composition (Windows 7 Basic), so the Aero tier shows the toolbar without glass, as §4 specifies. The images are in +`docs/images/ui/` and the README. + +Open items: Aero glass with composition on (verified on the 11 host only as geometry); XP and 11 lab guests are not installed; the Wizard97 header +band stays white on dark; error strings that name `{code}` inline keep it there (the details are omitted for them); +the flyout shows at most 12 profiles without scrolling. + +## 11. Risks + +- **Custom draw scope creep.** Each custom-drawn control is code that must handle DPI, focus, disabled state and + theme changes. Mitigation: only the main window and the prompts are custom drawn in A; everything else keeps the + system look with theme fonts. +- **Visual mismatch.** A Fluent main window next to a classic property sheet. Accepted for 1.0; the dark title bar + and shared fonts keep them related. +- **Verification gaps.** No Win11 VM and no working Aero glass in Hyper-V (§8.2). Mitigation: the host covers 2c, + forced-tier smoke tests cover the code paths, and the gaps are listed in `docs/dev/lab-results.md`. +- **Behavior differences between Windows 11 builds** for backdrops and borders. Mitigation: everything is a DWM + attribute that fails harmlessly; the model gates by documented build floors. +- **Remote Desktop.** DWM drops the backdrop over RDP; glass is off on 7 RDP unless composition is enabled. The + solid fallback must be tested (§8.2). +- **Undocumented APIs** (option B only): see §10. + +## 12. Platform facts used (sources) + +| Fact | Source | +|---|---| +| `DWMWA_USE_IMMERSIVE_DARK_MODE` (20) and `DWMWA_WINDOW_CORNER_PREFERENCE` (33), `DWMWA_BORDER_COLOR` (34) from 22000; `DWMWA_SYSTEMBACKDROP_TYPE` (38) from 22621 | [DWMWINDOWATTRIBUTE](https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute), [DWM_SYSTEMBACKDROP_TYPE](https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwm_systembackdrop_type) | +| Mica in Win32 apps, fallback when transparency is off | [Apply Mica in Win32 desktop apps](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/ui/apply-mica-win32) | +| `DwmExtendFrameIntoClientArea`, `DwmIsCompositionEnabled` (Vista+; composition always on from 8) | [DwmExtendFrameIntoClientArea](https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/nf-dwmapi-dwmextendframeintoclientarea), [DwmIsCompositionEnabled](https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/nf-dwmapi-dwmiscompositionenabled) | +| Custom frames and text on glass (`DrawThemeTextEx`, buffered paint) | [Custom Window Frame Using DWM](https://learn.microsoft.com/en-us/windows/win32/dwm/customframe) | +| `TaskDialogIndirect`: Vista, comctl32 v6; `nDefaultButton` falls back to the first button | [TASKDIALOGCONFIG](https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-taskdialogconfig) | +| Command links (`BS_COMMANDLINK`, `BCM_SETNOTE`), Vista+ | [Button styles](https://learn.microsoft.com/en-us/windows/win32/controls/button-styles) | +| `PSH_AEROWIZARD`, Vista+ | [PROPSHEETHEADER](https://learn.microsoft.com/en-us/windows/win32/api/prsht/ns-prsht-propsheetheadera_v2) | +| Windows App SDK / WinUI 3 floor and deployment | [Windows App SDK system requirements](https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/system-requirements) | +| Custom draw of common controls | [About Custom Draw](https://learn.microsoft.com/en-us/windows/win32/controls/about-custom-draw) | diff --git a/docs/design/README.md b/docs/design/README.md index 971973e..fbb78df 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -15,6 +15,7 @@ section). Remaining items are lab verifications. | [`03-apps-platform.md`](03-apps-platform.md) | Processes, IPC, profiles and policies, credentials, CLI, tray/main window, Explorer integration, localization, logging | | [`04-build-install-update.md`](04-build-install-update.md) | Toolchains, flavours, CRT, CI, test lab, setup EXE, MSI, Dokany redistribution, updater, signing, releases | | [`05-threat-model.md`](05-threat-model.md) | Assets, trust boundaries, STRIDE threats T-01..T-73, security tests per milestone, recommended changes TM-R01..R39 and decisions TM-Q01..Q09 (decided in D33–D43; status in §11) | +| [`06-ui-tiers.md`](06-ui-tiers.md) | Accepted 2026-10-31 as option B (D47): OS-tiered UI rendering (Classic on XP, Aero on Vista/7, Fluent with dark mode and Mica on 10/11), mechanisms, screens per tier, tests and options | Precedence when documents disagree: `decisions.md`, then the resolutions in `00-integration-review.md`, then the document that owns the topic (01: SSH/SFTP/crypto/known_hosts I/O; 02: VFS/WebDAV/Dokany; 03: processes, IPC, diff --git a/docs/design/decisions.md b/docs/design/decisions.md index b43c65a..2739826 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -51,6 +51,7 @@ Where this file differs from the review's recommendation, this file wins. | D45 | **Everything is labeled under the LibreStatic organization.** Repository `github.com/LibreStatic/ShellDrive`. Publisher / `CompanyName` / ARP publisher: `LibreStatic`. Reverse-DNS id `com.librestatic.shelldrive` for every free-form identifier: AppUserModelID, MSIX package name, update channel/product ids, signing key ids, ETW provider name, Credential Manager target prefix (`com.librestatic.shelldrive:`), named pipes (`\\.\pipe\com.librestatic.shelldrive...`), kernel object names (`Global\com.librestatic.shelldrive.`), ADMX namespace (`com.librestatic.shelldrive.policies`). Registry: `HKCU\|HKLM\SOFTWARE\LibreStatic\ShellDrive`, policies `SOFTWARE\Policies\LibreStatic\ShellDrive`. Folders: `%APPDATA%\LibreStatic\ShellDrive`, `%LOCALAPPDATA%\LibreStatic\ShellDrive`, `%ProgramData%\LibreStatic\ShellDrive`, `%ProgramFiles%\LibreStatic\ShellDrive`, per-user `%LOCALAPPDATA%\Programs\LibreStatic\ShellDrive`. C symbols keep the `sd_` prefix; the service name stays `ShellDriveSvc`; product and binary names stay `ShellDrive` / `shelldrive*`. | Set 2026-10-24 at the user's request. | | D46 | **The repository is public** (`github.com/LibreStatic/ShellDrive`), so GitHub Actions minutes are free; 04 §9.5 "private repository (interim)" no longer applies. Local CI (`tools/ci-local.ps1`) stays the authority for interop and Dokany tests. | Set 2026-10-25 at the user's request. | | D44 | **`mark_of_the_web` defaults to `off`, independent of `security.trust`** (confirmed by the user 2026-10-24). | Tying it to the `untrusted` default (D41) would tag every file on a Dokany drive with Mark-of-the-Web. | +| D47 | **OS-tiered UI (06-ui-tiers.md), option B**: one binary per flavour picks Classic (XP), Aero (Vista/7, glass band) or Fluent (8–11: flat on 8–10, Mica on 11 22H2+) at run time; full dark mode on Windows 11 and on 10 from 1809 through the undocumented uxtheme helpers behind an allow-list and a kill switch (`[ui] dark_helpers`, policy `UiDarkHelpers`); the menu bar becomes a "···" overflow on 10/11; Windows 11 tray flyout and Aero wizard in the first slice. | Set 2026-10-31 by the user from the mock-ups. Supersedes 03 §7.4's "no undocumented uxtheme ordinals". | ## Resulting build flavours diff --git a/docs/images/ui/win10-classic.png b/docs/images/ui/win10-classic.png new file mode 100644 index 0000000..b9993bd Binary files /dev/null and b/docs/images/ui/win10-classic.png differ diff --git a/docs/images/ui/win10-fluent-dark.png b/docs/images/ui/win10-fluent-dark.png new file mode 100644 index 0000000..233a3ce Binary files /dev/null and b/docs/images/ui/win10-fluent-dark.png differ diff --git a/docs/images/ui/win10-fluent-light.png b/docs/images/ui/win10-fluent-light.png new file mode 100644 index 0000000..c07fb8f Binary files /dev/null and b/docs/images/ui/win10-fluent-light.png differ diff --git a/docs/images/ui/win11-classic.png b/docs/images/ui/win11-classic.png new file mode 100644 index 0000000..019fbc2 Binary files /dev/null and b/docs/images/ui/win11-classic.png differ diff --git a/docs/images/ui/win11-fluent-dark.png b/docs/images/ui/win11-fluent-dark.png new file mode 100644 index 0000000..5b06846 Binary files /dev/null and b/docs/images/ui/win11-fluent-dark.png differ diff --git a/docs/images/ui/win11-fluent-light.png b/docs/images/ui/win11-fluent-light.png new file mode 100644 index 0000000..b074983 Binary files /dev/null and b/docs/images/ui/win11-fluent-light.png differ diff --git a/docs/images/ui/win7-basic.png b/docs/images/ui/win7-basic.png new file mode 100644 index 0000000..dda57fd Binary files /dev/null and b/docs/images/ui/win7-basic.png differ diff --git a/docs/images/ui/win7-classic.png b/docs/images/ui/win7-classic.png new file mode 100644 index 0000000..02932ec Binary files /dev/null and b/docs/images/ui/win7-classic.png differ diff --git a/l10n/policy/de.po b/l10n/policy/de.po index b5d3959..28767d3 100644 --- a/l10n/policy/de.po +++ b/l10n/policy/de.po @@ -318,3 +318,31 @@ msgstr "Standard" msgctxt "policy.logprivacy.opt.1" msgid "Strict" msgstr "Streng" + +msgctxt "policy.uistyle.title" +msgid "User interface style" +msgstr "Stil der Benutzeroberfläche" + +msgctxt "policy.uistyle.explain" +msgid "Sets the look of the ShellDrive windows. 0 matches the style of Windows, 1 forces the classic look. Use 1 for locked-down or remote desktop machines." +msgstr "Legt das Aussehen der ShellDrive-Fenster fest. 0 folgt dem Windows-Stil, 1 erzwingt das klassische Aussehen. Verwende 1 auf gesperrten Computern oder Remotedesktop-Rechnern." + +msgctxt "policy.uistyle.label" +msgid "Style:" +msgstr "Stil:" + +msgctxt "policy.uistyle.opt.0" +msgid "Match Windows" +msgstr "Windows folgen" + +msgctxt "policy.uistyle.opt.1" +msgid "Classic" +msgstr "Klassisch" + +msgctxt "policy.uidarkhelpers.title" +msgid "Allow dark mode in menus, scroll bars and lists" +msgstr "Dunkelmodus in Menüs, Bildlaufleisten und Listen zulassen" + +msgctxt "policy.uidarkhelpers.explain" +msgid "Controls the dark mode of menus, scroll bars and lists, which relies on undocumented Windows functions. Disabled turns it off and locks the setting; Enabled or Not configured lets users choose." +msgstr "Steuert den Dunkelmodus von Menüs, Bildlaufleisten und Listen, der undokumentierte Windows-Funktionen nutzt. Deaktiviert schaltet ihn ab und sperrt die Option; Aktiviert oder Nicht konfiguriert lässt jeden Benutzer wählen." diff --git a/l10n/policy/en.po b/l10n/policy/en.po index 11fb4b2..1682da0 100644 --- a/l10n/policy/en.po +++ b/l10n/policy/en.po @@ -319,3 +319,31 @@ msgstr "" msgctxt "policy.logprivacy.opt.1" msgid "Strict" msgstr "" + +msgctxt "policy.uistyle.title" +msgid "User interface style" +msgstr "" + +msgctxt "policy.uistyle.explain" +msgid "Sets the look of the ShellDrive windows. 0 matches the style of Windows, 1 forces the classic look. Use 1 for locked-down or remote desktop machines." +msgstr "" + +msgctxt "policy.uistyle.label" +msgid "Style:" +msgstr "" + +msgctxt "policy.uistyle.opt.0" +msgid "Match Windows" +msgstr "" + +msgctxt "policy.uistyle.opt.1" +msgid "Classic" +msgstr "" + +msgctxt "policy.uidarkhelpers.title" +msgid "Allow dark mode in menus, scroll bars and lists" +msgstr "" + +msgctxt "policy.uidarkhelpers.explain" +msgid "Controls the dark mode of menus, scroll bars and lists, which relies on undocumented Windows functions. Disabled turns it off and locks the setting; Enabled or Not configured lets users choose." +msgstr "" diff --git a/l10n/policy/es.po b/l10n/policy/es.po index 99e4b9e..9f45876 100644 --- a/l10n/policy/es.po +++ b/l10n/policy/es.po @@ -318,3 +318,31 @@ msgstr "Estándar" msgctxt "policy.logprivacy.opt.1" msgid "Strict" msgstr "Estricto" + +msgctxt "policy.uistyle.title" +msgid "User interface style" +msgstr "Estilo de la interfaz" + +msgctxt "policy.uistyle.explain" +msgid "Sets the look of the ShellDrive windows. 0 matches the style of Windows, 1 forces the classic look. Use 1 for locked-down or remote desktop machines." +msgstr "Define el aspecto de las ventanas de ShellDrive. 0 sigue el estilo de Windows y 1 fuerza el aspecto clásico. Usá 1 en equipos bloqueados o de escritorio remoto." + +msgctxt "policy.uistyle.label" +msgid "Style:" +msgstr "Estilo:" + +msgctxt "policy.uistyle.opt.0" +msgid "Match Windows" +msgstr "Seguir a Windows" + +msgctxt "policy.uistyle.opt.1" +msgid "Classic" +msgstr "Clásico" + +msgctxt "policy.uidarkhelpers.title" +msgid "Allow dark mode in menus, scroll bars and lists" +msgstr "Permitir el modo oscuro en menús, barras de desplazamiento y listas" + +msgctxt "policy.uidarkhelpers.explain" +msgid "Controls the dark mode of menus, scroll bars and lists, which relies on undocumented Windows functions. Disabled turns it off and locks the setting; Enabled or Not configured lets users choose." +msgstr "Controla el modo oscuro de los menús, las barras de desplazamiento y las listas, que usa funciones no documentadas de Windows. Deshabilitada lo desactiva y bloquea la opción; Habilitada o No configurada deja la elección a cada usuario." diff --git a/l10n/policy/fr.po b/l10n/policy/fr.po index 30e6a84..496fc3f 100644 --- a/l10n/policy/fr.po +++ b/l10n/policy/fr.po @@ -318,3 +318,31 @@ msgstr "Standard" msgctxt "policy.logprivacy.opt.1" msgid "Strict" msgstr "Strict" + +msgctxt "policy.uistyle.title" +msgid "User interface style" +msgstr "Style de l'interface" + +msgctxt "policy.uistyle.explain" +msgid "Sets the look of the ShellDrive windows. 0 matches the style of Windows, 1 forces the classic look. Use 1 for locked-down or remote desktop machines." +msgstr "Définit l'apparence des fenêtres de ShellDrive. 0 suit le style de Windows ; 1 impose l'apparence classique. Utilisez 1 sur les postes verrouillés ou en bureau à distance." + +msgctxt "policy.uistyle.label" +msgid "Style:" +msgstr "Style :" + +msgctxt "policy.uistyle.opt.0" +msgid "Match Windows" +msgstr "Suivre Windows" + +msgctxt "policy.uistyle.opt.1" +msgid "Classic" +msgstr "Classique" + +msgctxt "policy.uidarkhelpers.title" +msgid "Allow dark mode in menus, scroll bars and lists" +msgstr "Autoriser le mode sombre dans les menus, les barres de défilement et les listes" + +msgctxt "policy.uidarkhelpers.explain" +msgid "Controls the dark mode of menus, scroll bars and lists, which relies on undocumented Windows functions. Disabled turns it off and locks the setting; Enabled or Not configured lets users choose." +msgstr "Contrôle le mode sombre des menus, des barres de défilement et des listes, qui repose sur des fonctions Windows non documentées. Désactivée le désactive et verrouille l'option ; Activée ou Non configurée laisse chaque utilisateur choisir." diff --git a/l10n/policy/it.po b/l10n/policy/it.po index 14f5fa6..0f984ad 100644 --- a/l10n/policy/it.po +++ b/l10n/policy/it.po @@ -318,3 +318,31 @@ msgstr "Standard" msgctxt "policy.logprivacy.opt.1" msgid "Strict" msgstr "Rigoroso" + +msgctxt "policy.uistyle.title" +msgid "User interface style" +msgstr "Stile dell'interfaccia" + +msgctxt "policy.uistyle.explain" +msgid "Sets the look of the ShellDrive windows. 0 matches the style of Windows, 1 forces the classic look. Use 1 for locked-down or remote desktop machines." +msgstr "Imposta l'aspetto delle finestre di ShellDrive. 0 segue lo stile di Windows, 1 impone l'aspetto classico. Usa 1 sui computer bloccati o con desktop remoto." + +msgctxt "policy.uistyle.label" +msgid "Style:" +msgstr "Stile:" + +msgctxt "policy.uistyle.opt.0" +msgid "Match Windows" +msgstr "Segui Windows" + +msgctxt "policy.uistyle.opt.1" +msgid "Classic" +msgstr "Classico" + +msgctxt "policy.uidarkhelpers.title" +msgid "Allow dark mode in menus, scroll bars and lists" +msgstr "Consenti la modalità scura in menu, barre di scorrimento ed elenchi" + +msgctxt "policy.uidarkhelpers.explain" +msgid "Controls the dark mode of menus, scroll bars and lists, which relies on undocumented Windows functions. Disabled turns it off and locks the setting; Enabled or Not configured lets users choose." +msgstr "Controlla la modalità scura di menu, barre di scorrimento ed elenchi, che si basa su funzioni di Windows non documentate. Disabilitata la disattiva e blocca l'opzione; Abilitata o Non configurata lascia scegliere a ogni utente." diff --git a/l10n/policy/pt-BR.po b/l10n/policy/pt-BR.po index 512bc7a..0106be0 100644 --- a/l10n/policy/pt-BR.po +++ b/l10n/policy/pt-BR.po @@ -318,3 +318,31 @@ msgstr "Padrão" msgctxt "policy.logprivacy.opt.1" msgid "Strict" msgstr "Estrito" + +msgctxt "policy.uistyle.title" +msgid "User interface style" +msgstr "Estilo da interface" + +msgctxt "policy.uistyle.explain" +msgid "Sets the look of the ShellDrive windows. 0 matches the style of Windows, 1 forces the classic look. Use 1 for locked-down or remote desktop machines." +msgstr "Define a aparência das janelas do ShellDrive. 0 segue o estilo do Windows e 1 força a aparência clássica. Use 1 em computadores bloqueados ou de área de trabalho remota." + +msgctxt "policy.uistyle.label" +msgid "Style:" +msgstr "Estilo:" + +msgctxt "policy.uistyle.opt.0" +msgid "Match Windows" +msgstr "Seguir o Windows" + +msgctxt "policy.uistyle.opt.1" +msgid "Classic" +msgstr "Clássico" + +msgctxt "policy.uidarkhelpers.title" +msgid "Allow dark mode in menus, scroll bars and lists" +msgstr "Permitir o modo escuro em menus, barras de rolagem e listas" + +msgctxt "policy.uidarkhelpers.explain" +msgid "Controls the dark mode of menus, scroll bars and lists, which relies on undocumented Windows functions. Disabled turns it off and locks the setting; Enabled or Not configured lets users choose." +msgstr "Controla o modo escuro de menus, barras de rolagem e listas, que usa funções não documentadas do Windows. Desabilitada o desativa e bloqueia a opção; Habilitada ou Não configurada permite que cada usuário escolha." diff --git a/l10n/ui/de.po b/l10n/ui/de.po index 44c0e6f..079f17d 100644 --- a/l10n/ui/de.po +++ b/l10n/ui/de.po @@ -1900,8 +1900,8 @@ msgstr "ShellDrive konnte das Administratorfenster nicht öffnen. Versuch es ern #. Prompt dialog text for token-only WebDAV (03 7.5, TM-R19). Shown when Windows cannot sign in, every time it is enabled. msgctxt "ui.webdav.token_only_ask" -msgid "Windows could not sign in to ShellDrive's local file server. Continue with a less protected connection? Other programs that learn the drive's address can then read your files." -msgstr "Windows konnte sich nicht am lokalen Dateiserver von ShellDrive anmelden. Mit einer weniger geschützten Verbindung fortfahren? Andere Programme, die die Adresse des Laufwerks kennen, können dann deine Dateien lesen." +msgid "Windows could not sign in to ShellDrive's local file server. If you continue, other programs that learn the drive's address can read your files." +msgstr "Windows konnte sich nicht am lokalen Dateiserver von ShellDrive anmelden. Wenn du fortfährst, können andere Programme, die die Adresse des Laufwerks kennen, deine Dateien lesen." #. Prompt dialog title for the token-only WebDAV question (03 7.5). msgctxt "ui.prompt.title_token_only" @@ -2424,8 +2424,8 @@ msgstr "S&chließen" #. Question before host keys of a source are no longer read. {source} is PuTTY or OpenSSH. msgctxt "ui.hki.stop_confirm" -msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again. Stop using this source?" -msgstr "ShellDrive liest die von {source} gespeicherten Host-Schlüssel nicht mehr. Server, die nur dort bekannt sind, bitten dich erneut, ihren Schlüssel zu bestätigen. Diese Quelle nicht mehr verwenden?" +msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again." +msgstr "ShellDrive liest die von {source} gespeicherten Host-Schlüssel nicht mehr. Server, die nur dort bekannt sind, bitten dich erneut, ihren Schlüssel zu bestätigen." #. Message after host keys of a source are no longer read. {source} is PuTTY or OpenSSH. msgctxt "ui.hki.stop_done" @@ -2534,8 +2534,8 @@ msgstr "ShellDrive warnt für diesen Server nicht mehr vor diesen Algorithmen. E #. Security details: question after the setting was saved. {drive} is the drive letter. msgctxt "ui.secdlg.refuse_ask" -msgid "Outdated algorithms are refused for this server from now on. Reconnect {drive} now?" -msgstr "Veraltete Algorithmen werden für diesen Server ab jetzt abgelehnt. {drive} jetzt neu verbinden?" +msgid "Outdated algorithms are refused for this server from now on." +msgstr "Veraltete Algorithmen werden für diesen Server ab jetzt abgelehnt." #. Host key manager button: copies the SHA256 fingerprint of the selected key. msgctxt "ui.hostkeys.btn_copy_fp" @@ -2979,3 +2979,74 @@ msgstr "Von OpenSSH kopiert" msgctxt "ui.hostkeys.origin_server" msgid "Updated by server" msgstr "Vom Server aktualisiert" + +#. Settings: label of the list that picks the look of the windows (06 §5.3). +msgctxt "ui.settings.appearance" +msgid "Appearance" +msgstr "Darstellung" + +#. Settings: Appearance choice, follow the Windows version and its light or dark mode. +msgctxt "ui.settings.appearance_auto" +msgid "Match Windows (recommended)" +msgstr "Windows folgen (empfohlen)" + +#. Settings: Appearance choice, the classic look on every Windows version. +msgctxt "ui.settings.appearance_classic" +msgid "Classic" +msgstr "Klassisch" + +#. Settings: checkbox, the dark-mode helpers (06 §3.6). Disabled before Windows 10 version 1809. +msgctxt "ui.settings.dark_helpers" +msgid "Use dark mode in menus, scroll bars and lists" +msgstr "Dunkelmodus in Menüs, Bildlaufleisten und Listen verwenden" + +#. Settings: shown after the Appearance setting changes; the main window builds its controls only when it is created. +msgctxt "ui.settings.appearance_restart" +msgid "The main window switches between the Classic and the modern look when you restart ShellDrive. Other windows update right away." +msgstr "Das Hauptfenster wechselt zwischen klassischem und modernem Aussehen, wenn du ShellDrive neu startest. Andere Fenster werden sofort aktualisiert." + +#. Main window: accessible name and tooltip of the "..." overflow button that holds the menu commands. +msgctxt "ui.main.more_options" +msgid "More options" +msgstr "Weitere Optionen" + +#. Tray flyout: one row of a connection. {name} is the profile name, {drive} is a drive letter such as E:. +msgctxt "ui.flyout.row_label" +msgid "{name} ({drive})" +msgstr "{name} ({drive})" + +msgctxt "ui.prompt.note.trust" +msgid "ShellDrive saves this key and warns you if it changes." +msgstr "ShellDrive speichert diesen Schlüssel und warnt dich, wenn er sich ändert." + +msgctxt "ui.prompt.note.once" +msgid "Connects now without saving the key. ShellDrive asks again next time." +msgstr "Verbindet jetzt, ohne den Schlüssel zu speichern. ShellDrive fragt beim nächsten Mal erneut." + +msgctxt "ui.prompt.note.cancel" +msgid "Does not connect. Nothing is saved." +msgstr "Es wird keine Verbindung aufgebaut. Nichts wird gespeichert." + +msgctxt "ui.prompt.note.anyway" +msgid "Connects using these algorithms." +msgstr "Verbindet mit diesen Algorithmen." + +msgctxt "ui.prompt.note.token_continue" +msgid "Mounts the drive without Windows signing in. ShellDrive asks again every time." +msgstr "Bindet das Laufwerk ein, ohne dass Windows sich anmeldet. ShellDrive fragt jedes Mal erneut." + +msgctxt "ui.prompt.token_only_head" +msgid "Continue with a less protected connection?" +msgstr "Mit einer weniger geschützten Verbindung fortfahren?" + +msgctxt "ui.win.error_details" +msgid "Error code: {code}" +msgstr "Fehlercode: {code}" + +msgctxt "ui.hki.stop_title" +msgid "Stop using the host keys from {source}?" +msgstr "Host-Schlüssel von {source} nicht mehr verwenden?" + +msgctxt "ui.secdlg.refuse_title" +msgid "Reconnect {drive} now?" +msgstr "{drive} jetzt neu verbinden?" diff --git a/l10n/ui/en.po b/l10n/ui/en.po index 3d46f2b..27570cb 100644 --- a/l10n/ui/en.po +++ b/l10n/ui/en.po @@ -1900,7 +1900,7 @@ msgstr "" #. Prompt dialog text for token-only WebDAV (03 7.5, TM-R19). Shown when Windows cannot sign in, every time it is enabled. msgctxt "ui.webdav.token_only_ask" -msgid "Windows could not sign in to ShellDrive's local file server. Continue with a less protected connection? Other programs that learn the drive's address can then read your files." +msgid "Windows could not sign in to ShellDrive's local file server. If you continue, other programs that learn the drive's address can read your files." msgstr "" #. Prompt dialog title for the token-only WebDAV question (03 7.5). @@ -2423,7 +2423,7 @@ msgstr "" #. Question before host keys of a source are no longer read. {source} is PuTTY or OpenSSH. msgctxt "ui.hki.stop_confirm" -msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again. Stop using this source?" +msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again." msgstr "" #. Message after host keys of a source are no longer read. {source} is PuTTY or OpenSSH. @@ -2533,7 +2533,7 @@ msgstr "" #. Security details: question after the setting was saved. {drive} is the drive letter. msgctxt "ui.secdlg.refuse_ask" -msgid "Outdated algorithms are refused for this server from now on. Reconnect {drive} now?" +msgid "Outdated algorithms are refused for this server from now on." msgstr "" #. Host key manager button: copies the SHA256 fingerprint of the selected key. @@ -2977,3 +2977,83 @@ msgstr "" msgctxt "ui.hostkeys.origin_server" msgid "Updated by server" msgstr "" + +#. Settings: label of the list that picks the look of the windows (06 §5.3). +msgctxt "ui.settings.appearance" +msgid "Appearance" +msgstr "" + +#. Settings: Appearance choice, follow the Windows version and its light or dark mode. +msgctxt "ui.settings.appearance_auto" +msgid "Match Windows (recommended)" +msgstr "" + +#. Settings: Appearance choice, the classic look on every Windows version. +msgctxt "ui.settings.appearance_classic" +msgid "Classic" +msgstr "" + +#. Settings: checkbox, the dark-mode helpers (06 §3.6). Disabled before Windows 10 version 1809. +msgctxt "ui.settings.dark_helpers" +msgid "Use dark mode in menus, scroll bars and lists" +msgstr "" + +#. Settings: shown after the Appearance setting changes; the main window builds its controls only when it is created. +msgctxt "ui.settings.appearance_restart" +msgid "The main window switches between the Classic and the modern look when you restart ShellDrive. Other windows update right away." +msgstr "" + +#. Main window: accessible name and tooltip of the "..." overflow button that holds the menu commands. +msgctxt "ui.main.more_options" +msgid "More options" +msgstr "" + +#. Tray flyout: one row of a connection. {name} is the profile name, {drive} is a drive letter such as E:. +msgctxt "ui.flyout.row_label" +msgid "{name} ({drive})" +msgstr "" + +#. Prompt dialog: the note under the "Trust and connect" command link (Windows Vista and later). +msgctxt "ui.prompt.note.trust" +msgid "ShellDrive saves this key and warns you if it changes." +msgstr "" + +#. Prompt dialog: the note under the "Connect once" command link. +msgctxt "ui.prompt.note.once" +msgid "Connects now without saving the key. ShellDrive asks again next time." +msgstr "" + +#. Prompt dialog: the note under the "Cancel" command link of the host key, outdated algorithms and less protected connection questions. +msgctxt "ui.prompt.note.cancel" +msgid "Does not connect. Nothing is saved." +msgstr "" + +#. Prompt dialog: the note under the "Continue anyway" command link of the outdated algorithms question. +msgctxt "ui.prompt.note.anyway" +msgid "Connects using these algorithms." +msgstr "" + +#. Prompt dialog: the note under the "Continue" command link of the less protected WebDAV connection question (03 7.5). +msgctxt "ui.prompt.note.token_continue" +msgid "Mounts the drive without Windows signing in. ShellDrive asks again every time." +msgstr "" + +#. Prompt dialog: the main instruction (first line) of the less protected WebDAV connection question (03 7.5). +msgctxt "ui.prompt.token_only_head" +msgid "Continue with a less protected connection?" +msgstr "" + +#. Error message: the expandable details of a task dialog. {code} is the error code name. +msgctxt "ui.win.error_details" +msgid "Error code: {code}" +msgstr "" + +#. Question: the main instruction of the confirmation to stop reading the host keys of another SSH program. {source} is the program name (PuTTY or OpenSSH). +msgctxt "ui.hki.stop_title" +msgid "Stop using the host keys from {source}?" +msgstr "" + +#. Question: the main instruction of the offer to reconnect after outdated algorithms are refused. {drive} is a drive letter such as E:. +msgctxt "ui.secdlg.refuse_title" +msgid "Reconnect {drive} now?" +msgstr "" diff --git a/l10n/ui/es.po b/l10n/ui/es.po index d2c7357..d7935a5 100644 --- a/l10n/ui/es.po +++ b/l10n/ui/es.po @@ -1910,8 +1910,8 @@ msgstr "ShellDrive no pudo abrir su ventana de administrador. Probá de nuevo de #. Prompt dialog text for token-only WebDAV (03 7.5, TM-R19). Shown when Windows cannot sign in, every time it is enabled. msgctxt "ui.webdav.token_only_ask" -msgid "Windows could not sign in to ShellDrive's local file server. Continue with a less protected connection? Other programs that learn the drive's address can then read your files." -msgstr "Windows no pudo iniciar sesión en el servidor local de archivos de ShellDrive. ¿Continuar con una conexión menos protegida? Otros programas que conozcan la dirección de la unidad pueden leer tus archivos." +msgid "Windows could not sign in to ShellDrive's local file server. If you continue, other programs that learn the drive's address can read your files." +msgstr "Windows no pudo iniciar sesión en el servidor local de archivos de ShellDrive. Si continuás, otros programas que conozcan la dirección de la unidad pueden leer tus archivos." #. Prompt dialog title for the token-only WebDAV question (03 7.5). msgctxt "ui.prompt.title_token_only" @@ -2437,8 +2437,8 @@ msgstr "&Cerrar" #. Question before host keys of a source are no longer read. {source} is PuTTY or OpenSSH. msgctxt "ui.hki.stop_confirm" -msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again. Stop using this source?" -msgstr "ShellDrive dejará de leer las claves de host guardadas por {source}. Los servidores conocidos solo allí volverán a pedirte que confirmes su clave. ¿Dejar de usar este origen?" +msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again." +msgstr "ShellDrive dejará de leer las claves de host guardadas por {source}. Los servidores conocidos solo allí volverán a pedirte que confirmes su clave." #. Message after host keys of a source are no longer read. {source} is PuTTY or OpenSSH. msgctxt "ui.hki.stop_done" @@ -2547,8 +2547,8 @@ msgstr "ShellDrive no volverá a avisar de estos algoritmos para este servidor. #. Security details: question after the setting was saved. {drive} is the drive letter. msgctxt "ui.secdlg.refuse_ask" -msgid "Outdated algorithms are refused for this server from now on. Reconnect {drive} now?" -msgstr "A partir de ahora se rechazan los algoritmos obsoletos para este servidor. ¿Reconectar {drive} ahora?" +msgid "Outdated algorithms are refused for this server from now on." +msgstr "A partir de ahora se rechazan los algoritmos obsoletos para este servidor." #. Host key manager button: copies the SHA256 fingerprint of the selected key. msgctxt "ui.hostkeys.btn_copy_fp" @@ -2993,3 +2993,74 @@ msgstr "Copiada de OpenSSH" msgctxt "ui.hostkeys.origin_server" msgid "Updated by server" msgstr "Actualizada por el servidor" + +#. Settings: label of the list that picks the look of the windows (06 §5.3). +msgctxt "ui.settings.appearance" +msgid "Appearance" +msgstr "Apariencia" + +#. Settings: Appearance choice, follow the Windows version and its light or dark mode. +msgctxt "ui.settings.appearance_auto" +msgid "Match Windows (recommended)" +msgstr "Seguir a Windows (recomendado)" + +#. Settings: Appearance choice, the classic look on every Windows version. +msgctxt "ui.settings.appearance_classic" +msgid "Classic" +msgstr "Clásico" + +#. Settings: checkbox, the dark-mode helpers (06 §3.6). Disabled before Windows 10 version 1809. +msgctxt "ui.settings.dark_helpers" +msgid "Use dark mode in menus, scroll bars and lists" +msgstr "Usar el modo oscuro en menús, barras de desplazamiento y listas" + +#. Settings: shown after the Appearance setting changes; the main window builds its controls only when it is created. +msgctxt "ui.settings.appearance_restart" +msgid "The main window switches between the Classic and the modern look when you restart ShellDrive. Other windows update right away." +msgstr "La ventana principal cambia entre el aspecto clásico y el moderno al reiniciar ShellDrive. Las demás ventanas se actualizan enseguida." + +#. Main window: accessible name and tooltip of the "..." overflow button that holds the menu commands. +msgctxt "ui.main.more_options" +msgid "More options" +msgstr "Más opciones" + +#. Tray flyout: one row of a connection. {name} is the profile name, {drive} is a drive letter such as E:. +msgctxt "ui.flyout.row_label" +msgid "{name} ({drive})" +msgstr "{name} ({drive})" + +msgctxt "ui.prompt.note.trust" +msgid "ShellDrive saves this key and warns you if it changes." +msgstr "ShellDrive guarda esta clave y te avisa si cambia." + +msgctxt "ui.prompt.note.once" +msgid "Connects now without saving the key. ShellDrive asks again next time." +msgstr "Se conecta ahora sin guardar la clave. ShellDrive vuelve a preguntar la próxima vez." + +msgctxt "ui.prompt.note.cancel" +msgid "Does not connect. Nothing is saved." +msgstr "No se conecta. No se guarda nada." + +msgctxt "ui.prompt.note.anyway" +msgid "Connects using these algorithms." +msgstr "Se conecta usando estos algoritmos." + +msgctxt "ui.prompt.note.token_continue" +msgid "Mounts the drive without Windows signing in. ShellDrive asks again every time." +msgstr "Monta la unidad sin que Windows inicie sesión. ShellDrive vuelve a preguntar cada vez." + +msgctxt "ui.prompt.token_only_head" +msgid "Continue with a less protected connection?" +msgstr "¿Continuar con una conexión menos protegida?" + +msgctxt "ui.win.error_details" +msgid "Error code: {code}" +msgstr "Código de error: {code}" + +msgctxt "ui.hki.stop_title" +msgid "Stop using the host keys from {source}?" +msgstr "¿Dejar de usar las claves de host de {source}?" + +msgctxt "ui.secdlg.refuse_title" +msgid "Reconnect {drive} now?" +msgstr "¿Reconectar {drive} ahora?" diff --git a/l10n/ui/fr.po b/l10n/ui/fr.po index 8cf9293..23ac6a0 100644 --- a/l10n/ui/fr.po +++ b/l10n/ui/fr.po @@ -1910,8 +1910,8 @@ msgstr "ShellDrive n'a pas pu ouvrir sa fenêtre d'administration. Réessayez de #. Prompt dialog text for token-only WebDAV (03 7.5, TM-R19). Shown when Windows cannot sign in, every time it is enabled. msgctxt "ui.webdav.token_only_ask" -msgid "Windows could not sign in to ShellDrive's local file server. Continue with a less protected connection? Other programs that learn the drive's address can then read your files." -msgstr "Windows n'a pas pu vous connecter au serveur de fichiers local de ShellDrive. Continuer avec une connexion moins protégée ? D'autres programmes qui connaissent l'adresse du lecteur peuvent alors lire vos fichiers." +msgid "Windows could not sign in to ShellDrive's local file server. If you continue, other programs that learn the drive's address can read your files." +msgstr "Windows n'a pas pu vous connecter au serveur de fichiers local de ShellDrive. Si vous continuez, d'autres programmes qui connaissent l'adresse du lecteur peuvent lire vos fichiers." #. Prompt dialog title for the token-only WebDAV question (03 7.5). msgctxt "ui.prompt.title_token_only" @@ -2437,8 +2437,8 @@ msgstr "&Fermer" #. Question before host keys of a source are no longer read. {source} is PuTTY or OpenSSH. msgctxt "ui.hki.stop_confirm" -msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again. Stop using this source?" -msgstr "ShellDrive ne lira plus les clés d'hôte enregistrées par {source}. Les serveurs connus uniquement là vous demanderont de confirmer à nouveau leur clé. Cesser d'utiliser cette source ?" +msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again." +msgstr "ShellDrive ne lira plus les clés d'hôte enregistrées par {source}. Les serveurs connus uniquement là vous demanderont de confirmer à nouveau leur clé." #. Message after host keys of a source are no longer read. {source} is PuTTY or OpenSSH. msgctxt "ui.hki.stop_done" @@ -2547,8 +2547,8 @@ msgstr "ShellDrive n'avertira plus pour ces algorithmes sur ce serveur. Un autre #. Security details: question after the setting was saved. {drive} is the drive letter. msgctxt "ui.secdlg.refuse_ask" -msgid "Outdated algorithms are refused for this server from now on. Reconnect {drive} now?" -msgstr "Les algorithmes obsolètes sont désormais refusés pour ce serveur. Reconnecter {drive} maintenant ?" +msgid "Outdated algorithms are refused for this server from now on." +msgstr "Les algorithmes obsolètes sont désormais refusés pour ce serveur." #. Host key manager button: copies the SHA256 fingerprint of the selected key. msgctxt "ui.hostkeys.btn_copy_fp" @@ -2993,3 +2993,74 @@ msgstr "Copiée depuis OpenSSH" msgctxt "ui.hostkeys.origin_server" msgid "Updated by server" msgstr "Mise à jour par le serveur" + +#. Settings: label of the list that picks the look of the windows (06 §5.3). +msgctxt "ui.settings.appearance" +msgid "Appearance" +msgstr "Apparence" + +#. Settings: Appearance choice, follow the Windows version and its light or dark mode. +msgctxt "ui.settings.appearance_auto" +msgid "Match Windows (recommended)" +msgstr "Suivre Windows (recommandé)" + +#. Settings: Appearance choice, the classic look on every Windows version. +msgctxt "ui.settings.appearance_classic" +msgid "Classic" +msgstr "Classique" + +#. Settings: checkbox, the dark-mode helpers (06 §3.6). Disabled before Windows 10 version 1809. +msgctxt "ui.settings.dark_helpers" +msgid "Use dark mode in menus, scroll bars and lists" +msgstr "Utiliser le mode sombre dans les menus, les barres de défilement et les listes" + +#. Settings: shown after the Appearance setting changes; the main window builds its controls only when it is created. +msgctxt "ui.settings.appearance_restart" +msgid "The main window switches between the Classic and the modern look when you restart ShellDrive. Other windows update right away." +msgstr "La fenêtre principale passe de l'apparence classique à l'apparence moderne (ou l'inverse) au prochain démarrage de ShellDrive. Les autres fenêtres sont mises à jour immédiatement." + +#. Main window: accessible name and tooltip of the "..." overflow button that holds the menu commands. +msgctxt "ui.main.more_options" +msgid "More options" +msgstr "Plus d'options" + +#. Tray flyout: one row of a connection. {name} is the profile name, {drive} is a drive letter such as E:. +msgctxt "ui.flyout.row_label" +msgid "{name} ({drive})" +msgstr "{name} ({drive})" + +msgctxt "ui.prompt.note.trust" +msgid "ShellDrive saves this key and warns you if it changes." +msgstr "ShellDrive enregistre cette clé et vous avertit si elle change." + +msgctxt "ui.prompt.note.once" +msgid "Connects now without saving the key. ShellDrive asks again next time." +msgstr "Se connecte maintenant sans enregistrer la clé. ShellDrive redemande la prochaine fois." + +msgctxt "ui.prompt.note.cancel" +msgid "Does not connect. Nothing is saved." +msgstr "Aucune connexion n'est établie. Rien n'est enregistré." + +msgctxt "ui.prompt.note.anyway" +msgid "Connects using these algorithms." +msgstr "Se connecte avec ces algorithmes." + +msgctxt "ui.prompt.note.token_continue" +msgid "Mounts the drive without Windows signing in. ShellDrive asks again every time." +msgstr "Monte le lecteur sans que Windows se connecte. ShellDrive redemande à chaque fois." + +msgctxt "ui.prompt.token_only_head" +msgid "Continue with a less protected connection?" +msgstr "Continuer avec une connexion moins protégée ?" + +msgctxt "ui.win.error_details" +msgid "Error code: {code}" +msgstr "Code d'erreur : {code}" + +msgctxt "ui.hki.stop_title" +msgid "Stop using the host keys from {source}?" +msgstr "Ne plus utiliser les clés d'hôte de {source} ?" + +msgctxt "ui.secdlg.refuse_title" +msgid "Reconnect {drive} now?" +msgstr "Reconnecter {drive} maintenant ?" diff --git a/l10n/ui/it.po b/l10n/ui/it.po index 0f87270..103dd73 100644 --- a/l10n/ui/it.po +++ b/l10n/ui/it.po @@ -1910,8 +1910,8 @@ msgstr "ShellDrive non è riuscito ad aprire la finestra di amministrazione. Rip #. Prompt dialog text for token-only WebDAV (03 7.5, TM-R19). Shown when Windows cannot sign in, every time it is enabled. msgctxt "ui.webdav.token_only_ask" -msgid "Windows could not sign in to ShellDrive's local file server. Continue with a less protected connection? Other programs that learn the drive's address can then read your files." -msgstr "Windows non è riuscito ad accedere al server di file locale di ShellDrive. Continuare con una connessione meno protetta? Altri programmi che conoscono l'indirizzo dell'unità possono leggere i tuoi file." +msgid "Windows could not sign in to ShellDrive's local file server. If you continue, other programs that learn the drive's address can read your files." +msgstr "Windows non è riuscito ad accedere al server di file locale di ShellDrive. Se continui, altri programmi che conoscono l'indirizzo dell'unità possono leggere i tuoi file." #. Prompt dialog title for the token-only WebDAV question (03 7.5). msgctxt "ui.prompt.title_token_only" @@ -2437,8 +2437,8 @@ msgstr "C&hiudi" #. Question before host keys of a source are no longer read. {source} is PuTTY or OpenSSH. msgctxt "ui.hki.stop_confirm" -msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again. Stop using this source?" -msgstr "ShellDrive non leggerà più le chiavi host salvate da {source}. I server noti solo lì ti chiederanno di nuovo di confermare la loro chiave. Smettere di usare questa origine?" +msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again." +msgstr "ShellDrive non leggerà più le chiavi host salvate da {source}. I server noti solo lì ti chiederanno di nuovo di confermare la loro chiave." #. Message after host keys of a source are no longer read. {source} is PuTTY or OpenSSH. msgctxt "ui.hki.stop_done" @@ -2547,8 +2547,8 @@ msgstr "ShellDrive non avviserà più per questi algoritmi su questo server. Un #. Security details: question after the setting was saved. {drive} is the drive letter. msgctxt "ui.secdlg.refuse_ask" -msgid "Outdated algorithms are refused for this server from now on. Reconnect {drive} now?" -msgstr "D'ora in poi gli algoritmi obsoleti vengono rifiutati per questo server. Riconnettere {drive} ora?" +msgid "Outdated algorithms are refused for this server from now on." +msgstr "D'ora in poi gli algoritmi obsoleti vengono rifiutati per questo server." #. Host key manager button: copies the SHA256 fingerprint of the selected key. msgctxt "ui.hostkeys.btn_copy_fp" @@ -2993,3 +2993,74 @@ msgstr "Copiata da OpenSSH" msgctxt "ui.hostkeys.origin_server" msgid "Updated by server" msgstr "Aggiornata dal server" + +#. Settings: label of the list that picks the look of the windows (06 §5.3). +msgctxt "ui.settings.appearance" +msgid "Appearance" +msgstr "Aspetto" + +#. Settings: Appearance choice, follow the Windows version and its light or dark mode. +msgctxt "ui.settings.appearance_auto" +msgid "Match Windows (recommended)" +msgstr "Segui Windows (consigliato)" + +#. Settings: Appearance choice, the classic look on every Windows version. +msgctxt "ui.settings.appearance_classic" +msgid "Classic" +msgstr "Classico" + +#. Settings: checkbox, the dark-mode helpers (06 §3.6). Disabled before Windows 10 version 1809. +msgctxt "ui.settings.dark_helpers" +msgid "Use dark mode in menus, scroll bars and lists" +msgstr "Usa la modalità scura in menu, barre di scorrimento ed elenchi" + +#. Settings: shown after the Appearance setting changes; the main window builds its controls only when it is created. +msgctxt "ui.settings.appearance_restart" +msgid "The main window switches between the Classic and the modern look when you restart ShellDrive. Other windows update right away." +msgstr "La finestra principale passa dall'aspetto classico a quello moderno al riavvio di ShellDrive. Le altre finestre si aggiornano subito." + +#. Main window: accessible name and tooltip of the "..." overflow button that holds the menu commands. +msgctxt "ui.main.more_options" +msgid "More options" +msgstr "Altre opzioni" + +#. Tray flyout: one row of a connection. {name} is the profile name, {drive} is a drive letter such as E:. +msgctxt "ui.flyout.row_label" +msgid "{name} ({drive})" +msgstr "{name} ({drive})" + +msgctxt "ui.prompt.note.trust" +msgid "ShellDrive saves this key and warns you if it changes." +msgstr "ShellDrive salva questa chiave e ti avvisa se cambia." + +msgctxt "ui.prompt.note.once" +msgid "Connects now without saving the key. ShellDrive asks again next time." +msgstr "Si connette ora senza salvare la chiave. ShellDrive chiede di nuovo la prossima volta." + +msgctxt "ui.prompt.note.cancel" +msgid "Does not connect. Nothing is saved." +msgstr "Non si connette. Non viene salvato nulla." + +msgctxt "ui.prompt.note.anyway" +msgid "Connects using these algorithms." +msgstr "Si connette usando questi algoritmi." + +msgctxt "ui.prompt.note.token_continue" +msgid "Mounts the drive without Windows signing in. ShellDrive asks again every time." +msgstr "Monta l'unità senza che Windows acceda. ShellDrive chiede di nuovo ogni volta." + +msgctxt "ui.prompt.token_only_head" +msgid "Continue with a less protected connection?" +msgstr "Continuare con una connessione meno protetta?" + +msgctxt "ui.win.error_details" +msgid "Error code: {code}" +msgstr "Codice di errore: {code}" + +msgctxt "ui.hki.stop_title" +msgid "Stop using the host keys from {source}?" +msgstr "Smettere di usare le chiavi host di {source}?" + +msgctxt "ui.secdlg.refuse_title" +msgid "Reconnect {drive} now?" +msgstr "Riconnettere {drive} ora?" diff --git a/l10n/ui/pt-BR.po b/l10n/ui/pt-BR.po index 1ab1d26..c199303 100644 --- a/l10n/ui/pt-BR.po +++ b/l10n/ui/pt-BR.po @@ -1910,8 +1910,8 @@ msgstr "O ShellDrive não conseguiu abrir a janela de administração. Tente de #. Prompt dialog text for token-only WebDAV (03 7.5, TM-R19). Shown when Windows cannot sign in, every time it is enabled. msgctxt "ui.webdav.token_only_ask" -msgid "Windows could not sign in to ShellDrive's local file server. Continue with a less protected connection? Other programs that learn the drive's address can then read your files." -msgstr "O Windows não conseguiu entrar no servidor local de arquivos do ShellDrive. Continuar com uma conexão menos protegida? Outros programas que descobrirem o endereço da unidade podem ler seus arquivos." +msgid "Windows could not sign in to ShellDrive's local file server. If you continue, other programs that learn the drive's address can read your files." +msgstr "O Windows não conseguiu entrar no servidor local de arquivos do ShellDrive. Se você continuar, outros programas que descobrirem o endereço da unidade podem ler seus arquivos." #. Prompt dialog title for the token-only WebDAV question (03 7.5). msgctxt "ui.prompt.title_token_only" @@ -2437,8 +2437,8 @@ msgstr "&Fechar" #. Question before host keys of a source are no longer read. {source} is PuTTY or OpenSSH. msgctxt "ui.hki.stop_confirm" -msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again. Stop using this source?" -msgstr "O ShellDrive deixará de ler as chaves de host salvas pelo {source}. Os servidores conhecidos apenas ali pedirão que você confirme a chave novamente. Parar de usar esta origem?" +msgid "ShellDrive will no longer read the host keys saved by {source}. Servers known only there will ask you to confirm their key again." +msgstr "O ShellDrive deixará de ler as chaves de host salvas pelo {source}. Os servidores conhecidos apenas ali pedirão que você confirme a chave novamente." #. Message after host keys of a source are no longer read. {source} is PuTTY or OpenSSH. msgctxt "ui.hki.stop_done" @@ -2547,8 +2547,8 @@ msgstr "O ShellDrive não avisará mais sobre estes algoritmos neste servidor. O #. Security details: question after the setting was saved. {drive} is the drive letter. msgctxt "ui.secdlg.refuse_ask" -msgid "Outdated algorithms are refused for this server from now on. Reconnect {drive} now?" -msgstr "A partir de agora, os algoritmos obsoletos são recusados neste servidor. Reconectar {drive} agora?" +msgid "Outdated algorithms are refused for this server from now on." +msgstr "A partir de agora, os algoritmos obsoletos são recusados neste servidor." #. Host key manager button: copies the SHA256 fingerprint of the selected key. msgctxt "ui.hostkeys.btn_copy_fp" @@ -2993,3 +2993,74 @@ msgstr "Copiada do OpenSSH" msgctxt "ui.hostkeys.origin_server" msgid "Updated by server" msgstr "Atualizada pelo servidor" + +#. Settings: label of the list that picks the look of the windows (06 §5.3). +msgctxt "ui.settings.appearance" +msgid "Appearance" +msgstr "Aparência" + +#. Settings: Appearance choice, follow the Windows version and its light or dark mode. +msgctxt "ui.settings.appearance_auto" +msgid "Match Windows (recommended)" +msgstr "Seguir o Windows (recomendado)" + +#. Settings: Appearance choice, the classic look on every Windows version. +msgctxt "ui.settings.appearance_classic" +msgid "Classic" +msgstr "Clássico" + +#. Settings: checkbox, the dark-mode helpers (06 §3.6). Disabled before Windows 10 version 1809. +msgctxt "ui.settings.dark_helpers" +msgid "Use dark mode in menus, scroll bars and lists" +msgstr "Usar o modo escuro em menus, barras de rolagem e listas" + +#. Settings: shown after the Appearance setting changes; the main window builds its controls only when it is created. +msgctxt "ui.settings.appearance_restart" +msgid "The main window switches between the Classic and the modern look when you restart ShellDrive. Other windows update right away." +msgstr "A janela principal alterna entre o visual clássico e o moderno quando você reiniciar o ShellDrive. As outras janelas são atualizadas na hora." + +#. Main window: accessible name and tooltip of the "..." overflow button that holds the menu commands. +msgctxt "ui.main.more_options" +msgid "More options" +msgstr "Mais opções" + +#. Tray flyout: one row of a connection. {name} is the profile name, {drive} is a drive letter such as E:. +msgctxt "ui.flyout.row_label" +msgid "{name} ({drive})" +msgstr "{name} ({drive})" + +msgctxt "ui.prompt.note.trust" +msgid "ShellDrive saves this key and warns you if it changes." +msgstr "O ShellDrive salva esta chave e avisa você se ela mudar." + +msgctxt "ui.prompt.note.once" +msgid "Connects now without saving the key. ShellDrive asks again next time." +msgstr "Conecta agora sem salvar a chave. O ShellDrive pergunta de novo da próxima vez." + +msgctxt "ui.prompt.note.cancel" +msgid "Does not connect. Nothing is saved." +msgstr "Não conecta. Nada é salvo." + +msgctxt "ui.prompt.note.anyway" +msgid "Connects using these algorithms." +msgstr "Conecta usando esses algoritmos." + +msgctxt "ui.prompt.note.token_continue" +msgid "Mounts the drive without Windows signing in. ShellDrive asks again every time." +msgstr "Monta a unidade sem que o Windows entre. O ShellDrive pergunta de novo a cada vez." + +msgctxt "ui.prompt.token_only_head" +msgid "Continue with a less protected connection?" +msgstr "Continuar com uma conexão menos protegida?" + +msgctxt "ui.win.error_details" +msgid "Error code: {code}" +msgstr "Código de erro: {code}" + +msgctxt "ui.hki.stop_title" +msgid "Stop using the host keys from {source}?" +msgstr "Parar de usar as chaves de host de {source}?" + +msgctxt "ui.secdlg.refuse_title" +msgid "Reconnect {drive} now?" +msgstr "Reconectar {drive} agora?" diff --git a/res/policy/policies.txt b/res/policy/policies.txt index b052752..6ffe796 100644 --- a/res/policy/policies.txt +++ b/res/policy/policies.txt @@ -46,3 +46,5 @@ HideTrayIcon|both|boolean|-|xp|interface DisableNetworkLocations|both|boolean|-|xp|network DisableModernContextMenu|both|boolean|-|win11|interface LogPrivacy|both|enum|0,1|xp|logging +UiStyle|both|enum|0,1|xp|interface +UiDarkHelpers|both|boolean|-|xp|interface diff --git a/src/sd_config/include/sd_config/sd_config.h b/src/sd_config/include/sd_config/sd_config.h index c0acd6f..4009ffa 100644 --- a/src/sd_config/include/sd_config/sd_config.h +++ b/src/sd_config/include/sd_config/sd_config.h @@ -249,6 +249,7 @@ sd_err sd_config_identity_store(const wchar_t *dir, const char *hostkey_fp, uint /* Notification kinds with a [ui] notify_ switch (03 §7.7). Always-on kinds (host key changed, write failed, agent stopped unexpectedly, session objects interference) have no switch. */ enum { SD_SETTINGS_PRIVACY_STANDARD = 0, SD_SETTINGS_PRIVACY_STRICT = 1 }; +enum { SD_SETTINGS_STYLE_AUTO = 0, SD_SETTINGS_STYLE_CLASSIC = 1 }; typedef enum sd_notify_kind { SD_NOTIFY_CONNECTION_LOST = 0, SD_NOTIFY_RECONNECTED, SD_NOTIFY_MOUNT_FAILED, SD_NOTIFY_MOUNTED, SD_NOTIFY_LEGACY_ALGORITHMS, SD_NOTIFY_HOSTKEYS_OFFERED, SD_NOTIFY_HOSTKEYS_SAVED, SD_NOTIFY_PROXY_PASSWORD, @@ -276,6 +277,9 @@ typedef struct sd_settings { int log_privacy; /* [log] privacy = standard (default) | strict: SD_SETTINGS_PRIVACY_* (10.1, TM-R31) */ int agent_start_at_logon; /* [agent] start_at_logon = yes | no (default): the agent's HKCU Run value (3.2 Start) */ unsigned char notify_kind[SD_NOTIFY_COUNT]; /* [ui] notify_ = yes | no, each under the notify master (7.7) */ + /* UI tiers (06 §3.6, §5.3) */ + int ui_style; /* [ui] style = auto (default) | classic: SD_SETTINGS_STYLE_* */ + int ui_dark_helpers; /* [ui] dark_helpers = on (default) | off: the kill switch of the dark-mode helpers */ } sd_settings; void sd_settings_defaults(sd_settings *s); sd_err sd_settings_from_ini(const sd_ini *ini, sd_settings *s); /* SD_E_INVALID_PARAM if a value was bad */ diff --git a/src/sd_config/profile.c b/src/sd_config/profile.c index 483fb82..08a41d1 100644 --- a/src/sd_config/profile.c +++ b/src/sd_config/profile.c @@ -705,6 +705,8 @@ void sd_settings_defaults(sd_settings *s) strcpy_s(s->update_channel, sizeof(s->update_channel), "stable"); s->default_write_mode = SD_PROFILE_WRITE_THROUGH; s->log_privacy = SD_SETTINGS_PRIVACY_STANDARD; + s->ui_style = SD_SETTINGS_STYLE_AUTO; + s->ui_dark_helpers = 1; memcpy(s->notify_kind, k_notify_defaults, sizeof(s->notify_kind)); } @@ -773,6 +775,16 @@ sd_err sd_settings_from_ini(const sd_ini *ini, sd_settings *s) else if (strcmp(v, "strict") == 0) s->log_privacy = SD_SETTINGS_PRIVACY_STRICT; else { SD_LOGW("config", "settings.ini: invalid [log] privacy"); e = SD_E_INVALID_PARAM; } } + if ((v = sd_ini_get(ini, "ui", "style")) != NULL) { + if (strcmp(v, "auto") == 0) s->ui_style = SD_SETTINGS_STYLE_AUTO; + else if (strcmp(v, "classic") == 0) s->ui_style = SD_SETTINGS_STYLE_CLASSIC; + else { SD_LOGW("config", "settings.ini: invalid [ui] style"); e = SD_E_INVALID_PARAM; } + } + if ((v = sd_ini_get(ini, "ui", "dark_helpers")) != NULL) { + int b; + if (sd_ini_parse_bool(v, &b)) s->ui_dark_helpers = b ? 1 : 0; + else { SD_LOGW("config", "settings.ini: invalid [ui] dark_helpers"); e = SD_E_INVALID_PARAM; } + } if ((v = sd_ini_get(ini, "agent", "start_at_logon")) != NULL) { int b; if (sd_ini_parse_bool(v, &b)) s->agent_start_at_logon = b ? 1 : 0; @@ -829,7 +841,8 @@ sd_err sd_settings_save(const sd_settings *s) sd_err e; int lvl; if (s == NULL || s->default_backend < 0 || s->default_backend > 2 || s->default_write_mode < 0 || - s->default_write_mode > 1 || s->log_privacy < 0 || s->log_privacy > 1) + s->default_write_mode > 1 || s->log_privacy < 0 || s->log_privacy > 1 || + s->ui_style < 0 || s->ui_style > 1) return SD_E_INVALID_PARAM; if ((e = sd_config_ensure_dirs()) != SD_OK) return e; @@ -867,6 +880,8 @@ sd_err sd_settings_save(const sd_settings *s) /* M4: every key is written, so the file says what is in effect */ if (e == SD_OK) e = sd_ini_set(ini, "defaults", "write_mode", s->default_write_mode ? "behind" : "through"); if (e == SD_OK) e = sd_ini_set(ini, "log", "privacy", s->log_privacy ? "strict" : "standard"); + if (e == SD_OK) e = sd_ini_set(ini, "ui", "style", s->ui_style ? "classic" : "auto"); + if (e == SD_OK) e = sd_ini_set(ini, "ui", "dark_helpers", s->ui_dark_helpers ? "on" : "off"); if (e == SD_OK) e = sd_ini_set(ini, "agent", "start_at_logon", s->agent_start_at_logon ? "yes" : "no"); { unsigned i; diff --git a/tests/os-imports/xp-sp2-x64.txt b/tests/os-imports/xp-sp2-x64.txt index d4e7ee5..c53e4fa 100644 --- a/tests/os-imports/xp-sp2-x64.txt +++ b/tests/os-imports/xp-sp2-x64.txt @@ -372,6 +372,8 @@ user32.dll!DestroyWindow user32.dll!DispatchMessageW user32.dll!FindWindowW user32.dll!GetDlgItemTextW # NT 3.1+ (host key manager dialogs) +user32.dll!GetSysColor +user32.dll!GetSysColorBrush # NT 3.1+ (dark dialog brushes) user32.dll!GetMessageW user32.dll!GetProcessWindowStation user32.dll!GetUserObjectInformationW @@ -428,6 +430,7 @@ comdlg32.dll!GetOpenFileNameW # gdi32: fonts, the DIB icon bitmaps and the device caps of the system DPI (documentation: Windows 2000). Pending capture. gdi32.dll!CreateBitmap gdi32.dll!CreateDIBSection +gdi32.dll!CreateSolidBrush gdi32.dll!CreateFontIndirectW gdi32.dll!DeleteObject gdi32.dll!GetDeviceCaps @@ -465,6 +468,13 @@ user32.dll!IsWindowEnabled user32.dll!MessageBeep user32.dll!LoadCursorW user32.dll!MonitorFromWindow +user32.dll!MonitorFromPoint # Windows 98 / 2000+ (tray flyout work area, ui_flyout.c) +user32.dll!GetMonitorInfoW +user32.dll!GetFocus +user32.dll!AdjustWindowRectEx +user32.dll!DrawFocusRect +user32.dll!InflateRect +user32.dll!PtInRect user32.dll!MoveWindow user32.dll!PostQuitMessage user32.dll!RegisterWindowMessageW @@ -492,6 +502,7 @@ user32.dll!InsertMenuItemW gdi32.dll!CreateCompatibleDC gdi32.dll!DeleteDC gdi32.dll!GetTextExtentPoint32W +gdi32.dll!GetTextFaceW # NT 3.1+ (dialog font probing) gdi32.dll!GetTextMetricsW gdi32.dll!SelectObject kernel32.dll!MulDiv @@ -521,6 +532,7 @@ user32.dll!EndDialog user32.dll!GetParent user32.dll!GetPropW user32.dll!ScreenToClient +user32.dll!RemovePropW # NT 3.1+ (theme window properties) user32.dll!SetPropW user32.dll!wsprintfW user32.dll!MapWindowPoints @@ -562,3 +574,28 @@ version.dll!VerQueryValueW user32.dll!GetWindow user32.dll!ScrollWindowEx user32.dll!SetScrollInfo + +# Fluent main window (apps/shelldrive-ui/ui_main.c, 06 section 6.1): owner-drawn command bar, hover tracking, card and +# header painting, screen capture of --test-shot. All documented from Windows 2000, so present on XP SP2/SP3. +user32.dll!GetDlgCtrlID +user32.dll!GetKeyState +user32.dll!BeginPaint +user32.dll!EndPaint +user32.dll!SetWindowRgn +user32.dll!RedrawWindow +user32.dll!FillRect +user32.dll!FrameRect +user32.dll!GetClassNameW +user32.dll!CallWindowProcW +user32.dll!TrackMouseEvent +gdi32.dll!CreatePen +gdi32.dll!EnumFontFamiliesExW +gdi32.dll!Rectangle +gdi32.dll!RoundRect +gdi32.dll!SetBkColor +gdi32.dll!SetBkMode +gdi32.dll!SetTextColor +gdi32.dll!CreateRoundRectRgn +gdi32.dll!BitBlt +gdi32.dll!PtInRegion +gdi32.dll!SelectClipRgn diff --git a/tests/os-imports/xp-sp3-x86.txt b/tests/os-imports/xp-sp3-x86.txt index 5e995ee..89c881f 100644 --- a/tests/os-imports/xp-sp3-x86.txt +++ b/tests/os-imports/xp-sp3-x86.txt @@ -378,6 +378,8 @@ user32.dll!DestroyWindow user32.dll!DispatchMessageW user32.dll!FindWindowW user32.dll!GetDlgItemTextW # NT 3.1+ (host key manager dialogs) +user32.dll!GetSysColor +user32.dll!GetSysColorBrush # NT 3.1+ (dark dialog brushes) user32.dll!GetMessageW user32.dll!GetProcessWindowStation user32.dll!GetUserObjectInformationW @@ -434,6 +436,7 @@ comdlg32.dll!GetOpenFileNameW # gdi32: fonts, the DIB icon bitmaps and the device caps of the system DPI (documentation: Windows 2000). Pending capture. gdi32.dll!CreateBitmap gdi32.dll!CreateDIBSection +gdi32.dll!CreateSolidBrush gdi32.dll!CreateFontIndirectW gdi32.dll!DeleteObject gdi32.dll!GetDeviceCaps @@ -471,6 +474,12 @@ user32.dll!IsWindowEnabled user32.dll!MessageBeep user32.dll!LoadCursorW user32.dll!MonitorFromWindow +user32.dll!MonitorFromPoint # Windows 98 / 2000+ (tray flyout work area, ui_flyout.c) +user32.dll!GetMonitorInfoW +user32.dll!GetFocus +user32.dll!DrawFocusRect +user32.dll!InflateRect +user32.dll!PtInRect user32.dll!MoveWindow user32.dll!PostQuitMessage user32.dll!RegisterWindowMessageW @@ -498,6 +507,7 @@ user32.dll!InsertMenuItemW gdi32.dll!CreateCompatibleDC gdi32.dll!DeleteDC gdi32.dll!GetTextExtentPoint32W +gdi32.dll!GetTextFaceW # NT 3.1+ (dialog font probing) gdi32.dll!GetTextMetricsW gdi32.dll!SelectObject kernel32.dll!MulDiv @@ -527,6 +537,7 @@ user32.dll!EndDialog user32.dll!GetParent user32.dll!GetPropW user32.dll!ScreenToClient +user32.dll!RemovePropW # NT 3.1+ (theme window properties) user32.dll!SetPropW user32.dll!wsprintfW user32.dll!MapWindowPoints @@ -580,3 +591,25 @@ version.dll!VerQueryValueW user32.dll!GetWindow user32.dll!ScrollWindowEx user32.dll!SetScrollInfo + +# Fluent main window (apps/shelldrive-ui/ui_main.c, 06 section 6.1): owner-drawn command bar, hover tracking, card and +# header painting, screen capture of --test-shot. All documented from Windows 2000, so present on XP SP2/SP3. +user32.dll!GetDlgCtrlID +user32.dll!GetKeyState +user32.dll!SetWindowRgn +user32.dll!RedrawWindow +user32.dll!FrameRect +user32.dll!GetClassNameW +user32.dll!CallWindowProcW +user32.dll!TrackMouseEvent +gdi32.dll!CreatePen +gdi32.dll!EnumFontFamiliesExW +gdi32.dll!Rectangle +gdi32.dll!RoundRect +gdi32.dll!SetBkColor +gdi32.dll!SetBkMode +gdi32.dll!SetTextColor +gdi32.dll!CreateRoundRectRgn +gdi32.dll!BitBlt +gdi32.dll!PtInRegion +gdi32.dll!SelectClipRgn diff --git a/tests/ui/CMakeLists.txt b/tests/ui/CMakeLists.txt index 36c3a7b..1ed9aa2 100644 --- a/tests/ui/CMakeLists.txt +++ b/tests/ui/CMakeLists.txt @@ -11,7 +11,7 @@ set(_ui_dir "${CMAKE_SOURCE_DIR}/apps/shelldrive-ui") sd_add_unit_test(ui SOURCES main.c test_wire.c test_dpi.c test_dlg.c test_prompt.c test_profile.c test_logs.c test_hostkeys.c - test_settings.c test_notify.c test_dark.c test_wizard.c test_update.c test_admin.c test_service.c test_hkissue.c test_mainview.c test_events.c test_notice.c test_evhist.c + test_settings.c test_notify.c test_theme.c test_wizard.c test_update.c test_admin.c test_service.c test_hkissue.c test_mainview.c test_events.c test_notice.c test_evhist.c test_taskdlg.c ${SD_UI_PURE_SOURCES} DEPS sd_config sd_crypto sd_ssh sd_platform sd_base) target_include_directories(sd-test-ui PRIVATE "${_ui_dir}") @@ -23,6 +23,12 @@ set_tests_properties(ui.smoke PROPERTIES LABELS ui TIMEOUT 120) add_test(NAME ui.prompts COMMAND $ --test-prompts) set_tests_properties(ui.prompts PROPERTIES LABELS ui TIMEOUT 120) +# The prompts under every tier (06 §8.1): command links, the main instruction style, the dark palette. +foreach(_theme classic aero fluent-c-dark fluent-c-light) + add_test(NAME ui.prompts.${_theme} COMMAND $ --test-prompts --theme=${_theme}) + set_tests_properties(ui.prompts.${_theme} PROPERTIES LABELS ui TIMEOUT 120) +endforeach() + add_test(NAME ui.editor COMMAND $ --test-editor) set_tests_properties(ui.editor PROPERTIES LABELS ui TIMEOUT 120) @@ -32,6 +38,9 @@ set_tests_properties(ui.windows PROPERTIES LABELS ui TIMEOUT 120) add_test(NAME ui.wizard COMMAND $ --test-wizard) set_tests_properties(ui.wizard PROPERTIES LABELS ui TIMEOUT 120) +add_test(NAME ui.flyout COMMAND $ --test-flyout --theme=fluent-c-dark) +set_tests_properties(ui.flyout PROPERTIES LABELS ui TIMEOUT 120) + # The smoke modes (apps/shelldrive-ui/main.c) take a config root unique to each process and remove it at exit, so # they need no RESOURCE_LOCK (ui_test_store was for the old fixed %TEMP%\shelldrive-ui-test folder). diff --git a/tests/ui/main.c b/tests/ui/main.c index 95f217b..7fa2180 100644 --- a/tests/ui/main.c +++ b/tests/ui/main.c @@ -140,19 +140,32 @@ SD_TEST_DECL(settings_security_fields_survive_a_save_load_round_trip); SD_TEST_DECL(settings_r17_mapping_and_defaults); SD_TEST_DECL(settings_r17_policy_locks_are_never_written); SD_TEST_DECL(settings_r17_diff_writes_only_changed_keys); +SD_TEST_DECL(settings_ui_tier_policy); SD_TEST_DECL(settings_r17_values_survive_a_save_load_round_trip); SD_TEST_DECL(profile_editor_write_mode_shows_the_effective_default); -/* test_notify.c, test_dark.c and test_wizard.c: the balloon rate limit, the dark mode decision and the wizard's +/* test_notify.c, test_theme.c and test_wizard.c: the balloon rate limit, the theme decision and the wizard's should-show logic. */ SD_TEST_DECL(notify_limit_one_per_subject_and_kind); SD_TEST_DECL(notify_limit_is_per_subject_and_kind); SD_TEST_DECL(notify_clock_going_back_is_not_a_burst); SD_TEST_DECL(notify_table_is_bounded_and_keeps_recent_pairs); SD_TEST_DECL(notify_interval_is_one_minute); -SD_TEST_DECL(dark_needs_build_22000_and_the_api); -SD_TEST_DECL(dark_follows_the_app_theme_value); -SD_TEST_DECL(dark_stays_light_on_older_systems); +SD_TEST_DECL(theme_tiers_follow_the_os); +SD_TEST_DECL(theme_high_contrast_and_the_classic_setting_force_classic); +SD_TEST_DECL(theme_dark_follows_the_app_theme_and_the_helpers); +SD_TEST_DECL(theme_titlebar_attr_19_20_or_none); +SD_TEST_DECL(theme_helpers_allow_list); +SD_TEST_DECL(theme_forced_tier_overrides_the_os); +SD_TEST_DECL(taskdlg_map_buttons_and_default); +SD_TEST_DECL(taskdlg_map_icons); +SD_TEST_DECL(taskdlg_map_rejects_what_a_task_dialog_lacks); +SD_TEST_DECL(taskdlg_plain_text_joins_the_parts); +SD_TEST_DECL(dlg_heading_is_taller_with_a_larger_font); +SD_TEST_DECL(dlg_icon_indents_the_text_beside_it); +SD_TEST_DECL(dlg_commands_are_links_or_plain_buttons); +SD_TEST_DECL(dlg_commands_mark_only_the_requested_default); +SD_TEST_DECL(theme_palette_meets_the_contrast_floors); SD_TEST_DECL(wizard_opens_for_an_empty_store_only_once); SD_TEST_DECL(wizard_never_opens_for_tray_or_prompt_host); @@ -282,6 +295,7 @@ static const sd_test_case k_tests[] = { SD_TEST_ENTRY(settings_r17_mapping_and_defaults), SD_TEST_ENTRY(settings_r17_policy_locks_are_never_written), SD_TEST_ENTRY(settings_r17_diff_writes_only_changed_keys), + SD_TEST_ENTRY(settings_ui_tier_policy), SD_TEST_ENTRY(settings_r17_values_survive_a_save_load_round_trip), SD_TEST_ENTRY(profile_editor_write_mode_shows_the_effective_default), SD_TEST_ENTRY(notify_limit_one_per_subject_and_kind), @@ -289,9 +303,21 @@ static const sd_test_case k_tests[] = { SD_TEST_ENTRY(notify_clock_going_back_is_not_a_burst), SD_TEST_ENTRY(notify_table_is_bounded_and_keeps_recent_pairs), SD_TEST_ENTRY(notify_interval_is_one_minute), - SD_TEST_ENTRY(dark_needs_build_22000_and_the_api), - SD_TEST_ENTRY(dark_follows_the_app_theme_value), - SD_TEST_ENTRY(dark_stays_light_on_older_systems), + SD_TEST_ENTRY(theme_tiers_follow_the_os), + SD_TEST_ENTRY(theme_high_contrast_and_the_classic_setting_force_classic), + SD_TEST_ENTRY(theme_dark_follows_the_app_theme_and_the_helpers), + SD_TEST_ENTRY(theme_titlebar_attr_19_20_or_none), + SD_TEST_ENTRY(theme_helpers_allow_list), + SD_TEST_ENTRY(theme_forced_tier_overrides_the_os), + SD_TEST_ENTRY(taskdlg_map_buttons_and_default), + SD_TEST_ENTRY(taskdlg_map_icons), + SD_TEST_ENTRY(taskdlg_map_rejects_what_a_task_dialog_lacks), + SD_TEST_ENTRY(taskdlg_plain_text_joins_the_parts), + SD_TEST_ENTRY(dlg_heading_is_taller_with_a_larger_font), + SD_TEST_ENTRY(dlg_icon_indents_the_text_beside_it), + SD_TEST_ENTRY(dlg_commands_are_links_or_plain_buttons), + SD_TEST_ENTRY(dlg_commands_mark_only_the_requested_default), + SD_TEST_ENTRY(theme_palette_meets_the_contrast_floors), SD_TEST_ENTRY(wizard_opens_for_an_empty_store_only_once), SD_TEST_ENTRY(wizard_never_opens_for_tray_or_prompt_host), SD_TEST_ENTRY(service_view_reads_the_status_reply), diff --git a/tests/ui/test_dark.c b/tests/ui/test_dark.c deleted file mode 100644 index dd3968d..0000000 --- a/tests/ui/test_dark.c +++ /dev/null @@ -1,32 +0,0 @@ -/* The dark mode decision (apps/shelldrive-ui/ui_dark_model.c, 03 §7.4). Pure: the inputs are the OS build, whether - DwmSetWindowAttribute was resolved, and the AppsUseLightTheme value (-1 when absent). */ -#include - -#include "sd_test.h" -#include "ui_dark_model.h" - -SD_TEST(dark_needs_build_22000_and_the_api) -{ - SD_CHECK_EQ_INT(ui_dark_wanted(22000u, 1, 0), 1); - SD_CHECK_EQ_INT(ui_dark_wanted(22631u, 1, 0), 1); - SD_CHECK_EQ_INT(ui_dark_wanted(21999u, 1, 0), 0); /* last Windows 10 build */ - SD_CHECK_EQ_INT(ui_dark_wanted(22000u, 0, 0), 0); /* API not resolved */ - SD_CHECK_EQ_INT((int)UI_DARK_MIN_BUILD, 22000); - SD_CHECK_EQ_INT((int)UI_DARK_ATTRIBUTE, 20); -} - -SD_TEST(dark_follows_the_app_theme_value) -{ - SD_CHECK_EQ_INT(ui_dark_wanted(22631u, 1, 0), 1); /* dark apps */ - SD_CHECK_EQ_INT(ui_dark_wanted(22631u, 1, 1), 0); /* light apps */ - SD_CHECK_EQ_INT(ui_dark_wanted(22631u, 1, -1), 0); /* value absent: light */ -} - -SD_TEST(dark_stays_light_on_older_systems) -{ - SD_CHECK_EQ_INT(ui_dark_wanted(2600u, 1, 0), 0); /* Windows XP */ - SD_CHECK_EQ_INT(ui_dark_wanted(7601u, 1, 0), 0); /* Windows 7 SP1 */ - SD_CHECK_EQ_INT(ui_dark_wanted(9200u, 1, 0), 0); /* Windows 8 */ - SD_CHECK_EQ_INT(ui_dark_wanted(19045u, 1, 0), 0); /* Windows 10 22H2 */ - SD_CHECK_EQ_INT(ui_dark_wanted(0u, 1, 0), 0); /* unknown version */ -} diff --git a/tests/ui/test_settings.c b/tests/ui/test_settings.c index 1aaff2f..915946b 100644 --- a/tests/ui/test_settings.c +++ b/tests/ui/test_settings.c @@ -464,3 +464,58 @@ SD_TEST(settings_r17_values_survive_a_save_load_round_trip) sd_config_set_root_override(NULL); remove_tree(root); } + +SD_TEST(settings_ui_tier_policy) +{ + sd_settings s; + ui_settings_model m, a; + ui_set_policy pol; + unsigned locked; + int style, dark; + sd_settings_defaults(&s); + ui_settings_from_sd(&s, &m); + SD_CHECK_EQ_INT(m.ui_style, SD_SETTINGS_STYLE_AUTO); + SD_CHECK_EQ_INT(m.dark_helpers, 1); + a = m; + a.ui_style = SD_SETTINGS_STYLE_CLASSIC; + a.dark_helpers = 0; + SD_CHECK((ui_settings_changed(&m, &a) & (UI_SET_F_STYLE | UI_SET_F_DARK)) == (UI_SET_F_STYLE | UI_SET_F_DARK)); + ui_settings_to_sd(&a, &s); + SD_CHECK_EQ_INT(s.ui_style, SD_SETTINGS_STYLE_CLASSIC); + SD_CHECK_EQ_INT(s.ui_dark_helpers, 0); + + /* no policy: the settings decide */ + memset(&pol, 0, sizeof(pol)); + ui_settings_theme_effective(&s, &pol, &style, &dark); + SD_CHECK_EQ_INT(style, SD_SETTINGS_STYLE_CLASSIC); + SD_CHECK_EQ_INT(dark, 0); + + /* UiStyle = 0 forces auto and UiDarkHelpers = 0 forces the helpers off; both lock their controls */ + sd_settings_defaults(&s); + s.ui_style = SD_SETTINGS_STYLE_CLASSIC; + pol.style_set = 1; + pol.style_classic = 0; + pol.dark_off = 1; + ui_settings_theme_effective(&s, &pol, &style, &dark); + SD_CHECK_EQ_INT(style, SD_SETTINGS_STYLE_AUTO); + SD_CHECK_EQ_INT(dark, 0); + ui_settings_from_sd(&s, &m); + locked = ui_settings_apply_policy(&m, &pol); + SD_CHECK(locked == (UI_SET_F_STYLE | UI_SET_F_DARK)); + SD_CHECK_EQ_INT(m.ui_style, SD_SETTINGS_STYLE_AUTO); + SD_CHECK_EQ_INT(m.dark_helpers, 0); + /* a locked field is never written back */ + m.ui_style = SD_SETTINGS_STYLE_AUTO; + ui_settings_to_sd_masked(&m, locked, &s); + SD_CHECK_EQ_INT(s.ui_style, SD_SETTINGS_STYLE_CLASSIC); + SD_CHECK_EQ_INT(s.ui_dark_helpers, 1); + + /* UiStyle = 1 forces classic */ + memset(&pol, 0, sizeof(pol)); + pol.style_set = 1; + pol.style_classic = 1; + sd_settings_defaults(&s); + ui_settings_theme_effective(&s, &pol, &style, &dark); + SD_CHECK_EQ_INT(style, SD_SETTINGS_STYLE_CLASSIC); + SD_CHECK_EQ_INT(dark, 1); +} diff --git a/tests/ui/test_taskdlg.c b/tests/ui/test_taskdlg.c new file mode 100644 index 0000000..ab89021 --- /dev/null +++ b/tests/ui/test_taskdlg.c @@ -0,0 +1,198 @@ +/* The OS-tiered messages and trust prompts (06 §6.2): the MB_* flags to task dialog mapping, the plain text + fallback, and the dialog generator's main instruction, icon column and command links. */ +#include +#include +#include + +#include "sd_test.h" +#include "sd_base/sd_err.h" +#include "sd_base/sd_mem.h" +#include "ui_dlg.h" +#include "ui_taskdlg_model.h" + +/* Two characters per dialog unit of line width; each line is 8 dialog units high. */ +static int fake_measure(void *ctx, const wchar_t *text, int width_dlu, int *natural) +{ + size_t n = wcslen(text), per = (size_t)(width_dlu * 2); + int lines; + (void)ctx; + if (per == 0) + per = 1; + lines = (int)((n + per - 1) / per); + if (lines < 1) + lines = 1; + if (natural != NULL) + *natural = (int)n * 2; + return lines * 8; +} + +SD_TEST(taskdlg_map_buttons_and_default) +{ + ui_task_map m; + SD_CHECK(ui_task_map_flags(MB_OK, &m) == 1); + SD_CHECK_EQ_INT(m.buttons, UI_TASK_BTN_OK); + SD_CHECK_EQ_INT(m.default_id, IDOK); + SD_CHECK_EQ_INT(m.allow_cancel, 1); + + SD_CHECK(ui_task_map_flags(MB_OKCANCEL, &m) == 1); + SD_CHECK_EQ_INT(m.buttons, UI_TASK_BTN_OK | UI_TASK_BTN_CANCEL); + SD_CHECK_EQ_INT(m.default_id, IDOK); + SD_CHECK(ui_task_map_flags(MB_OKCANCEL | MB_DEFBUTTON2, &m) == 1); + SD_CHECK_EQ_INT(m.default_id, IDCANCEL); /* repair data and the like keep Cancel as the default */ + + SD_CHECK(ui_task_map_flags(MB_YESNO | MB_DEFBUTTON2, &m) == 1); + SD_CHECK_EQ_INT(m.buttons, UI_TASK_BTN_YES | UI_TASK_BTN_NO); + SD_CHECK_EQ_INT(m.default_id, IDNO); + SD_CHECK_EQ_INT(m.allow_cancel, 0); /* no Cancel button: Escape does not answer No for the user */ + + SD_CHECK(ui_task_map_flags(MB_YESNOCANCEL, &m) == 1); + SD_CHECK_EQ_INT(m.buttons, UI_TASK_BTN_YES | UI_TASK_BTN_NO | UI_TASK_BTN_CANCEL); + SD_CHECK_EQ_INT(m.default_id, IDYES); + SD_CHECK(ui_task_map_flags(MB_YESNOCANCEL | MB_DEFBUTTON3, &m) == 1); + SD_CHECK_EQ_INT(m.default_id, IDCANCEL); + SD_CHECK_EQ_INT(m.allow_cancel, 1); + + SD_CHECK(ui_task_map_flags(MB_RETRYCANCEL, &m) == 1); + SD_CHECK_EQ_INT(m.default_id, IDRETRY); + + /* a default past the last button falls back to the first */ + SD_CHECK(ui_task_map_flags(MB_YESNO | MB_DEFBUTTON3, &m) == 1); + SD_CHECK_EQ_INT(m.default_id, IDYES); +} + +SD_TEST(taskdlg_map_icons) +{ + ui_task_map m; + SD_CHECK(ui_task_map_flags(MB_OK | MB_ICONERROR, &m) == 1); + SD_CHECK_EQ_INT(m.icon, UI_TASK_ICON_ERROR); + SD_CHECK(ui_task_map_flags(MB_OK | MB_ICONSTOP, &m) == 1); + SD_CHECK_EQ_INT(m.icon, UI_TASK_ICON_ERROR); + SD_CHECK(ui_task_map_flags(MB_OK | MB_ICONWARNING, &m) == 1); + SD_CHECK_EQ_INT(m.icon, UI_TASK_ICON_WARNING); + SD_CHECK(ui_task_map_flags(MB_OK | MB_ICONEXCLAMATION, &m) == 1); + SD_CHECK_EQ_INT(m.icon, UI_TASK_ICON_WARNING); + SD_CHECK(ui_task_map_flags(MB_OK | MB_ICONINFORMATION, &m) == 1); + SD_CHECK_EQ_INT(m.icon, UI_TASK_ICON_INFO); + SD_CHECK(ui_task_map_flags(MB_YESNO | MB_ICONQUESTION, &m) == 1); + SD_CHECK_EQ_INT(m.icon, UI_TASK_ICON_NONE); /* the guidelines drop the question icon */ + SD_CHECK(ui_task_map_flags(MB_OK, &m) == 1); + SD_CHECK_EQ_INT(m.icon, UI_TASK_ICON_NONE); + /* MB_SETFOREGROUND and friends do not matter */ + SD_CHECK(ui_task_map_flags(MB_OK | MB_ICONERROR | MB_SETFOREGROUND, &m) == 1); + SD_CHECK_EQ_INT(m.icon, UI_TASK_ICON_ERROR); +} + +SD_TEST(taskdlg_map_rejects_what_a_task_dialog_lacks) +{ + ui_task_map m; + SD_CHECK(ui_task_map_flags(MB_ABORTRETRYIGNORE, &m) == 0); + SD_CHECK(ui_task_map_flags(MB_OK, NULL) == 0); +} + +SD_TEST(taskdlg_plain_text_joins_the_parts) +{ + wchar_t buf[64]; + size_t n; + n = ui_task_plain_text(L"Main", L"Content", L"Details", buf, SD_COUNTOF(buf)); + SD_CHECK_EQ_INT(n, wcslen(L"Main\n\nContent\n\nDetails")); + SD_CHECK(wcscmp(buf, L"Main\n\nContent\n\nDetails") == 0); + n = ui_task_plain_text(L"Main", NULL, L"Details", buf, SD_COUNTOF(buf)); + SD_CHECK(wcscmp(buf, L"Main\n\nDetails") == 0); + n = ui_task_plain_text(L"Main", L"", NULL, buf, SD_COUNTOF(buf)); + SD_CHECK(wcscmp(buf, L"Main") == 0); + SD_CHECK_EQ_INT(n, 4); + /* too small a buffer, or no main text: nothing */ + SD_CHECK_EQ_INT(ui_task_plain_text(L"Main", L"Content", NULL, buf, 8), 0); + SD_CHECK(buf[0] == 0); + SD_CHECK_EQ_INT(ui_task_plain_text(L"", L"Content", NULL, buf, SD_COUNTOF(buf)), 0); + SD_CHECK_EQ_INT(ui_task_plain_text(NULL, NULL, NULL, buf, SD_COUNTOF(buf)), 0); +} + +SD_TEST(dlg_heading_is_taller_with_a_larger_font) +{ + static ui_dlg a, b; + const wchar_t *text = L"The authenticity of host example.org (port 22) can't be established."; + int ha, hb; + SD_REQUIRE(ui_dlg_init(&a, L"T", 300, L"Segoe UI", 9, fake_measure, NULL) == SD_OK); + SD_REQUIRE(ui_dlg_init(&b, L"T", 300, L"Segoe UI", 9, fake_measure, NULL) == SD_OK); + b.heading_pct = 160; + SD_CHECK(ui_dlg_heading(&a, 1, text, 0) == SD_OK); + SD_CHECK(ui_dlg_heading(&b, 1, text, 0) == SD_OK); + ha = a.items[0].cy; + hb = b.items[0].cy; + SD_CHECK(hb > ha); + SD_CHECK_EQ_INT(a.items[0].cx, 300 - 2 * 7); + /* a heading that wraps in the narrower virtual column takes more lines */ + SD_CHECK(ui_dlg_heading(&b, 2, L"A longer main instruction that surely wraps more than once at a larger size.", 0) == SD_OK); + SD_CHECK(b.items[1].cy >= hb); + SD_CHECK(b.cursor > a.cursor); +} + +SD_TEST(dlg_icon_indents_the_text_beside_it) +{ + static ui_dlg d; + int icon_bottom; + SD_REQUIRE(ui_dlg_init(&d, L"T", 300, L"Segoe UI", 9, fake_measure, NULL) == SD_OK); + SD_CHECK(ui_dlg_icon(&d, 50) == SD_OK); + SD_CHECK(ui_dlg_heading(&d, 51, L"Heading", 0) == SD_OK); + SD_CHECK_EQ_INT(d.items[0].x, 7); + SD_CHECK_EQ_INT(d.items[0].cx, UI_DLG_ICON_CX); + SD_CHECK_EQ_INT(d.items[1].x, d.icon_left); /* beside the icon */ + SD_CHECK_EQ_INT(d.items[1].cx, 300 - d.icon_left - 7); + icon_bottom = d.icon_bottom; + while (d.cursor < icon_bottom) + SD_CHECK(ui_dlg_text(&d, 52, L"More text beside the icon.", 0) == SD_OK); + /* below the icon the full width is back */ + SD_CHECK(ui_dlg_text(&d, 53, L"Below the icon.", 0) == SD_OK); + SD_CHECK_EQ_INT(d.items[d.count - 1].x, 7); + SD_CHECK_EQ_INT(d.items[d.count - 1].cx, 300 - 14); +} + +SD_TEST(dlg_commands_are_links_or_plain_buttons) +{ + static ui_dlg link, plain; + static const WORD ids[3] = { IDOK, 225, IDCANCEL }; + static const wchar_t *const texts[3] = { L"Trust and connect", L"Connect once", L"Cancel" }; + static const wchar_t *const notes[3] = { L"Saves the key.", L"Does not save it, so ShellDrive asks again next time.", NULL }; + unsigned char *blob = NULL; + size_t len = 0, count = 0, i; + + SD_REQUIRE(ui_dlg_init(&link, L"T", 300, L"Segoe UI", 9, fake_measure, NULL) == SD_OK); + link.command_links = 1; + SD_CHECK(ui_dlg_commands(&link, ids, texts, notes, 3, 0) == SD_OK); + SD_CHECK_EQ_INT(link.count, 3); + for (i = 0; i < 3; i++) { + SD_CHECK_EQ_INT(link.items[i].style & 0xF, BS_COMMANDLINK); /* 0x0E, never the default link */ + SD_CHECK_EQ_INT(link.items[i].x, 7); + SD_CHECK_EQ_INT(link.items[i].cx, 300 - 14); + SD_CHECK(link.items[i].style & WS_TABSTOP); + if (i > 0) + SD_CHECK(link.items[i].y > link.items[i - 1].y + link.items[i - 1].cy - 1); + } + SD_CHECK(link.items[1].cy > link.items[2].cy); /* the longer note takes more height */ + SD_CHECK(ui_dlg_build(&link, &blob, &len) == SD_OK); + SD_CHECK(ui_dlg_parse_check(blob, len, &count, NULL, 0) == SD_OK); + SD_CHECK_EQ_INT(count, 3); + sd_free(blob); + + SD_REQUIRE(ui_dlg_init(&plain, L"T", 300, L"Segoe UI", 9, fake_measure, NULL) == SD_OK); + SD_CHECK(ui_dlg_commands(&plain, ids, texts, notes, 3, 0) == SD_OK); + SD_CHECK_EQ_INT(plain.count, 3); + for (i = 0; i < 3; i++) { + SD_CHECK_EQ_INT(plain.items[i].style & 0xF, BS_PUSHBUTTON); /* no default button: trust decision */ + SD_CHECK_EQ_INT(plain.items[i].cy, UI_DLG_BUTTON_HEIGHT); + } +} + +SD_TEST(dlg_commands_mark_only_the_requested_default) +{ + static ui_dlg d; + static const WORD ids[2] = { IDOK, IDCANCEL }; + static const wchar_t *const texts[2] = { L"Yes", L"No" }; + SD_REQUIRE(ui_dlg_init(&d, L"T", 300, L"Segoe UI", 9, fake_measure, NULL) == SD_OK); + d.command_links = 1; + SD_CHECK(ui_dlg_commands(&d, ids, texts, NULL, 2, IDCANCEL) == SD_OK); + SD_CHECK_EQ_INT(d.items[0].style & 0xF, BS_COMMANDLINK); + SD_CHECK_EQ_INT(d.items[1].style & 0xF, 0xF); /* BS_DEFCOMMANDLINK */ + SD_CHECK(ui_dlg_commands(&d, NULL, texts, NULL, 2, 0) != SD_OK); +} diff --git a/tests/ui/test_theme.c b/tests/ui/test_theme.c new file mode 100644 index 0000000..2726912 --- /dev/null +++ b/tests/ui/test_theme.c @@ -0,0 +1,343 @@ +/* The theme decision and palette (apps/shelldrive-ui/ui_theme_model.c, 06 §4, §5.1, §3.6). Pure: the inputs + are the OS version and the settings-like flags of ui_theme_inputs. */ +#include + +#include + +#include "sd_test.h" +#include "ui_theme_model.h" + +/* A Windows 11 24H2 host: Fluent 2c, light apps, themed, composition on. */ +static ui_theme_inputs mk_inputs(void) +{ + ui_theme_inputs in; + memset(&in, 0, sizeof(in)); + in.os_major = 10; + in.os_minor = 0; + in.os_build = 26100; + in.dwm_present = 1; + in.composition = 1; + in.high_contrast = 0; + in.apps_use_light = 1; + in.themed = 1; + in.remote_session = 0; + in.style_setting = UI_THEME_STYLE_AUTO; + in.dark_helpers_setting = 1; + in.helper_ordinals = 1; + in.forced_tier = -1; + return in; +} + +static ui_theme select_with(const ui_theme_inputs *in) +{ + ui_theme t; + ui_theme_select(in, &t); + return t; +} + +/* The tier table of 06 §8.1: one row per OS the lab runs (§8.2). */ +SD_TEST(theme_tiers_follow_the_os) +{ + ui_theme_inputs in; + ui_theme t; + + in = mk_inputs(); + in.os_major = 5; in.os_minor = 1; in.os_build = 2600; /* XP SP3 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_CLASSIC); + SD_CHECK_EQ_INT(t.level, 0); + SD_CHECK_EQ_INT(t.glass, 0); + SD_CHECK_EQ_INT(t.task_dialogs, 0); /* no TaskDialogIndirect on XP */ + + in = mk_inputs(); + in.os_major = 6; in.os_minor = 0; in.os_build = 6002; /* Vista SP2, composed */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_AERO); + SD_CHECK_EQ_INT(t.glass, 1); + SD_CHECK_EQ_INT(t.task_dialogs, 1); + + in.composition = 0; /* Basic or Classic theme on 7 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_AERO); + SD_CHECK_EQ_INT(t.glass, 0); /* the glass band drops, the tier stays (06 §3.2) */ + SD_CHECK_EQ_INT(t.task_dialogs, 1); + + in.composition = 1; + in.remote_session = 1; /* an RDP session */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.glass, 0); + + in = mk_inputs(); + in.os_major = 6; in.os_minor = 1; in.os_build = 7601; /* 7 SP1 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_AERO); + SD_CHECK_EQ_INT(t.glass, 1); + + in = mk_inputs(); + in.os_major = 6; in.os_minor = 2; in.os_build = 9200; /* 8 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_FLUENT); + SD_CHECK_EQ_INT(t.level, UI_LEVEL_A); + SD_CHECK_EQ_INT(t.command_bar, 1); + SD_CHECK_EQ_INT(t.backdrop, 0); + SD_CHECK_EQ_INT(t.corners, 0); + + in.os_build = 10240; /* 10 1507 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_FLUENT); + SD_CHECK_EQ_INT(t.level, UI_LEVEL_A); + + in.os_build = 17763; /* 10 1809 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.level, UI_LEVEL_A); + + in.os_build = 19045; /* 10 22H2 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.level, UI_LEVEL_A); + + in.os_build = 22000; /* 11 21H2 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.level, UI_LEVEL_B); + SD_CHECK_EQ_INT(t.corners, 1); + SD_CHECK_EQ_INT(t.backdrop, 0); + + in.os_build = 22621; /* 11 22H2 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.level, UI_LEVEL_C); + SD_CHECK_EQ_INT(t.backdrop, 1); + SD_CHECK_EQ_INT(t.corners, 1); + + in.os_build = 26100; /* 11 24H2 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.level, UI_LEVEL_C); + SD_CHECK_EQ_INT(t.backdrop, 1); +} + +SD_TEST(theme_high_contrast_and_the_classic_setting_force_classic) +{ + ui_theme_inputs in; + ui_theme t; + + in = mk_inputs(); + in.os_build = 19045; + in.high_contrast = 1; + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_CLASSIC); + SD_CHECK_EQ_INT(t.task_dialogs, 1); /* they follow High Contrast by themselves (06 §4) */ + SD_CHECK_EQ_INT(t.command_bar, 0); + SD_CHECK_EQ_INT(t.dark, 0); + + in.high_contrast = 0; + in.style_setting = UI_THEME_STYLE_CLASSIC; + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_CLASSIC); + SD_CHECK_EQ_INT(t.task_dialogs, 1); + + in = mk_inputs(); /* the Windows Classic theme: not themed */ + in.themed = 0; + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_CLASSIC); + SD_CHECK_EQ_INT(t.task_dialogs, 1); + + in = mk_inputs(); /* the setting cannot lift the XP floor */ + in.os_major = 5; in.os_minor = 1; in.os_build = 2600; + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_CLASSIC); + SD_CHECK_EQ_INT(t.task_dialogs, 0); +} + +SD_TEST(theme_dark_follows_the_app_theme_and_the_helpers) +{ + ui_theme_inputs in; + ui_theme t; + + in = mk_inputs(); + in.os_build = 19045; + in.apps_use_light = 0; /* 10 22H2 with dark apps: the helpers of 3.6 (D47) */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.dark, 1); + SD_CHECK_EQ_INT(t.dark_helpers, 1); + + in.dark_helpers_setting = 0; /* the kill switch (06 §3.6) */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.dark, 0); /* Windows 10 has no documented path */ + SD_CHECK_EQ_INT(t.dark_helpers, 0); + + in = mk_inputs(); + in.os_build = 10240; + in.apps_use_light = 0; /* 10 1507: the helpers do not exist yet */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.dark, 0); + + in = mk_inputs(); + in.os_build = 22000; + in.apps_use_light = 0; /* 11: the documented title bar */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.dark, 1); + SD_CHECK_EQ_INT(t.dark_helpers, 1); + + in.dark_helpers_setting = 0; + t = select_with(&in); + SD_CHECK_EQ_INT(t.dark, 1); /* the documented path survives the kill switch */ + SD_CHECK_EQ_INT(t.dark_helpers, 0); + + in.dark_helpers_setting = 1; + in.apps_use_light = 1; + t = select_with(&in); + SD_CHECK_EQ_INT(t.dark, 0); + + in.apps_use_light = -1; /* value absent: light (03 §7.4) */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.dark, 0); + + in = mk_inputs(); + in.os_major = 6; in.os_minor = 1; in.os_build = 7601; + in.apps_use_light = 0; + t = select_with(&in); + SD_CHECK_EQ_INT(t.dark, 0); /* Aero never goes dark */ +} + +SD_TEST(theme_titlebar_attr_19_20_or_none) +{ + ui_theme_inputs in; + ui_theme t; + + in = mk_inputs(); + in.apps_use_light = 0; + t = select_with(&in); /* 26100 */ + SD_CHECK_EQ_INT(t.titlebar_attr, UI_THEME_TITLEBAR_ATTR_20); + + in.os_build = 22000; + t = select_with(&in); + SD_CHECK_EQ_INT(t.titlebar_attr, UI_THEME_TITLEBAR_ATTR_20); + + in.os_build = 19045; /* 10 22H2: 20 from build 18985 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.titlebar_attr, UI_THEME_TITLEBAR_ATTR_20); + + in.os_build = 18362; /* 10 1903: still the undocumented 19 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.titlebar_attr, UI_THEME_TITLEBAR_ATTR_19); + + in.os_build = 17763; /* 10 1809 */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.titlebar_attr, UI_THEME_TITLEBAR_ATTR_19); + + in.dark_helpers_setting = 0; /* without the helpers Windows 10 stays light */ + t = select_with(&in); + SD_CHECK_EQ_INT(t.titlebar_attr, UI_THEME_TITLEBAR_ATTR_NONE); + + in = mk_inputs(); + in.apps_use_light = 1; + t = select_with(&in); + SD_CHECK_EQ_INT(t.titlebar_attr, UI_THEME_TITLEBAR_ATTR_NONE); +} + +SD_TEST(theme_helpers_allow_list) +{ + SD_CHECK_EQ_INT(ui_theme_helpers_allowed(10240u, 1, 1), 0); /* 1507: the ordinals do not exist */ + SD_CHECK_EQ_INT(ui_theme_helpers_allowed(17762u, 1, 1), 0); /* one build below 1809 */ + SD_CHECK_EQ_INT(ui_theme_helpers_allowed(17763u, 1, 1), 1); /* 1809 */ + SD_CHECK_EQ_INT(ui_theme_helpers_allowed(19045u, 0, 1), 1); /* allow-listed: no ordinal check needed */ + SD_CHECK_EQ_INT(ui_theme_helpers_allowed(26100u, 1, 1), 1); /* the newest build the lab tested */ + SD_CHECK_EQ_INT(ui_theme_helpers_allowed(26101u, 1, 1), 1); /* newer: allowed while the exports resolve */ + SD_CHECK_EQ_INT(ui_theme_helpers_allowed(26101u, 0, 1), 0); /* newer without the exports: off */ + SD_CHECK_EQ_INT(ui_theme_helpers_allowed(26101u, 1, 0), 0); /* the kill switch wins */ + SD_CHECK_EQ_INT((int)UI_THEME_HELPERS_MAX_TESTED, 26100); +} + +SD_TEST(theme_forced_tier_overrides_the_os) +{ + ui_theme_inputs in; + ui_theme t; + + in = mk_inputs(); + in.os_build = 19045; /* a Windows 10 host exercising the other tiers (06 §8.1) */ + in.forced_tier = UI_FORCE_TIER(UI_TIER_CLASSIC, 0, 0); + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_CLASSIC); + SD_CHECK_EQ_INT(t.level, 0); /* only Fluent has a sub-level (06 §3.3) */ + SD_CHECK_EQ_INT(t.command_bar, 0); + + in.forced_tier = UI_FORCE_TIER(UI_TIER_AERO, 0, 0); + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_AERO); + SD_CHECK_EQ_INT(t.level, 0); + SD_CHECK_EQ_INT(t.glass, 1); /* composition is on here */ + + in.forced_tier = UI_FORCE_TIER(UI_TIER_FLUENT, UI_LEVEL_A, 0); + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_FLUENT); + SD_CHECK_EQ_INT(t.level, UI_LEVEL_A); + SD_CHECK_EQ_INT(t.backdrop, 0); + + in.forced_tier = UI_FORCE_TIER(UI_TIER_FLUENT, UI_LEVEL_C, 0); + t = select_with(&in); + SD_CHECK_EQ_INT(t.level, UI_LEVEL_C); + SD_CHECK_EQ_INT(t.backdrop, 1); /* the 2c code path runs on the forced level */ + + in = mk_inputs(); + in.os_major = 5; in.os_minor = 1; in.os_build = 2600; + in.apps_use_light = 0; + in.forced_tier = UI_FORCE_TIER(UI_TIER_FLUENT, UI_LEVEL_B, 1); + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_FLUENT); + SD_CHECK_EQ_INT(t.level, UI_LEVEL_B); + SD_CHECK_EQ_INT(t.dark, 0); /* XP has no helpers, whatever the override says */ + SD_CHECK_EQ_INT(t.corners, 0); + SD_CHECK_EQ_INT(t.task_dialogs, 0); + + in = mk_inputs(); /* -light shows the light palette on a dark host */ + in.apps_use_light = 0; + in.forced_tier = UI_FORCE_TIER(UI_TIER_FLUENT, UI_LEVEL_C, 0) | UI_FORCE_LIGHT_BIT; + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_FLUENT); + SD_CHECK_EQ_INT(t.dark, 0); + in.forced_tier = UI_FORCE_TIER(UI_TIER_FLUENT, UI_LEVEL_C, 1) | UI_FORCE_LIGHT_BIT; + t = select_with(&in); + SD_CHECK_EQ_INT(t.dark, 0); /* light wins over dark */ + + in = mk_inputs(); /* -1 follows the OS */ + in.forced_tier = -1; + t = select_with(&in); + SD_CHECK_EQ_INT(t.tier, UI_TIER_FLUENT); + SD_CHECK_EQ_INT(t.level, UI_LEVEL_C); +} + +SD_TEST(theme_palette_meets_the_contrast_floors) +{ + static const COLORREF k_accents[] = { + RGB(0x00, 0x78, 0xD4), RGB(0xFF, 0xB9, 0x00), RGB(0xE8, 0x11, 0x23), + RGB(0x00, 0xCC, 0x6A), RGB(0xFF, 0xFF, 0xFF), RGB(0x00, 0x00, 0x00) + }; + static const int k_dark[2] = { 0, 1 }; + COLORREF sys[UI_TOK_COUNT], out[UI_TOK_COUNT]; + ui_theme t; + size_t i; + int d; + + /* Classic and Aero return the system colors untouched (06 §5.1). */ + memset(&t, 0, sizeof(t)); + t.tier = UI_TIER_CLASSIC; + for (i = 0; i < (size_t)UI_TOK_COUNT; i++) + sys[i] = RGB((int)i * 3, 5, 9); + ui_theme_palette(&t, RGB(1, 2, 3), sys, out); + for (i = 0; i < (size_t)UI_TOK_COUNT; i++) + SD_CHECK_EQ_INT(out[i], sys[i]); + + /* Fluent light and dark: the floors of 7.1 for every accent the tests feed (06 §5.1). */ + for (d = 0; d < 2; d++) { + memset(&t, 0, sizeof(t)); + t.tier = UI_TIER_FLUENT; + t.level = k_dark[d] ? UI_LEVEL_B : UI_LEVEL_A; + t.dark = k_dark[d]; + for (i = 0; i < SD_COUNTOF(k_accents); i++) { + ui_theme_palette(&t, k_accents[i], sys, out); + SD_CHECK(ui_theme_contrast_x100(out[UI_TOK_TEXT], out[UI_TOK_LAYER]) >= 450); + SD_CHECK(ui_theme_contrast_x100(out[UI_TOK_TEXT_SECONDARY], out[UI_TOK_LAYER]) >= 450); + SD_CHECK(ui_theme_contrast_x100(out[UI_TOK_ACCENT], out[UI_TOK_LAYER]) >= 300); + SD_CHECK(ui_theme_contrast_x100(out[UI_TOK_ACCENT_TEXT], out[UI_TOK_ACCENT]) >= 450); + SD_CHECK_EQ_INT(out[UI_TOK_FOCUS], out[UI_TOK_TEXT]); + } + } +} diff --git a/tests/unit/sd_config/main.c b/tests/unit/sd_config/main.c index aadd305..18aa251 100644 --- a/tests/unit/sd_config/main.c +++ b/tests/unit/sd_config/main.c @@ -33,6 +33,7 @@ SD_TEST_DECL(menu_format_caps); SD_TEST_DECL(menu_writer_through_profile_store); SD_TEST_DECL(settings_m4_defaults); SD_TEST_DECL(settings_m4_parse_values); +SD_TEST_DECL(settings_ui_tier_keys); SD_TEST_DECL(settings_m4_notify_kinds_and_codes); SD_TEST_DECL(settings_m4_write_mode_resolution); SD_TEST_DECL(profile_m4_write_mode_key_round_trip); @@ -72,6 +73,7 @@ static const sd_test_case k_tests[] = { SD_TEST_ENTRY(menu_writer_through_profile_store), SD_TEST_ENTRY(settings_m4_defaults), SD_TEST_ENTRY(settings_m4_parse_values), + SD_TEST_ENTRY(settings_ui_tier_keys), SD_TEST_ENTRY(settings_m4_notify_kinds_and_codes), SD_TEST_ENTRY(settings_m4_write_mode_resolution), SD_TEST_ENTRY(profile_m4_write_mode_key_round_trip), diff --git a/tests/unit/sd_config/test_config_m4.c b/tests/unit/sd_config/test_config_m4.c index 8d3862e..dd7929b 100644 --- a/tests/unit/sd_config/test_config_m4.c +++ b/tests/unit/sd_config/test_config_m4.c @@ -79,6 +79,35 @@ SD_TEST(settings_m4_defaults) SD_CHECK_EQ_INT(s.notify_kind[SD_NOTIFY_DISK_CACHE], 1); } +SD_TEST(settings_ui_tier_keys) +{ + sd_settings s; + sd_ini *ini; + sd_settings_defaults(&s); + SD_CHECK_EQ_INT(s.ui_style, SD_SETTINGS_STYLE_AUTO); /* 06 5.3 */ + SD_CHECK_EQ_INT(s.ui_dark_helpers, 1); /* 06 3.6 */ + ini = parse_text("[ui]\r\nstyle = classic\r\ndark_helpers = off\r\n"); + SD_REQUIRE(ini != NULL); + SD_CHECK(sd_settings_from_ini(ini, &s) == SD_OK); + SD_CHECK_EQ_INT(s.ui_style, SD_SETTINGS_STYLE_CLASSIC); + SD_CHECK_EQ_INT(s.ui_dark_helpers, 0); + sd_ini_free(ini); + ini = parse_text("[ui]\r\nstyle = auto\r\ndark_helpers = on\r\n"); + SD_REQUIRE(ini != NULL); + SD_CHECK(sd_settings_from_ini(ini, &s) == SD_OK); + SD_CHECK_EQ_INT(s.ui_style, SD_SETTINGS_STYLE_AUTO); + SD_CHECK_EQ_INT(s.ui_dark_helpers, 1); + sd_ini_free(ini); + /* invalid values are reported and keep the defaults */ + sd_settings_defaults(&s); + ini = parse_text("[ui]\r\nstyle = fancy\r\ndark_helpers = sometimes\r\n"); + SD_REQUIRE(ini != NULL); + SD_CHECK(sd_settings_from_ini(ini, &s) == SD_E_INVALID_PARAM); + SD_CHECK_EQ_INT(s.ui_style, SD_SETTINGS_STYLE_AUTO); + SD_CHECK_EQ_INT(s.ui_dark_helpers, 1); + sd_ini_free(ini); +} + SD_TEST(settings_m4_parse_values) { sd_settings s; diff --git a/tests/unit/sd_policy/test_policy.c b/tests/unit/sd_policy/test_policy.c index 265090e..11c6ced 100644 --- a/tests/unit/sd_policy/test_policy.c +++ b/tests/unit/sd_policy/test_policy.c @@ -12,7 +12,7 @@ SD_TEST(policy_table_is_complete) { sd_policy_id id; int i, j; - const int want_count = 21; + const int want_count = 23; SD_CHECK((int)SD_POLICY_ID_COUNT == want_count); SD_CHECK(wcscmp(sd_policy_key(), k_key) == 0); for (i = 0; i < (int)SD_POLICY_ID_COUNT; i++) { diff --git a/tools/sd-admxgen/test_admxgen.c b/tools/sd-admxgen/test_admxgen.c index 5cc6381..7b78ad5 100644 --- a/tools/sd-admxgen/test_admxgen.c +++ b/tools/sd-admxgen/test_admxgen.c @@ -215,7 +215,7 @@ static void test_real_inputs(void) sdg_parse_table_text(&t, text, SD_ADMXGEN_TABLE); free(text); CHECK(sdg_nerrors == 0); - CHECK(t.npolicies == 21); + CHECK(t.npolicies == 23); for (i = 0; i < SDG_NLOCALES; i++) { sprintf(file, "%s/%s.po", SD_ADMXGEN_PO_DIR, sdg_locales[i].code); @@ -248,7 +248,7 @@ static void test_real_inputs(void) count++; p++; } - CHECK(count == 21); + CHECK(count == 23); } sdg_sb_free(&admx); sdg_sb_free(&adml);